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    /// Reconstruct legacy `${datastores.X.url}` values from journaled
189    /// `provision:` checkpoints.
190    ///
191    /// Local checkpoints store a single `url`. Render checkpoints store
192    /// `internal_url` / `external_url`; `prefer_external` selects which
193    /// (service env → internal, operator prepare / verify → external).
194    pub fn add_datastore_checkpoints(
195        &mut self,
196        checkpoints: &[crate::state::Checkpoint],
197        prefer_external: bool,
198    ) {
199        for checkpoint in checkpoints {
200            let Some(name) = checkpoint.step_id.strip_prefix("provision:") else {
201                continue;
202            };
203            let Ok(payload) = serde_json::from_str::<serde_json::Value>(&checkpoint.payload) else {
204                continue;
205            };
206            let url = payload
207                .get("url")
208                .and_then(|value| value.as_str())
209                .or_else(|| {
210                    let primary = if prefer_external {
211                        "external_url"
212                    } else {
213                        "internal_url"
214                    };
215                    let fallback = if prefer_external {
216                        "internal_url"
217                    } else {
218                        "external_url"
219                    };
220                    payload
221                        .get(primary)
222                        .and_then(|value| value.as_str())
223                        .or_else(|| payload.get(fallback).and_then(|value| value.as_str()))
224                });
225            if let Some(url) = url {
226                self.datastore_urls.insert(name.to_owned(), url.to_owned());
227            }
228        }
229    }
230
231    /// Substitute every `${...}` in `value` from this namespace.
232    pub fn resolve(&self, value: &str, location: &str) -> Result<String, DefError> {
233        let mut out = String::with_capacity(value.len());
234        let mut rest = value;
235        while let Some(start) = rest.find("${") {
236            out.push_str(&rest[..start]);
237            let after = &rest[start + 2..];
238            let Some(end) = after.find('}') else {
239                return Err(DefError::ReferenceSyntax {
240                    location: location.to_owned(),
241                    reference: rest[start..].to_owned(),
242                    detail: "unterminated ${...}".into(),
243                });
244            };
245            let reference = Reference::parse_reference(&after[..end], location)?;
246            out.push_str(&self.lookup(&reference, location)?);
247            rest = &after[end + 1..];
248        }
249        out.push_str(rest);
250        Ok(out)
251    }
252}
253
254/// Substitute every `${...}` in `value` from the namespace.
255pub fn resolve(value: &str, namespace: &Namespace, location: &str) -> Result<String, DefError> {
256    namespace.resolve(value, location)
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn tokenizes_all_namespace_forms() {
265        let refs = references(
266            "${stack.name} ${instance.name} ${services.web.origin} ${datastores.db.url} ${secrets.KEY} ${integrations.clerk.secret_key}",
267            "test",
268        )
269        .unwrap();
270        assert_eq!(
271            refs,
272            vec![
273                Reference::StackName,
274                Reference::InstanceName,
275                Reference::ServiceOrigin("web".into()),
276                Reference::DatastoreUrl("db".into()),
277                Reference::Secret("KEY".into()),
278                Reference::IntegrationOutput {
279                    integration: "clerk".into(),
280                    output: "secret_key".into(),
281                },
282            ]
283        );
284    }
285
286    #[test]
287    fn port_is_not_a_reference() {
288        assert!(references("vite --port $PORT", "test").unwrap().is_empty());
289    }
290
291    #[test]
292    fn unterminated_reference_fails() {
293        assert!(references("${services.web.origin", "test").is_err());
294    }
295
296    #[test]
297    fn unknown_form_fails() {
298        assert!(references("${services.web.port}", "test").is_err());
299    }
300
301    #[test]
302    fn resolves_mixed_text() {
303        let mut namespace = Namespace {
304            stack_name: DnsName::try_new("atto").unwrap(),
305            instance_name: DnsName::try_new("demo").unwrap(),
306            ..Namespace::default()
307        };
308        namespace
309            .service_origins
310            .insert("web".into(), "http://web.demo.localhost:4444".into());
311        let resolved = resolve(
312            "origin=${services.web.origin};i=${instance.name}",
313            &namespace,
314            "t",
315        )
316        .unwrap();
317        assert_eq!(resolved, "origin=http://web.demo.localhost:4444;i=demo");
318    }
319}