Skip to main content

stackless_core/def/
validate.rs

1//! Definition validation: everything that fails "at parse time, not at
2//! `up` time" (ARCHITECTURE.md §1 resolution rules).
3//!
4//! Core knows no substrate by name (ground rule: the Substrate trait is
5//! the only provider seam); callers pass the names of registered
6//! substrates so unknown keys can be told apart from substrate blocks.
7
8use std::collections::BTreeMap;
9
10use super::error::DefError;
11use super::interp::{self, Reference};
12use super::model::{Integration, Service, StackDef};
13
14impl StackDef {
15    /// Validate the whole definition against the rules registered substrates share.
16    /// Callers pass the names of registered substrates (core knows none by name).
17    pub fn validate_hosts(&self, known_substrates: &[&str]) -> Result<(), DefError> {
18        validate_definition(self, known_substrates)
19    }
20
21    /// `up --on <s>` fails at validation if any service lacks the config
22    /// that substrate requires (ARCHITECTURE.md §2).
23    pub fn validate_for_substrate(&self, substrate: &str) -> Result<(), DefError> {
24        for (name, service) in &self.services {
25            if !service.substrates.contains_key(substrate) {
26                return Err(DefError::SubstrateConfigMissing {
27                    service: name.clone(),
28                    substrate: substrate.to_owned(),
29                });
30            }
31        }
32        Ok(())
33    }
34}
35
36fn validate_definition(def: &StackDef, known_substrates: &[&str]) -> Result<(), DefError> {
37    if !crate::types::dns_safe(def.stack.name.as_str()) {
38        return Err(DefError::NameInvalid {
39            kind: "stack",
40            name: def.stack.name.as_str().to_owned(),
41        });
42    }
43    if def.services.is_empty() {
44        return Err(DefError::NoServices);
45    }
46
47    validate_substrate_keys(&def.stack.substrates, "stack", known_substrates)?;
48    validate_integrations(def, known_substrates)?;
49
50    let mut root_origins = Vec::new();
51    for (name, service) in &def.services {
52        if !crate::types::dns_safe(name) {
53            return Err(DefError::NameInvalid {
54                kind: "service",
55                name: name.clone(),
56            });
57        }
58        if service.root_origin {
59            root_origins.push(name.clone());
60        }
61        validate_substrate_keys(
62            &service.substrates,
63            &format!("services.{name}"),
64            known_substrates,
65        )?;
66        validate_service_references(def, name, service, known_substrates)?;
67    }
68    if root_origins.len() > 1 {
69        return Err(DefError::RootOriginConflict {
70            services: root_origins,
71        });
72    }
73
74    if let Some(verify) = &def.stack.verify {
75        for (key, value) in &verify.env {
76            let location = format!("stack.verify.env.{key}");
77            let refs = interp::references(value, &location)?;
78            validate_references(def, &refs, &location)?;
79        }
80        for (tier, spec) in &verify.tiers {
81            if !crate::types::dns_safe(tier) {
82                return Err(DefError::NameInvalid {
83                    kind: "verify tier",
84                    name: tier.clone(),
85                });
86            }
87            for (key, value) in &spec.env {
88                let location = format!("stack.verify.tiers.{tier}.env.{key}");
89                let refs = interp::references(value, &location)?;
90                validate_references(def, &refs, &location)?;
91            }
92        }
93    }
94
95    Ok(())
96}
97
98fn validate_substrate_keys(
99    substrates: &BTreeMap<String, toml::Value>,
100    location: &str,
101    known_substrates: &[&str],
102) -> Result<(), DefError> {
103    for (key, value) in substrates {
104        if key == "depends_on" {
105            // A dependency must be expressed in wiring; an ordering need
106            // with no wiring expression is a definition bug (§1).
107            return Err(DefError::DependsOnRejected {
108                location: location.to_owned(),
109            });
110        }
111        if !known_substrates.contains(&key.as_str()) {
112            return Err(DefError::UnknownKey {
113                location: location.to_owned(),
114                key: key.clone(),
115                known_substrates: known_substrates.iter().map(|s| (*s).to_owned()).collect(),
116            });
117        }
118        if !value.is_table() {
119            return Err(DefError::SubstrateBlockInvalid {
120                location: format!("{location}.{key}"),
121                found: value.type_str().to_owned(),
122            });
123        }
124    }
125    Ok(())
126}
127
128fn validate_service_references(
129    def: &StackDef,
130    name: &str,
131    service: &Service,
132    known_substrates: &[&str],
133) -> Result<(), DefError> {
134    // Injected same-named secrets must be resolvable before anything
135    // provisions, so they must be in the required list.
136    for key in &service.secrets {
137        if !def.secrets.required.contains(key) {
138            return Err(DefError::SecretNotRequired {
139                location: format!("services.{name}.secrets"),
140                key: key.clone(),
141            });
142        }
143    }
144    for (key, value) in &service.env {
145        let location = format!("services.{name}.env.{key}");
146        let refs = interp::references(value, &location)?;
147        validate_references(def, &refs, &location)?;
148    }
149    // Substrate env overlays participate in wiring (§1: substrate env
150    // blocks overlay the common env), so their references validate too.
151    for substrate in known_substrates {
152        let overlay = service.substrate_env(name, substrate)?;
153        for (key, value) in &overlay {
154            let location = format!("services.{name}.{substrate}.env.{key}");
155            let refs = interp::references(value, &location)?;
156            validate_references(def, &refs, &location)?;
157        }
158    }
159    Ok(())
160}
161
162fn validate_integrations(def: &StackDef, known_substrates: &[&str]) -> Result<(), DefError> {
163    for (name, integration) in &def.integrations {
164        if !crate::types::dns_safe(name) {
165            return Err(DefError::NameInvalid {
166                kind: "integration",
167                name: name.clone(),
168            });
169        }
170        if integration.provider.is_empty() {
171            return Err(DefError::IntegrationInvalid {
172                integration: name.clone(),
173                detail: "provider is required".into(),
174            });
175        }
176        validate_integration_substrate_keys(name, integration, known_substrates)?;
177        validate_integration_string_refs(def, name, integration, known_substrates)?;
178    }
179    Ok(())
180}
181
182fn validate_integration_substrate_keys(
183    name: &str,
184    integration: &Integration,
185    known_substrates: &[&str],
186) -> Result<(), DefError> {
187    let substrates: std::collections::BTreeMap<String, toml::Value> = integration
188        .fields
189        .iter()
190        .filter(|(key, _)| known_substrates.contains(&key.as_str()))
191        .map(|(key, value)| (key.clone(), value.clone()))
192        .collect();
193    validate_substrate_keys(
194        &substrates,
195        &format!("integrations.{name}"),
196        known_substrates,
197    )
198}
199
200fn validate_integration_string_refs(
201    def: &StackDef,
202    name: &str,
203    integration: &Integration,
204    known_substrates: &[&str],
205) -> Result<(), DefError> {
206    for (key, value) in integration.config_fields(known_substrates) {
207        let Some(text) = value.as_str() else {
208            continue;
209        };
210        let location = format!("integrations.{name}.{key}");
211        let refs = interp::references(text, &location)?;
212        validate_references(def, &refs, &location)?;
213    }
214    for substrate in known_substrates {
215        let Some(block) = integration.host_block(substrate) else {
216            continue;
217        };
218        for (key, value) in block {
219            let Some(text) = value.as_str() else {
220                continue;
221            };
222            let location = format!("integrations.{name}.{substrate}.{key}");
223            let refs = interp::references(text, &location)?;
224            validate_references(def, &refs, &location)?;
225        }
226    }
227    Ok(())
228}
229
230fn validate_references(def: &StackDef, refs: &[Reference], location: &str) -> Result<(), DefError> {
231    for reference in refs {
232        match reference {
233            Reference::StackName | Reference::InstanceName => {}
234            Reference::ServiceOrigin(target) => {
235                if !def.services.contains_key(target) {
236                    return Err(DefError::UndeclaredReference {
237                        location: location.to_owned(),
238                        kind: "service",
239                        name: target.clone(),
240                    });
241                }
242            }
243            Reference::DatastoreUrl(target) => {
244                // Fresh files cannot declare `[datastores.*]`; only
245                // snapshot resume populates `legacy_datastores`.
246                if !def.legacy_datastores.contains(target) {
247                    return Err(DefError::UndeclaredReference {
248                        location: location.to_owned(),
249                        kind: "datastore",
250                        name: target.clone(),
251                    });
252                }
253            }
254            Reference::Secret(key) => {
255                if !def.secrets.required.contains(key) {
256                    return Err(DefError::SecretNotRequired {
257                        location: location.to_owned(),
258                        key: key.clone(),
259                    });
260                }
261            }
262            Reference::IntegrationOutput { integration, .. } => {
263                if !def.integrations.contains_key(integration) {
264                    return Err(DefError::UndeclaredReference {
265                        location: location.to_owned(),
266                        kind: "integration",
267                        name: integration.clone(),
268                    });
269                }
270            }
271        }
272    }
273    Ok(())
274}
275
276#[cfg(test)]
277mod tests {
278    use crate::def::StackDef;
279    use crate::types::dns_safe;
280
281    #[test]
282    fn dns_safety() {
283        assert!(dns_safe("atto"));
284        assert!(dns_safe("a1-b2"));
285        assert!(!dns_safe(""));
286        assert!(!dns_safe("Atto"));
287        assert!(!dns_safe("1atto"));
288        assert!(!dns_safe("atto-"));
289        assert!(!dns_safe("at to"));
290        assert!(!dns_safe(&"a".repeat(64)));
291    }
292
293    #[test]
294    fn verify_tier_keys_must_be_dns_safe() {
295        let text = r#"
296[stack]
297name = "bad"
298
299[stack.verify.tiers."a);func"]
300run = "true"
301
302[services.web]
303source = { repo = "https://example.invalid/x", ref = "main" }
304health = { path = "/" }
305
306[services.web.local]
307run = "true"
308"#;
309        let def = StackDef::parse(text).expect("parse");
310        let err = def.validate_hosts(&["local"]).expect_err("tier name");
311        assert!(matches!(
312            err,
313            crate::def::DefError::NameInvalid {
314                kind: "verify tier",
315                ..
316            }
317        ));
318    }
319}