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}