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}