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}