stackless-core 0.2.0

Definition model, state store, and lifecycle engine for stackless
Documentation
//! The `Substrate` trait — the one provider seam (ARCHITECTURE.md §8).
//!
//! Core never names a substrate; providers implement this trait and the
//! binary registers them by name. Everything per-substrate flows
//! through here: validation, capabilities, defaults, and the resource
//! operations the lifecycle engine drives (execute / observe /
//! destroy). Adding a provider must require zero changes to the engine
//! or state modules.

use std::collections::BTreeMap;
use std::time::Duration;

use serde::Serialize;

use crate::def::{Namespace, StackDef};
use crate::engine::Step;
use crate::fault::{ErrorContext, Fault};
use crate::state::Checkpoint;

/// Structured spend data for cloud `--json` envelopes (§4).
#[derive(Debug, Clone, Serialize)]
pub struct SpendInfo {
    pub provider: String,
    pub cap_usd: u32,
    pub summary: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub data: Option<serde_json::Value>,
}

/// A substrate failure, flattened at the trait boundary so the §2
/// error contract (stable code + remediation) crosses it intact
/// whatever error enum the provider uses internally.
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
pub struct SubstrateFault {
    pub code: &'static str,
    pub message: String,
    pub remediation: String,
    pub context: Box<ErrorContext>,
}

impl SubstrateFault {
    pub fn from_fault(fault: &dyn Fault) -> Self {
        Self {
            code: fault.code(),
            message: fault.to_string(),
            remediation: fault.remediation(),
            context: Box::new(fault.context()),
        }
    }
}

impl Fault for SubstrateFault {
    fn code(&self) -> &'static str {
        self.code
    }

    fn remediation(&self) -> String {
        self.remediation.clone()
    }

    fn context(&self) -> ErrorContext {
        self.context.as_ref().clone()
    }
}

/// Steps that perform work but create no destructible resource (hooks,
/// health gates) record this kind; teardown drops their checkpoints
/// without a destroy/observe round-trip.
pub const ACTION_RESOURCE_KIND: &str = "action";

pub fn action_resource(step_id: &str) -> StepResource {
    StepResource {
        resource_kind: ACTION_RESOURCE_KIND.into(),
        resource_id: step_id.to_owned(),
        payload: "{}".into(),
    }
}

pub fn present_or_gone(present: bool) -> Observation {
    if present {
        Observation::Present
    } else {
        Observation::Gone
    }
}

/// Which env resolution path is building a namespace.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NamespacePurpose {
    /// Service runtime env (Render: internal DB URLs).
    ServiceEnv,
    /// Operator-side prepare hooks (Render: external DB URLs).
    OperatorPrepare,
    /// `stackless verify` env resolution.
    Verify,
}

/// What a recorded resource looks like when re-checked against the
/// substrate (invariant 4: the manifest says where to look, the
/// substrate says what's true).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Observation {
    Present,
    Gone,
}

/// One service's recent logs as a substrate retrieved them, for the `logs`
/// verb. The substrate owns where the lines come from (a cloud API, a local
/// file); the CLI only renders them.
#[derive(Debug, Clone)]
pub struct ServiceLog {
    pub service: String,
    /// Provenance tag for `--json` output (e.g. `"render_api"`, `"file"`).
    pub source: &'static str,
    /// Local log file path, when the lines were read from disk.
    pub log_path: Option<String>,
    pub lines: Vec<String>,
}

/// What `execute` hands back for the journal: the resource the step
/// created (or re-affirmed), recorded before the engine proceeds.
#[derive(Debug, Clone)]
pub struct StepResource {
    pub resource_kind: String,
    pub resource_id: String,
    /// Substrate-specific JSON needed to re-find the resource later.
    pub payload: String,
}

/// Everything a substrate gets to execute one step.
#[derive(Debug)]
pub struct StepContext<'a> {
    pub instance: &'a str,
    pub def: &'a StackDef,
    pub step: &'a Step,
    /// Recorded `--source` pins (service → path), local-only.
    pub source_overrides: &'a BTreeMap<String, String>,
    /// Snapshot `--source` pins into instance-owned space instead of using
    /// them in place.
    pub dirty: bool,
    /// Checkpoints recorded so far, in order — earlier steps' resources
    /// (ports, paths, connection strings) live here.
    pub prior: &'a [Checkpoint],
}

#[async_trait::async_trait]
pub trait Substrate: Send + Sync {
    /// The name instances are bound to at creation (`--on <name>`).
    fn name(&self) -> &str;

    /// Substrate-specific shape validation of the definition — core has
    /// already checked everything substrate-blind.
    fn validate_definition(&self, def: &StackDef) -> Result<(), SubstrateFault>;

    /// Whether `--source service=path` pins are allowed here. Local
    /// substrates say yes; deploy-from-ref substrates say no (§1).
    fn supports_source_override(&self) -> bool;

    /// Per-substrate lease default (§6).
    fn default_lease(&self) -> Duration;

    /// The origin `${services.X.origin}` resolves to for this substrate.
    fn service_origin(&self, def: &StackDef, instance: &str, service: &str) -> String;

    /// Build the interpolation namespace for one instance.
    fn build_namespace(
        &self,
        def: &StackDef,
        instance: &str,
        prior: &[Checkpoint],
        secrets: &BTreeMap<String, String>,
        purpose: NamespacePurpose,
    ) -> Namespace;

    /// Execute one step, returning the resource for the journal.
    async fn execute(&self, ctx: StepContext<'_>) -> Result<StepResource, SubstrateFault>;

    /// Re-check a recorded resource against reality.
    async fn observe(
        &self,
        instance: &str,
        checkpoint: &Checkpoint,
    ) -> Result<Observation, SubstrateFault>;

    /// Destroy a recorded resource. Returning `Ok` is a claim the
    /// engine immediately verifies with `observe` — silence is not
    /// success (invariant 4).
    async fn destroy(&self, instance: &str, checkpoint: &Checkpoint) -> Result<(), SubstrateFault>;

    /// Substrate-wide cleanup after verified teardown (e.g. delete a
    /// shared Stripe Projects environment).
    async fn finalize_teardown(&self, _instance: &str) -> Result<(), SubstrateFault> {
        Ok(())
    }

    /// Structured spend for `--json` envelopes (§4). Substrates that spend
    /// nothing (local) return `None`.
    async fn spend(&self) -> Option<SpendInfo> {
        None
    }

    /// Human spend line after `up`/`down` (§4 — never silently nothing).
    async fn spend_line(&self) -> Option<String> {
        self.spend().await.map(|info| info.summary)
    }

    /// Recent logs for `services` (§2 — recent window, no streaming). `None`
    /// means this substrate has no log facility (the daemon never saw the
    /// processes and there is no remote log API); the CLI reports that rather
    /// than inventing output.
    async fn fetch_logs(
        &self,
        _def: &StackDef,
        _instance: &str,
        _services: &[String],
        _tail: usize,
    ) -> Result<Option<Vec<ServiceLog>>, SubstrateFault> {
        Ok(None)
    }
}