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            for (key, value) in &spec.env {
82                let location = format!("stack.verify.tiers.{tier}.env.{key}");
83                let refs = interp::references(value, &location)?;
84                validate_references(def, &refs, &location)?;
85            }
86        }
87    }
88
89    Ok(())
90}
91
92fn validate_substrate_keys(
93    substrates: &BTreeMap<String, toml::Value>,
94    location: &str,
95    known_substrates: &[&str],
96) -> Result<(), DefError> {
97    for (key, value) in substrates {
98        if key == "depends_on" {
99            // A dependency must be expressed in wiring; an ordering need
100            // with no wiring expression is a definition bug (§1).
101            return Err(DefError::DependsOnRejected {
102                location: location.to_owned(),
103            });
104        }
105        if !known_substrates.contains(&key.as_str()) {
106            return Err(DefError::UnknownKey {
107                location: location.to_owned(),
108                key: key.clone(),
109                known_substrates: known_substrates.iter().map(|s| (*s).to_owned()).collect(),
110            });
111        }
112        if !value.is_table() {
113            return Err(DefError::SubstrateBlockInvalid {
114                location: format!("{location}.{key}"),
115                found: value.type_str().to_owned(),
116            });
117        }
118    }
119    Ok(())
120}
121
122fn validate_service_references(
123    def: &StackDef,
124    name: &str,
125    service: &Service,
126    known_substrates: &[&str],
127) -> Result<(), DefError> {
128    // Injected same-named secrets must be resolvable before anything
129    // provisions, so they must be in the required list.
130    for key in &service.secrets {
131        if !def.secrets.required.contains(key) {
132            return Err(DefError::SecretNotRequired {
133                location: format!("services.{name}.secrets"),
134                key: key.clone(),
135            });
136        }
137    }
138    for (key, value) in &service.env {
139        let location = format!("services.{name}.env.{key}");
140        let refs = interp::references(value, &location)?;
141        validate_references(def, &refs, &location)?;
142    }
143    // Substrate env overlays participate in wiring (§1: substrate env
144    // blocks overlay the common env), so their references validate too.
145    for substrate in known_substrates {
146        let overlay = service.substrate_env(name, substrate)?;
147        for (key, value) in &overlay {
148            let location = format!("services.{name}.{substrate}.env.{key}");
149            let refs = interp::references(value, &location)?;
150            validate_references(def, &refs, &location)?;
151        }
152    }
153    Ok(())
154}
155
156fn validate_integrations(def: &StackDef, known_substrates: &[&str]) -> Result<(), DefError> {
157    for (name, integration) in &def.integrations {
158        if !crate::types::dns_safe(name) {
159            return Err(DefError::NameInvalid {
160                kind: "integration",
161                name: name.clone(),
162            });
163        }
164        if integration.provider.is_empty() {
165            return Err(DefError::IntegrationInvalid {
166                integration: name.clone(),
167                detail: "provider is required".into(),
168            });
169        }
170        validate_integration_substrate_keys(name, integration, known_substrates)?;
171        validate_integration_string_refs(def, name, integration, known_substrates)?;
172    }
173    Ok(())
174}
175
176fn validate_integration_substrate_keys(
177    name: &str,
178    integration: &Integration,
179    known_substrates: &[&str],
180) -> Result<(), DefError> {
181    let substrates: std::collections::BTreeMap<String, toml::Value> = integration
182        .fields
183        .iter()
184        .filter(|(key, _)| known_substrates.contains(&key.as_str()))
185        .map(|(key, value)| (key.clone(), value.clone()))
186        .collect();
187    validate_substrate_keys(
188        &substrates,
189        &format!("integrations.{name}"),
190        known_substrates,
191    )
192}
193
194fn validate_integration_string_refs(
195    def: &StackDef,
196    name: &str,
197    integration: &Integration,
198    known_substrates: &[&str],
199) -> Result<(), DefError> {
200    for (key, value) in integration.config_fields(known_substrates) {
201        let Some(text) = value.as_str() else {
202            continue;
203        };
204        let location = format!("integrations.{name}.{key}");
205        let refs = interp::references(text, &location)?;
206        validate_references(def, &refs, &location)?;
207    }
208    for substrate in known_substrates {
209        let Some(block) = integration.host_block(substrate) else {
210            continue;
211        };
212        for (key, value) in block {
213            let Some(text) = value.as_str() else {
214                continue;
215            };
216            let location = format!("integrations.{name}.{substrate}.{key}");
217            let refs = interp::references(text, &location)?;
218            validate_references(def, &refs, &location)?;
219        }
220    }
221    Ok(())
222}
223
224fn validate_references(def: &StackDef, refs: &[Reference], location: &str) -> Result<(), DefError> {
225    for reference in refs {
226        match reference {
227            Reference::StackName | Reference::InstanceName => {}
228            Reference::ServiceOrigin(target) => {
229                if !def.services.contains_key(target) {
230                    return Err(DefError::UndeclaredReference {
231                        location: location.to_owned(),
232                        kind: "service",
233                        name: target.clone(),
234                    });
235                }
236            }
237            Reference::DatastoreUrl(target) => {
238                // Fresh files cannot declare `[datastores.*]`; only
239                // snapshot resume populates `legacy_datastores`.
240                if !def.legacy_datastores.contains(target) {
241                    return Err(DefError::UndeclaredReference {
242                        location: location.to_owned(),
243                        kind: "datastore",
244                        name: target.clone(),
245                    });
246                }
247            }
248            Reference::Secret(key) => {
249                if !def.secrets.required.contains(key) {
250                    return Err(DefError::SecretNotRequired {
251                        location: location.to_owned(),
252                        key: key.clone(),
253                    });
254                }
255            }
256            Reference::IntegrationOutput { integration, .. } => {
257                if !def.integrations.contains_key(integration) {
258                    return Err(DefError::UndeclaredReference {
259                        location: location.to_owned(),
260                        kind: "integration",
261                        name: integration.clone(),
262                    });
263                }
264            }
265        }
266    }
267    Ok(())
268}
269
270#[cfg(test)]
271mod tests {
272    use crate::types::dns_safe;
273
274    #[test]
275    fn dns_safety() {
276        assert!(dns_safe("atto"));
277        assert!(dns_safe("a1-b2"));
278        assert!(!dns_safe(""));
279        assert!(!dns_safe("Atto"));
280        assert!(!dns_safe("1atto"));
281        assert!(!dns_safe("atto-"));
282        assert!(!dns_safe("at to"));
283        assert!(!dns_safe(&"a".repeat(64)));
284    }
285}