//! Process composition. Only the control plane opens databases. use crate::{ bootstrap::Application, control::{self, Control}, gateway::GatewayRuntime, http, shutdown_signal, }; use anyhow::Context; use clap::Args; use nyro_config::compile::Snapshot; use nyro_kernel::Host; use std::{ net::SocketAddr, path::{Path, PathBuf}, sync::Arc, time::Duration, }; use tokio_util::{sync::CancellationToken, task::TaskTracker}; #[derive(Args, Clone)] pub(crate) struct DataOptions { #[arg(long, env = "NYRO_LISTEN", default_value = "127.0.0.1:19530")] pub(crate) listen: SocketAddr, #[arg(long, env = "NYRO_CONCURRENCY", default_value_t = 64)] pub(crate) concurrency: usize, } #[derive(Args)] pub(crate) struct ProxyOptions { /// Read runtime resources once at startup. #[arg( short, long, env = "NYRO_CONFIG", required_unless_present = "server", conflicts_with = "server" )] config: Option, /// HTTPS control-plane origin (HTTP is permitted only for loopback testing). #[arg(long, env = "NYRO_SERVER", requires_all = ["sync_token_file", "node_id"])] server: Option, #[arg(long, env = "NYRO_SYNC_TOKEN_FILE", requires = "server")] sync_token_file: Option, #[arg(long, env = "NYRO_NODE_ID", requires = "server")] node_id: Option, #[command(flatten)] data: DataOptions, } #[derive(Args)] pub(crate) struct Options { #[arg( long, env = "NYRO_DATABASE", required_unless_present = "postgres_url_file", conflicts_with = "postgres_url_file" )] database: Option, #[arg(long, env = "NYRO_POSTGRES_URL_FILE")] postgres_url_file: Option, #[arg(long, env = "NYRO_ADMIN_LISTEN", default_value = "127.0.0.1:19531")] admin_listen: SocketAddr, #[arg(long, env = "NYRO_ADMIN_TOKEN_FILE")] admin_token_file: PathBuf, /// Run an embedded gateway using the same snapshot/application contract as remote nodes. #[arg(long, env = "NYRO_ENABLE_PROXY", default_value_t = false)] enable_proxy: bool, /// Dedicated loopback listener. Expose remotely through an HTTPS reverse proxy. #[arg(long, env = "NYRO_SYNC_LISTEN", requires = "sync_token_file")] sync_listen: Option, #[arg(long, env = "NYRO_SYNC_TOKEN_FILE", requires = "sync_listen")] sync_token_file: Option, #[command(flatten)] data: DataOptions, } pub(crate) fn read_file(path: &Path, max: usize) -> anyhow::Result { use std::io::Read; anyhow::ensure!( std::fs::metadata(path) .map_err(|_| anyhow::anyhow!("Could not read startup file"))? .is_file(), "Startup input must be a regular file" ); let file = std::fs::File::open(path).map_err(|_| anyhow::anyhow!("Could not read startup file"))?; anyhow::ensure!( file.metadata()?.is_file(), "Startup input must be a regular file" ); let mut value = String::new(); file.take(max as u64 + 1) .read_to_string(&mut value) .map_err(|_| anyhow::anyhow!("Could not read startup file"))?; anyhow::ensure!(value.len() <= max, "Startup file exceeds size limit"); Ok(value) } fn token(path: &Path) -> anyhow::Result { let token = read_file(path, 1024)? .trim_end_matches(['\r', '\n']) .to_owned(); anyhow::ensure!( token.len() >= 16 && token.bytes().all(|b| b.is_ascii_graphic()), "Token must contain 16 to 1024 visible ASCII characters" ); Ok(token) } fn keys(token: String) -> anyhow::Result { Ok(nyro_authn::KeyAuth::new(vec![nyro_authn::KeyCredential { id: "admin".into(), secret: token, enabled: true, expires_at: None, }])?) } fn follow + 'static>( source: S, node: String, application: Arc, host: Arc>, stop: CancellationToken, ) -> tokio::task::JoinHandle> { tokio::spawn(async move { nyro_sync::run(&source, &node, stop, |snapshot| { let application = application.clone(); let host = host.clone(); async move { application.apply(&host, &snapshot.config).await } }) .await }) } fn listener( tasks: &mut tokio::task::JoinSet>, tcp: tokio::net::TcpListener, app: axum::Router, stop: CancellationToken, ) { tasks.spawn(async move { axum::serve(tcp, app) .with_graceful_shutdown(stop.cancelled_owned()) .await .context("Listener failed") }); } async fn finish( mut tasks: tokio::task::JoinSet>, mut sync: Option>>, stop: CancellationToken, host: Option>>, control: Option>, tracker: TaskTracker, ) -> anyhow::Result<()> { let outcome = tokio::select! { result = shutdown_signal() => result.context("Shutdown signal failed"), result = tasks.join_next() => match result { Some(Ok(result)) => result, _ => Err(anyhow::anyhow!("Listener task failed")) }, result = async { match sync.as_mut() { Some(task) => task.await.map_err(anyhow::Error::from)?.map_err(anyhow::Error::from), None => std::future::pending().await } } => result, }; stop.cancel(); if let Some(control) = &control { control.shutdown().await; } // Cancellation wakes long polls; an application transition is owned by the kernel. if let Some(task) = sync.as_mut() && !task.is_finished() { let _ = task.await; } let cleanup = if let Some(host) = &host { host.shutdown().await.map_err(anyhow::Error::from) } else { Ok(()) }; let drained = tokio::time::timeout(Duration::from_secs(5), async { while let Some(task) = tasks.join_next().await { task??; } Ok::<_, anyhow::Error>(()) }) .await; tasks.abort_all(); tracker.close(); let tracked = tokio::time::timeout(Duration::from_secs(5), tracker.wait()).await; outcome?; cleanup?; drained.context("HTTP connections did not drain")??; tracked.context("Response cleanup did not finish")?; Ok(()) } pub(crate) async fn proxy(options: ProxyOptions) -> anyhow::Result<()> { let application = Arc::new(Application::new(options.data.concurrency)?); let host = Arc::new(Host::new(Default::default())); let stop = CancellationToken::new(); let tracker = TaskTracker::new(); let tcp = tokio::net::TcpListener::bind(options.data.listen) .await .context("Could not bind proxy listener")?; let sync = match (&options.config, &options.server) { (Some(path), None) => { let resources = nyro_config::resources::Resources::from_yaml(&read_file( path, nyro_control::MAX_CONFIG_BYTES, )?)?; application .apply(&host, &Snapshot::file(resources)) .await .map_err(|_| anyhow::anyhow!("Could not activate resource configuration"))?; None } (None, Some(server)) => { let node = options.node_id.as_deref().unwrap_or(""); anyhow::ensure!( (1..=128).contains(&node.len()) && node .bytes() .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)), "Invalid node ID" ); let client = nyro_sync::HttpClient::::new( server, &token( options .sync_token_file .as_deref() .context("Sync token required")?, )?, )?; Some(follow( client, node.into(), application, host.clone(), stop.clone(), )) } _ => anyhow::bail!("Exactly one configuration source is required"), }; let mut tasks = tokio::task::JoinSet::new(); listener( &mut tasks, tcp, http::router(host.clone(), tracker.clone()), stop.clone(), ); tracing::info!(listen = %options.data.listen, "Proxy listener started; /readyz reports active configuration"); finish(tasks, sync, stop, Some(host), None, tracker).await } pub(crate) async fn run(options: Options) -> anyhow::Result<()> { anyhow::ensure!( options.admin_listen.ip().is_loopback(), "Admin listener must use loopback" ); if let Some(listen) = options.sync_listen { anyhow::ensure!( listen.ip().is_loopback(), "Sync listener must use loopback behind an HTTPS reverse proxy" ); } let admin_token = token(&options.admin_token_file)?; let sync_token = options.sync_token_file.as_deref().map(token).transpose()?; anyhow::ensure!( sync_token.as_ref() != Some(&admin_token), "Admin and sync tokens must be different" ); let mut store = match (&options.database, &options.postgres_url_file) { (Some(path), None) => nyro_control::resource::Store::open(path).await?, (None, Some(path)) => { nyro_control::resource::Store::open_postgres( read_file(path, 16384)?.trim_end_matches(['\r', '\n']), ) .await? } _ => anyhow::bail!("Exactly one database source is required"), }; let snapshot = store.snapshot().await?; let control = Control::new(store, snapshot, keys(admin_token)?)?; let admin = tokio::net::TcpListener::bind(options.admin_listen) .await .context("Could not bind admin listener")?; let sync_tcp = match options.sync_listen { Some(listen) => Some( tokio::net::TcpListener::bind(listen) .await .context("Could not bind sync listener")?, ), None => None, }; let data_tcp = if options.enable_proxy { Some( tokio::net::TcpListener::bind(options.data.listen) .await .context("Could not bind data listener")?, ) } else { None }; let stop = CancellationToken::new(); let tracker = TaskTracker::new(); let mut tasks = tokio::task::JoinSet::new(); listener( &mut tasks, admin, control::router(control.clone()), stop.clone(), ); if let Some(tcp) = sync_tcp { listener( &mut tasks, tcp, nyro_sync::http_router(control.hub.clone(), sync_token.as_deref().unwrap())?, stop.clone(), ); } let (host, sync) = if let Some(tcp) = data_tcp { let application = Arc::new(Application::new(options.data.concurrency)?); let host = Arc::new(Host::new(Default::default())); let sync = follow( control.hub.clone(), "embedded".into(), application, host.clone(), stop.clone(), ); listener( &mut tasks, tcp, http::router(host.clone(), tracker.clone()), stop.clone(), ); (Some(host), Some(sync)) } else { (None, None) }; tracing::info!(admin_listen = %options.admin_listen, proxy = options.enable_proxy, "Control plane ready"); finish(tasks, sync, stop, host, Some(control), tracker).await } #[cfg(test)] mod tests { use clap::Parser; #[test] fn serve_is_control_only_by_default_and_proxy_requires_one_source() { let cli = crate::Cli::try_parse_from([ "nyro", "serve", "--database", "control.db", "--admin-token-file", "token", ]) .unwrap(); let crate::Command::Serve(options) = cli.command else { panic!() }; assert!(!options.enable_proxy); assert!(crate::Cli::try_parse_from(["nyro", "proxy"]).is_err()); assert!( crate::Cli::try_parse_from(["nyro", "proxy", "--config", "resources.yaml"]).is_ok() ); assert!( crate::Cli::try_parse_from(["nyro", "proxy", "--server", "https://example.test"]) .is_err() ); assert!( crate::Cli::try_parse_from([ "nyro", "proxy", "--config", "resources.yaml", "--server", "https://example.test" ]) .is_err() ); } }