// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 //! Shared CLI entrypoint for the gateway binaries. use clap::parser::ValueSource; use clap::{ArgAction, ArgMatches, Command, CommandFactory, FromArgMatches, Parser}; use miette::{IntoDiagnostic, Result}; use openshell_core::ComputeDriverKind; use openshell_core::config::DEFAULT_SERVER_PORT; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use tracing::{info, warn}; use tracing_subscriber::EnvFilter; use crate::certgen; use crate::compute::driver_config::GuestTlsPaths; use crate::config_file::{self, ConfigFile, GatewayFileSection}; use crate::defaults::{self, LocalTlsPaths}; use crate::{ServerStartupConfig, run_server, tracing_bus::TracingLogBus}; /// `OpenShell` gateway process - gRPC and HTTP server with protocol multiplexing. /// /// Top-level CLI. When invoked without a subcommand the binary runs the /// gateway server using `RunArgs`. The `generate-certs` subcommand is used by /// the Helm pre-install hook to bootstrap mTLS Secrets. #[derive(Parser, Debug)] #[command(version = openshell_core::VERSION)] #[command(about = "OpenShell gRPC/HTTP server", long_about = None)] struct Cli { #[command(subcommand)] command: Option, #[command(flatten)] run: RunArgs, } #[derive(clap::Subcommand, Debug)] enum Commands { /// Generate mTLS PKI and write Kubernetes Secrets (Helm pre-install hook). GenerateCerts(certgen::CertgenArgs), } #[derive(clap::Args, Debug)] #[allow(clippy::struct_excessive_bools)] struct RunArgs { /// Path to a TOML configuration file (see RFC 0003). /// /// When set, gateway-wide settings and per-driver tables are read from /// the file. Gateway command-line flags and `OPENSHELL_*` environment /// variables continue to take precedence over gateway file values. #[arg(long, env = "OPENSHELL_GATEWAY_CONFIG")] config: Option, /// IP address to bind the server, health, and metrics listeners to. #[arg(long, default_value = "127.0.0.1", env = "OPENSHELL_BIND_ADDRESS")] bind_address: IpAddr, /// Port to bind the server to. #[arg(long, default_value_t = DEFAULT_SERVER_PORT, env = "OPENSHELL_SERVER_PORT")] port: u16, /// Port for unauthenticated health endpoints (healthz, readyz). /// Set to 0 to disable the dedicated health listener. #[arg(long, default_value_t = 0, env = "OPENSHELL_HEALTH_PORT")] health_port: u16, /// Port for the Prometheus metrics endpoint (/metrics). /// Set to 0 to disable the dedicated metrics listener. #[arg(long, default_value_t = 0, env = "OPENSHELL_METRICS_PORT")] metrics_port: u16, /// Log level (trace, debug, info, warn, error). #[arg(long, default_value = "info", env = "OPENSHELL_LOG_LEVEL")] log_level: String, /// Path to TLS certificate file (required unless --disable-tls). #[arg(long, env = "OPENSHELL_TLS_CERT")] tls_cert: Option, /// Path to TLS private key file (required unless --disable-tls). #[arg(long, env = "OPENSHELL_TLS_KEY")] tls_key: Option, /// Path to CA certificate for client certificate verification (mTLS). #[arg(long, env = "OPENSHELL_TLS_CLIENT_CA")] tls_client_ca: Option, /// Database URL for persistence. /// /// When unset, the gateway stores state under the `XDG` state /// directory. Kept as an Option at the clap layer so the `generate-certs` /// subcommand can run without gateway runtime defaults. #[arg(long, env = "OPENSHELL_DB_URL")] db_url: Option, /// Compute drivers configured for this gateway. /// /// Accepts a comma-delimited list such as `kubernetes` or /// `kubernetes,podman`. The configuration format is future-proofed for /// multiple drivers, but the gateway currently requires exactly one. /// When unset, the gateway auto-detects the driver based on the runtime /// environment (Kubernetes → Podman → Docker CLI or socket). VM is never /// auto-detected and requires explicit configuration. #[arg( long, alias = "driver", env = "OPENSHELL_DRIVERS", value_delimiter = ',', value_parser = parse_compute_driver )] drivers: Vec, /// Path to a Unix domain socket served by a remote compute driver /// implementing `compute_driver.proto`. /// /// When set, the socket is associated with the single driver name supplied /// by `--drivers` or `OPENSHELL_DRIVERS`. Reserved built-in driver names /// such as Docker, Podman, Kubernetes, and VM do not accept socket /// endpoints. #[arg(long, env = "OPENSHELL_COMPUTE_DRIVER_SOCKET")] compute_driver_socket: Option, /// Disable TLS entirely — listen on plaintext HTTP. /// Use this when the gateway sits behind a reverse proxy or tunnel /// (e.g. Cloudflare Tunnel) that terminates TLS at the edge. #[arg(long, env = "OPENSHELL_DISABLE_TLS")] disable_tls: bool, /// OIDC issuer URL for JWT-based authentication. /// When set, the server validates `authorization: Bearer` tokens on gRPC /// requests against the issuer's JWKS endpoint. #[arg(long, env = "OPENSHELL_OIDC_ISSUER")] oidc_issuer: Option, /// Enable mTLS client certificate authentication for local single-user gateways. /// /// When unset, this defaults on for Docker, Podman, and VM gateways that /// have client certificate verification configured and no OIDC issuer. /// Kubernetes deployments must use OIDC or fronting-proxy auth instead. #[arg( long = "enable-mtls-auth", env = "OPENSHELL_ENABLE_MTLS_AUTH", default_value_t = false, action = ArgAction::Set )] enable_mtls_auth: bool, /// Expected OIDC audience claim (typically the client ID). #[arg(long, env = "OPENSHELL_OIDC_AUDIENCE", default_value = "openshell-cli")] oidc_audience: String, /// JWKS key cache TTL in seconds. #[arg(long, env = "OPENSHELL_OIDC_JWKS_TTL", default_value_t = 3600)] oidc_jwks_ttl: u64, /// Dot-separated path to the roles array in the JWT claims. /// Keycloak: `realm_access.roles` (default). Entra ID: "roles". Okta: "groups". #[arg( long, env = "OPENSHELL_OIDC_ROLES_CLAIM", default_value = "realm_access.roles" )] oidc_roles_claim: String, /// Role name that grants admin access. #[arg( long, env = "OPENSHELL_OIDC_ADMIN_ROLE", default_value = "openshell-admin" )] oidc_admin_role: String, /// Role name that grants standard user access. #[arg( long, env = "OPENSHELL_OIDC_USER_ROLE", default_value = "openshell-user" )] oidc_user_role: String, /// Dot-separated path to the scopes value in the JWT claims. /// When set, the server enforces scope-based permissions on top of roles. /// Keycloak: "scope". Okta: "scp". Leave empty to disable scope enforcement. #[arg(long, env = "OPENSHELL_OIDC_SCOPES_CLAIM", default_value = "")] oidc_scopes_claim: String, /// Maximum gRPC requests allowed per rate-limit window. Set to 0 to disable. #[arg(long, env = "OPENSHELL_GRPC_RATE_LIMIT_REQUESTS")] grpc_rate_limit_requests: Option, /// gRPC rate-limit window length in seconds. Set to 0 to disable. #[arg(long, env = "OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS")] grpc_rate_limit_window_seconds: Option, /// Subject Alternative Names configured on the gateway server certificate. /// Wildcard DNS SANs also enable sandbox service URLs under that domain. #[arg( long = "server-san", env = "OPENSHELL_SERVER_SAN", value_delimiter = ',' )] server_sans: Vec, /// Enable plaintext HTTP routing for loopback sandbox service URLs. #[arg( long, env = "OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP", default_value_t = true, action = ArgAction::Set )] enable_loopback_service_http: bool, } pub fn command() -> Command { Cli::command() .name("openshell-gateway") .bin_name("openshell-gateway") } pub async fn run_cli() -> Result<()> { rustls::crypto::ring::default_provider() .install_default() .map_err(|e| miette::miette!("failed to install rustls crypto provider: {e:?}"))?; let matches = command().get_matches(); let cli = Cli::from_arg_matches(&matches).expect("clap validated args"); match cli.command { Some(Commands::GenerateCerts(args)) => certgen::run(args).await, None => Box::pin(run_from_args(cli.run, matches)).await, } } fn prepare_server_config(args: &mut RunArgs, matches: &ArgMatches) -> Result { // Load TOML when explicitly requested, or from the default XDG location // when that file exists. Missing default config is not an error: runtime // defaults and OPENSHELL_* env vars are enough for package-managed starts. let config_path = resolve_config_path(args)?; let file: Option = if let Some(path) = config_path { Some(config_file::load(&path).map_err(|e| miette::miette!("{e}"))?) } else { None }; if let Some(file) = file.as_ref() { merge_file_into_args(args, &file.openshell.gateway, matches); } normalize_compute_driver_socket_args(args, matches)?; let local_tls = apply_runtime_defaults(args)?; let guest_tls = local_tls.as_ref().map(GuestTlsPaths::from); let local_jwt = defaults::complete_local_jwt_config()?; let bind = SocketAddr::new(args.bind_address, args.port); let has_client_ca = args.tls_client_ca.is_some(); let has_oidc = args.oidc_issuer.is_some(); let mtls_auth_enabled = resolve_mtls_auth_enabled(args, matches, file.as_ref()); if args.disable_tls && has_client_ca { return Err(miette::miette!( "--disable-tls and --tls-client-ca are mutually exclusive. Client certificate verification requires that TLS be enabled." )); } if mtls_auth_enabled && args.disable_tls { return Err(miette::miette!( "mTLS user authentication requires TLS. Remove --disable-tls or disable --enable-mtls-auth." )); } if mtls_auth_enabled && !has_client_ca { return Err(miette::miette!( "mTLS user authentication requires --tls-client-ca so client certificates can be verified." )); } if mtls_auth_enabled && matches!( effective_single_driver(args), Some(ComputeDriverKind::Kubernetes) ) { return Err(miette::miette!( "mTLS user authentication is not supported with the Kubernetes compute driver. Configure OIDC or a trusted fronting proxy for user authentication." )); } let tls = if args.disable_tls { None } else { let cert_path = args.tls_cert.clone().ok_or_else(|| { miette::miette!( "--tls-cert is required when TLS is enabled (use --disable-tls to skip)" ) })?; let key_path = args.tls_key.clone().ok_or_else(|| { miette::miette!("--tls-key is required when TLS is enabled (use --disable-tls to skip)") })?; Some(openshell_core::TlsConfig { cert_path, key_path, require_client_auth: has_client_ca && !has_oidc, client_ca_path: args.tls_client_ca.clone(), }) }; let db_url = args .db_url .clone() .expect("runtime defaults populate db_url"); let mut config = openshell_core::Config::new(tls) .with_bind_address(bind) .with_log_level(&args.log_level); if let Some(auth) = file.as_ref().and_then(|f| f.openshell.gateway.auth.clone()) { config.auth = auth; } config.mtls_auth.enabled = mtls_auth_enabled; // Listener addresses for the health and metrics endpoints. The file may // pin a different interface than the main listener (e.g. health on // 127.0.0.1 while gRPC binds 0.0.0.0); the full `SocketAddr` from the // file is preserved unless CLI/env supplied an explicit `--health-port` / // `--metrics-port`, in which case the port overrides the file value // while the IP defaults to `args.bind_address`. let file_gateway = file.as_ref().map(|f| &f.openshell.gateway); let health_bind = resolve_aux_listener( args.bind_address, args.health_port, matches, "health_port", || file_gateway.and_then(|g| g.health_bind_address), ); let metrics_bind = resolve_aux_listener( args.bind_address, args.metrics_port, matches, "metrics_port", || file_gateway.and_then(|g| g.metrics_bind_address), ); if let Some(addr) = health_bind { if args.port == addr.port() { return Err(miette::miette!( "--port and --health-port must be different (both set to {})", args.port )); } config = config.with_health_bind_address(addr); } if let Some(addr) = metrics_bind { if args.port == addr.port() { return Err(miette::miette!( "--port and --metrics-port must be different (both set to {})", args.port )); } if let Some(health) = health_bind && health.port() == addr.port() { return Err(miette::miette!( "--health-port and --metrics-port must be different (both set to {})", health.port() )); } config = config.with_metrics_bind_address(addr); } config = config .with_database_url(db_url) .with_compute_drivers(args.drivers.clone()) .with_grpc_rate_limit( args.grpc_rate_limit_requests, args.grpc_rate_limit_window_seconds, ) .with_server_sans(args.server_sans.clone()) .with_loopback_service_http(args.enable_loopback_service_http); validate_grpc_rate_limit_args( args.grpc_rate_limit_requests, args.grpc_rate_limit_window_seconds, )?; if let Some(socket) = args.compute_driver_socket.clone() { let driver = args .drivers .first() .expect("normalize_compute_driver_socket_args sets a driver for socket endpoints"); config = config.with_compute_driver_endpoint(driver.clone(), socket); } if let Some(ttl) = file .as_ref() .and_then(|f| f.openshell.gateway.ssh_session_ttl_secs) { config = config.with_ssh_session_ttl_secs(ttl); } if let Some(issuer) = args.oidc_issuer.clone() { config = config.with_oidc(openshell_core::OidcConfig { issuer, audience: args.oidc_audience.clone(), jwks_ttl_secs: args.oidc_jwks_ttl, roles_claim: args.oidc_roles_claim.clone(), admin_role: args.oidc_admin_role.clone(), user_role: args.oidc_user_role.clone(), scopes_claim: args.oidc_scopes_claim.clone(), }); } // `gateway_jwt` is configured through TOML in cluster deployments. Local // package-managed starts also auto-detect the JWT bundle written next to // the generated TLS bundle so upgrades pick up sandbox auth without a // user-authored config file. if let Some(jwt) = file .as_ref() .and_then(|f| f.openshell.gateway.gateway_jwt.clone()) { config.gateway_jwt = Some(jwt); } else if let Some(jwt) = local_jwt { config.gateway_jwt = Some(jwt); } Ok(ServerStartupConfig { config, config_file: file, guest_tls, }) } async fn run_from_args(mut args: RunArgs, matches: ArgMatches) -> Result<()> { let prepared = prepare_server_config(&mut args, &matches)?; let tracing_log_bus = TracingLogBus::new(); tracing_log_bus.install_subscriber( EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(&prepared.config.log_level)), ); let has_client_ca = prepared .config .tls .as_ref() .and_then(|tls| tls.client_ca_path.as_ref()) .is_some(); let has_oidc = prepared.config.oidc.is_some(); if prepared.config.tls.is_none() { warn!("TLS disabled — listening on plaintext HTTP"); } else { info!("TLS enabled — listening on encrypted HTTPS"); } if has_client_ca { info!("TLS client certificate verification enabled"); } if prepared.config.mtls_auth.enabled { info!("mTLS user authentication enabled"); } if has_oidc { info!("OIDC authentication enabled"); } if prepared.config.auth.allow_unauthenticated_users { warn!( "Unauthenticated user access enabled — only use this for trusted local development or a fully trusted fronting proxy" ); } if !prepared.config.auth.allow_unauthenticated_users && !prepared.config.mtls_auth.enabled && !has_oidc && prepared.config.gateway_jwt.is_none() { warn!( "Neither mTLS user auth nor OIDC nor sandbox JWT auth is configured — \ the gateway has no authentication mechanism" ); } info!(bind = %prepared.config.bind_address, "Starting OpenShell server"); Box::pin(run_server(prepared, tracing_log_bus)) .await .into_diagnostic() } fn parse_compute_driver(value: &str) -> std::result::Result { openshell_core::config::normalize_compute_driver_name(value) } fn resolve_config_path(args: &RunArgs) -> Result> { if let Some(path) = args.config.clone() { return Ok(Some(path)); } let default_path = defaults::default_gateway_config_path()?; Ok(default_path.is_file().then_some(default_path)) } fn apply_runtime_defaults(args: &mut RunArgs) -> Result> { let local_tls = if args.disable_tls { None } else { defaults::complete_local_tls_paths()? }; if args.db_url.is_none() { args.db_url = Some(defaults::default_database_url()?); } if !args.disable_tls && args.tls_cert.is_none() && args.tls_key.is_none() && args.tls_client_ca.is_none() && let Some(paths) = &local_tls { args.tls_cert = Some(paths.server_cert.clone()); args.tls_key = Some(paths.server_key.clone()); args.tls_client_ca = Some(paths.ca.clone()); } Ok(local_tls) } /// Returns `true` when an argument's value came from clap's built-in default /// (or was never supplied at all). When the predicate is `true`, the loader /// is free to replace the value with one read from the TOML config file. fn arg_defaulted(matches: &ArgMatches, id: &str) -> bool { matches!( matches.value_source(id), None | Some(ValueSource::DefaultValue) ) } /// Resolve the bind address for an auxiliary listener (health / metrics). /// /// The precedence is: /// 1. CLI flag or `OPENSHELL_*` env var explicitly set on the corresponding /// port argument → `bind_address:port` (port from CLI, IP from the main /// listener interface). /// 2. Full `SocketAddr` from `[openshell.gateway].{health,metrics}_bind_address` /// → used as-is (this is how operators pin a loopback-only health port /// on a gateway whose gRPC listener is bound publicly). /// 3. Otherwise the listener is disabled (returns `None`). fn resolve_aux_listener( bind_ip: IpAddr, port_arg: u16, matches: &ArgMatches, port_id: &str, file_addr: impl FnOnce() -> Option, ) -> Option { if !arg_defaulted(matches, port_id) { if port_arg == 0 { return None; } return Some(SocketAddr::new(bind_ip, port_arg)); } if let Some(addr) = file_addr() { return Some(addr); } if port_arg == 0 { None } else { Some(SocketAddr::new(bind_ip, port_arg)) } } /// Apply gateway-wide values from `[openshell.gateway]` onto `RunArgs` for /// every argument that is still sourced from clap's built-in default. /// /// The function intentionally does not touch `database_url` — that secret is /// env-only and the loader already rejected it when it appears in the file. fn merge_file_into_args(args: &mut RunArgs, file: &GatewayFileSection, matches: &ArgMatches) { if let Some(addr) = file.bind_address { if arg_defaulted(matches, "bind_address") { args.bind_address = addr.ip(); } if arg_defaulted(matches, "port") { args.port = addr.port(); } } // Note: file's full health_bind_address / metrics_bind_address are // consumed in `run_from_args`'s listener-resolution block so the IP // half of the SocketAddr is preserved. Copying only the port here // would silently relocate a loopback-intended listener onto the // public bind address. if let Some(level) = &file.log_level && arg_defaulted(matches, "log_level") { args.log_level.clone_from(level); } if let Some(drivers) = &file.compute_drivers && arg_defaulted(matches, "drivers") { args.drivers.clone_from(drivers); } if let Some(sans) = &file.server_sans && args.server_sans.is_empty() && arg_defaulted(matches, "server_sans") { args.server_sans.clone_from(sans); } if let Some(enabled) = file.enable_loopback_service_http && arg_defaulted(matches, "enable_loopback_service_http") { args.enable_loopback_service_http = enabled; } if let Some(mtls_auth) = &file.mtls_auth && arg_defaulted(matches, "enable_mtls_auth") { args.enable_mtls_auth = mtls_auth.enabled; } if let Some(disabled) = file.disable_tls && arg_defaulted(matches, "disable_tls") { args.disable_tls = disabled; } // TLS gateway listener fields if let Some(tls) = &file.tls { if args.tls_cert.is_none() && arg_defaulted(matches, "tls_cert") { args.tls_cert = Some(tls.cert_path.clone()); } if args.tls_key.is_none() && arg_defaulted(matches, "tls_key") { args.tls_key = Some(tls.key_path.clone()); } if args.tls_client_ca.is_none() && arg_defaulted(matches, "tls_client_ca") { args.tls_client_ca.clone_from(&tls.client_ca_path); } } // OIDC fields if let Some(oidc) = &file.oidc { if args.oidc_issuer.is_none() && arg_defaulted(matches, "oidc_issuer") { args.oidc_issuer = Some(oidc.issuer.clone()); } if arg_defaulted(matches, "oidc_audience") { args.oidc_audience.clone_from(&oidc.audience); } if arg_defaulted(matches, "oidc_jwks_ttl") { args.oidc_jwks_ttl = oidc.jwks_ttl_secs; } if arg_defaulted(matches, "oidc_roles_claim") { args.oidc_roles_claim.clone_from(&oidc.roles_claim); } if arg_defaulted(matches, "oidc_admin_role") { args.oidc_admin_role.clone_from(&oidc.admin_role); } if arg_defaulted(matches, "oidc_user_role") { args.oidc_user_role.clone_from(&oidc.user_role); } if arg_defaulted(matches, "oidc_scopes_claim") { args.oidc_scopes_claim.clone_from(&oidc.scopes_claim); } } if let Some(requests) = file.grpc_rate_limit_requests && args.grpc_rate_limit_requests.is_none() && arg_defaulted(matches, "grpc_rate_limit_requests") { args.grpc_rate_limit_requests = Some(requests); } if let Some(window) = file.grpc_rate_limit_window_seconds && args.grpc_rate_limit_window_seconds.is_none() && arg_defaulted(matches, "grpc_rate_limit_window_seconds") { args.grpc_rate_limit_window_seconds = Some(window); } } fn validate_grpc_rate_limit_args(requests: Option, window_seconds: Option) -> Result<()> { let disabled = matches!(requests, Some(0)) || matches!(window_seconds, Some(0)); if disabled { return Ok(()); } if matches!( (requests, window_seconds), (Some(requests), None) if requests > 0 ) || matches!( (requests, window_seconds), (None, Some(window_seconds)) if window_seconds > 0 ) { return Err(miette::miette!( "gRPC rate limiting requires both --grpc-rate-limit-requests and --grpc-rate-limit-window-seconds (TOML keys grpc_rate_limit_requests and grpc_rate_limit_window_seconds) to be positive; set either value to 0 to disable" )); } Ok(()) } fn normalize_compute_driver_socket_args(args: &mut RunArgs, matches: &ArgMatches) -> Result<()> { let Some(socket) = args.compute_driver_socket.as_ref() else { return Ok(()); }; if socket.as_os_str().is_empty() { return Err(miette::miette!( "--compute-driver-socket must not be an empty path" )); } if arg_defaulted(matches, "drivers") { return Err(miette::miette!( "--compute-driver-socket requires --drivers or OPENSHELL_DRIVERS= to select a non-reserved compute driver name" )); } match args.drivers.as_slice() { [driver] => { let driver = openshell_core::config::normalize_compute_driver_name(driver) .map_err(|err| miette::miette!("{err}"))?; if matches!( driver.parse::().ok(), Some( ComputeDriverKind::Docker | ComputeDriverKind::Podman | ComputeDriverKind::Kubernetes | ComputeDriverKind::Vm ) ) { return Err(miette::miette!( "--compute-driver-socket cannot be combined with reserved built-in compute driver '{driver}'" )); } args.drivers[0] = driver; Ok(()) } drivers => Err(miette::miette!( "--compute-driver-socket requires exactly one compute driver name, got: {}", drivers.join(",") )), } } fn effective_single_driver(args: &RunArgs) -> Option { match args.drivers.as_slice() { [] => openshell_core::config::detect_driver(), [driver] => driver.parse().ok(), _ => None, } } fn is_singleplayer_driver(args: &RunArgs) -> bool { matches!( effective_single_driver(args), Some(ComputeDriverKind::Docker | ComputeDriverKind::Podman | ComputeDriverKind::Vm) ) } fn resolve_mtls_auth_enabled( args: &RunArgs, matches: &ArgMatches, file: Option<&ConfigFile>, ) -> bool { let file_configured = file .and_then(|f| f.openshell.gateway.mtls_auth.as_ref()) .is_some(); if file_configured || !arg_defaulted(matches, "enable_mtls_auth") { return args.enable_mtls_auth; } if args.disable_tls || args.tls_client_ca.is_none() || args.oidc_issuer.is_some() { return false; } is_singleplayer_driver(args) } #[cfg(test)] mod tests { use super::{Cli, command}; use crate::TEST_ENV_LOCK as ENV_LOCK; use clap::Parser; use std::net::{IpAddr, Ipv4Addr}; struct EnvVarGuard { key: &'static str, original: Option, } impl EnvVarGuard { #[allow(unsafe_code)] fn set(key: &'static str, value: &str) -> Self { let original = std::env::var(key).ok(); // SAFETY: tests serialize environment mutation with ENV_LOCK. unsafe { std::env::set_var(key, value) }; Self { key, original } } #[allow(unsafe_code)] fn remove(key: &'static str) -> Self { let original = std::env::var(key).ok(); // SAFETY: tests serialize environment mutation with ENV_LOCK. unsafe { std::env::remove_var(key) }; Self { key, original } } } impl Drop for EnvVarGuard { #[allow(unsafe_code)] fn drop(&mut self) { match self.original.as_deref() { // SAFETY: tests serialize environment mutation with ENV_LOCK. Some(value) => unsafe { std::env::set_var(self.key, value) }, // SAFETY: tests serialize environment mutation with ENV_LOCK. None => unsafe { std::env::remove_var(self.key) }, } } } #[test] fn command_uses_gateway_binary_name() { let mut help = Vec::new(); command().write_long_help(&mut help).unwrap(); let help = String::from_utf8(help).unwrap(); assert!(help.contains("openshell-gateway")); } #[test] fn command_exposes_version() { let cmd = command(); let version = cmd.get_version().unwrap(); assert_eq!(version.to_string(), openshell_core::VERSION); } #[test] fn command_defaults_bind_address_to_loopback() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::LOCALHOST)); } #[test] fn command_parses_bind_address() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let cli = Cli::try_parse_from([ "openshell-gateway", "--db-url", "sqlite::memory:", "--bind-address", "127.0.0.1", ]) .unwrap(); assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::LOCALHOST)); } #[test] fn command_reads_bind_address_from_env() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::set("OPENSHELL_BIND_ADDRESS", "0.0.0.0"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]) .expect("env should provide bind address"); assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::UNSPECIFIED)); } #[test] fn command_enables_loopback_service_http_by_default() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); assert!(cli.run.enable_loopback_service_http); } #[test] fn command_disables_loopback_service_http_with_false_value() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP"); let cli = Cli::try_parse_from([ "openshell-gateway", "--db-url", "sqlite::memory:", "--enable-loopback-service-http=false", ]) .unwrap(); assert!(!cli.run.enable_loopback_service_http); } #[test] fn command_reads_loopback_service_http_from_env() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::set("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP", "false"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); assert!(!cli.run.enable_loopback_service_http); } #[test] fn command_reads_server_san_from_env() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::set("OPENSHELL_SERVER_SAN", "*.apps.example.com"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); assert_eq!(cli.run.server_sans, vec!["*.apps.example.com".to_string()]); } #[test] fn command_reads_mtls_auth_from_env() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::set("OPENSHELL_ENABLE_MTLS_AUTH", "true"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); assert!(cli.run.enable_mtls_auth); } #[test] fn command_parses_grpc_rate_limit_flags() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_GRPC_RATE_LIMIT_REQUESTS"); let _g2 = EnvVarGuard::remove("OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS"); let cli = Cli::try_parse_from([ "openshell-gateway", "--db-url", "sqlite::memory:", "--grpc-rate-limit-requests", "120", "--grpc-rate-limit-window-seconds", "60", ]) .unwrap(); assert_eq!(cli.run.grpc_rate_limit_requests, Some(120)); assert_eq!(cli.run.grpc_rate_limit_window_seconds, Some(60)); } #[test] fn validate_grpc_rate_limit_args_requires_positive_pair() { assert!(super::validate_grpc_rate_limit_args(None, None).is_ok()); assert!(super::validate_grpc_rate_limit_args(Some(0), None).is_ok()); assert!(super::validate_grpc_rate_limit_args(None, Some(0)).is_ok()); assert!(super::validate_grpc_rate_limit_args(Some(0), Some(60)).is_ok()); assert!(super::validate_grpc_rate_limit_args(Some(120), Some(0)).is_ok()); assert!(super::validate_grpc_rate_limit_args(Some(120), Some(60)).is_ok()); assert!(super::validate_grpc_rate_limit_args(Some(120), None).is_err()); assert!(super::validate_grpc_rate_limit_args(None, Some(60)).is_err()); } #[test] fn command_rejects_removed_driver_flags() { let err = command() .try_get_matches_from([ "openshell-gateway", "--db-url", "sqlite::memory:", "--sandbox-image", "example/sandbox:latest", ]) .expect_err("driver implementation flags should not be accepted"); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } #[test] fn command_rejects_removed_ssh_endpoint_flags() { for flag in [ "--ssh-gateway-host", "--ssh-gateway-port", "--sandbox-ssh-port", ] { let err = command() .try_get_matches_from([ "openshell-gateway", "--db-url", "sqlite::memory:", flag, "x", ]) .expect_err("SSH endpoint flags should not be accepted"); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } } #[test] fn generate_certs_subcommand_parses_without_db_url() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); let cli = Cli::try_parse_from([ "openshell-gateway", "generate-certs", "--namespace", "openshell", "--server-secret-name", "openshell-server-tls", "--client-secret-name", "openshell-client-tls", "--jwt-secret-name", "openshell-jwt-keys", "--server-san", "openshell.example.com", "--server-san", "10.0.0.1", ]) .expect("generate-certs should parse without --db-url"); assert!(matches!( cli.command, Some(super::Commands::GenerateCerts(_)) )); } #[test] fn generate_certs_local_mode_parses_without_kube_flags() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); let cli = Cli::try_parse_from([ "openshell-gateway", "generate-certs", "--output-dir", "/tmp/openshell-certgen", ]) .expect("--output-dir should make namespace/secret-name flags optional"); assert!(matches!( cli.command, Some(super::Commands::GenerateCerts(_)) )); } #[test] fn generate_certs_jwt_only_parses_without_tls_secret_names() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); let cli = Cli::try_parse_from([ "openshell-gateway", "generate-certs", "--namespace", "openshell", "--jwt-only", "--jwt-secret-name", "openshell-jwt-keys", ]) .expect("--jwt-only should make TLS secret-name flags optional"); assert!(matches!( cli.command, Some(super::Commands::GenerateCerts(_)) )); } #[test] fn bare_invocation_with_no_db_url_parses_for_runtime_defaults() { // db_url is Option at the clap level so subcommand parsing // does not require it. The Run path fills a default URL from XDG // state when neither CLI nor env supplied one. let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::remove("OPENSHELL_DB_URL"); let cli = Cli::try_parse_from(["openshell-gateway"]).expect("parses without --db-url"); assert!(cli.command.is_none()); assert!(cli.run.db_url.is_none()); } // ── Config-file merge tests ────────────────────────────────────────── // // `merge_file_into_args` is the bridge between `config_file::ConfigFile` // and `RunArgs`. These cases lock in the precedence rule: // // CLI flag > OPENSHELL_* env var > TOML file > built-in default // // by exercising each combination on representative gateway fields. use super::{ConfigFile, merge_file_into_args}; use clap::FromArgMatches; fn parse_with_args(argv: &[&str]) -> (super::RunArgs, clap::ArgMatches) { let matches = command().try_get_matches_from(argv).expect("parses"); let cli = Cli::from_arg_matches(&matches).expect("from arg matches"); (cli.run, matches) } fn config_file_from_toml(toml: &str) -> ConfigFile { toml::from_str(toml).expect("valid TOML in test fixture") } #[test] fn default_config_path_is_loaded_only_when_present() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); let _g1 = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _g2 = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path().to_str().unwrap()); let (args, _) = parse_with_args(&["openshell-gateway"]); assert_eq!(super::resolve_config_path(&args).unwrap(), None); let config = tmp.path().join("openshell").join("gateway.toml"); std::fs::create_dir_all(config.parent().unwrap()).unwrap(); std::fs::write(&config, "[openshell]\nversion = 1\n").unwrap(); assert_eq!(super::resolve_config_path(&args).unwrap(), Some(config)); } #[test] fn explicit_config_path_is_returned_even_when_missing() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let (args, _) = parse_with_args(&["openshell-gateway", "--config", "/tmp/missing.toml"]); assert_eq!( super::resolve_config_path(&args).unwrap(), Some(std::path::PathBuf::from("/tmp/missing.toml")) ); } #[test] fn runtime_defaults_populate_database_url_from_xdg_state() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); let _g2 = EnvVarGuard::set("XDG_STATE_HOME", tmp.path().to_str().unwrap()); let (mut args, _) = parse_with_args(&["openshell-gateway", "--disable-tls"]); let local_tls = super::apply_runtime_defaults(&mut args).unwrap(); let expected = format!( "sqlite:{}", tmp.path().join("openshell/gateway/openshell.db").display() ); assert!(local_tls.is_none()); assert_eq!(args.db_url.as_deref(), Some(expected.as_str())); assert!(tmp.path().join("openshell/gateway").is_dir()); } #[test] fn runtime_defaults_use_complete_local_tls_bundle() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let state = tempfile::tempdir().unwrap(); let tls = tempfile::tempdir().unwrap(); let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); let _g2 = EnvVarGuard::remove("OPENSHELL_TLS_CERT"); let _g3 = EnvVarGuard::remove("OPENSHELL_TLS_KEY"); let _g4 = EnvVarGuard::remove("OPENSHELL_TLS_CLIENT_CA"); let _g5 = EnvVarGuard::remove("OPENSHELL_DISABLE_TLS"); let _g6 = EnvVarGuard::set("XDG_STATE_HOME", state.path().to_str().unwrap()); let _g7 = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", tls.path().to_str().unwrap()); std::fs::create_dir_all(tls.path().join("server")).unwrap(); std::fs::create_dir_all(tls.path().join("client")).unwrap(); for rel in [ "ca.crt", "server/tls.crt", "server/tls.key", "client/tls.crt", "client/tls.key", ] { std::fs::write(tls.path().join(rel), "pem").unwrap(); } let (mut args, _) = parse_with_args(&["openshell-gateway"]); let local_tls = super::apply_runtime_defaults(&mut args) .unwrap() .expect("complete bundle should be returned"); assert_eq!(args.tls_cert, Some(tls.path().join("server/tls.crt"))); assert_eq!(args.tls_key, Some(tls.path().join("server/tls.key"))); assert_eq!(args.tls_client_ca, Some(tls.path().join("ca.crt"))); assert_eq!(local_tls.client_cert, tls.path().join("client/tls.crt")); } #[test] fn mtls_auth_auto_defaults_for_local_tls_driver() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_MTLS_AUTH"); let (args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--drivers", "docker", "--tls-cert", "/tmp/server.crt", "--tls-key", "/tmp/server.key", "--tls-client-ca", "/tmp/ca.crt", ]); assert!(super::resolve_mtls_auth_enabled(&args, &matches, None)); } #[test] fn mtls_auth_does_not_auto_default_for_kubernetes_driver() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_MTLS_AUTH"); let (args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--drivers", "kubernetes", "--tls-cert", "/tmp/server.crt", "--tls-key", "/tmp/server.key", "--tls-client-ca", "/tmp/ca.crt", ]); assert!(!super::resolve_mtls_auth_enabled(&args, &matches, None)); } #[test] fn file_mtls_auth_value_overrides_local_auto_default() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_MTLS_AUTH"); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--drivers", "docker", "--tls-cert", "/tmp/server.crt", "--tls-key", "/tmp/server.key", "--tls-client-ca", "/tmp/ca.crt", ]); let file = config_file_from_toml( r" [openshell.gateway.mtls_auth] enabled = false ", ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert!(!super::resolve_mtls_auth_enabled( &args, &matches, Some(&file) )); } #[test] fn file_value_applies_when_cli_uses_default() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let _g2 = EnvVarGuard::remove("OPENSHELL_SERVER_PORT"); let _g3 = EnvVarGuard::remove("OPENSHELL_LOG_LEVEL"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r#" [openshell.gateway] bind_address = "0.0.0.0:9090" log_level = "debug" "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.bind_address, IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(args.port, 9090); assert_eq!(args.log_level, "debug"); } #[test] fn cli_flag_overrides_file_value() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let _g2 = EnvVarGuard::remove("OPENSHELL_LOG_LEVEL"); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--log-level", "warn", ]); let file = config_file_from_toml( r#" [openshell.gateway] log_level = "debug" "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.log_level, "warn", "CLI flag must win over file"); } #[test] fn env_var_overrides_file_value() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::set("OPENSHELL_LOG_LEVEL", "trace"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r#" [openshell.gateway] log_level = "debug" "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.log_level, "trace", "env var must win over file"); } #[test] fn file_oidc_block_populates_oidc_args() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_OIDC_ISSUER"); let _g2 = EnvVarGuard::remove("OPENSHELL_OIDC_AUDIENCE"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r#" [openshell.gateway.oidc] issuer = "https://idp.example.com" audience = "openshell-cli" "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.oidc_issuer.as_deref(), Some("https://idp.example.com")); assert_eq!(args.oidc_audience, "openshell-cli"); } #[test] fn file_grpc_rate_limit_populates_args_when_cli_omits() { let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r" [openshell.gateway] grpc_rate_limit_requests = 100 grpc_rate_limit_window_seconds = 30 ", ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.grpc_rate_limit_requests, Some(100)); assert_eq!(args.grpc_rate_limit_window_seconds, Some(30)); } #[test] fn cli_grpc_rate_limit_overrides_file_value() { let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--grpc-rate-limit-requests", "20", ]); let file = config_file_from_toml( r" [openshell.gateway] grpc_rate_limit_requests = 100 grpc_rate_limit_window_seconds = 30 ", ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.grpc_rate_limit_requests, Some(20)); assert_eq!(args.grpc_rate_limit_window_seconds, Some(30)); } #[test] fn aux_listener_preserves_file_ip_against_public_bind() { use std::net::SocketAddr; let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::remove("OPENSHELL_HEALTH_PORT"); let (_args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file_addr: SocketAddr = "127.0.0.1:8081".parse().unwrap(); let resolved = super::resolve_aux_listener( IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0, &matches, "health_port", || Some(file_addr), ); assert_eq!( resolved, Some(file_addr), "TOML health_bind_address 127.0.0.1:8081 must not be relocated to 0.0.0.0:8081" ); } #[test] fn aux_listener_cli_port_overrides_file_addr() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::remove("OPENSHELL_HEALTH_PORT"); let (_args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--health-port", "9999", ]); let file_addr: std::net::SocketAddr = "127.0.0.1:8081".parse().unwrap(); let resolved = super::resolve_aux_listener( IpAddr::V4(Ipv4Addr::UNSPECIFIED), 9999, &matches, "health_port", || Some(file_addr), ); assert_eq!( resolved, Some("0.0.0.0:9999".parse().unwrap()), "CLI flag must win over file value" ); } #[test] fn file_disable_tls_applies() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::remove("OPENSHELL_DISABLE_TLS"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r" [openshell.gateway] disable_tls = true ", ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert!(args.disable_tls); } #[test] fn file_ssh_session_ttl_secs_is_parsed() { // The loader must accept and surface the documented key. The actual // wiring into `Config` happens in `run_from_args` against the parsed // file (not via `merge_file_into_args`, since there is no matching // `RunArgs` field), so this test pins the schema half. let file = config_file_from_toml( r" [openshell.gateway] ssh_session_ttl_secs = 1234 ", ); assert_eq!(file.openshell.gateway.ssh_session_ttl_secs, Some(1234)); } #[test] fn singleplayer_driver_matches_only_one_local_driver() { for driver in ["docker", "podman", "vm"] { let (args, _) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--drivers", driver, ]); assert!( super::is_singleplayer_driver(&args), "{driver} should be singleplayer" ); } let (k8s, _) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--drivers", "kubernetes", ]); assert!(!super::is_singleplayer_driver(&k8s)); let (multi, _) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--drivers", "docker,podman", ]); assert!(!super::is_singleplayer_driver(&multi)); } #[test] fn compute_driver_socket_flag_uses_explicit_driver_name() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER_SOCKET"); let _g2 = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--drivers", "Kyma", "--compute-driver-socket", "/run/openshell/kyma.sock", ]); super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap(); assert_eq!( args.compute_driver_socket.as_deref(), Some(std::path::Path::new("/run/openshell/kyma.sock")) ); assert_eq!(args.drivers, ["kyma"]); assert!(super::effective_single_driver(&args).is_none()); } #[test] fn compute_driver_socket_requires_explicit_driver_name() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER_SOCKET"); let _g2 = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--compute-driver-socket", "/run/openshell/kyma.sock", ]); let err = super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap_err(); assert!( err.to_string().contains("requires --drivers "), "unexpected error: {err}" ); } #[test] fn compute_driver_socket_rejects_reserved_builtin_drivers() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER_SOCKET"); let _g2 = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--drivers", "docker", "--compute-driver-socket", "/run/openshell/extension.sock", ]); let err = super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap_err(); assert!( err.to_string() .contains("cannot be combined with reserved built-in compute driver 'docker'"), "unexpected error: {err}" ); } #[test] fn compute_driver_socket_rejects_vm_endpoint() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER_SOCKET"); let _g2 = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--drivers", "vm", "--compute-driver-socket", "/run/openshell/vm.sock", ]); let err = super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap_err(); assert!( err.to_string() .contains("cannot be combined with reserved built-in compute driver 'vm'"), "unexpected error: {err}" ); } #[test] fn compute_driver_socket_reads_from_env_var() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::set( "OPENSHELL_COMPUTE_DRIVER_SOCKET", "/var/run/openshell/kyma.sock", ); let _g2 = EnvVarGuard::set("OPENSHELL_DRIVERS", "kyma"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap(); assert_eq!( args.compute_driver_socket.as_deref(), Some(std::path::Path::new("/var/run/openshell/kyma.sock")) ); assert_eq!(args.drivers, ["kyma"]); } #[test] fn file_populates_service_routing_fields() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_SERVER_SAN"); let _g2 = EnvVarGuard::remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r#" [openshell.gateway] server_sans = ["gateway.local", "*.dev.openshell.localhost"] enable_loopback_service_http = false "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!( args.server_sans, vec![ "gateway.local".to_string(), "*.dev.openshell.localhost".to_string() ] ); assert!(!args.enable_loopback_service_http); } #[test] fn env_var_overrides_file_loopback_service_http() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::set("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP", "true"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r" [openshell.gateway] enable_loopback_service_http = false ", ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert!( args.enable_loopback_service_http, "env var must win over file" ); } #[test] fn server_config_preparation_ignores_unselected_driver_tables() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let state = tempfile::tempdir().unwrap(); let local_tls = tempfile::tempdir().unwrap(); let _g1 = EnvVarGuard::set("XDG_STATE_HOME", state.path().to_str().unwrap()); let _g2 = EnvVarGuard::set( "OPENSHELL_LOCAL_TLS_DIR", local_tls.path().to_str().unwrap(), ); let config_path = state.path().join("gateway.toml"); std::fs::write( &config_path, r#" [openshell.drivers.docker] unknown_docker_key = true [openshell.drivers.vm] mem_mib = "not-a-number" "#, ) .unwrap(); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--config", config_path.to_str().unwrap(), "--db-url", "sqlite::memory:", "--drivers", "podman", "--disable-tls", ]); let prepared = super::prepare_server_config(&mut args, &matches).expect("server config is prepared"); assert_eq!(prepared.config.compute_drivers, vec!["podman".to_string()]); let file = prepared.config_file.expect("config file is preserved"); assert!(file.openshell.drivers.contains_key("docker")); assert!(file.openshell.drivers.contains_key("vm")); } #[test] fn driver_inherits_shared_image_from_gateway_section() { // [openshell.gateway].default_image inherits into the K8s driver // table when the driver-specific table does not set it. let file = config_file_from_toml( r#" [openshell.gateway] default_image = "ghcr.io/nvidia/openshell/sandbox:1.0" [openshell.drivers.kubernetes] namespace = "agents" "#, ); let merged = crate::config_file::driver_table( super::ComputeDriverKind::Kubernetes.as_str(), &file.openshell.gateway, file.openshell.drivers.get("kubernetes"), ); let parsed = merged .try_into::() .expect("merged table deserializes"); assert_eq!(parsed.default_image, "ghcr.io/nvidia/openshell/sandbox:1.0"); assert_eq!(parsed.namespace, "agents"); } #[test] fn driver_specific_value_overrides_gateway_inheritance() { let file = config_file_from_toml( r#" [openshell.gateway] default_image = "gateway-default:1.0" [openshell.drivers.kubernetes] default_image = "k8s-specific:1.0" "#, ); let merged = crate::config_file::driver_table( super::ComputeDriverKind::Kubernetes.as_str(), &file.openshell.gateway, file.openshell.drivers.get("kubernetes"), ); let parsed = merged .try_into::() .expect("deserializes"); assert_eq!(parsed.default_image, "k8s-specific:1.0"); } }