Skip to main content

lightshuttle_runtime/lifecycle/
handle.rs

1//! Control-plane facing handle: a stable, backend-agnostic seam over
2//! [`crate::LifecycleManager`].
3//!
4//! The [`LifecycleHandle`] trait exposes only the operations the
5//! dashboard, REST API and CLI subcommands need. The concrete
6//! [`ManagerHandle`] adapter wraps an `Arc<LifecycleManager<R>>` and
7//! erases nothing of substance: the trait stays generic so its callers
8//! pay zero allocation per call.
9
10use std::sync::Arc;
11
12use thiserror::Error;
13use tokio::sync::broadcast;
14
15use crate::error::RuntimeError;
16use crate::lifecycle::manager::LifecycleManager;
17use crate::lifecycle::status::LifecycleEvent;
18use crate::lifecycle::view::{ResourceStatus, ResourceView, image_label, last_error_from};
19use crate::runtime::{ContainerRuntime, LogChunkStream};
20
21/// Errors returned by [`LifecycleHandle`] operations.
22#[derive(Debug, Error)]
23pub enum LifecycleHandleError {
24    /// The requested resource does not exist in the current plan.
25    #[error("resource `{0}` does not exist in the current plan")]
26    UnknownResource(String),
27    /// The handle does not support this operation yet (e.g. `restart`
28    /// before the `restart_one` primitive lands in the manager).
29    #[error("operation `{0}` is not supported by this handle yet")]
30    NotSupported(&'static str),
31    /// Underlying runtime error.
32    #[error(transparent)]
33    Runtime(#[from] RuntimeError),
34}
35
36/// Control-plane facing view of a running stack.
37///
38/// Implementations expose just enough to drive a dashboard, REST API
39/// and CLI subcommands without leaking any backend type.
40pub trait LifecycleHandle: Send + Sync {
41    /// List every resource managed by this stack with its current view.
42    fn list(
43        &self,
44    ) -> impl std::future::Future<Output = Result<Vec<ResourceView>, LifecycleHandleError>> + Send;
45
46    /// Look up a single resource by name.
47    fn get(
48        &self,
49        name: &str,
50    ) -> impl std::future::Future<Output = Result<ResourceView, LifecycleHandleError>> + Send;
51
52    /// Restart a single resource by name.
53    fn restart(
54        &self,
55        name: &str,
56    ) -> impl std::future::Future<Output = Result<(), LifecycleHandleError>> + Send;
57
58    /// Stream logs for a single resource. When `follow` is true the
59    /// stream stays open and emits new chunks as they arrive.
60    fn logs(
61        &self,
62        name: &str,
63        follow: bool,
64    ) -> impl std::future::Future<Output = Result<LogChunkStream, LifecycleHandleError>> + Send;
65
66    /// Open a fresh subscription on the lifecycle event broadcast.
67    /// Implementations return a `broadcast::Receiver` so multiple
68    /// consumers (REST handlers, WebSocket sessions, CLI followers)
69    /// can read independently.
70    fn subscribe_events(&self) -> broadcast::Receiver<LifecycleEvent>;
71}
72
73/// Newtype adapter turning an `Arc<LifecycleManager<R>>` into a
74/// [`LifecycleHandle`].
75pub struct ManagerHandle<R: ContainerRuntime + 'static> {
76    inner: Arc<LifecycleManager<R>>,
77}
78
79// Manual `Clone` impl: the derived one would require `R: Clone`, but
80// the only field is an `Arc`, so cloning a `ManagerHandle` never has
81// to clone `R` itself.
82impl<R: ContainerRuntime + 'static> Clone for ManagerHandle<R> {
83    fn clone(&self) -> Self {
84        Self {
85            inner: Arc::clone(&self.inner),
86        }
87    }
88}
89
90impl<R: ContainerRuntime + 'static> ManagerHandle<R> {
91    /// Wrap a shared manager.
92    #[must_use]
93    pub fn new(inner: Arc<LifecycleManager<R>>) -> Self {
94        Self { inner }
95    }
96
97    /// Borrow the underlying manager.
98    #[must_use]
99    pub fn manager(&self) -> &Arc<LifecycleManager<R>> {
100        &self.inner
101    }
102}
103
104impl<R: ContainerRuntime + 'static> LifecycleHandle for ManagerHandle<R> {
105    async fn list(&self) -> Result<Vec<ResourceView>, LifecycleHandleError> {
106        let plan = self.inner.plan_arc();
107        let mut out: Vec<ResourceView> = Vec::with_capacity(plan.nodes().len());
108        for node in plan.nodes() {
109            let snapshot = self
110                .inner
111                .snapshot(&node.name)
112                .ok_or_else(|| LifecycleHandleError::UnknownResource(node.name.clone()))?;
113            out.push(ResourceView {
114                name: node.name.clone(),
115                kind: node.kind.clone(),
116                status: ResourceStatus::from(&snapshot.status),
117                healthy: matches!(
118                    snapshot.status,
119                    crate::lifecycle::status::NodeStatus::Healthy
120                ),
121                image: image_label(&node.spec.image),
122                started_at: snapshot.started_at,
123                last_error: last_error_from(&snapshot.status),
124            });
125        }
126        Ok(out)
127    }
128
129    async fn get(&self, name: &str) -> Result<ResourceView, LifecycleHandleError> {
130        let plan = self.inner.plan_arc();
131        let node = plan
132            .nodes()
133            .iter()
134            .find(|n| n.name == name)
135            .ok_or_else(|| LifecycleHandleError::UnknownResource(name.to_owned()))?;
136        let snapshot = self
137            .inner
138            .snapshot(name)
139            .ok_or_else(|| LifecycleHandleError::UnknownResource(name.to_owned()))?;
140        Ok(ResourceView {
141            name: node.name.clone(),
142            kind: node.kind.clone(),
143            status: ResourceStatus::from(&snapshot.status),
144            healthy: matches!(
145                snapshot.status,
146                crate::lifecycle::status::NodeStatus::Healthy
147            ),
148            image: image_label(&node.spec.image),
149            started_at: snapshot.started_at,
150            last_error: last_error_from(&snapshot.status),
151        })
152    }
153
154    async fn restart(&self, name: &str) -> Result<(), LifecycleHandleError> {
155        self.inner.restart_one(name).await.map_err(|err| match err {
156            crate::LifecycleError::ResourceNotFound(name) => {
157                LifecycleHandleError::UnknownResource(name)
158            }
159            crate::LifecycleError::Start { source, .. }
160            | crate::LifecycleError::Stop { source, .. }
161            | crate::LifecycleError::SpecBuild { source, .. } => {
162                LifecycleHandleError::Runtime(source)
163            }
164            other => LifecycleHandleError::Runtime(RuntimeError::InvalidSpec(other.to_string())),
165        })
166    }
167
168    async fn logs(&self, name: &str, follow: bool) -> Result<LogChunkStream, LifecycleHandleError> {
169        let plan = self.inner.plan_arc();
170        if !plan.nodes().iter().any(|n| n.name == name) {
171            return Err(LifecycleHandleError::UnknownResource(name.to_owned()));
172        }
173        let snapshot = self
174            .inner
175            .snapshot(name)
176            .ok_or_else(|| LifecycleHandleError::UnknownResource(name.to_owned()))?;
177        let container_id = snapshot.container_id.ok_or_else(|| {
178            LifecycleHandleError::Runtime(RuntimeError::InvalidSpec(format!(
179                "resource `{name}` is not running"
180            )))
181        })?;
182        let stream = self.inner.runtime_arc().logs(&container_id, follow).await?;
183        Ok(stream)
184    }
185
186    fn subscribe_events(&self) -> broadcast::Receiver<LifecycleEvent> {
187        self.inner.subscribe_events()
188    }
189}