Skip to main content

stackless_core/engine/
run.rs

1//! The lifecycle engine (§2): plan steps, checkpoint before
2//! proceeding, reconcile recorded state against observation. Shared by
3//! `up`, resume, daemon adoption, and the reaper — they are the same
4//! machinery.
5
6use std::collections::BTreeMap;
7use std::path::PathBuf;
8use std::time::{Duration, Instant};
9
10use serde::Serialize;
11
12use super::error::EngineError;
13use super::progress::{NullProgress, ProgressSink, StepProgress, StepProgressEvent, epoch_ms};
14
15use crate::def::{DefError, StackDef};
16use crate::state::{InstanceStatus, Store};
17use crate::substrate::{Observation, StepContext, Substrate};
18
19pub struct Engine<'a> {
20    pub store: &'a Store,
21    pub substrate: &'a dyn Substrate,
22}
23
24impl std::fmt::Debug for Engine<'_> {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.debug_struct("Engine")
27            .field("substrate", &self.substrate.name())
28            .finish_non_exhaustive()
29    }
30}
31
32pub struct UpRequest<'a> {
33    pub instance: &'a str,
34    /// The raw definition text, snapshotted at creation (invariant 1).
35    pub definition_text: &'a str,
36    pub def: &'a StackDef,
37    pub source_overrides: BTreeMap<String, String>,
38    /// `--dirty`: snapshot `--source` pins into instance-owned space.
39    pub dirty: bool,
40    /// Where the definition file lives (sibling secrets resolve here).
41    pub definition_dir: String,
42    /// `--lease`; defaults to the substrate's (§6).
43    pub lease: Option<Duration>,
44    /// Step progress telemetry; defaults to [`NullProgress`] when unset.
45    pub progress: Option<&'a mut dyn ProgressSink>,
46}
47
48impl std::fmt::Debug for UpRequest<'_> {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("UpRequest")
51            .field("instance", &self.instance)
52            .field("definition_dir", &self.definition_dir)
53            .field("lease", &self.lease)
54            .field("progress", &self.progress.is_some())
55            .finish_non_exhaustive()
56    }
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
60pub struct StepTiming {
61    pub id: String,
62    pub duration_ms: u64,
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
66pub struct UpOutcome {
67    pub executed: Vec<String>,
68    pub skipped: Vec<String>,
69    pub duration_ms: u64,
70    pub steps: Vec<StepTiming>,
71}
72
73#[derive(Debug, PartialEq, Eq)]
74pub enum DownOutcome {
75    /// Runtime and billable resources verifiably gone; tombstone left.
76    Destroyed,
77    /// The instance was already a tombstone.
78    AlreadyDown,
79}
80
81impl Engine<'_> {
82    /// Bring an instance up, resuming if it exists (invariant 3 — there
83    /// is no separate resume verb).
84    pub async fn up(&self, request: UpRequest<'_>) -> Result<UpOutcome, EngineError> {
85        if !crate::types::dns_safe(request.instance) {
86            return Err(DefError::NameInvalid {
87                kind: "instance",
88                name: request.instance.to_owned(),
89            }
90            .into());
91        }
92        request.def.validate_for_substrate(self.substrate.name())?;
93        self.substrate
94            .validate_definition(request.def)
95            .map_err(|fault| EngineError::SubstrateValidation {
96                substrate: self.substrate.name().to_owned(),
97                fault,
98            })?;
99        if !request.source_overrides.is_empty() && !self.substrate.supports_source_override() {
100            return Err(EngineError::SourceOverrideUnsupported {
101                substrate: self.substrate.name().to_owned(),
102            });
103        }
104        if !request.source_overrides.is_empty() {
105            self.check_source_override_collisions(
106                request.instance,
107                &request.source_overrides,
108                request.dirty,
109            )?;
110        }
111
112        // Resolve or create the record; the substrate is part of the
113        // instance's identity and is never asked for again (§2).
114        let mut source_overrides = request.source_overrides.clone();
115        let mut dirty = request.dirty;
116        match self.store.instance(request.instance)? {
117            Some(existing) if existing.substrate.as_str() != self.substrate.name() => {
118                return Err(EngineError::SubstrateMismatch {
119                    instance: request.instance.to_owned(),
120                    existing: existing.substrate.as_str().to_owned(),
121                    requested: self.substrate.name().to_owned(),
122                });
123            }
124            Some(existing) => {
125                if !request.source_overrides.is_empty() {
126                    self.store
127                        .update_source_overrides(request.instance, &request.source_overrides)?;
128                } else if existing.status == InstanceStatus::Active {
129                    // The pin was recorded at creation (§1); resume
130                    // honors it rather than re-deriving anything.
131                    source_overrides = existing.source_overrides.clone();
132                }
133                if request.dirty {
134                    self.store.update_dirty(request.instance, true)?;
135                    dirty = true;
136                } else if existing.status == InstanceStatus::Active {
137                    dirty = existing.dirty;
138                }
139                // `up` on a tombstone is a fresh birth under the old name.
140                if existing.status == InstanceStatus::Tombstoned {
141                    self.store.revive_instance(
142                        request.instance,
143                        request.definition_text,
144                        &request.source_overrides,
145                        request.dirty,
146                    )?;
147                    dirty = request.dirty;
148                }
149            }
150            None => {
151                match self.store.create_instance(
152                    request.instance,
153                    self.substrate.name(),
154                    request.definition_text,
155                    &request.source_overrides,
156                    &request.definition_dir,
157                    request.dirty,
158                ) {
159                    Ok(_) => {}
160                    // A concurrent up created it first; the lock claim
161                    // below arbitrates.
162                    Err(crate::state::StateError::InstanceExists {
163                        existing_substrate, ..
164                    }) if existing_substrate == self.substrate.name() => {}
165                    Err(err) => return Err(err.into()),
166                }
167            }
168        }
169
170        let claim = self.store.claim_lock(request.instance, "up")?;
171        let lease = request
172            .lease
173            .unwrap_or_else(|| self.substrate.default_lease());
174        self.store.renew_lease(request.instance, lease)?;
175
176        let mut request = request;
177        let result = self.run_steps(&mut request, &source_overrides, dirty).await;
178        self.store.release_lock(&claim)?;
179        let outcome = result?;
180        // A successful `up` renews again (§6).
181        self.store
182            .renew_lease_at_recorded_duration(request.instance)?;
183        Ok(outcome)
184    }
185
186    async fn run_steps(
187        &self,
188        request: &mut UpRequest<'_>,
189        source_overrides: &std::collections::BTreeMap<String, String>,
190        dirty: bool,
191    ) -> Result<UpOutcome, EngineError> {
192        let up_started = Instant::now();
193        let steps = request.def.plan()?;
194        let total = steps.len();
195        let mut null = NullProgress;
196        let progress = request.progress.as_deref_mut().unwrap_or(&mut null);
197        let mut outcome = UpOutcome::default();
198        for (offset, step) in steps.iter().enumerate() {
199            let index = offset + 1;
200            let step_started = Instant::now();
201            let progress_event =
202                |event: StepProgressEvent, code: Option<&'static str>| -> StepProgress {
203                    let duration_ms = match event {
204                        StepProgressEvent::Started => None,
205                        _ => Some(step_started.elapsed().as_millis() as u64),
206                    };
207                    StepProgress {
208                        event,
209                        instance: request.instance.to_owned(),
210                        step_id: step.id.clone(),
211                        step_kind: step.kind,
212                        node: step.node.clone(),
213                        index,
214                        total,
215                        code,
216                        at_epoch_ms: epoch_ms(),
217                        duration_ms,
218                    }
219                };
220            progress.on_step(progress_event(StepProgressEvent::Started, None));
221            // Resume reconciles against observation, not memory
222            // (invariant 4): a recorded step is only skipped if its
223            // resource is still really there.
224            if let Some(checkpoint) = self.store.checkpoint(request.instance, &step.id)? {
225                let observation = self
226                    .substrate
227                    .observe(request.instance, &checkpoint)
228                    .await
229                    .map_err(|fault| {
230                        progress
231                            .on_step(progress_event(StepProgressEvent::Failed, Some(fault.code)));
232                        EngineError::Step {
233                            instance: request.instance.to_owned(),
234                            step: step.id.clone(),
235                            fault,
236                        }
237                    })?;
238                if observation == Observation::Present {
239                    let elapsed = step_started.elapsed().as_millis() as u64;
240                    progress.on_step(progress_event(StepProgressEvent::Skipped, None));
241                    outcome.skipped.push(step.id.clone());
242                    outcome.steps.push(StepTiming {
243                        id: step.id.clone(),
244                        duration_ms: elapsed,
245                    });
246                    continue;
247                }
248            }
249            let prior = self.store.checkpoints(request.instance)?;
250            let resource = self
251                .substrate
252                .execute(StepContext {
253                    instance: request.instance,
254                    def: request.def,
255                    step,
256                    source_overrides,
257                    dirty,
258                    prior: &prior,
259                })
260                .await
261                .map_err(|fault| {
262                    progress.on_step(progress_event(StepProgressEvent::Failed, Some(fault.code)));
263                    EngineError::Step {
264                        instance: request.instance.to_owned(),
265                        step: step.id.clone(),
266                        fault,
267                    }
268                })?;
269            // Checkpoint before proceeding (§2/§4).
270            self.store.record_checkpoint(
271                request.instance,
272                &step.id,
273                &resource.resource_kind,
274                &resource.resource_id,
275                &resource.payload,
276            )?;
277            let elapsed = step_started.elapsed().as_millis() as u64;
278            progress.on_step(progress_event(StepProgressEvent::Completed, None));
279            outcome.executed.push(step.id.clone());
280            outcome.steps.push(StepTiming {
281                id: step.id.clone(),
282                duration_ms: elapsed,
283            });
284        }
285        outcome.duration_ms = up_started.elapsed().as_millis() as u64;
286        Ok(outcome)
287    }
288
289    /// Verified teardown, dependents-first (reverse journal order).
290    /// Exits with survivors listed if anything that bills or holds
291    /// state remains — the same path `down` and the reaper use.
292    pub async fn down(&self, instance: &str) -> Result<DownOutcome, EngineError> {
293        let record = self.store.instance(instance)?.ok_or_else(|| {
294            crate::state::StateError::InstanceNotFound {
295                name: instance.to_owned(),
296            }
297        })?;
298        if record.status == InstanceStatus::Tombstoned {
299            return Ok(DownOutcome::AlreadyDown);
300        }
301        if record.substrate.as_str() != self.substrate.name() {
302            return Err(EngineError::SubstrateMismatch {
303                instance: instance.to_owned(),
304                existing: record.substrate.as_str().to_owned(),
305                requested: self.substrate.name().to_owned(),
306            });
307        }
308
309        let claim = self.store.claim_lock(instance, "down")?;
310        let result = self.destroy_all(instance).await;
311        self.store.release_lock(&claim)?;
312        let survivors = result?;
313        if !survivors.is_empty() {
314            return Err(EngineError::TeardownSurvivors {
315                instance: instance.to_owned(),
316                survivors,
317            });
318        }
319        if let Err(fault) = self.substrate.finalize_teardown(instance).await {
320            return Err(EngineError::Step {
321                instance: instance.to_owned(),
322                step: "finalize_teardown".into(),
323                fault,
324            });
325        }
326        self.store.tombstone_instance(instance)?;
327        self.store.delete_lease(instance)?;
328        // A successful teardown clears any recorded reap failure —
329        // whether this `down` came from the reaper or the operator (§6).
330        self.store.clear_reap_failure(instance)?;
331        Ok(DownOutcome::Destroyed)
332    }
333
334    async fn destroy_all(&self, instance: &str) -> Result<Vec<String>, EngineError> {
335        let mut checkpoints = self.store.checkpoints(instance)?;
336        checkpoints.reverse();
337        let mut survivors = Vec::new();
338        for checkpoint in &checkpoints {
339            // Hooks and gates created nothing destructible.
340            if checkpoint.resource_kind == crate::substrate::ACTION_RESOURCE_KIND {
341                self.store
342                    .remove_checkpoint(instance, &checkpoint.step_id)?;
343                continue;
344            }
345            if self.substrate.destroy(instance, checkpoint).await.is_err() {
346                survivors.push(checkpoint.resource_id.clone());
347                continue;
348            }
349            // Destruction is confirmed by observation, never inferred
350            // from the absence of errors (invariant 4).
351            match self.substrate.observe(instance, checkpoint).await {
352                Ok(Observation::Gone) => {
353                    self.store
354                        .remove_checkpoint(instance, &checkpoint.step_id)?;
355                }
356                _ => survivors.push(checkpoint.resource_id.clone()),
357            }
358        }
359        Ok(survivors)
360    }
361
362    fn check_source_override_collisions(
363        &self,
364        instance: &str,
365        source_overrides: &BTreeMap<String, String>,
366        request_dirty: bool,
367    ) -> Result<(), EngineError> {
368        if request_dirty {
369            return Ok(());
370        }
371        let canonical_new: BTreeMap<String, PathBuf> = source_overrides
372            .iter()
373            .filter_map(|(service, path)| {
374                std::fs::canonicalize(path)
375                    .ok()
376                    .map(|canonical| (service.clone(), canonical))
377            })
378            .collect();
379        for record in self.store.instances()? {
380            if record.status != InstanceStatus::Active || record.name.as_str() == instance {
381                continue;
382            }
383            if record.dirty {
384                continue;
385            }
386            for (service, path) in &record.source_overrides {
387                let Some(want) = canonical_new.get(service) else {
388                    continue;
389                };
390                let Ok(have) = std::fs::canonicalize(path) else {
391                    continue;
392                };
393                if have == *want {
394                    return Err(EngineError::SourceOverrideShared {
395                        instance: instance.to_owned(),
396                        service: service.clone(),
397                        path: path.clone(),
398                        other: record.name.as_str().to_owned(),
399                    });
400                }
401            }
402        }
403        Ok(())
404    }
405}