Skip to main content

stackless_core/
substrate.rs

1//! The `Substrate` trait — the one provider seam (ARCHITECTURE.md §8).
2//!
3//! Core never names a substrate; providers implement this trait and the
4//! binary registers them by name. Everything per-substrate flows
5//! through here: validation, capabilities, defaults, and the resource
6//! operations the lifecycle engine drives (execute / observe /
7//! destroy). Adding a provider must require zero changes to the engine
8//! or state modules.
9
10use std::collections::BTreeMap;
11use std::time::Duration;
12
13use serde::Serialize;
14
15use crate::def::{Namespace, StackDef};
16use crate::engine::Step;
17use crate::fault::{ErrorContext, Fault};
18use crate::state::Checkpoint;
19
20/// Structured spend data for cloud `--json` envelopes (§4).
21#[derive(Debug, Clone, Serialize)]
22pub struct SpendInfo {
23    pub provider: String,
24    pub cap_usd: u32,
25    pub summary: String,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub data: Option<serde_json::Value>,
28}
29
30/// A substrate failure, flattened at the trait boundary so the §2
31/// error contract (stable code + remediation) crosses it intact
32/// whatever error enum the provider uses internally.
33#[derive(Debug, thiserror::Error)]
34#[error("{message}")]
35pub struct SubstrateFault {
36    pub code: &'static str,
37    pub message: String,
38    pub remediation: String,
39    pub context: Box<ErrorContext>,
40}
41
42impl SubstrateFault {
43    pub fn from_fault(fault: &dyn Fault) -> Self {
44        Self {
45            code: fault.code(),
46            message: fault.to_string(),
47            remediation: fault.remediation(),
48            context: Box::new(fault.context()),
49        }
50    }
51}
52
53impl Fault for SubstrateFault {
54    fn code(&self) -> &'static str {
55        self.code
56    }
57
58    fn remediation(&self) -> String {
59        self.remediation.clone()
60    }
61
62    fn context(&self) -> ErrorContext {
63        self.context.as_ref().clone()
64    }
65}
66
67/// Steps that perform work but create no destructible resource (hooks,
68/// health gates) record this kind; teardown drops their checkpoints
69/// without a destroy/observe round-trip.
70pub const ACTION_RESOURCE_KIND: &str = "action";
71
72pub fn action_resource(step_id: &str) -> StepResource {
73    StepResource {
74        resource_kind: ACTION_RESOURCE_KIND.into(),
75        resource_id: step_id.to_owned(),
76        payload: "{}".into(),
77    }
78}
79
80pub fn present_or_gone(present: bool) -> Observation {
81    if present {
82        Observation::Present
83    } else {
84        Observation::Gone
85    }
86}
87
88/// Which env resolution path is building a namespace.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum NamespacePurpose {
91    /// Service runtime env (Render: internal DB URLs).
92    ServiceEnv,
93    /// Operator-side prepare hooks (Render: external DB URLs).
94    OperatorPrepare,
95    /// `stackless verify` env resolution.
96    Verify,
97}
98
99/// What a recorded resource looks like when re-checked against the
100/// substrate (invariant 4: the manifest says where to look, the
101/// substrate says what's true).
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Observation {
104    Present,
105    Gone,
106}
107
108/// One service's recent logs as a substrate retrieved them, for the `logs`
109/// verb. The substrate owns where the lines come from (a cloud API, a local
110/// file); the CLI only renders them.
111#[derive(Debug, Clone)]
112pub struct ServiceLog {
113    pub service: String,
114    /// Provenance tag for `--json` output (e.g. `"render_api"`, `"file"`).
115    pub source: &'static str,
116    /// Local log file path, when the lines were read from disk.
117    pub log_path: Option<String>,
118    pub lines: Vec<String>,
119}
120
121/// What `execute` hands back for the journal: the resource the step
122/// created (or re-affirmed), recorded before the engine proceeds.
123#[derive(Debug, Clone)]
124pub struct StepResource {
125    pub resource_kind: String,
126    pub resource_id: String,
127    /// Substrate-specific JSON needed to re-find the resource later.
128    pub payload: String,
129}
130
131/// Everything a substrate gets to execute one step.
132#[derive(Debug)]
133pub struct StepContext<'a> {
134    pub instance: &'a str,
135    pub def: &'a StackDef,
136    pub step: &'a Step,
137    /// Recorded `--source` pins (service → path), local-only.
138    pub source_overrides: &'a BTreeMap<String, String>,
139    /// Snapshot `--source` pins into instance-owned space instead of using
140    /// them in place.
141    pub dirty: bool,
142    /// Checkpoints recorded so far, in order — earlier steps' resources
143    /// (ports, paths, connection strings) live here.
144    pub prior: &'a [Checkpoint],
145}
146
147#[async_trait::async_trait]
148pub trait Substrate: Send + Sync {
149    /// The name instances are bound to at creation (`--on <name>`).
150    fn name(&self) -> &str;
151
152    /// Substrate-specific shape validation of the definition — core has
153    /// already checked everything substrate-blind.
154    fn validate_definition(&self, def: &StackDef) -> Result<(), SubstrateFault>;
155
156    /// Whether `--source service=path` pins are allowed here. Local
157    /// substrates say yes; deploy-from-ref substrates say no (§1).
158    fn supports_source_override(&self) -> bool;
159
160    /// Per-substrate lease default (§6).
161    fn default_lease(&self) -> Duration;
162
163    /// The origin `${services.X.origin}` resolves to for this substrate.
164    fn service_origin(&self, def: &StackDef, instance: &str, service: &str) -> String;
165
166    /// Build the interpolation namespace for one instance.
167    fn build_namespace(
168        &self,
169        def: &StackDef,
170        instance: &str,
171        prior: &[Checkpoint],
172        secrets: &BTreeMap<String, String>,
173        purpose: NamespacePurpose,
174    ) -> Namespace;
175
176    /// Execute one step, returning the resource for the journal.
177    async fn execute(&self, ctx: StepContext<'_>) -> Result<StepResource, SubstrateFault>;
178
179    /// Re-check a recorded resource against reality.
180    async fn observe(
181        &self,
182        instance: &str,
183        checkpoint: &Checkpoint,
184    ) -> Result<Observation, SubstrateFault>;
185
186    /// Destroy a recorded resource. Returning `Ok` is a claim the
187    /// engine immediately verifies with `observe` — silence is not
188    /// success (invariant 4).
189    async fn destroy(&self, instance: &str, checkpoint: &Checkpoint) -> Result<(), SubstrateFault>;
190
191    /// Substrate-wide cleanup after verified teardown (e.g. delete a
192    /// shared Stripe Projects environment).
193    async fn finalize_teardown(&self, _instance: &str) -> Result<(), SubstrateFault> {
194        Ok(())
195    }
196
197    /// Structured spend for `--json` envelopes (§4). Substrates that spend
198    /// nothing (local) return `None`.
199    async fn spend(&self) -> Option<SpendInfo> {
200        None
201    }
202
203    /// Human spend line after `up`/`down` (§4 — never silently nothing).
204    async fn spend_line(&self) -> Option<String> {
205        self.spend().await.map(|info| info.summary)
206    }
207
208    /// Recent logs for `services` (§2 — recent window, no streaming). `None`
209    /// means this substrate has no log facility (the daemon never saw the
210    /// processes and there is no remote log API); the CLI reports that rather
211    /// than inventing output.
212    async fn fetch_logs(
213        &self,
214        _def: &StackDef,
215        _instance: &str,
216        _services: &[String],
217        _tail: usize,
218    ) -> Result<Option<Vec<ServiceLog>>, SubstrateFault> {
219        Ok(None)
220    }
221}