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 dashboard,
5//! REST API, and CLI subcommands need. The concrete [`ManagerHandle`] adapter
6//! wraps an `Arc<LifecycleManager<R>>` and erases nothing of substance: the
7//! trait stays generic so callers pay zero allocation per call.
8//!
9//! The indirection also makes it possible to inject a test double for the
10//! entire control plane without requiring a real [`crate::ContainerRuntime`].
11//!
12//! # Example
13//!
14//! ```rust,no_run
15//! use std::sync::Arc;
16//! use lightshuttle_runtime::{LifecycleHandle, LifecyclePlan, LifecycleManager, ManagerHandle};
17//! use lightshuttle_runtime::testkit::MockRuntime;
18//! use lightshuttle_manifest::Manifest;
19//!
20//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
21//! let manifest = Manifest::parse("project:\n  name: app\nresources: {}")?;
22//! let plan = LifecyclePlan::from_manifest(&manifest)?;
23//! let (manager, _events) = LifecycleManager::new(plan, MockRuntime::new());
24//! let handle = ManagerHandle::new(Arc::new(manager));
25//!
26//! let resources = handle.list().await?;
27//! println!("{} resource(s)", resources.len());
28//! # Ok(())
29//! # }
30//! ```
31
32use std::sync::Arc;
33
34use thiserror::Error;
35use tokio::sync::broadcast;
36
37use crate::error::RuntimeError;
38use crate::lifecycle::manager::LifecycleManager;
39use crate::lifecycle::status::LifecycleEvent;
40use crate::lifecycle::view::{ResourceStatus, ResourceView, image_label, last_error_from};
41use crate::runtime::{ContainerRuntime, LogChunkStream};
42
43/// Errors returned by [`LifecycleHandle`] operations.
44#[derive(Debug, Error)]
45pub enum LifecycleHandleError {
46    /// The requested resource does not exist in the current plan.
47    #[error("resource `{0}` does not exist in the current plan")]
48    UnknownResource(String),
49    /// The handle does not support this operation yet (e.g. `restart`
50    /// before the `restart_one` primitive lands in the manager).
51    #[error("operation `{0}` is not supported by this handle yet")]
52    NotSupported(&'static str),
53    /// Underlying runtime error.
54    #[error(transparent)]
55    Runtime(#[from] RuntimeError),
56}
57
58/// Control-plane facing view of a running stack.
59///
60/// Implementations expose just enough to drive a dashboard, REST API, and CLI
61/// subcommands without leaking any backend type. The concrete implementation
62/// shipped with this crate is [`ManagerHandle`].
63///
64/// Every async method returns [`LifecycleHandleError`] on failure.
65pub trait LifecycleHandle: Send + Sync {
66    /// Return a snapshot of every resource managed by this stack.
67    ///
68    /// The returned [`ResourceView`] values are ordered by the topological
69    /// plan order (dependencies before dependents).
70    fn list(
71        &self,
72    ) -> impl std::future::Future<Output = Result<Vec<ResourceView>, LifecycleHandleError>> + Send;
73
74    /// Look up a single resource by its manifest-declared name.
75    ///
76    /// Returns [`LifecycleHandleError::UnknownResource`] when the name is not
77    /// part of the current plan.
78    fn get(
79        &self,
80        name: &str,
81    ) -> impl std::future::Future<Output = Result<ResourceView, LifecycleHandleError>> + Send;
82
83    /// Restart a single resource by its manifest-declared name.
84    ///
85    /// Delegates to [`crate::LifecycleManager::restart_one`]. Dependents keep
86    /// running. Returns [`LifecycleHandleError::UnknownResource`] when the name
87    /// is not part of the current plan.
88    fn restart(
89        &self,
90        name: &str,
91    ) -> impl std::future::Future<Output = Result<(), LifecycleHandleError>> + Send;
92
93    /// Stream logs for a single resource by its manifest-declared name.
94    ///
95    /// When `follow` is `true` the stream stays open and emits new chunks as
96    /// they arrive; when `false` the stream completes after existing logs are
97    /// drained. Returns [`LifecycleHandleError::UnknownResource`] when the
98    /// name is not part of the plan, or a [`LifecycleHandleError::Runtime`]
99    /// variant when the container is not yet running.
100    fn logs(
101        &self,
102        name: &str,
103        follow: bool,
104    ) -> impl std::future::Future<Output = Result<LogChunkStream, LifecycleHandleError>> + Send;
105
106    /// Open a fresh subscription on the lifecycle event broadcast channel.
107    ///
108    /// Multiple consumers (REST handlers, WebSocket sessions, CLI progress bars)
109    /// can hold independent receivers. A receiver that falls more than the
110    /// channel capacity behind will observe a `RecvError::Lagged` and must
111    /// resynchronise by calling [`LifecycleHandle::list`].
112    fn subscribe_events(&self) -> broadcast::Receiver<LifecycleEvent>;
113}
114
115/// Newtype adapter turning an `Arc<LifecycleManager<R>>` into a
116/// [`LifecycleHandle`].
117pub struct ManagerHandle<R: ContainerRuntime + 'static> {
118    inner: Arc<LifecycleManager<R>>,
119}
120
121// Manual `Clone` impl: the derived one would require `R: Clone`, but
122// the only field is an `Arc`, so cloning a `ManagerHandle` never has
123// to clone `R` itself.
124impl<R: ContainerRuntime + 'static> Clone for ManagerHandle<R> {
125    fn clone(&self) -> Self {
126        Self {
127            inner: Arc::clone(&self.inner),
128        }
129    }
130}
131
132impl<R: ContainerRuntime + 'static> ManagerHandle<R> {
133    /// Wrap a shared [`LifecycleManager`] in a [`ManagerHandle`].
134    ///
135    /// The handle is cheaply cloneable: cloning it increments the `Arc` reference
136    /// count without touching the manager or the runtime.
137    #[must_use]
138    pub fn new(inner: Arc<LifecycleManager<R>>) -> Self {
139        Self { inner }
140    }
141
142    /// Borrow a reference to the underlying shared [`LifecycleManager`].
143    #[must_use]
144    pub fn manager(&self) -> &Arc<LifecycleManager<R>> {
145        &self.inner
146    }
147}
148
149impl<R: ContainerRuntime + 'static> LifecycleHandle for ManagerHandle<R> {
150    async fn list(&self) -> Result<Vec<ResourceView>, LifecycleHandleError> {
151        let plan = self.inner.plan_arc();
152        let mut out: Vec<ResourceView> = Vec::with_capacity(plan.nodes().len());
153        for node in plan.nodes() {
154            let snapshot = self
155                .inner
156                .snapshot(&node.name)
157                .ok_or_else(|| LifecycleHandleError::UnknownResource(node.name.clone()))?;
158            out.push(ResourceView {
159                name: node.name.clone(),
160                kind: node.kind.clone(),
161                status: ResourceStatus::from(&snapshot.status),
162                healthy: matches!(
163                    snapshot.status,
164                    crate::lifecycle::status::NodeStatus::Healthy
165                ),
166                image: image_label(&node.spec.image),
167                started_at: snapshot.started_at,
168                last_error: last_error_from(&snapshot.status),
169            });
170        }
171        Ok(out)
172    }
173
174    async fn get(&self, name: &str) -> Result<ResourceView, LifecycleHandleError> {
175        let plan = self.inner.plan_arc();
176        let node = plan
177            .nodes()
178            .iter()
179            .find(|n| n.name == name)
180            .ok_or_else(|| LifecycleHandleError::UnknownResource(name.to_owned()))?;
181        let snapshot = self
182            .inner
183            .snapshot(name)
184            .ok_or_else(|| LifecycleHandleError::UnknownResource(name.to_owned()))?;
185        Ok(ResourceView {
186            name: node.name.clone(),
187            kind: node.kind.clone(),
188            status: ResourceStatus::from(&snapshot.status),
189            healthy: matches!(
190                snapshot.status,
191                crate::lifecycle::status::NodeStatus::Healthy
192            ),
193            image: image_label(&node.spec.image),
194            started_at: snapshot.started_at,
195            last_error: last_error_from(&snapshot.status),
196        })
197    }
198
199    async fn restart(&self, name: &str) -> Result<(), LifecycleHandleError> {
200        self.inner.restart_one(name).await.map_err(|err| match err {
201            crate::LifecycleError::ResourceNotFound(name) => {
202                LifecycleHandleError::UnknownResource(name)
203            }
204            crate::LifecycleError::Start { source, .. }
205            | crate::LifecycleError::Stop { source, .. } => LifecycleHandleError::Runtime(source),
206            crate::LifecycleError::SpecBuild { source, .. } => {
207                LifecycleHandleError::Runtime(RuntimeError::InvalidSpec(source.to_string()))
208            }
209            other => LifecycleHandleError::Runtime(RuntimeError::InvalidSpec(other.to_string())),
210        })
211    }
212
213    async fn logs(&self, name: &str, follow: bool) -> Result<LogChunkStream, LifecycleHandleError> {
214        let plan = self.inner.plan_arc();
215        if !plan.nodes().iter().any(|n| n.name == name) {
216            return Err(LifecycleHandleError::UnknownResource(name.to_owned()));
217        }
218        let snapshot = self
219            .inner
220            .snapshot(name)
221            .ok_or_else(|| LifecycleHandleError::UnknownResource(name.to_owned()))?;
222        let container_id = snapshot.container_id.ok_or_else(|| {
223            LifecycleHandleError::Runtime(RuntimeError::InvalidSpec(format!(
224                "resource `{name}` is not running"
225            )))
226        })?;
227        let stream = self.inner.runtime_arc().logs(&container_id, follow).await?;
228        Ok(stream)
229    }
230
231    fn subscribe_events(&self) -> broadcast::Receiver<LifecycleEvent> {
232        self.inner.subscribe_events()
233    }
234}