Skip to main content

stackless_core/def/
model.rs

1//! The definition model: serde structs sized exactly to the schema in
2//! ARCHITECTURE.md §1.
3//!
4//! A service is substrate-independent identity + wiring + health; how a
5//! substrate runs it is nested per substrate and captured here as opaque
6//! TOML (`substrates` maps). Core never interprets a substrate block
7//! beyond two contracts that §1 fixes across all substrates: the block
8//! must be a table, and an `env` key inside it overlays the common env.
9
10use std::collections::{BTreeMap, BTreeSet};
11
12use serde::Deserialize;
13
14use super::error::DefError;
15use crate::types::{DnsName, HttpStatus};
16
17/// Top level of `stackless.toml`. Unknown top-level sections are
18/// rejected (an old binary cannot honor a section it does not know).
19#[derive(Debug, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct StackDef {
22    pub stack: Stack,
23    #[serde(default)]
24    pub secrets: SecretsSpec,
25    #[serde(default)]
26    pub integrations: BTreeMap<String, Integration>,
27    #[serde(default)]
28    pub services: BTreeMap<String, Service>,
29    /// Names from a stripped legacy `[datastores.*]` section in an
30    /// instance snapshot. Empty for fresh files. Lets
31    /// `${datastores.*.url}` validate and resolve from journaled
32    /// provision checkpoints on resume.
33    #[serde(skip)]
34    pub legacy_datastores: BTreeSet<String>,
35}
36
37#[derive(Debug, Deserialize)]
38pub struct Stack {
39    pub name: DnsName,
40    #[serde(default)]
41    pub projects: ProjectsSpec,
42    pub verify: Option<VerifyRoot>,
43    /// Per-substrate stack config (e.g. `[stack.render]` region),
44    /// plus any unknown keys — validation tells them apart.
45    #[serde(flatten)]
46    pub substrates: BTreeMap<String, toml::Value>,
47}
48
49#[derive(Debug, Default, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct ProjectsSpec {
52    pub stripe: Option<StripeProjectSpec>,
53}
54
55#[derive(Debug, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct StripeProjectSpec {
58    pub project: Option<String>,
59}
60
61/// The proof contract, run by `stackless verify` (ARCHITECTURE.md §7).
62#[derive(Debug, Clone, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct VerifySpec {
65    pub run: String,
66    #[serde(default)]
67    pub env: BTreeMap<String, String>,
68}
69
70/// `[stack.verify]` plus optional named tiers under `[stack.verify.tiers.<name>]`.
71#[derive(Debug, Default, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct VerifyRoot {
74    pub run: Option<String>,
75    #[serde(default)]
76    pub env: BTreeMap<String, String>,
77    #[serde(default)]
78    pub tiers: BTreeMap<String, VerifySpec>,
79}
80
81impl VerifyRoot {
82    pub fn is_declared(&self) -> bool {
83        self.run.is_some() || !self.tiers.is_empty()
84    }
85
86    pub fn resolve(&self, tier: Option<&str>) -> Option<VerifySpec> {
87        match tier {
88            None | Some("default") => self.run.as_ref().map(|run| VerifySpec {
89                run: run.clone(),
90                env: self.env.clone(),
91            }),
92            Some(name) => self.tiers.get(name).cloned(),
93        }
94    }
95}
96
97#[derive(Debug, Default, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct SecretsSpec {
100    #[serde(default)]
101    pub required: Vec<String>,
102}
103
104#[derive(Debug, Deserialize)]
105pub struct Integration {
106    /// Catalog adapter (e.g. `clerk` → `clerk/auth`).
107    pub provider: String,
108    /// Provider config and optional per-host override tables
109    /// (`[integrations.<name>.<host>]`), allowed only for host-bound
110    /// providers that declare per-host config in the integrations registry.
111    #[serde(flatten)]
112    pub fields: BTreeMap<String, toml::Value>,
113}
114
115impl Integration {
116    /// Config keys excluding registered host override tables. `known_substrates`
117    /// names the keys that count as host overrides (substrate names), so they are
118    /// stripped from the provider's own config.
119    pub fn config_fields(&self, known_substrates: &[&str]) -> BTreeMap<String, toml::Value> {
120        self.fields
121            .iter()
122            .filter(|(key, _)| !known_substrates.contains(&key.as_str()))
123            .map(|(key, value)| (key.clone(), value.clone()))
124            .collect()
125    }
126
127    pub fn host_block(&self, host: &str) -> Option<&toml::Table> {
128        self.fields.get(host).and_then(toml::Value::as_table)
129    }
130
131    /// Parent config merged with a host override table when present.
132    pub fn effective_config(
133        &self,
134        host: &str,
135        known_substrates: &[&str],
136    ) -> BTreeMap<String, toml::Value> {
137        let mut out = self.config_fields(known_substrates);
138        if let Some(override_table) = self.host_block(host) {
139            for (key, value) in override_table {
140                out.insert(key.clone(), value.clone());
141            }
142        }
143        out
144    }
145
146    /// Every host-key table nested under this integration.
147    pub fn host_blocks(&self, known_substrates: &[&str]) -> BTreeMap<String, &toml::Table> {
148        self.fields
149            .iter()
150            .filter_map(|(key, value)| {
151                if !known_substrates.contains(&key.as_str()) {
152                    return None;
153                }
154                Some((key.clone(), value.as_table()?))
155            })
156            .collect()
157    }
158}
159
160#[derive(Debug, Deserialize)]
161pub struct Service {
162    pub source: Source,
163    /// Runs once after the service's source is materialized.
164    pub setup: Option<String>,
165    /// Runs on every `up`, after dependencies are ready, before start.
166    pub prepare: Option<String>,
167    /// Secrets injected as same-named env vars; must be in `[secrets].required`.
168    #[serde(default)]
169    pub secrets: Vec<String>,
170    #[serde(default)]
171    pub env: BTreeMap<String, String>,
172    /// Every service declares a health check (ARCHITECTURE.md §1).
173    pub health: Health,
174    /// At most one service per stack also claims `http://{instance}.localhost`.
175    #[serde(default)]
176    pub root_origin: bool,
177    /// Per-substrate run config (`[services.X.local]`, `[services.X.render]`, ...).
178    #[serde(flatten)]
179    pub substrates: BTreeMap<String, toml::Value>,
180}
181
182/// Code sources are git references (ARCHITECTURE.md §1).
183#[derive(Debug, Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct Source {
186    pub repo: String,
187    #[serde(rename = "ref")]
188    pub reference: String,
189}
190
191/// `health = { path, status = 200, contains = "..." }` (ARCHITECTURE.md §7).
192#[derive(Debug, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct Health {
195    pub path: String,
196    #[serde(default = "default_health_status")]
197    pub status: HttpStatus,
198    pub contains: Option<String>,
199}
200
201fn default_health_status() -> HttpStatus {
202    HttpStatus::OK
203}
204
205impl Service {
206    /// The `env` overlay inside a substrate block, when present.
207    ///
208    /// §1 resolution rules: substrate `env` blocks overlay the common
209    /// `env`. This is the one key core reads inside an otherwise opaque
210    /// substrate block.
211    pub fn substrate_env(
212        &self,
213        service_name: &str,
214        substrate: &str,
215    ) -> Result<BTreeMap<String, String>, DefError> {
216        let Some(block) = self.substrates.get(substrate) else {
217            return Ok(BTreeMap::new());
218        };
219        let location = format!("services.{service_name}.{substrate}.env");
220        let Some(table) = block.as_table() else {
221            // Non-table substrate blocks are rejected by validation;
222            // treat as no overlay here.
223            return Ok(BTreeMap::new());
224        };
225        let Some(env) = table.get("env") else {
226            return Ok(BTreeMap::new());
227        };
228        let Some(env) = env.as_table() else {
229            return Err(DefError::EnvNotStrings { location });
230        };
231        let mut out = BTreeMap::new();
232        for (key, value) in env {
233            let Some(value) = value.as_str() else {
234                return Err(DefError::EnvNotStrings { location });
235            };
236            out.insert(key.clone(), value.to_owned());
237        }
238        Ok(out)
239    }
240
241    /// The common env with the substrate overlay applied (overlay wins).
242    pub fn effective_env(
243        &self,
244        service_name: &str,
245        substrate: &str,
246    ) -> Result<BTreeMap<String, String>, DefError> {
247        let overlay = self.substrate_env(service_name, substrate)?;
248        if overlay.is_empty() {
249            return Ok(self.env.clone());
250        }
251        let mut env = self.env.clone();
252        env.extend(overlay);
253        Ok(env)
254    }
255}