lightshuttle_runtime/runtime.rs
1//! Container runtime abstraction and its supporting domain types.
2//!
3//! Defines the [`ContainerRuntime`] trait and the value types it operates on:
4//! [`ContainerId`], [`ContainerStatus`], [`LogChunk`], [`LogStream`], and the
5//! [`LogChunkStream`] type alias. Concrete implementations (e.g.
6//! [`crate::DockerRuntime`]) live in sibling modules.
7
8use std::pin::Pin;
9use std::time::{Duration, SystemTime};
10
11use futures::stream::Stream;
12
13use crate::error::Result;
14use lightshuttle_spec::ContainerSpec;
15
16/// Opaque identifier for a container managed by the runtime.
17///
18/// The internal representation is whatever string the underlying daemon
19/// uses (Docker returns 64-character hexadecimal hashes); callers must
20/// not depend on the format.
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22pub struct ContainerId(String);
23
24impl ContainerId {
25 /// Build a [`ContainerId`] from a daemon-supplied string.
26 #[must_use]
27 pub fn new(id: impl Into<String>) -> Self {
28 Self(id.into())
29 }
30
31 /// Borrow the raw identifier string.
32 #[must_use]
33 pub fn as_str(&self) -> &str {
34 &self.0
35 }
36}
37
38impl std::fmt::Display for ContainerId {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 f.write_str(&self.0)
41 }
42}
43
44/// Lifecycle status reported by the runtime when inspecting a container.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum ContainerStatus {
47 /// The runtime has accepted the start request but the container is
48 /// not yet running.
49 Starting,
50
51 /// The container is running and either has no healthcheck or has
52 /// not produced a healthcheck result yet.
53 Running,
54
55 /// The container is running and reports a healthy healthcheck.
56 Healthy,
57
58 /// The container is running and reports an unhealthy healthcheck.
59 Unhealthy,
60
61 /// The container has exited.
62 Stopped {
63 /// Exit code reported by the container, when known.
64 exit_code: Option<i32>,
65 },
66}
67
68/// Which stream a log chunk came from.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum LogStream {
71 /// Standard output.
72 Stdout,
73 /// Standard error.
74 Stderr,
75}
76
77/// One chunk of streamed log output.
78#[derive(Debug, Clone)]
79pub struct LogChunk {
80 /// Source stream of the chunk.
81 pub stream: LogStream,
82 /// Wall-clock timestamp reported by the runtime.
83 pub timestamp: SystemTime,
84 /// Raw bytes of the chunk; may or may not end with a newline.
85 pub bytes: Vec<u8>,
86}
87
88/// Boxed, pinned stream of [`LogChunk`] items for a single container.
89///
90/// Returned by [`ContainerRuntime::logs`]. The stream is `Send` so it can be
91/// forwarded across async task boundaries (e.g. from a worker task to an HTTP
92/// response body or a WebSocket session).
93pub type LogChunkStream = Pin<Box<dyn Stream<Item = Result<LogChunk>> + Send>>;
94
95/// Container runtime abstraction.
96///
97/// The trait is intentionally narrow: it exposes only the operations
98/// that the lifecycle manager needs. Daemon-specific capabilities
99/// (network inspection, image management) stay private to each
100/// implementation.
101///
102/// Implementations live in submodules such as [`crate::DockerRuntime`].
103pub trait ContainerRuntime: Send + Sync {
104 /// Start a container according to `spec`. Pulls the image if not
105 /// already present locally.
106 fn start(
107 &self,
108 spec: &ContainerSpec,
109 ) -> impl std::future::Future<Output = Result<ContainerId>> + Send;
110
111 /// Stop a container, sending `SIGTERM` and then `SIGKILL` after
112 /// `grace`. Idempotent: stopping an already stopped container is a
113 /// no-op.
114 fn stop(
115 &self,
116 id: &ContainerId,
117 grace: Duration,
118 ) -> impl std::future::Future<Output = Result<()>> + Send;
119
120 /// Remove a container by name, forcing removal even if it is still
121 /// running. Idempotent: removing a container that does not exist is a
122 /// no-op. Named volumes are preserved.
123 ///
124 /// The lifecycle manager calls this before every `start` so that a
125 /// re-up or restart replaces the previous container instead of
126 /// colliding with its name.
127 fn remove(&self, name: &str) -> impl std::future::Future<Output = Result<()>> + Send;
128
129 /// Report the current status of a container.
130 fn inspect(
131 &self,
132 id: &ContainerId,
133 ) -> impl std::future::Future<Output = Result<ContainerStatus>> + Send;
134
135 /// Block until the container reports a healthy status or `timeout`
136 /// elapses. Returns [`crate::RuntimeError::Timeout`] in the latter
137 /// case.
138 fn wait_healthy(
139 &self,
140 id: &ContainerId,
141 timeout: Duration,
142 ) -> impl std::future::Future<Output = Result<()>> + Send;
143
144 /// Stream logs from a container. When `follow` is true the stream
145 /// stays open and emits new chunks as they arrive; when false the
146 /// stream completes after the existing logs are drained.
147 fn logs(
148 &self,
149 id: &ContainerId,
150 follow: bool,
151 ) -> impl std::future::Future<Output = Result<LogChunkStream>> + Send;
152
153 /// Ensure a per-project user-defined bridge network exists, creating
154 /// it when absent. Idempotent: concurrent calls are safe because a
155 /// `409 Conflict` response (network already exists) is treated as
156 /// success. Containers attached to this network can reach each other
157 /// by their container name, enabling `resources.<name>.url` hostnames
158 /// to resolve without extra configuration.
159 fn ensure_project_network(
160 &self,
161 project: &str,
162 ) -> impl std::future::Future<Output = Result<()>> + Send;
163
164 /// Remove the per-project bridge network. Idempotent: a `404 Not
165 /// Found` response is treated as success. Call after all containers
166 /// belonging to the project have been removed; Docker refuses to
167 /// delete a network that still has active endpoints.
168 fn teardown_project_network(
169 &self,
170 project: &str,
171 ) -> impl std::future::Future<Output = Result<()>> + Send;
172}