Files
Evan Lezar a5161d0bc3 refactor(server): normalize compute driver config acquisition (#1974)
* refactor(server): remove unused compute runtime constructor parameter

Signed-off-by: Evan Lezar <elezar@nvidia.com>

* refactor(server): normalize compute driver type imports

Signed-off-by: Evan Lezar <elezar@nvidia.com>

* refactor(server): key driver config tables by name

Signed-off-by: Evan Lezar <elezar@nvidia.com>

* refactor(server): normalize compute driver config acquisition

Signed-off-by: Evan Lezar <elezar@nvidia.com>

---------

Signed-off-by: Evan Lezar <elezar@nvidia.com>
2026-06-29 18:59:08 +02:00

1813 lines
62 KiB
Rust

// 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<Commands>,
#[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<PathBuf>,
/// 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<PathBuf>,
/// Path to TLS private key file (required unless --disable-tls).
#[arg(long, env = "OPENSHELL_TLS_KEY")]
tls_key: Option<PathBuf>,
/// Path to CA certificate for client certificate verification (mTLS).
#[arg(long, env = "OPENSHELL_TLS_CLIENT_CA")]
tls_client_ca: Option<PathBuf>,
/// 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<String>,
/// 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<String>,
/// 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<PathBuf>,
/// 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<String>,
/// 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<u64>,
/// 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<u64>,
/// 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<String>,
/// 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<ServerStartupConfig> {
// 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<ConfigFile> = 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<String, String> {
openshell_core::config::normalize_compute_driver_name(value)
}
fn resolve_config_path(args: &RunArgs) -> Result<Option<PathBuf>> {
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<Option<LocalTlsPaths>> {
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<SocketAddr>,
) -> Option<SocketAddr> {
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<u64>, window_seconds: Option<u64>) -> 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 <name> or OPENSHELL_DRIVERS=<name> 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::<ComputeDriverKind>().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<ComputeDriverKind> {
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<String>,
}
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<String> 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 <name>"),
"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::<openshell_driver_kubernetes::KubernetesComputeConfig>()
.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::<openshell_driver_kubernetes::KubernetesComputeConfig>()
.expect("deserializes");
assert_eq!(parsed.default_image, "k8s-specific:1.0");
}
}