mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-15 05:09:44 +08:00
Previously, Docker was auto-detected when the CLI was installed or a candidate Unix socket existed. Neither check verified that the Docker API was responsive. A similar check was done when auto-detecting Podman in the past, but was replaced in1f07bf04with a probe of candidate Podman sockets instead. This change applies the functional API probing approach introduced for Podman in1f07bf04to Docker. It also makes Docker driver initialization use the same socket-selection mechanism as Docker auto-detection instead of Bollard’s local defaults. This means the previously auto-detectable Docker socket paths $HOME/.docker/run/docker.sock and $XDG_RUNTIME_DIR/docker.sock will actually be usable. When no working compute driver can be auto-detected, the gateway exits early with a message saying as much: > configuration error: no compute driver configured and auto-detection found no > suitable driver; set --drivers or OPENSHELL_DRIVERS to kubernetes, podman, > docker, or vm This makes for a better user experience when installing OpenShell without an available supported compute driver. Signed-off-by: Kris Hicks <khicks@nvidia.com>
1824 lines
62 KiB
Rust
1824 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). 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_gateway_interceptors(
|
|
file.as_ref()
|
|
.map(|f| f.openshell.gateway.interceptors.clone())
|
|
.unwrap_or_default(),
|
|
)
|
|
.with_server_sans(args.server_sans.clone())
|
|
.with_loopback_service_http(args.enable_loopback_service_http);
|
|
if let Some(sources) = file
|
|
.as_ref()
|
|
.and_then(|file| file.openshell.gateway.provider_profile_sources.clone())
|
|
{
|
|
config = config.with_provider_profile_sources(sources);
|
|
}
|
|
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");
|
|
}
|
|
}
|