Skip to main content

aion_server/
run.rs

1//! Run loop for the Aion workflow server: tracing initialization,
2//! configuration load, transport startup, and signal-driven graceful
3//! shutdown.
4//!
5//! This is the library entry point behind the `aion server` command. It
6//! preserves the operational contract of the former standalone
7//! `aion-server` binary: exit code 2 for configuration errors, the drain
8//! outcome's exit code on shutdown, and 130 when a second termination
9//! signal forces immediate exit.
10
11use std::{net::SocketAddr, process::ExitCode};
12
13use tokio::net::TcpListener;
14use tonic::transport::Server as TonicServer;
15use tracing::{error, info};
16
17use crate::{
18    ServerConfig, ServerError, ServerState, api,
19    config::{CliOverrides, NamespaceMode, StoreBackend},
20    observability,
21    shutdown::{self, ShutdownOutcome},
22};
23
24/// Run the Aion workflow server until it shuts down, returning the process
25/// exit code.
26///
27/// Initializes the JSON tracing subscriber, loads and validates the merged
28/// configuration (file, environment, then `overrides`), serves the gRPC and
29/// HTTP transports, and drains gracefully after the first termination
30/// signal. Every failure is logged through tracing and mapped to the exit
31/// code contract above; the caller only has to exit with the returned code.
32pub async fn run(overrides: CliOverrides) -> ExitCode {
33    match run_server(overrides).await {
34        Ok(code) => code,
35        Err(error) => {
36            error!(%error, "aion-server failed");
37            if error.is_config() {
38                ExitCode::from(2)
39            } else {
40                ExitCode::FAILURE
41            }
42        }
43    }
44}
45
46async fn run_server(cli: CliOverrides) -> Result<ExitCode, ServerError> {
47    observability::tracing::init()?;
48
49    let config = ServerConfig::load(&cli)?;
50    reject_auth_without_feature(&config)?;
51    let store_backend = config.store.backend;
52    let state = ServerState::build(config).await?;
53    reject_tls_until_supported(&state)?;
54
55    let runtime = state.runtime_config();
56    let grpc_address = runtime.listen.grpc;
57    let http_address = runtime.listen.http;
58    let workflow_packages: Vec<String> = runtime
59        .workflow_packages
60        .iter()
61        .map(|path| path.display().to_string())
62        .collect();
63    info!(
64        version = env!("CARGO_PKG_VERSION"),
65        grpc_address = %grpc_address,
66        http_address = %http_address,
67        default_namespace = %runtime.default_namespace,
68        namespace_mode = namespace_mode_label(&runtime.namespace.mode),
69        store_backend = store_backend_label(store_backend),
70        auth_enabled = runtime.auth.enabled,
71        deploy_enabled = runtime.deploy.enabled,
72        metrics_enabled = runtime.metrics.enabled,
73        workflow_package_count = workflow_packages.len(),
74        workflow_packages = ?workflow_packages,
75        "aion-server startup banner"
76    );
77    let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
78    let mut grpc = tokio::spawn(serve_grpc(state.clone(), grpc_address, shutdown_rx.clone()));
79    let mut http = tokio::spawn(serve_http(state.clone(), http_address, shutdown_rx));
80
81    let outcome = tokio::select! {
82        result = &mut grpc => {
83            transport_result("gRPC", result)?;
84            state.shutdown()?;
85            ShutdownOutcome::Clean
86        },
87        result = &mut http => {
88            transport_result("HTTP", result)?;
89            state.shutdown()?;
90            ShutdownOutcome::Clean
91        },
92        result = shutdown_signal() => {
93            result?;
94            let _receiver_count = shutdown_tx.send(true);
95            let outcome = shutdown::drain_after_first_signal(state.clone(), async {
96                let _ = shutdown_signal().await;
97            }).await?;
98            if !matches!(outcome, ShutdownOutcome::Forced) {
99                transport_result("gRPC", grpc.await)?;
100                transport_result("HTTP", http.await)?;
101            }
102            outcome
103        },
104    };
105
106    Ok(outcome.exit_code())
107}
108
109fn transport_result(
110    transport: &'static str,
111    result: Result<Result<(), ServerError>, tokio::task::JoinError>,
112) -> Result<(), ServerError> {
113    match result {
114        Ok(transport_outcome) => transport_outcome,
115        Err(join_error) => Err(ServerError::Transport {
116            transport,
117            message: join_error.to_string(),
118        }),
119    }
120}
121
122async fn serve_grpc(
123    state: ServerState,
124    address: SocketAddr,
125    shutdown: tokio::sync::watch::Receiver<bool>,
126) -> Result<(), ServerError> {
127    let workflow = api::grpc::workflow_service(state.clone());
128    let worker = api::worker_grpc::worker_service(state.clone());
129    let mut router = TonicServer::builder()
130        .add_service(workflow)
131        .add_service(worker);
132    // Dark by default: the deploy service joins the listener only when the
133    // operator commissioned it; otherwise the surface answers Unimplemented.
134    if state.runtime_config().deploy.enabled {
135        router = router.add_service(api::deploy_grpc::deploy_service(state)?);
136    }
137    router
138        .serve_with_shutdown(address, shutdown_requested(shutdown))
139        .await
140        .map_err(|source| transport_bind("grpc", address, source))?;
141    Ok(())
142}
143
144async fn serve_http(
145    state: ServerState,
146    address: SocketAddr,
147    shutdown: tokio::sync::watch::Receiver<bool>,
148) -> Result<(), ServerError> {
149    let listener = TcpListener::bind(address)
150        .await
151        .map_err(|source| transport_bind("http", address, source))?;
152    axum::serve(listener, api::http::http_router(state)?)
153        .with_graceful_shutdown(shutdown_requested(shutdown))
154        .await
155        .map_err(|source| transport_bind("http", address, source))?;
156    Ok(())
157}
158
159async fn shutdown_requested(mut shutdown: tokio::sync::watch::Receiver<bool>) {
160    while !*shutdown.borrow_and_update() {
161        if shutdown.changed().await.is_err() {
162            break;
163        }
164    }
165}
166
167async fn shutdown_signal() -> Result<(), ServerError> {
168    #[cfg(unix)]
169    {
170        use tokio::signal::unix::{SignalKind, signal};
171
172        let mut terminate = signal(SignalKind::terminate())
173            .map_err(|source| signal_listener("SIGTERM", &source))?;
174        let mut interrupt =
175            signal(SignalKind::interrupt()).map_err(|source| signal_listener("SIGINT", &source))?;
176        tokio::select! {
177            _ = terminate.recv() => Ok(()),
178            _ = interrupt.recv() => Ok(()),
179        }
180    }
181
182    #[cfg(not(unix))]
183    {
184        tokio::signal::ctrl_c()
185            .await
186            .map_err(|source| signal_listener("shutdown signal", &source))
187    }
188}
189
190fn signal_listener(listener: &'static str, source: &std::io::Error) -> ServerError {
191    ServerError::SignalListener {
192        listener,
193        message: source.to_string(),
194    }
195}
196
197fn reject_auth_without_feature(config: &ServerConfig) -> Result<(), ServerError> {
198    if cfg!(not(feature = "auth")) && config.auth.enabled {
199        return Err(ServerError::Config {
200            message: "auth.enabled=true but binary compiled without auth feature".to_owned(),
201        });
202    }
203    Ok(())
204}
205
206fn reject_tls_until_supported(state: &ServerState) -> Result<(), ServerError> {
207    if state.runtime_config().tls.is_some() {
208        return Err(ServerError::Config {
209            message: "configured TLS material cannot be served until transport TLS is wired"
210                .to_owned(),
211        });
212    }
213    Ok(())
214}
215
216fn store_backend_label(backend: StoreBackend) -> &'static str {
217    match backend {
218        StoreBackend::Memory => "memory",
219        StoreBackend::LibSql => "libsql",
220    }
221}
222
223fn namespace_mode_label(mode: &NamespaceMode) -> &'static str {
224    match mode {
225        NamespaceMode::SharedEngine => "SharedEngine",
226        NamespaceMode::SingleTenant { .. } => "SingleTenant",
227    }
228}
229
230fn transport_bind<E>(transport: &'static str, address: SocketAddr, source: E) -> ServerError
231where
232    E: std::error::Error,
233{
234    ServerError::TransportBind {
235        transport,
236        address,
237        message: source.to_string(),
238    }
239}