Skip to main content

lightshuttle_runtime/lifecycle/
manager.rs

1//! Coordinated startup, supervision, and shutdown of every resource in a
2//! [`crate::LifecyclePlan`].
3//!
4//! The main type, [`LifecycleManager`], is generic over any
5//! [`crate::ContainerRuntime`] implementation. It spawns one `tokio` task per
6//! resource; each task waits for its dependencies to reach a ready state before
7//! calling `start` on the runtime. Status transitions are published on a
8//! `tokio::sync::watch` channel (consumed by peer tasks for ordering) and on a
9//! `tokio::sync::broadcast` channel (consumed by the CLI, dashboard, and tests
10//! via [`LifecycleManager::subscribe_events`]).
11//!
12//! ## Startup sequence (per resource)
13//!
14//! 1. Wait for every dependency to reach [`crate::NodeStatus::Running`] or
15//!    [`crate::NodeStatus::Healthy`].
16//! 2. Collect dependency outputs and resolve `${resources.*}` interpolations.
17//! 3. Inject `LSH_<DEP>_<PROPERTY>` environment variables automatically.
18//! 4. Remove any stale container with the same name.
19//! 5. Call [`crate::ContainerRuntime::start`].
20//! 6. Poll [`crate::ContainerRuntime::wait_healthy`] until healthy or timeout.
21//!
22//! ## Teardown
23//!
24//! Resources are stopped in reverse topological order. Each stop sends
25//! `SIGTERM` and waits up to the configured grace window before issuing
26//! `SIGKILL`. After all containers are removed, the per-project bridge network
27//! is torn down.
28
29use std::collections::HashMap;
30use std::sync::{Arc, Mutex};
31use std::time::{Duration, SystemTime};
32
33use lightshuttle_manifest::{InterpolationContext, Interpolator};
34use tokio::sync::{broadcast, watch};
35use tracing::{Instrument, debug, info, info_span, instrument, warn};
36
37/// Buffer size for the broadcast event channel. Slow subscribers that
38/// fall behind by more than this number of events will see lagged
39/// messages and have to resynchronise.
40const EVENT_CHANNEL_CAPACITY: usize = 256;
41
42use crate::error::RuntimeError;
43use crate::lifecycle::error::LifecycleError;
44use crate::lifecycle::plan::LifecyclePlan;
45use crate::lifecycle::status::{LifecycleEvent, NodeStatus};
46use crate::runtime::{ContainerId, ContainerRuntime};
47use lightshuttle_spec::{ContainerSpec, ResourceOutputs};
48
49/// Default healthcheck timeout, applied when the manifest does not
50/// provide one of its own. Kept conservative for v0.1.
51const DEFAULT_HEALTHCHECK_TIMEOUT: Duration = Duration::from_secs(60);
52
53/// Per-resource shared state.
54#[derive(Clone)]
55struct NodeHandle {
56    status_tx: Arc<watch::Sender<NodeStatus>>,
57    status_rx: watch::Receiver<NodeStatus>,
58    outputs_tx: Arc<watch::Sender<Option<ResourceOutputs>>>,
59    outputs_rx: watch::Receiver<Option<ResourceOutputs>>,
60    container_id: Arc<Mutex<Option<ContainerId>>>,
61    started_at: Arc<Mutex<Option<SystemTime>>>,
62}
63
64/// Point-in-time snapshot of one managed resource, consumed by the
65/// control plane via [`super::handle::ManagerHandle`].
66pub(super) struct NodeSnapshot {
67    /// Lifecycle status at the moment of the snapshot.
68    pub(super) status: NodeStatus,
69    /// Wall-clock time at which the runtime accepted the start request.
70    pub(super) started_at: Option<SystemTime>,
71    /// Container identifier returned by the runtime, when known.
72    pub(super) container_id: Option<ContainerId>,
73}
74
75/// Coordinates the startup, supervision, and shutdown of every resource
76/// declared in a [`LifecyclePlan`].
77///
78/// Construct with [`LifecycleManager::new`], optionally inject extra
79/// environment variables with [`LifecycleManager::with_env`], then call one of:
80///
81/// - [`LifecycleManager::start_all`]: start all resources and return.
82/// - [`LifecycleManager::run_until_signal`]: start all resources, block until
83///   `SIGINT` or `SIGTERM`, then stop cleanly (the typical `lightshuttle up`
84///   entry point).
85///
86/// # Example
87///
88/// ```rust,no_run
89/// use std::time::Duration;
90///
91/// use lightshuttle_manifest::Manifest;
92/// use lightshuttle_runtime::{LifecyclePlan, LifecycleManager};
93/// use lightshuttle_runtime::testkit::MockRuntime;
94///
95/// # #[tokio::main]
96/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
97/// let manifest = Manifest::parse(
98///     "project:\n  name: app\nresources:\n  db:\n    postgres:\n      version: \"16\"\n"
99/// )?;
100/// let plan = LifecyclePlan::from_manifest(&manifest)?;
101/// let (manager, mut events) = LifecycleManager::new(plan, MockRuntime::new());
102///
103/// manager.start_all().await?;
104/// manager.stop_all(Duration::from_secs(5)).await?;
105/// # Ok(())
106/// # }
107/// ```
108pub struct LifecycleManager<R: ContainerRuntime + 'static> {
109    plan: Arc<LifecyclePlan>,
110    runtime: Arc<R>,
111    nodes: HashMap<String, NodeHandle>,
112    event_tx: broadcast::Sender<LifecycleEvent>,
113    extra_env: Arc<HashMap<String, String>>,
114}
115
116impl<R: ContainerRuntime + 'static> LifecycleManager<R> {
117    /// Build a manager bound to `plan` and `runtime`. Returns a fresh
118    /// event subscriber alongside; further subscribers can be obtained
119    /// from [`Self::subscribe_events`].
120    #[must_use]
121    pub fn new(plan: LifecyclePlan, runtime: R) -> (Self, broadcast::Receiver<LifecycleEvent>) {
122        let (event_tx, event_rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
123        let mut nodes: HashMap<String, NodeHandle> = HashMap::new();
124        for node in plan.nodes() {
125            let (status_tx, status_rx) = watch::channel(NodeStatus::Pending);
126            let (outputs_tx, outputs_rx) = watch::channel(None);
127            nodes.insert(
128                node.name.clone(),
129                NodeHandle {
130                    status_tx: Arc::new(status_tx),
131                    status_rx,
132                    outputs_tx: Arc::new(outputs_tx),
133                    outputs_rx,
134                    container_id: Arc::new(Mutex::new(None)),
135                    started_at: Arc::new(Mutex::new(None)),
136                },
137            );
138        }
139        let manager = Self {
140            plan: Arc::new(plan),
141            runtime: Arc::new(runtime),
142            nodes,
143            event_tx,
144            extra_env: Arc::new(HashMap::new()),
145        };
146        (manager, event_rx)
147    }
148
149    /// Merge additional environment variables into the interpolation context
150    /// used for every resource.
151    ///
152    /// Variables provided here take precedence over same-named variables from
153    /// the ambient process environment. The typical use-case is forwarding the
154    /// contents of a `.env` file so that `${env.NAME}` references in the
155    /// manifest resolve to the file values. Call before [`Self::start_all`].
156    ///
157    /// Returns `self` for method chaining.
158    #[must_use]
159    pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
160        self.extra_env = Arc::new(env);
161        self
162    }
163
164    /// Scan every resource spec for `${env.VAR}` references that cannot be
165    /// resolved and return a single error listing all missing names.
166    ///
167    /// Delegates to [`LifecyclePlan::env_report`] so the fail-fast preflight
168    /// and the `lightshuttle secrets check` diagnostic command share one source
169    /// of truth. Call before [`Self::start_all`] to surface missing variables
170    /// before any container is started, which avoids a partial stack start
171    /// followed by an immediate rollback.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`crate::LifecycleError::MissingEnvVars`] with a sorted,
176    /// deduplicated list of every missing variable name.
177    pub fn check_required_env(&self) -> Result<(), LifecycleError> {
178        let report = self.plan.env_report(&self.extra_env);
179        if report.has_missing() {
180            Err(LifecycleError::MissingEnvVars {
181                names: report.missing(),
182            })
183        } else {
184            Ok(())
185        }
186    }
187
188    /// Start every resource in topological order, with independent branches
189    /// starting in parallel.
190    ///
191    /// Each resource waits for its dependencies to become ready (i.e. reach
192    /// [`crate::NodeStatus::Running`] or [`crate::NodeStatus::Healthy`]) before
193    /// calling [`crate::ContainerRuntime::start`]. Readiness is gate-kept by the
194    /// healthcheck: a container with a declared healthcheck must report
195    /// [`crate::ContainerStatus::Healthy`] before its dependents may proceed.
196    ///
197    /// On the first failure, every resource that has already started is stopped
198    /// automatically (best-effort, 10-second grace) before the error is
199    /// returned.
200    ///
201    /// # Errors
202    ///
203    /// Returns the first [`crate::LifecycleError`] encountered. Secondary
204    /// failures from the automatic rollback are logged but not returned.
205    pub async fn start_all(&self) -> Result<(), LifecycleError> {
206        let mut handles: Vec<tokio::task::JoinHandle<Result<(), LifecycleError>>> =
207            Vec::with_capacity(self.plan.nodes().len());
208
209        for node in self.plan.nodes() {
210            let mut dep_status_rxs: HashMap<String, watch::Receiver<NodeStatus>> = HashMap::new();
211            let mut dep_outputs_rxs: HashMap<String, watch::Receiver<Option<ResourceOutputs>>> =
212                HashMap::new();
213            for dep in &node.depends_on {
214                let handle = self
215                    .nodes
216                    .get(dep)
217                    .ok_or_else(|| LifecycleError::ResourceNotFound(dep.clone()))?;
218                dep_status_rxs.insert(dep.clone(), handle.status_rx.clone());
219                dep_outputs_rxs.insert(dep.clone(), handle.outputs_rx.clone());
220            }
221
222            let node_handle = self.nodes[&node.name].clone();
223            let spec = node.spec.clone();
224            let own_outputs = node.outputs.clone();
225            let name = node.name.clone();
226            let runtime = Arc::clone(&self.runtime);
227            let event_tx = self.event_tx.clone();
228            let extra_env = Arc::clone(&self.extra_env);
229
230            let task = tokio::spawn(async move {
231                start_one(
232                    name,
233                    spec,
234                    own_outputs,
235                    runtime,
236                    node_handle,
237                    dep_status_rxs,
238                    dep_outputs_rxs,
239                    event_tx,
240                    extra_env,
241                )
242                .await
243            });
244            handles.push(task);
245        }
246
247        let mut first_error: Option<LifecycleError> = None;
248        for handle in handles {
249            match handle.await {
250                Ok(Ok(())) => {}
251                Ok(Err(err)) => {
252                    if first_error.is_none() {
253                        first_error = Some(err);
254                    }
255                }
256                Err(join_err) => {
257                    if first_error.is_none() {
258                        first_error = Some(LifecycleError::Start {
259                            resource: "<panicked task>".to_owned(),
260                            source: RuntimeError::InvalidSpec(join_err.to_string()),
261                        });
262                    }
263                }
264            }
265        }
266
267        if let Some(err) = first_error {
268            warn!(error = %err, "start_all failed; rolling back");
269            let _ = self.stop_all(Duration::from_secs(10)).await;
270            return Err(err);
271        }
272
273        let _ = self.event_tx.send(LifecycleEvent::StackStarted);
274        info!(
275            "stack started: {} resource(s) healthy",
276            self.plan.nodes().len()
277        );
278        Ok(())
279    }
280
281    /// Stop every resource in reverse topological order.
282    ///
283    /// Each resource receives `SIGTERM`. After `grace` elapses, the runtime
284    /// sends `SIGKILL` to any container that has not exited yet. Resources are
285    /// stopped in the reverse of startup order (dependents before their
286    /// dependencies). After all containers are removed, the per-project bridge
287    /// network is torn down (failure is logged but does not abort the call).
288    ///
289    /// # Errors
290    ///
291    /// Returns the first [`crate::LifecycleError::Stop`] encountered. Other
292    /// stop failures are logged but not propagated.
293    #[instrument(skip_all, fields(resources = self.plan.nodes().len()))]
294    pub async fn stop_all(&self, grace: Duration) -> Result<(), LifecycleError> {
295        let _ = self.event_tx.send(LifecycleEvent::StackStopping);
296
297        let mut errors: Vec<(String, RuntimeError)> = Vec::new();
298        for node in self.plan.nodes().iter().rev() {
299            let Some(handle) = self.nodes.get(&node.name) else {
300                continue;
301            };
302            let id = {
303                let guard = handle
304                    .container_id
305                    .lock()
306                    .expect("container_id mutex poisoned");
307                guard.clone()
308            };
309            let Some(id) = id else { continue };
310            let stop_span = info_span!("stop", resource = %node.name);
311            match self.runtime.stop(&id, grace).instrument(stop_span).await {
312                Ok(()) => {
313                    let _ = handle.status_tx.send(NodeStatus::Stopped);
314                    let _ = self.event_tx.send(LifecycleEvent::ResourceStopped {
315                        name: node.name.clone(),
316                    });
317                }
318                Err(e) => errors.push((node.name.clone(), e)),
319            }
320        }
321
322        let _ = self.event_tx.send(LifecycleEvent::StackStopped);
323
324        // Remove the per-project bridge network. Containers that failed
325        // to stop may still hold endpoints, causing Docker to reject the
326        // request: log the failure and continue so callers always see
327        // the primary stop errors, not a secondary network error.
328        if let Some(project) = self.plan.nodes().first().map(|n| n.spec.project.as_str()) {
329            if let Err(e) = self.runtime.teardown_project_network(project).await {
330                warn!(error = %e, "could not remove project network");
331            }
332        }
333
334        if let Some((resource, source)) = errors.into_iter().next() {
335            return Err(LifecycleError::Stop { resource, source });
336        }
337        Ok(())
338    }
339
340    /// Start the stack, wait for `SIGINT` or `SIGTERM`, then stop cleanly.
341    ///
342    /// This is the opinionated entry point for the `lightshuttle up` command.
343    /// It calls [`Self::start_all`], blocks until a shutdown signal is received,
344    /// then calls [`Self::stop_all`] with the provided `grace` window.
345    ///
346    /// On Unix, both `SIGINT` (Ctrl+C) and `SIGTERM` trigger the teardown.
347    /// On Windows, only `Ctrl+C` is intercepted.
348    ///
349    /// # Example
350    ///
351    /// ```rust,no_run
352    /// use std::time::Duration;
353    ///
354    /// use lightshuttle_manifest::Manifest;
355    /// use lightshuttle_runtime::{DockerRuntime, LifecyclePlan, LifecycleManager};
356    ///
357    /// # #[tokio::main]
358    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
359    /// let manifest = Manifest::parse(
360    ///     "project:\n  name: app\nresources:\n  db:\n    postgres:\n      version: \"16\"\n"
361    /// )?;
362    /// let plan = LifecyclePlan::from_manifest(&manifest)?;
363    /// let runtime = DockerRuntime::connect()?;
364    /// let (manager, _events) = LifecycleManager::new(plan, runtime);
365    ///
366    /// // Blocks until Ctrl+C or SIGTERM.
367    /// manager.run_until_signal(Duration::from_secs(30)).await?;
368    /// # Ok(())
369    /// # }
370    /// ```
371    ///
372    /// # Errors
373    ///
374    /// Propagates errors from [`Self::start_all`] or [`Self::stop_all`].
375    pub async fn run_until_signal(&self, grace: Duration) -> Result<(), LifecycleError> {
376        self.start_all().await?;
377        wait_for_shutdown_signal().await;
378        self.stop_all(grace).await
379    }
380
381    /// Restart a single resource without touching its dependents.
382    ///
383    /// The target is stopped via `SIGTERM` (10-second grace window), its
384    /// container id and started-at timestamp are cleared, then the full
385    /// `start_one` cycle is re-run from the same cached spec. Three events are
386    /// emitted on the lifecycle channel in order: [`crate::LifecycleEvent::ResourceStopped`],
387    /// [`crate::LifecycleEvent::ResourceStarted`], [`crate::LifecycleEvent::ResourceHealthy`].
388    ///
389    /// Dependents keep running. Their internal `watch` channels observe the
390    /// target's status transition through `Stopped` -> `Pending` -> `Starting`
391    /// -> `Running` -> `Healthy`, so upstream processes that hold a watch
392    /// receiver can pause themselves locally until the dependency is healthy
393    /// again.
394    ///
395    /// # Errors
396    ///
397    /// Returns [`crate::LifecycleError::ResourceNotFound`] when `resource` is
398    /// not part of the plan, or a [`crate::LifecycleError::Start`] /
399    /// [`crate::LifecycleError::Stop`] variant on runtime failure.
400    #[instrument(skip(self), fields(resource = %resource))]
401    pub async fn restart_one(&self, resource: &str) -> Result<(), LifecycleError> {
402        let node = self
403            .plan
404            .nodes()
405            .iter()
406            .find(|n| n.name == resource)
407            .ok_or_else(|| LifecycleError::ResourceNotFound(resource.to_owned()))?;
408        let handle = self
409            .nodes
410            .get(resource)
411            .ok_or_else(|| LifecycleError::ResourceNotFound(resource.to_owned()))?;
412
413        // Stop the running container if any.
414        let id = {
415            let guard = handle
416                .container_id
417                .lock()
418                .expect("container_id mutex poisoned");
419            guard.clone()
420        };
421        if let Some(id) = id {
422            self.runtime
423                .stop(&id, Duration::from_secs(10))
424                .await
425                .map_err(|source| LifecycleError::Stop {
426                    resource: resource.to_owned(),
427                    source,
428                })?;
429            *handle
430                .container_id
431                .lock()
432                .expect("container_id mutex poisoned") = None;
433            *handle.started_at.lock().expect("started_at mutex poisoned") = None;
434            let _ = handle.status_tx.send(NodeStatus::Stopped);
435            let _ = self.event_tx.send(LifecycleEvent::ResourceStopped {
436                name: resource.to_owned(),
437            });
438        }
439
440        // Reset to Pending so start_one drives the full restart cycle.
441        let _ = handle.status_tx.send(NodeStatus::Pending);
442
443        // Collect dependency watch receivers. Deps are already Healthy,
444        // so start_one's wait loop returns instantly.
445        let mut dep_status_rxs: HashMap<String, watch::Receiver<NodeStatus>> = HashMap::new();
446        let mut dep_outputs_rxs: HashMap<String, watch::Receiver<Option<ResourceOutputs>>> =
447            HashMap::new();
448        for dep in &node.depends_on {
449            let dep_handle = self
450                .nodes
451                .get(dep)
452                .ok_or_else(|| LifecycleError::ResourceNotFound(dep.clone()))?;
453            dep_status_rxs.insert(dep.clone(), dep_handle.status_rx.clone());
454            dep_outputs_rxs.insert(dep.clone(), dep_handle.outputs_rx.clone());
455        }
456
457        start_one(
458            resource.to_owned(),
459            node.spec.clone(),
460            node.outputs.clone(),
461            Arc::clone(&self.runtime),
462            handle.clone(),
463            dep_status_rxs,
464            dep_outputs_rxs,
465            self.event_tx.clone(),
466            Arc::clone(&self.extra_env),
467        )
468        .await
469    }
470
471    /// Open a new subscription on the lifecycle event broadcast.
472    ///
473    /// Multiple subscribers can read concurrently. Subscribers that
474    /// fall more than 256 events behind (the broadcast channel capacity)
475    /// observe a `RecvError::Lagged` and have to resynchronise.
476    #[must_use]
477    pub fn subscribe_events(&self) -> broadcast::Receiver<LifecycleEvent> {
478        self.event_tx.subscribe()
479    }
480
481    /// Shared reference to the underlying execution plan.
482    pub(super) fn plan_arc(&self) -> &Arc<LifecyclePlan> {
483        &self.plan
484    }
485
486    /// Shared reference to the underlying container runtime.
487    pub(super) fn runtime_arc(&self) -> &Arc<R> {
488        &self.runtime
489    }
490
491    /// Point-in-time snapshot of one resource, or `None` when the name
492    /// is not part of the plan.
493    pub(super) fn snapshot(&self, name: &str) -> Option<NodeSnapshot> {
494        let handle = self.nodes.get(name)?;
495        let status = handle.status_rx.borrow().clone();
496        let started_at = *handle.started_at.lock().expect("started_at mutex poisoned");
497        let container_id = handle
498            .container_id
499            .lock()
500            .expect("container_id mutex poisoned")
501            .clone();
502        Some(NodeSnapshot {
503            status,
504            started_at,
505            container_id,
506        })
507    }
508}
509
510#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
511#[instrument(name = "start", skip_all, fields(resource = %name))]
512async fn start_one<R: ContainerRuntime + 'static>(
513    name: String,
514    spec: ContainerSpec,
515    own_outputs: ResourceOutputs,
516    runtime: Arc<R>,
517    handle: NodeHandle,
518    dep_status_rxs: HashMap<String, watch::Receiver<NodeStatus>>,
519    mut dep_outputs_rxs: HashMap<String, watch::Receiver<Option<ResourceOutputs>>>,
520    event_tx: broadcast::Sender<LifecycleEvent>,
521    extra_env: Arc<HashMap<String, String>>,
522) -> Result<(), LifecycleError> {
523    // 1. Wait for every dependency to become ready.
524    for (dep_name, mut rx) in dep_status_rxs {
525        loop {
526            let status = rx.borrow_and_update().clone();
527            if status.is_ready() {
528                debug!(node = %name, dep = %dep_name, "dependency ready");
529                break;
530            }
531            if let NodeStatus::Failed { reason } = status {
532                let _ = handle.status_tx.send(NodeStatus::Failed {
533                    reason: format!("dependency `{dep_name}` failed: {reason}"),
534                });
535                return Err(LifecycleError::DependencyFailed {
536                    resource: name,
537                    dependency: dep_name,
538                    reason,
539                });
540            }
541            if rx.changed().await.is_err() {
542                let reason = format!("dependency `{dep_name}` watch channel closed");
543                let _ = handle.status_tx.send(NodeStatus::Failed {
544                    reason: reason.clone(),
545                });
546                return Err(LifecycleError::DependencyFailed {
547                    resource: name,
548                    dependency: dep_name,
549                    reason,
550                });
551            }
552        }
553    }
554
555    // 2. Collect dependency outputs.
556    let mut dep_outputs: HashMap<String, ResourceOutputs> = HashMap::new();
557    for (dep_name, rx) in &mut dep_outputs_rxs {
558        loop {
559            if let Some(out) = rx.borrow_and_update().clone() {
560                dep_outputs.insert(dep_name.clone(), out);
561                break;
562            }
563            if rx.changed().await.is_err() {
564                let reason = format!("dependency `{dep_name}` outputs channel closed");
565                let _ = handle.status_tx.send(NodeStatus::Failed {
566                    reason: reason.clone(),
567                });
568                return Err(LifecycleError::DependencyFailed {
569                    resource: name,
570                    dependency: dep_name.clone(),
571                    reason,
572                });
573            }
574        }
575    }
576
577    // 3. Resolve interpolations and inject LSH_<DEP>_<PROP> env vars.
578    let resolved_spec = match interpolate_and_inject(spec, &dep_outputs, &extra_env) {
579        Ok(s) => s,
580        Err(reason) => {
581            let _ = handle.status_tx.send(NodeStatus::Failed {
582                reason: reason.clone(),
583            });
584            return Err(LifecycleError::Start {
585                resource: name,
586                source: RuntimeError::InvalidSpec(reason),
587            });
588        }
589    };
590
591    // 4. Remove any container left over from a previous run so the
592    //    create call below never collides with a stale name.
593    let _ = handle.status_tx.send(NodeStatus::Starting);
594    if let Err(source) = runtime.remove(&resolved_spec.name).await {
595        let _ = handle.status_tx.send(NodeStatus::Failed {
596            reason: source.to_string(),
597        });
598        let _ = event_tx.send(LifecycleEvent::ResourceFailed {
599            name: name.clone(),
600            error: source.to_string(),
601        });
602        return Err(LifecycleError::Start {
603            resource: name,
604            source,
605        });
606    }
607
608    // 5. Start the container.
609    let id = match runtime.start(&resolved_spec).await {
610        Ok(id) => id,
611        Err(source) => {
612            let _ = handle.status_tx.send(NodeStatus::Failed {
613                reason: source.to_string(),
614            });
615            let _ = event_tx.send(LifecycleEvent::ResourceFailed {
616                name: name.clone(),
617                error: source.to_string(),
618            });
619            return Err(LifecycleError::Start {
620                resource: name,
621                source,
622            });
623        }
624    };
625
626    {
627        let mut guard = handle
628            .container_id
629            .lock()
630            .expect("container_id mutex poisoned");
631        *guard = Some(id.clone());
632    }
633    {
634        let mut guard = handle.started_at.lock().expect("started_at mutex poisoned");
635        *guard = Some(SystemTime::now());
636    }
637    let _ = handle.status_tx.send(NodeStatus::Running);
638    let _ = event_tx.send(LifecycleEvent::ResourceStarted {
639        name: name.clone(),
640        container_id: id.to_string(),
641    });
642
643    // 6. Wait for the healthcheck.
644    let wait_span = info_span!("wait_healthy", resource = %name);
645    match runtime
646        .wait_healthy(&id, DEFAULT_HEALTHCHECK_TIMEOUT)
647        .instrument(wait_span)
648        .await
649    {
650        Ok(()) => {
651            let _ = handle.outputs_tx.send(Some(own_outputs));
652            let _ = handle.status_tx.send(NodeStatus::Healthy);
653            let _ = event_tx.send(LifecycleEvent::ResourceHealthy { name: name.clone() });
654            Ok(())
655        }
656        Err(RuntimeError::Timeout { .. }) => {
657            let reason = format!("healthcheck timed out after {DEFAULT_HEALTHCHECK_TIMEOUT:?}");
658            let _ = handle.status_tx.send(NodeStatus::Failed {
659                reason: reason.clone(),
660            });
661            let _ = event_tx.send(LifecycleEvent::ResourceFailed {
662                name: name.clone(),
663                error: reason,
664            });
665            Err(LifecycleError::HealthcheckTimeout {
666                resource: name,
667                timeout: DEFAULT_HEALTHCHECK_TIMEOUT,
668            })
669        }
670        Err(source) => {
671            let _ = handle.status_tx.send(NodeStatus::Failed {
672                reason: source.to_string(),
673            });
674            let _ = event_tx.send(LifecycleEvent::ResourceFailed {
675                name: name.clone(),
676                error: source.to_string(),
677            });
678            Err(LifecycleError::Start {
679                resource: name,
680                source,
681            })
682        }
683    }
684}
685
686/// Apply two-pass interpolation to `spec`: resolve every
687/// `${resources.<name>.<property>}` against `dep_outputs`, then inject
688/// `LSH_<DEP>_<PROPERTY>` automatic environment variables.
689///
690/// Returns the resolved spec or a human-readable diagnostic when an
691/// interpolation references an unknown resource or property.
692fn interpolate_and_inject(
693    mut spec: ContainerSpec,
694    dep_outputs: &HashMap<String, ResourceOutputs>,
695    extra_env: &HashMap<String, String>,
696) -> std::result::Result<ContainerSpec, String> {
697    let mut ctx = InterpolationContext::from_env()
698        .with_env(extra_env.iter().map(|(k, v)| (k.clone(), v.clone())));
699    for (name, outputs) in dep_outputs {
700        ctx = ctx.with_resource(name.clone(), outputs.clone());
701    }
702    let interpolator = Interpolator::new(&ctx);
703
704    // Resolve env values.
705    let mut resolved_env = std::collections::HashMap::with_capacity(spec.env.len());
706    for (k, v) in spec.env.drain() {
707        let resolved = interpolator.resolve(&v).map_err(|e| e.to_string())?;
708        resolved_env.insert(k, resolved);
709    }
710
711    // Inject LSH_<DEP>_<PROPERTY> variables.
712    for (dep_name, outputs) in dep_outputs {
713        let dep_upper = dep_name.to_uppercase().replace('-', "_");
714        for (prop, value) in outputs {
715            let prop_upper = prop.to_uppercase().replace('-', "_");
716            let key = format!("LSH_{dep_upper}_{prop_upper}");
717            resolved_env.entry(key).or_insert_with(|| value.clone());
718        }
719    }
720    spec.env = resolved_env;
721
722    // Resolve command arguments.
723    if let Some(args) = spec.command.as_mut() {
724        for arg in args.iter_mut() {
725            *arg = interpolator.resolve(arg).map_err(|e| e.to_string())?;
726        }
727    }
728
729    Ok(spec)
730}
731
732#[cfg(unix)]
733async fn wait_for_shutdown_signal() {
734    use tokio::signal::unix::{SignalKind, signal};
735    let mut sigterm = match signal(SignalKind::terminate()) {
736        Ok(s) => s,
737        Err(e) => {
738            warn!("failed to install SIGTERM handler: {e}");
739            let _ = tokio::signal::ctrl_c().await;
740            return;
741        }
742    };
743    tokio::select! {
744        _ = tokio::signal::ctrl_c() => info!("received SIGINT"),
745        _ = sigterm.recv() => info!("received SIGTERM"),
746    }
747}
748
749#[cfg(windows)]
750async fn wait_for_shutdown_signal() {
751    let _ = tokio::signal::ctrl_c().await;
752    info!("received Ctrl+C");
753}