Skip to main content

stackless_core/def/
interp.rs

1//! The interpolation namespace (ARCHITECTURE.md §1).
2//!
3//! Env values reference a namespace evaluated per instance per
4//! substrate. `$PORT` is deliberately *not* an interpolation reference —
5//! it is injected by the local substrate into `run` commands only, so
6//! the tokenizer here only understands `${...}` forms.
7
8use std::collections::BTreeMap;
9
10use super::error::DefError;
11use crate::types::DnsName;
12
13/// A parsed `${...}` reference.
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
15pub enum Reference {
16    /// `${stack.name}`
17    StackName,
18    /// `${instance.name}`
19    InstanceName,
20    /// `${services.X.origin}`
21    ServiceOrigin(String),
22    /// `${datastores.X.url}` — legacy first-class datastore wiring kept
23    /// so instance snapshots can still resolve URLs from journaled
24    /// provision checkpoints after the section was removed from the schema.
25    DatastoreUrl(String),
26    /// `${secrets.KEY}`
27    Secret(String),
28    /// `${integrations.X.output}`
29    IntegrationOutput { integration: String, output: String },
30}
31
32/// Extract every `${...}` reference from a value.
33pub fn references(value: &str, location: &str) -> Result<Vec<Reference>, DefError> {
34    Reference::collect_in(value, location)
35}
36
37impl Reference {
38    /// Extract every `${...}` reference from a value.
39    ///
40    /// `location` names where the value lives (e.g. `services.api.env.DATABASE_URL`)
41    /// so errors point at the right line of the definition.
42    pub fn collect_in(value: &str, location: &str) -> Result<Vec<Self>, DefError> {
43        let mut refs = Vec::new();
44        let mut rest = value;
45        while let Some(start) = rest.find("${") {
46            let after = &rest[start + 2..];
47            let Some(end) = after.find('}') else {
48                return Err(DefError::ReferenceSyntax {
49                    location: location.to_owned(),
50                    reference: rest[start..].to_owned(),
51                    detail: "unterminated ${...}".into(),
52                });
53            };
54            refs.push(Self::parse(&after[..end], location)?);
55            rest = &after[end + 1..];
56        }
57        Ok(refs)
58    }
59
60    fn parse(inner: &str, location: &str) -> Result<Self, DefError> {
61        Self::parse_reference(inner, location)
62    }
63
64    fn parse_reference(inner: &str, location: &str) -> Result<Self, DefError> {
65        let parts: Vec<&str> = inner.split('.').collect();
66        let reference = match parts.as_slice() {
67            ["instance", "name"] => Reference::InstanceName,
68            ["stack", "name"] => Reference::StackName,
69            ["services", name, "origin"] => Reference::ServiceOrigin((*name).to_owned()),
70            ["datastores", name, "url"] => Reference::DatastoreUrl((*name).to_owned()),
71            ["secrets", key] => Reference::Secret((*key).to_owned()),
72            ["integrations", name, output] => Reference::IntegrationOutput {
73                integration: (*name).to_owned(),
74                output: (*output).to_owned(),
75            },
76            _ => {
77                return Err(DefError::ReferenceSyntax {
78                    location: location.to_owned(),
79                    reference: format!("${{{inner}}}"),
80                    detail: "not a recognized namespace form".into(),
81                });
82            }
83        };
84        Ok(reference)
85    }
86}
87
88/// The values a substrate supplies for one instance. Built per instance
89/// per substrate; resolution itself is substrate-blind.
90#[derive(Debug)]
91pub struct Namespace {
92    pub stack_name: DnsName,
93    pub instance_name: DnsName,
94    pub service_origins: BTreeMap<String, String>,
95    /// Legacy `${datastores.X.url}` values reconstructed from journaled
96    /// `provision:` checkpoints (local container / render-postgres).
97    pub datastore_urls: BTreeMap<String, String>,
98    pub secrets: BTreeMap<String, String>,
99    pub integrations: BTreeMap<String, BTreeMap<String, String>>,
100}
101
102impl Default for Namespace {
103    fn default() -> Self {
104        Self {
105            stack_name: DnsName::from_stored("stack"),
106            instance_name: DnsName::from_stored("instance"),
107            service_origins: BTreeMap::new(),
108            datastore_urls: BTreeMap::new(),
109            secrets: BTreeMap::new(),
110            integrations: BTreeMap::new(),
111        }
112    }
113}
114
115impl Namespace {
116    fn lookup(&self, reference: &Reference, location: &str) -> Result<String, DefError> {
117        match reference {
118            Reference::StackName => Ok(self.stack_name.as_str().to_owned()),
119            Reference::InstanceName => Ok(self.instance_name.as_str().to_owned()),
120            Reference::ServiceOrigin(name) => {
121                self.service_origins.get(name).cloned().ok_or_else(|| {
122                    DefError::UndeclaredReference {
123                        location: location.to_owned(),
124                        kind: "service",
125                        name: name.clone(),
126                    }
127                })
128            }
129            Reference::DatastoreUrl(name) => {
130                self.datastore_urls.get(name).cloned().ok_or_else(|| {
131                    DefError::UndeclaredReference {
132                        location: location.to_owned(),
133                        kind: "datastore",
134                        name: name.clone(),
135                    }
136                })
137            }
138            Reference::Secret(key) => {
139                self.secrets
140                    .get(key)
141                    .cloned()
142                    .ok_or_else(|| DefError::UndeclaredReference {
143                        location: location.to_owned(),
144                        kind: "secret",
145                        name: key.clone(),
146                    })
147            }
148            Reference::IntegrationOutput {
149                integration,
150                output,
151            } => self
152                .integrations
153                .get(integration)
154                .and_then(|outputs| outputs.get(output))
155                .cloned()
156                .ok_or_else(|| DefError::UndeclaredReference {
157                    location: location.to_owned(),
158                    kind: "integration output",
159                    name: format!("{integration}.{output}"),
160                }),
161        }
162    }
163
164    /// Load integration output checkpoints into the interpolation namespace.
165    ///
166    /// Payload shape is provider-agnostic:
167    /// `{ "outputs": { "secret_key": "...", "publishable_key": "..." } }`.
168    pub fn add_integration_checkpoints(&mut self, checkpoints: &[crate::state::Checkpoint]) {
169        for checkpoint in checkpoints {
170            let Some(name) = checkpoint.step_id.strip_prefix("integration:") else {
171                continue;
172            };
173            let Ok(payload) = serde_json::from_str::<serde_json::Value>(&checkpoint.payload) else {
174                continue;
175            };
176            let Some(outputs) = payload.get("outputs").and_then(|value| value.as_object()) else {
177                continue;
178            };
179            let entry = self.integrations.entry(name.to_owned()).or_default();
180            for (key, value) in outputs {
181                if let Some(value) = value.as_str() {
182                    entry.insert(key.clone(), value.to_owned());
183                }
184            }
185        }
186    }
187
188    pub fn integration_outputs_from_checkpoints(
189        checkpoints: &[crate::state::Checkpoint],
190    ) -> BTreeMap<String, BTreeMap<String, String>> {
191        let mut ns = Self::default();
192        ns.add_integration_checkpoints(checkpoints);
193        ns.integrations
194    }
195
196    /// Reconstruct legacy `${datastores.X.url}` values from journaled
197    /// `provision:` checkpoints.
198    ///
199    /// Local checkpoints store a single `url`. Render checkpoints store
200    /// `internal_url` / `external_url`; `prefer_external` selects which
201    /// (service env → internal, operator prepare / verify → external).
202    pub fn add_datastore_checkpoints(
203        &mut self,
204        checkpoints: &[crate::state::Checkpoint],
205        prefer_external: bool,
206    ) {
207        for checkpoint in checkpoints {
208            let Some(name) = checkpoint.step_id.strip_prefix("provision:") else {
209                continue;
210            };
211            let Ok(payload) = serde_json::from_str::<serde_json::Value>(&checkpoint.payload) else {
212                continue;
213            };
214            let url = payload
215                .get("url")
216                .and_then(|value| value.as_str())
217                .or_else(|| {
218                    let primary = if prefer_external {
219                        "external_url"
220                    } else {
221                        "internal_url"
222                    };
223                    let fallback = if prefer_external {
224                        "internal_url"
225                    } else {
226                        "external_url"
227                    };
228                    payload
229                        .get(primary)
230                        .and_then(|value| value.as_str())
231                        .or_else(|| payload.get(fallback).and_then(|value| value.as_str()))
232                });
233            if let Some(url) = url {
234                self.datastore_urls.insert(name.to_owned(), url.to_owned());
235            }
236        }
237    }
238
239    /// Substitute every `${...}` in `value` from this namespace.
240    pub fn resolve(&self, value: &str, location: &str) -> Result<String, DefError> {
241        let mut out = String::with_capacity(value.len());
242        let mut rest = value;
243        while let Some(start) = rest.find("${") {
244            out.push_str(&rest[..start]);
245            let after = &rest[start + 2..];
246            let Some(end) = after.find('}') else {
247                return Err(DefError::ReferenceSyntax {
248                    location: location.to_owned(),
249                    reference: rest[start..].to_owned(),
250                    detail: "unterminated ${...}".into(),
251                });
252            };
253            let reference = Reference::parse_reference(&after[..end], location)?;
254            out.push_str(&self.lookup(&reference, location)?);
255            rest = &after[end + 1..];
256        }
257        out.push_str(rest);
258        Ok(out)
259    }
260}
261
262/// Substitute every `${...}` in `value` from the namespace.
263pub fn resolve(value: &str, namespace: &Namespace, location: &str) -> Result<String, DefError> {
264    namespace.resolve(value, location)
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn tokenizes_all_namespace_forms() {
273        let refs = references(
274            "${stack.name} ${instance.name} ${services.web.origin} ${datastores.db.url} ${secrets.KEY} ${integrations.clerk.secret_key}",
275            "test",
276        )
277        .unwrap();
278        assert_eq!(
279            refs,
280            vec![
281                Reference::StackName,
282                Reference::InstanceName,
283                Reference::ServiceOrigin("web".into()),
284                Reference::DatastoreUrl("db".into()),
285                Reference::Secret("KEY".into()),
286                Reference::IntegrationOutput {
287                    integration: "clerk".into(),
288                    output: "secret_key".into(),
289                },
290            ]
291        );
292    }
293
294    #[test]
295    fn port_is_not_a_reference() {
296        assert!(references("vite --port $PORT", "test").unwrap().is_empty());
297    }
298
299    #[test]
300    fn unterminated_reference_fails() {
301        assert!(references("${services.web.origin", "test").is_err());
302    }
303
304    #[test]
305    fn unknown_form_fails() {
306        assert!(references("${services.web.port}", "test").is_err());
307    }
308
309    #[test]
310    fn resolves_mixed_text() {
311        let mut namespace = Namespace {
312            stack_name: DnsName::try_new("atto").unwrap(),
313            instance_name: DnsName::try_new("demo").unwrap(),
314            ..Namespace::default()
315        };
316        namespace
317            .service_origins
318            .insert("web".into(), "http://web.demo.localhost:4444".into());
319        let resolved = resolve(
320            "origin=${services.web.origin};i=${instance.name}",
321            &namespace,
322            "t",
323        )
324        .unwrap();
325        assert_eq!(resolved, "origin=http://web.demo.localhost:4444;i=demo");
326    }
327}