Skip to main content

lightshuttle_control/
server.rs

1//! HTTP server for the local control plane.
2//!
3//! The two public entry points are:
4//!
5//! - [`bind`]: opens a [`tokio::net::TcpListener`] before the server is
6//!   constructed (allows reading back the OS-assigned port when port `0` is
7//!   passed).
8//! - [`ControlServer`]: wraps a [`crate::ControlState`] and drives the axum
9//!   router until a caller-supplied shutdown future resolves.
10
11use std::future::Future;
12use std::net::SocketAddr;
13
14use lightshuttle_runtime::LifecycleHandle;
15use tokio::net::TcpListener;
16
17use crate::routes::router;
18use crate::state::ControlState;
19
20/// Open a TCP listener that will be passed to [`ControlServer::serve`].
21///
22/// Pass a port of `0` to let the OS assign a free port; read it back
23/// with [`TcpListener::local_addr`] after the call returns.
24///
25/// This is a free function rather than an associated function on the
26/// generic [`ControlServer`] so callers can open the socket before
27/// constructing state, without needing a turbofish to pin `H`.
28///
29/// Always bind to a loopback address (`127.0.0.1`) in practice: the
30/// control plane carries no authentication and is intended only for
31/// local developer use.
32///
33/// # Errors
34///
35/// Returns an [`std::io::Error`] if the bind fails (address already in
36/// use, permission denied, etc.).
37///
38/// # Example
39///
40/// ```rust,no_run
41/// use std::net::SocketAddr;
42/// use lightshuttle_control::bind;
43///
44/// # async fn run() -> std::io::Result<()> {
45/// // Loopback only: the control plane has no authentication.
46/// let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
47/// let listener = bind(addr).await?;
48/// let port = listener.local_addr()?.port();
49/// println!("control plane listening on port {port}");
50/// # Ok(())
51/// # }
52/// ```
53pub async fn bind(addr: SocketAddr) -> std::io::Result<TcpListener> {
54    TcpListener::bind(addr).await
55}
56
57/// HTTP server that hosts the control plane router.
58///
59/// Generic over `H`, which must implement
60/// [`lightshuttle_runtime::LifecycleHandle`]. The handle is held inside a
61/// [`crate::ControlState`] and shared across all route handlers via axum's
62/// state mechanism.
63///
64/// # Usage
65///
66/// 1. Call [`bind`] to open a listener (loopback only, no authentication).
67/// 2. Build a [`crate::ControlState`] with the project name and handle.
68/// 3. Construct a [`ControlServer`] via [`ControlServer::new`].
69/// 4. Await [`ControlServer::serve`] with a shutdown future.
70///
71/// For in-process integration tests, use [`ControlServer::into_router`] to
72/// get the raw axum router and drive it with `tower::ServiceExt::oneshot`
73/// without opening a TCP socket.
74pub struct ControlServer<H>
75where
76    H: LifecycleHandle + Clone + Send + Sync + 'static,
77{
78    state: ControlState<H>,
79}
80
81impl<H> ControlServer<H>
82where
83    H: LifecycleHandle + Clone + Send + Sync + 'static,
84{
85    /// Build a server from the given shared state.
86    ///
87    /// The state holds the project name, the lifecycle handle, and the
88    /// Prometheus metrics renderer. It is moved into the axum router when
89    /// [`ControlServer::serve`] or [`ControlServer::into_router`] is called.
90    #[must_use]
91    pub fn new(state: ControlState<H>) -> Self {
92        Self { state }
93    }
94
95    /// Consume the server and return the underlying [`axum::Router`].
96    ///
97    /// Useful for in-process integration tests: pass the router to
98    /// `tower::ServiceExt::oneshot` to send synthetic requests without the
99    /// overhead of a real TCP bind.
100    pub fn into_router(self) -> axum::Router {
101        router(self.state)
102    }
103
104    /// Run the control plane on `listener` until `shutdown` resolves.
105    ///
106    /// Starts accepting connections immediately. When `shutdown` resolves,
107    /// axum performs a graceful shutdown: it stops accepting new connections
108    /// and waits for in-flight requests to complete before returning.
109    ///
110    /// # Errors
111    ///
112    /// Propagates any [`std::io::Error`] from the underlying TCP accept loop.
113    ///
114    /// # Example
115    ///
116    /// ```rust,no_run
117    /// use std::net::SocketAddr;
118    /// use lightshuttle_control::{ControlState, ControlServer, bind};
119    /// # use lightshuttle_runtime::{
120    /// #     LifecycleEvent, LifecycleHandle, LifecycleHandleError,
121    /// #     LogChunkStream, ResourceView,
122    /// # };
123    /// # use tokio::sync::broadcast;
124    /// # #[derive(Clone)]
125    /// # struct MyHandle;
126    /// # impl LifecycleHandle for MyHandle {
127    /// #     async fn list(&self) -> Result<Vec<ResourceView>, LifecycleHandleError> { Ok(vec![]) }
128    /// #     async fn get(&self, _: &str) -> Result<ResourceView, LifecycleHandleError> {
129    /// #         Err(LifecycleHandleError::NotSupported("get"))
130    /// #     }
131    /// #     async fn restart(&self, _: &str) -> Result<(), LifecycleHandleError> {
132    /// #         Err(LifecycleHandleError::NotSupported("restart"))
133    /// #     }
134    /// #     async fn logs(&self, _: &str, _: bool) -> Result<LogChunkStream, LifecycleHandleError> {
135    /// #         Err(LifecycleHandleError::NotSupported("logs"))
136    /// #     }
137    /// #     fn subscribe_events(&self) -> broadcast::Receiver<LifecycleEvent> {
138    /// #         broadcast::channel(1).1
139    /// #     }
140    /// # }
141    ///
142    /// # async fn run() -> std::io::Result<()> {
143    /// let addr: SocketAddr = "127.0.0.1:9090".parse().unwrap();
144    /// let listener = bind(addr).await?;
145    /// let state = ControlState::new("my-project", MyHandle);
146    /// let server = ControlServer::new(state);
147    ///
148    /// // Shutdown when Ctrl-C is received.
149    /// server
150    ///     .serve(listener, async { tokio::signal::ctrl_c().await.ok(); })
151    ///     .await
152    /// # }
153    /// ```
154    pub async fn serve<F>(self, listener: TcpListener, shutdown: F) -> std::io::Result<()>
155    where
156        F: Future<Output = ()> + Send + 'static,
157    {
158        let app = router(self.state);
159        axum::serve(listener, app)
160            .with_graceful_shutdown(shutdown)
161            .await
162    }
163}