diff --git a/.github/workflows/rpm-package.yml b/.github/workflows/rpm-package.yml index dfafcd933..aba623c9b 100644 --- a/.github/workflows/rpm-package.yml +++ b/.github/workflows/rpm-package.yml @@ -113,6 +113,44 @@ jobs: echo "=== Built RPMs ===" ls -lah artifacts/ + - name: Verify runtime-neutral RPM installation + run: | + set -euo pipefail + + cli_rpm="" + gateway_rpm="" + for rpm_file in artifacts/*.rpm; do + case "$(rpm -qp --queryformat '%{NAME}' "$rpm_file")" in + openshell) cli_rpm="$rpm_file" ;; + openshell-gateway) gateway_rpm="$rpm_file" ;; + esac + done + test -n "$cli_rpm" + test -n "$gateway_rpm" + + if rpm -qp --recommends "$cli_rpm" | grep -Eq '^podman([[:space:]]|$)'; then + echo "::error::The openshell RPM still recommends Podman" + exit 1 + fi + if rpm -qp --requires "$gateway_rpm" | grep -Eq '^podman([[:space:]]|$)'; then + echo "::error::The openshell-gateway RPM still requires Podman" + exit 1 + fi + + if rpm -q podman >/dev/null 2>&1; then + dnf remove -y podman + fi + if rpm -q podman >/dev/null 2>&1; then + echo "::error::Podman must be absent before the package install test" + exit 1 + fi + + dnf install -y "$cli_rpm" "$gateway_rpm" + if rpm -q podman >/dev/null 2>&1; then + echo "::error::Installing OpenShell unexpectedly installed Podman" + exit 1 + fi + - name: Upload RPM artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/Cargo.lock b/Cargo.lock index 13b670f55..13dfdc002 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3906,6 +3906,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite 0.26.2", "toml", + "toml_edit", "tonic", "tower 0.5.3", "tower-http 0.6.8", diff --git a/Cargo.toml b/Cargo.toml index f450cd5c8..dd39ead0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yml = "0.0.12" toml = "0.8" +toml_edit = "0.22" apollo-parser = "0.8.5" tower-mcp-types = "0.12.0" diff --git a/architecture/build.md b/architecture/build.md index a3cb2e25f..112cf26b1 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -86,9 +86,10 @@ Runtime layout: gateway binaries must not reference `GLIBC_*` symbols newer than `GLIBC_2.28`; release workflows verify this before publishing artifacts. The gateway bundles z3, so the image does not need a distro-provided z3 runtime. -- **VM driver**: host GNU-linked binary installed at - `/usr/libexec/openshell/openshell-driver-vm` in Linux packages and published - as a release artifact. Linux GNU VM driver binaries must not reference +- **VM driver**: host GNU-linked binary included at + `/usr/libexec/openshell/openshell-driver-vm` in Debian packages and published + as a standalone release artifact for RPM hosts. Homebrew installs it in the + formula libexec directory; RPM and Snap do not bundle it. Linux GNU VM driver binaries must not reference `GLIBC_*` symbols newer than `GLIBC_2.28`; release workflows verify this before publishing artifacts. - **Supervisor**: `scratch` base, static musl binary at `/openshell-sandbox`. diff --git a/architecture/gateway.md b/architecture/gateway.md index d873b2a10..0a8c44fab 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -429,6 +429,27 @@ requested present -> generate and write. This guards continuity across restarts and upgrades while still recovering cleanly if an operator deletes everything and starts over. +### Package installer invariants + +Package installers follow these boundaries across Debian, RPM, and Homebrew: + +- Select the supported native package method deterministically for the host. +- Install matching OpenShell CLI and gateway artifacts in the package manager's + standard executable path. +- Install and enable the gateway as a supervised user service. +- Preserve existing gateway configuration and state across installs and + upgrades. +- Keep the listener on loopback with TLS unless the operator changes it. +- Do not install, remove, start, or configure optional Docker and Podman + runtimes. +- Leave compute-driver selection automatic unless the operator explicitly pins + a driver. +- Treat package installation and gateway health as separate outcomes. A + gateway startup failure leaves the package and service installed, returns a + nonzero installer status, and prints recovery instructions. +- Remain safe to rerun after the operator fixes an optional runtime or service + prerequisite. + Operators who manage TLS PKI with cert-manager enable `certManager.enabled`; cert-manager takes precedence over built-in TLS generation and the chart still renders the JWT-only hook. Operators who pre-create all TLS and JWT Secrets can @@ -448,6 +469,18 @@ Driver implementation settings live in the TOML driver tables. See `docs/reference/gateway-config.mdx` for worked per-driver examples and RFC 0003 for the full schema. +`openshell-gateway config detect-driver` exposes the gateway's automatic driver +detection as a side-effect-free, machine-readable command. +`openshell-gateway config set` provides typed, comment-preserving updates for +operator-managed settings. It resolves the same explicit or XDG config path as +gateway startup, validates the complete document, and replaces it atomically. +Service environment overrides remain an operator escape hatch because they +take precedence over later TOML edits. + +When selection remains automatic, the gateway probes available runtimes at +every process start. Runtime-specific recovery guidance belongs to gateway and +installer diagnostics so it stays synchronized with detection behavior. + `database_url` is env-only and rejected when present in the file (`OPENSHELL_DB_URL` / `--db-url`). diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index ba6b9d401..0272b4bbe 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -113,30 +113,40 @@ impl FromStr for ComputeDriverKind { } } -/// Auto-detect the appropriate compute driver based on the runtime environment. +/// Detect available compute drivers based on the runtime environment. /// /// Priority order: Kubernetes → Podman → Docker. /// VM is never auto-detected (requires explicit `--drivers vm`). /// -/// Returns the first driver where the environment check passes. -/// Returns `None` if no compatible driver is found. -pub fn detect_driver() -> Option { +/// Returns every available driver in selection priority order. +/// +/// VM is excluded because it requires explicit operator selection. +#[must_use] +pub fn detect_drivers() -> Vec { + let mut drivers = Vec::new(); + // Kubernetes: check for KUBERNETES_SERVICE_HOST env var (set inside pods) if std::env::var_os("KUBERNETES_SERVICE_HOST").is_some() { - return Some(ComputeDriverKind::Kubernetes); + drivers.push(ComputeDriverKind::Kubernetes); } // Podman: check for a reachable local API socket. if is_podman_available() { - return Some(ComputeDriverKind::Podman); + drivers.push(ComputeDriverKind::Podman); } // Docker: check if the CLI is available or a local Docker socket exists. if is_docker_available() { - return Some(ComputeDriverKind::Docker); + drivers.push(ComputeDriverKind::Docker); } - None + drivers +} + +/// Returns the first available driver in automatic selection priority order. +#[must_use] +pub fn detect_driver() -> Option { + detect_drivers().into_iter().next() } /// Check if a binary is available on the system PATH. diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md index 724bde06c..149632812 100644 --- a/crates/openshell-driver-vm/README.md +++ b/crates/openshell-driver-vm/README.md @@ -263,7 +263,13 @@ auto-detection. Set `OPENSHELL_DRIVERS=vm` to force the VM driver. On RPM-family Linux x86_64 and aarch64 systems, `install.sh` installs the `openshell` and `openshell-gateway` RPM packages from the selected release tag. -The RPM gateway package is configured for the Podman driver. +The RPM does not include `openshell-driver-vm`. Install the matching standalone +release artifact under one of the directories the gateway searches +(`~/.local/libexec/openshell`, `/usr/libexec/openshell`, +`/usr/local/libexec/openshell`, or `/usr/local/libexec`), or set +`[openshell.drivers.vm].driver_dir` to its location. The RPM leaves compute +driver selection unset, so the gateway auto-detects Podman or Docker unless VM +is selected explicitly. On Apple Silicon macOS, `install.sh` stages the generated `openshell.rb` formula from the selected release in the `nvidia/openshell` Homebrew tap. diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index b5c9b34d7..2ea5d0f0d 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -77,6 +77,8 @@ pin-project-lite = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } +tempfile = "3" tokio-stream = { workspace = true } sqlx = { workspace = true } reqwest = { workspace = true } @@ -92,7 +94,6 @@ russh = "0.57" rand = { workspace = true } petname = "2" ipnet = "2" -tempfile = "3" rustix = { workspace = true } x509-parser = "0.16" arc-swap = "1" diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 9aee2bc6d..7d34c0dea 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -39,17 +39,19 @@ struct Cli { enum Commands { /// Generate mTLS PKI and write Kubernetes Secrets (Helm pre-install hook). GenerateCerts(certgen::CertgenArgs), + /// Inspect or update the gateway TOML configuration. + Config(crate::config_edit::ConfigArgs), } #[derive(clap::Args, Debug)] #[allow(clippy::struct_excessive_bools)] struct RunArgs { - /// Path to a TOML configuration file (see RFC 0003). + /// Path to the gateway 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")] + /// Gateway startup reads this file. Config subcommands update it. Gateway + /// command-line flags and `OPENSHELL_*` environment variables continue to + /// take precedence over file values at runtime. + #[arg(long, env = "OPENSHELL_GATEWAY_CONFIG", global = true)] config: Option, /// IP address to bind the server, health, and metrics listeners to. @@ -228,6 +230,7 @@ pub async fn run_cli() -> Result<()> { match cli.command { Some(Commands::GenerateCerts(args)) => certgen::run(args).await, + Some(Commands::Config(args)) => crate::config_edit::run(args, cli.run.config), None => Box::pin(run_from_args(cli.run, matches)).await, } } @@ -1075,6 +1078,65 @@ mod tests { )); } + #[test] + fn config_set_uses_explicit_config_path() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _g = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("custom/gateway.toml"); + let path_string = path.to_string_lossy().into_owned(); + + let cli = Cli::try_parse_from([ + "openshell-gateway", + "config", + "set", + "--config", + &path_string, + "--compute-driver", + "podman", + "--bind-address", + "0.0.0.0:17670", + ]) + .expect("config set should parse without runtime arguments"); + let Cli { command, run } = cli; + let Some(super::Commands::Config(args)) = command else { + panic!("expected config subcommand"); + }; + + crate::config_edit::run(args, run.config).unwrap(); + + let loaded = crate::config_file::load(&path).unwrap(); + assert_eq!( + loaded.openshell.gateway.compute_drivers, + Some(vec!["podman".to_string()]) + ); + assert_eq!( + loaded.openshell.gateway.bind_address, + Some("0.0.0.0:17670".parse().unwrap()) + ); + } + + #[test] + fn config_set_requires_at_least_one_setting() { + let error = Cli::try_parse_from(["openshell-gateway", "config", "set"]) + .expect_err("config set without a setting should fail"); + + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn config_detect_driver_parses_without_runtime_arguments() { + let cli = Cli::try_parse_from(["openshell-gateway", "config", "detect-driver"]) + .expect("config detect-driver should parse"); + + assert!(matches!(cli.command, Some(super::Commands::Config(_)))); + } + #[test] fn bare_invocation_with_no_db_url_parses_for_runtime_defaults() { // db_url is Option at the clap level so subcommand parsing diff --git a/crates/openshell-server/src/config_edit.rs b/crates/openshell-server/src/config_edit.rs new file mode 100644 index 000000000..1cac98785 --- /dev/null +++ b/crates/openshell-server/src/config_edit.rs @@ -0,0 +1,296 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Typed, comment-preserving updates to the gateway TOML configuration. + +use std::fs; +use std::io::Write; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use clap::{ArgGroup, Args, Subcommand}; +use miette::{IntoDiagnostic, Result, WrapErr}; +use tempfile::NamedTempFile; +use toml_edit::{Array, DocumentMut, Item, Table, value}; + +use crate::{config_file, defaults}; + +#[derive(Debug, Args)] +pub struct ConfigArgs { + #[command(subcommand)] + command: ConfigCommand, +} + +#[derive(Debug, Subcommand)] +enum ConfigCommand { + /// Detect the compute driver available in the current environment. + DetectDriver, + /// Update selected fields in the gateway TOML configuration. + Set(SetArgs), +} + +#[derive(Debug, Args)] +#[command(group( + ArgGroup::new("setting") + .required(true) + .multiple(true) + .args(["compute_driver", "gateway_bind_address"]) +))] +struct SetArgs { + /// Select one compute driver, or use `auto` to remove an existing pin. + #[arg(long, value_name = "DRIVER")] + compute_driver: Option, + + /// Set the gateway listener socket address. + #[arg(long = "bind-address", value_name = "IP:PORT")] + gateway_bind_address: Option, +} + +pub fn run(args: ConfigArgs, explicit_path: Option) -> Result<()> { + match args.command { + ConfigCommand::DetectDriver => { + println!( + "{}", + detected_driver_name(openshell_core::config::detect_driver()) + ); + } + ConfigCommand::Set(settings) => { + let path = explicit_path.map_or_else(defaults::default_gateway_config_path, Ok)?; + set(&path, &settings)?; + println!("updated gateway configuration: {}", path.display()); + println!("Restart the gateway service for changes to take effect."); + } + } + Ok(()) +} + +fn detected_driver_name(driver: Option) -> &'static str { + driver.map_or("none", openshell_core::ComputeDriverKind::as_str) +} + +fn set(path: &Path, settings: &SetArgs) -> Result<()> { + let original = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) => { + return Err(err) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read gateway config '{}'", path.display())); + } + }; + + let mut document = if original.trim().is_empty() { + DocumentMut::new() + } else { + original + .parse::() + .into_diagnostic() + .wrap_err_with(|| format!("failed to parse gateway config '{}'", path.display()))? + }; + + let openshell = ensure_table(document.as_table_mut(), "openshell")?; + if !openshell.contains_key("version") { + openshell.insert("version", value(i64::from(config_file::SCHEMA_VERSION))); + } + let gateway = ensure_table(openshell, "gateway")?; + + if let Some(driver) = settings.compute_driver.as_deref() { + if driver.eq_ignore_ascii_case("auto") { + gateway.remove("compute_drivers"); + } else { + let driver = openshell_core::config::normalize_compute_driver_name(driver) + .map_err(|err| miette::miette!("{err}"))?; + let mut drivers = Array::new(); + drivers.push(driver); + gateway.insert("compute_drivers", value(drivers)); + } + } + if let Some(bind_address) = settings.gateway_bind_address { + gateway.insert("bind_address", value(bind_address.to_string())); + } + + let rendered = document.to_string(); + config_file::parse(&rendered, path).map_err(|err| miette::miette!("{err}"))?; + write_atomically(path, rendered.as_bytes()) +} + +fn ensure_table<'a>(parent: &'a mut Table, key: &str) -> Result<&'a mut Table> { + if !parent.contains_key(key) { + parent.insert(key, Item::Table(Table::new())); + } + parent + .get_mut(key) + .and_then(Item::as_table_mut) + .ok_or_else(|| miette::miette!("gateway config key '{key}' must be a TOML table")) +} + +fn write_atomically(path: &Path, contents: &[u8]) -> Result<()> { + let parent = path.parent().ok_or_else(|| { + miette::miette!( + "gateway config path '{}' has no parent directory", + path.display() + ) + })?; + let parent = if parent.as_os_str().is_empty() { + Path::new(".") + } else { + parent + }; + fs::create_dir_all(parent) + .into_diagnostic() + .wrap_err_with(|| format!("failed to create config directory '{}'", parent.display()))?; + + let permissions = fs::metadata(path) + .ok() + .map(|metadata| metadata.permissions()); + let mut temp = NamedTempFile::new_in(parent) + .into_diagnostic() + .wrap_err_with(|| format!("failed to create temporary file in '{}'", parent.display()))?; + temp.write_all(contents) + .into_diagnostic() + .wrap_err("failed to write gateway configuration")?; + temp.as_file() + .sync_all() + .into_diagnostic() + .wrap_err("failed to sync gateway configuration")?; + if let Some(permissions) = permissions { + temp.as_file() + .set_permissions(permissions) + .into_diagnostic() + .wrap_err("failed to preserve gateway config permissions")?; + } + temp.persist(path) + .map_err(|err| err.error) + .into_diagnostic() + .wrap_err_with(|| format!("failed to replace gateway config '{}'", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(driver: Option<&str>, bind_address: Option<&str>) -> SetArgs { + SetArgs { + compute_driver: driver.map(str::to_string), + gateway_bind_address: bind_address.map(|value| value.parse().unwrap()), + } + } + + #[test] + fn detect_driver_output_is_machine_readable() { + assert_eq!( + detected_driver_name(Some(openshell_core::ComputeDriverKind::Podman)), + "podman" + ); + assert_eq!( + detected_driver_name(Some(openshell_core::ComputeDriverKind::Docker)), + "docker" + ); + assert_eq!(detected_driver_name(None), "none"); + } + + #[test] + fn set_creates_config_with_driver_and_bind_address() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("openshell/gateway.toml"); + + set(&path, &settings(Some("podman"), Some("0.0.0.0:17670"))).unwrap(); + + let loaded = config_file::load(&path).unwrap(); + assert_eq!(loaded.openshell.version, Some(config_file::SCHEMA_VERSION)); + assert_eq!( + loaded.openshell.gateway.compute_drivers, + Some(vec!["podman".to_string()]) + ); + assert_eq!( + loaded.openshell.gateway.bind_address, + Some("0.0.0.0:17670".parse().unwrap()) + ); + } + + #[test] + fn set_preserves_comments_and_unrelated_settings() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("gateway.toml"); + fs::write( + &path, + "# keep this comment\n[openshell]\nversion = 1\n\n[openshell.gateway]\nlog_level = \"debug\"\ncompute_drivers = [\"docker\"]\n", + ) + .unwrap(); + + set(&path, &settings(Some("podman"), None)).unwrap(); + + let updated = fs::read_to_string(&path).unwrap(); + assert!(updated.contains("# keep this comment")); + assert!(updated.contains("log_level = \"debug\"")); + let loaded = config_file::load(&path).unwrap(); + assert_eq!( + loaded.openshell.gateway.compute_drivers, + Some(vec!["podman".to_string()]) + ); + } + + #[test] + fn auto_removes_driver_without_changing_bind_address() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("gateway.toml"); + fs::write( + &path, + "[openshell]\nversion = 1\n\n[openshell.gateway]\nbind_address = \"0.0.0.0:17670\"\ncompute_drivers = [\"podman\"]\n", + ) + .unwrap(); + + set(&path, &settings(Some("auto"), None)).unwrap(); + + let loaded = config_file::load(&path).unwrap(); + assert_eq!(loaded.openshell.gateway.compute_drivers, None); + assert_eq!( + loaded.openshell.gateway.bind_address, + Some("0.0.0.0:17670".parse().unwrap()) + ); + } + + #[test] + fn invalid_existing_config_is_not_replaced() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("gateway.toml"); + let original = "[openshell\ninvalid"; + fs::write(&path, original).unwrap(); + + let error = set(&path, &settings(Some("docker"), None)).unwrap_err(); + + assert!(error.to_string().contains("failed to parse gateway config")); + assert_eq!(fs::read_to_string(&path).unwrap(), original); + } + + #[test] + fn schema_validation_failure_is_not_written() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("gateway.toml"); + let original = "[openshell]\nversion = 999\n"; + fs::write(&path, original).unwrap(); + + let error = set(&path, &settings(Some("docker"), None)).unwrap_err(); + + assert!( + error + .to_string() + .contains("unsupported gateway config version") + ); + assert_eq!(fs::read_to_string(&path).unwrap(), original); + } + + #[test] + fn invalid_driver_name_is_not_written() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("gateway.toml"); + let original = "[openshell]\nversion = 1\n"; + fs::write(&path, original).unwrap(); + + let error = set(&path, &settings(Some("bad/name"), None)).unwrap_err(); + + assert!(error.to_string().contains("invalid compute driver name")); + assert_eq!(fs::read_to_string(&path).unwrap(), original); + } +} diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index b65b5f3b0..6ce5bde8c 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -197,10 +197,14 @@ pub fn load(path: &Path) -> Result { path: path.to_path_buf(), source, })?; + parse(&contents, path) +} + +pub(crate) fn parse(contents: &str, path: &Path) -> Result { if contents.trim().is_empty() { return Ok(ConfigFile::default()); } - let file: ConfigFile = toml::from_str(&contents).map_err(|source| ConfigFileError::Parse { + let file: ConfigFile = toml::from_str(contents).map_err(|source| ConfigFileError::Parse { path: path.to_path_buf(), source, })?; @@ -618,15 +622,15 @@ version = 2 } /// Contract test: the RPM default config template must parse against the - /// current schema and must pin the settings that Podman deployments require. + /// current schema and must retain the safe package defaults. /// /// This test loads `deploy/rpm/gateway.toml.default` through the same /// `load()` path that the gateway uses at runtime, catching: /// - template corruption or unknown fields (`deny_unknown_fields`) /// - schema drift (version bump or field renames) - /// - accidental changes to the bind address or compute driver list + /// - accidental changes to the bind address or runtime-neutral driver selection #[test] - fn rpm_default_config_parses_and_has_podman_defaults() { + fn rpm_default_config_parses_and_auto_detects_compute_driver() { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../deploy/rpm/gateway.toml.default"); let config = @@ -637,9 +641,8 @@ version = 2 .bind_address .expect("bind_address must be explicitly set in the RPM default config"); assert!( - addr.ip().is_unspecified(), - "RPM default bind_address must be 0.0.0.0 so Podman sandbox containers \ - can reach the gateway over the host network bridge, got {addr}" + addr.ip().is_loopback(), + "RPM default bind_address must remain loopback-only, got {addr}" ); assert_eq!( addr.port(), @@ -648,15 +651,10 @@ version = 2 openshell_core::config::DEFAULT_SERVER_PORT ); - let drivers = gw - .compute_drivers - .as_ref() - .expect("compute_drivers must be explicitly set in the RPM default config"); - assert_eq!( - drivers, - &["podman".to_string()], - "RPM default must pin compute_drivers to [podman] to prevent unexpected \ - driver selection when Docker is also installed" + assert!( + gw.compute_drivers.is_none(), + "RPM default must leave compute_drivers unset so the gateway auto-detects \ + an available local runtime" ); } } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 6462ccbbf..ca366a2d3 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -27,6 +27,7 @@ mod auth; pub mod certgen; pub mod cli; mod compute; +mod config_edit; pub mod config_file; mod defaults; mod grpc; @@ -838,16 +839,25 @@ fn configured_compute_driver( driver_startup: compute::driver_config::DriverStartupContext<'_>, ) -> Result { match config.compute_drivers.as_slice() { - [] => match openshell_core::config::detect_driver() { - Some(ComputeDriverKind::Vm) => Err(Error::config( - "vm compute driver is opt-in only; set --drivers vm or OPENSHELL_DRIVERS=vm", - )), - Some(driver) => Ok(ConfiguredComputeDriver::Builtin(driver)), - None => Err(Error::config( - "no compute driver configured and auto-detection found no suitable driver; \ - set --drivers or OPENSHELL_DRIVERS to kubernetes, podman, docker, or vm", - )), - }, + [] => { + let detected = openshell_core::config::detect_drivers(); + let Some(driver) = detected.first().copied() else { + return Err(Error::config( + "no compute driver configured and auto-detection found no suitable driver; \ + install and start Docker or Podman, or install the VM driver and select it \ + with `openshell-gateway config set --compute-driver vm`", + )); + }; + + if driver == ComputeDriverKind::Vm { + return Err(Error::config( + "vm compute driver is opt-in only; set --drivers vm or OPENSHELL_DRIVERS=vm", + )); + } + + log_auto_detected_driver(config, driver, &detected); + Ok(ConfiguredComputeDriver::Builtin(driver)) + } [driver] => resolve_configured_compute_driver(driver, driver_startup), drivers => Err(Error::config(format!( "multiple compute drivers are not supported yet; configured drivers: {}", @@ -856,6 +866,53 @@ fn configured_compute_driver( } } +fn log_auto_detected_driver( + config: &Config, + selected: ComputeDriverKind, + detected: &[ComputeDriverKind], +) { + let available = detected + .iter() + .map(|driver| driver.as_str()) + .collect::>() + .join(","); + info!( + driver = %selected, + available_drivers = %available, + "Auto-selected compute driver" + ); + + for guidance in auto_detection_guidance(config, selected, detected.len()) { + warn!( + driver = %selected, + available_drivers = %available, + bind_address = %config.bind_address, + "{guidance}" + ); + } +} + +fn auto_detection_guidance( + config: &Config, + selected: ComputeDriverKind, + detected_count: usize, +) -> Vec { + let mut guidance = Vec::new(); + if detected_count > 1 { + guidance.push( + "Multiple compute drivers are available. Auto-detection is evaluated at every gateway start; pin the intended driver with `openshell-gateway config set --compute-driver `, then restart the gateway service" + .to_string(), + ); + } + if selected == ComputeDriverKind::Podman && config.bind_address.ip().is_loopback() { + guidance.push(format!( + "Podman was auto-selected while the gateway is bound to loopback. If you use rootless Podman, run `openshell-gateway config set --bind-address 0.0.0.0:{}`, then restart the gateway service", + config.bind_address.port() + )); + } + guidance +} + fn resolve_configured_compute_driver( driver_name: &str, driver_startup: compute::driver_config::DriverStartupContext<'_>, @@ -899,8 +956,8 @@ fn warn_if_kubernetes_sandbox_jwt_expiry_disabled(config: &Config) { mod tests { use super::{ ConfiguredComputeDriver, ConnectionProtocol, MultiplexService, ServerState, TlsAcceptor, - allow_plaintext_service_http, classify_initial_bytes, configured_compute_driver, - gateway_listener_addresses, is_benign_tls_handshake_failure, + allow_plaintext_service_http, auto_detection_guidance, classify_initial_bytes, + configured_compute_driver, gateway_listener_addresses, is_benign_tls_handshake_failure, kubernetes_sandbox_jwt_expiry_disabled, serve_gateway_listener, }; use openshell_core::{ @@ -1253,6 +1310,34 @@ mod tests { } } + #[test] + fn auto_detection_warns_how_to_pin_when_multiple_drivers_are_available() { + let config = Config::new(None); + + let guidance = auto_detection_guidance(&config, ComputeDriverKind::Docker, 2).join("\n"); + + assert!(guidance.contains("Auto-detection is evaluated at every gateway start")); + assert!(guidance.contains("openshell-gateway config set --compute-driver ")); + assert!(guidance.contains("restart the gateway service")); + } + + #[test] + fn auto_detected_podman_on_loopback_warns_with_bind_command() { + let config = Config::new(None).with_bind_address("127.0.0.1:17670".parse().unwrap()); + + let guidance = auto_detection_guidance(&config, ComputeDriverKind::Podman, 1).join("\n"); + + assert!(guidance.contains("openshell-gateway config set --bind-address 0.0.0.0:17670")); + assert!(guidance.contains("If you use rootless Podman")); + } + + #[test] + fn auto_detected_podman_on_non_loopback_needs_no_bind_guidance() { + let config = Config::new(None).with_bind_address("0.0.0.0:17670".parse().unwrap()); + + assert!(auto_detection_guidance(&config, ComputeDriverKind::Podman, 1).is_empty()); + } + #[test] fn configured_compute_driver_rejects_multiple_entries() { let config = Config::new(None) diff --git a/deploy/man/openshell-gateway.8.md b/deploy/man/openshell-gateway.8.md index 2d584c4ba..70143a187 100644 --- a/deploy/man/openshell-gateway.8.md +++ b/deploy/man/openshell-gateway.8.md @@ -14,6 +14,11 @@ openshell-gateway - OpenShell gateway server daemon **openshell-gateway** \[*OPTIONS*\] +**openshell-gateway config detect-driver** + +**openshell-gateway config set** \[*--config PATH*\] +\[*--compute-driver DRIVER*\] \[*--bind-address IP:PORT*\] + # DESCRIPTION **openshell-gateway** is the control-plane server for OpenShell. It @@ -94,10 +99,9 @@ TLS. **--disable-tls** : Disable TLS entirely and listen on plaintext HTTP. When the bind - address is **0.0.0.0** (the RPM default), disabling TLS exposes the - API to the entire network without authentication. Only use when the - gateway sits behind a TLS-terminating reverse proxy, or restrict - **--bind-address** to **127.0.0.1**. + address is **0.0.0.0**, disabling TLS exposes the API to the entire + network without authentication. Only use when the gateway sits behind a + TLS-terminating reverse proxy. The RPM default is **127.0.0.1**. Environment: **OPENSHELL_DISABLE_TLS**. **--server-san** *SAN* @@ -111,6 +115,28 @@ Compute driver settings such as sandbox image, callback endpoint, image pull policy, network name, VM state directory, and guest TLS material are configured in the TOML file passed with **--config**. +# CONFIG SUBCOMMAND + +**openshell-gateway config detect-driver** runs the same automatic driver +detection as gateway startup and prints one value: **kubernetes**, **podman**, +**docker**, or **none**. It does not detect the opt-in VM driver, read the +gateway configuration, or change system state. + +**openshell-gateway config set** updates the gateway TOML file without +discarding comments or unrelated settings. It creates the default XDG config +file when needed, validates the complete result, and replaces the file +atomically. + +**--compute-driver** *DRIVER* +: Persist one compute driver. Use **auto** to remove the existing driver pin. + +**--bind-address** *IP:PORT* +: Persist the gateway listener socket address. + +**--config** *PATH* +: Update a specific gateway TOML file instead of the default XDG location. + Environment: **OPENSHELL_GATEWAY_CONFIG**. + # SYSTEMD INTEGRATION The package installs a systemd user unit at diff --git a/deploy/rpm/CONFIGURATION.md b/deploy/rpm/CONFIGURATION.md index a144cac8e..75374b412 100644 --- a/deploy/rpm/CONFIGURATION.md +++ b/deploy/rpm/CONFIGURATION.md @@ -13,29 +13,56 @@ The RPM ships a default TOML configuration template at `openshell-gateway.service`, the systemd unit copies this template to `~/.config/openshell/gateway.toml` if no config file exists there yet. -The defaults are tuned for rootless Podman use: +The default keeps the listener on loopback while leaving compute driver +selection automatic: ```toml [openshell] version = 1 [openshell.gateway] -bind_address = "0.0.0.0:17670" -compute_drivers = ["podman"] +bind_address = "127.0.0.1:17670" ``` -`bind_address = "0.0.0.0:17670"` is required because Podman sandbox -containers reach the gateway over the host network bridge and cannot -connect to `127.0.0.1` inside the gateway's network namespace. mTLS is -enabled by default and protects all connections. +The loopback default avoids exposing the gateway on host network interfaces. +Rootless Podman sandbox containers cannot reach host loopback from their +network namespace. Configure `bind_address = "0.0.0.0:17670"` before creating +Podman-backed sandboxes. + +When `compute_drivers` is unset, the gateway auto-detects Kubernetes, then a +reachable Podman socket, then Docker. Set `compute_drivers = ["docker"]` or +`compute_drivers = ["podman"]` to require a specific container runtime. + +The RPM does not include the VM driver. To use VM-backed sandboxes, download +the matching `openshell-driver-vm` release artifact, install the binary in a +conventional libexec directory (or set `driver_dir` to its location), and +select it explicitly: + +```toml +[openshell.gateway] +compute_drivers = ["vm"] + +[openshell.drivers.vm] +grpc_endpoint = "https://host.containers.internal:17670" +``` -`compute_drivers = ["podman"]` pins the compute driver to Podman. Without -this, the gateway auto-detects in order: Kubernetes, Podman, Docker. Pinning -prevents unexpected driver selection if Docker is also installed on the host. +The gateway never auto-detects VM support. If it cannot find the binary, the +startup error lists every directory it searched. ### Customizing the configuration -Edit `~/.config/openshell/gateway.toml` directly. The template at +Use the gateway config command to select a driver or update the listener. For +example, pin Docker: + +```shell +openshell-gateway config set --compute-driver docker +systemctl --user restart openshell-gateway +``` + +Pass `--bind-address IP:PORT` to update the listener. Use `--compute-driver +auto` to remove an existing driver pin. The command preserves comments and +unrelated settings. You can also edit +`~/.config/openshell/gateway.toml` directly. The template at `/usr/share/openshell-gateway/gateway.toml.default` is not read at runtime and is not overwritten by RPM upgrades. @@ -64,7 +91,7 @@ systemctl --user edit openshell-gateway The RPM enables mutual TLS by default. The gateway requires a valid client certificate for all API connections and listens on -`0.0.0.0:17670` by default (see "Default configuration" above). +`127.0.0.1:17670` by default (see "Default configuration" above). ### Auto-generated certificates @@ -214,8 +241,8 @@ overrides that persist across package upgrades. | TOML option | Default | Description | |-------------|---------|-------------| -| `bind_address` | `0.0.0.0:17670` (RPM default) | Address for the gRPC/HTTP API. | -| `compute_drivers` | `["podman"]` (RPM default) | When unset, the gateway auto-detects Kubernetes, then Podman, then Docker. The RPM default pins to Podman. | +| `bind_address` | `127.0.0.1:17670` | Address for the gRPC/HTTP API. Rootless Podman requires `0.0.0.0:17670`. | +| `compute_drivers` | unset | The gateway auto-detects Kubernetes, then a reachable Podman socket, then Docker. VM requires explicit selection. | | `default_image` | `ghcr.io/nvidia/openshell-community/sandboxes/base:latest` | Default sandbox image. | | `supervisor_image` | `ghcr.io/nvidia/openshell/supervisor:latest` | Supervisor image mounted into Podman sandboxes. | | `guest_tls_ca`, `guest_tls_cert`, `guest_tls_key` | auto-generated paths | Client TLS material bind-mounted into sandbox containers. | @@ -228,7 +255,7 @@ the gateway uses `sqlite:$XDG_STATE_HOME/openshell/gateway/openshell.db`. ### Driver TOML settings Create `~/.config/openshell/gateway.toml` when you need to customize driver -settings: +settings. For example, pin Podman explicitly: ```toml [openshell] diff --git a/deploy/rpm/QUICKSTART.md b/deploy/rpm/QUICKSTART.md index c6634ced9..ec80ab362 100644 --- a/deploy/rpm/QUICKSTART.md +++ b/deploy/rpm/QUICKSTART.md @@ -4,10 +4,19 @@ Get from `dnf install` to a running sandbox in five minutes. ## Prerequisites -### Podman (rootless) +### Container runtime -The gateway uses rootless Podman for sandbox containers. Verify -Podman is installed and the cgroup version is v2: +Install and start either Docker or Podman before starting the gateway. The RPM +does not install a container runtime. When `compute_drivers` is unset, the +gateway auto-detects Kubernetes, then a reachable Podman socket, then Docker. + +For Docker, verify that the daemon is running: + +```shell +docker info +``` + +For rootless Podman, verify Podman is installed and the cgroup version is v2: ```shell podman --version @@ -22,7 +31,9 @@ sudo grubby --update-kernel=ALL --args="systemd.unified_cgroup_hierarchy=1" sudo reboot ``` -### Subordinate UID/GID ranges +The following subordinate ID and socket steps apply only to rootless Podman. + +### Subordinate UID/GID ranges (Podman) Rootless containers require subordinate UID/GID mappings: @@ -45,13 +56,20 @@ socket activation: systemctl --user enable --now podman.socket ``` +Make the gateway reachable from rootless Podman containers before creating a +sandbox: + +```shell +openshell-gateway config set --bind-address 0.0.0.0:17670 +``` + ### Network access The gateway pulls container images from ghcr.io on first sandbox creation. Ensure the host can reach ghcr.io over HTTPS (port 443). -For air-gapped environments, pre-load images with `podman pull` and -set `image_pull_policy = "never"` in +For air-gapped environments, pre-load images with `docker pull` or +`podman pull` and set the selected driver's `image_pull_policy = "never"` in `~/.config/openshell/gateway.toml`. See CONFIGURATION.md for details. @@ -65,11 +83,11 @@ On first start, the gateway automatically generates: - A self-signed PKI bundle (CA, server cert, client cert) for mTLS -> **Note:** The RPM default configuration binds to `0.0.0.0:17670` so -> Podman sandbox containers can reach the gateway over the host network -> bridge. Mutual TLS (mTLS) is enabled automatically on first start, -> requiring a valid client certificate for every connection. See -> CONFIGURATION.md for details. +> **Note:** The RPM default configuration binds to `127.0.0.1:17670` to avoid +> exposing the gateway on host network interfaces. Driver auto-detection does +> not widen the listener. Configure Podman as shown above before creating +> Podman-backed sandboxes. Mutual TLS (mTLS) is enabled automatically on first +> start. See CONFIGURATION.md for details. Verify the service is running: diff --git a/deploy/rpm/TROUBLESHOOTING.md b/deploy/rpm/TROUBLESHOOTING.md index 68a1f4946..304152cc9 100644 --- a/deploy/rpm/TROUBLESHOOTING.md +++ b/deploy/rpm/TROUBLESHOOTING.md @@ -6,10 +6,9 @@ and upgrade procedures for the RPM deployment. ## CLI compatibility The RPM installs the gateway as a systemd user service. On a standard RPM -install the gateway auto-detects Podman because the package depends on it. -The published online docs and some CLI commands assume a Docker/K3s -deployment model. This section clarifies which commands work, which do not, -and what to use instead. +install the gateway auto-detects a reachable Podman socket, then Docker. The RPM +does not install either runtime. This section clarifies which commands work, +which do not, and what to use instead. ### Commands that work normally @@ -47,9 +46,9 @@ systemd commands directly: ### Building from local Dockerfiles -`openshell sandbox create --from ./Dockerfile` builds via the local -Docker daemon. With the RPM Podman driver, build the image with Podman -and reference it directly: +`openshell sandbox create --from ./Dockerfile` builds via the local Docker +daemon. When using Podman, build the image with Podman and reference it +directly: ```shell podman build -t my-sandbox ./my-dir @@ -134,6 +133,24 @@ journalctl --user -u openshell-gateway --no-pager -n 50 Common causes: +**No compute driver is available.** Install and start Docker or Podman, then +restart the gateway: + +```shell +# Docker +docker info +systemctl --user restart openshell-gateway + +# Or rootless Podman +systemctl --user enable --now podman.socket +systemctl --user restart openshell-gateway +``` + +To use VM instead, install the matching `openshell-driver-vm` release artifact +in a conventional libexec directory (or configure `driver_dir`), set +`compute_drivers = ["vm"]`, and restart the gateway. Configuration alone does +not install the VM driver, and the gateway never auto-detects it. + **cgroups v1 detected.** The Podman driver requires cgroups v2. Check the version: @@ -214,14 +231,14 @@ After upgrading the RPM packages: ```shell sudo dnf update openshell openshell-gateway -systemctl --user restart podman.socket systemctl --user restart openshell-gateway ``` The SQLite database schema is auto-migrated on startup. Running sandboxes are stopped during the restart. -Restarting `podman.socket` after a package upgrade is recommended: if the +When using Podman, restarting `podman.socket` after a package upgrade is +recommended: if the unit file changed on disk during the upgrade, the running socket may become non-functional until restarted, causing the gateway to fail with a connection error on `/run/user//podman/podman.sock`. The gateway @@ -231,6 +248,10 @@ Package upgrades do not overwrite `~/.config/openshell/gateway.toml` when you create one. New gateway process options can be added manually by referencing CONFIGURATION.md or running `openshell-gateway --help`. +Existing RPM configurations that contain `compute_drivers = ["podman"]` keep +that explicit selection after upgrade. Remove the setting to use automatic +Podman/Docker detection, or change it to `compute_drivers = ["docker"]`. + To pick up new container images after an upgrade: ```shell diff --git a/deploy/rpm/gateway.toml.default b/deploy/rpm/gateway.toml.default index d85379964..d604626d1 100644 --- a/deploy/rpm/gateway.toml.default +++ b/deploy/rpm/gateway.toml.default @@ -18,13 +18,11 @@ version = 1 [openshell.gateway] -# Podman sandbox containers reach the gateway over the host network bridge, -# which requires binding to all interfaces. Override to 127.0.0.1:17670 if -# you don't use Podman or want loopback-only access (e.g. behind a reverse -# proxy). mTLS is enabled by default and protects all connections. -bind_address = "0.0.0.0:17670" +# Listen on loopback by default. Rootless Podman requires an explicit +# 0.0.0.0:17670 bind so sandbox containers can reach the gateway through the +# host bridge. Update this setting with openshell-gateway config set before +# starting Podman-backed sandboxes. +bind_address = "127.0.0.1:17670" -# Pin to the Podman compute driver. Without this, the gateway auto-detects -# in order: Kubernetes, Podman, Docker. Pinning prevents unexpected driver -# selection if Docker is also installed on the host. -compute_drivers = ["podman"] +# The gateway auto-detects Kubernetes, then a reachable Podman socket, then +# Docker. Set compute_drivers explicitly to override automatic selection. diff --git a/docs/about/installation.mdx b/docs/about/installation.mdx index 87c8a8363..7b80ed35a 100644 --- a/docs/about/installation.mdx +++ b/docs/about/installation.mdx @@ -16,7 +16,10 @@ Install OpenShell with a single command: curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh ``` -The script detects your operating system and installs the OpenShell CLI and gateway with your native package manager. It then starts the local gateway server so you can begin creating sandboxes. +The script detects your operating system and installs the OpenShell CLI and gateway with your native package manager. It then starts the local gateway server so you can begin creating sandboxes. Install and start Docker or Podman first. For other compute options, refer to [Sandbox Compute Drivers](/reference/sandbox-compute-drivers). + +The installer reports package installation and gateway health separately. +Follow the printed recovery steps if the gateway is not healthy. You can also download release artifacts directly from the [OpenShell GitHub Releases](https://github.com/NVIDIA/OpenShell/releases) page. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 88b82870d..e74966eb4 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -29,6 +29,40 @@ Package-managed gateways do not require a TOML file. Create one at the package's | Fedora/RHEL RPM | `$XDG_CONFIG_HOME/openshell/gateway.toml`, usually `~/.config/openshell/gateway.toml` for the systemd user service. | | Snap | `$SNAP_COMMON/gateway.toml`, usually `/var/snap/openshell/common/gateway.toml`. | +## Update Configuration from the CLI + +Use `detect-driver` to run the same automatic driver detection as gateway +startup: + +```shell +openshell-gateway config detect-driver +``` + +The command prints one machine-readable value: `kubernetes`, `podman`, +`docker`, or `none`. It checks drivers in that order and does not detect the +opt-in VM driver. The command only inspects the current environment; it does +not read or update the gateway configuration. + +Use `openshell-gateway config set` for typed updates to driver selection and +the listener address: + +```shell +openshell-gateway config set \ + --compute-driver podman \ + --bind-address 0.0.0.0:17670 +``` + +The command updates `$XDG_CONFIG_HOME/openshell/gateway.toml`, creating the +file and parent directory when needed. Pass `--config ` or set +`OPENSHELL_GATEWAY_CONFIG` to update another file. The command validates the +result, replaces the file atomically, and preserves comments and unrelated +settings. Use `--compute-driver auto` to remove only the `compute_drivers` +setting. + +Restart the gateway service after updating a running installation. Gateway +process flags and `OPENSHELL_*` runtime overrides still take precedence over +the TOML file. + ## Layout The file is rooted at `[openshell]`. Gateway-wide settings live under `[openshell.gateway]`. Each compute driver owns its own `[openshell.drivers.]` table. Shared keys set at gateway scope are inherited into driver tables when not overridden. diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index f860d64d4..7eb0e2802 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -27,6 +27,26 @@ Non-reserved names select an extension driver and require a When `compute_drivers` is unset, the gateway auto-detects Kubernetes, then Podman, then Docker by CLI availability or a local Unix socket. The VM driver is never auto-detected; configure it explicitly with `compute_drivers = ["vm"]` or set `OPENSHELL_DRIVERS=vm` in the launch environment. +For Linux package-managed gateways, persist the selection without editing TOML +directly: + +```shell +openshell-gateway config set --compute-driver docker +systemctl --user restart openshell-gateway +``` + +Package availability differs by installation method: + +| Installation | `openshell-driver-vm` availability | +|---|---| +| Debian/Ubuntu package | Included under `/usr/libexec/openshell`; the gateway searches this location automatically after you select VM. | +| Homebrew | Included under the formula's libexec directory; the gateway searches the formula prefix automatically after you select VM. | +| Fedora/RHEL RPM | Not included. Install the matching standalone release artifact in a conventional libexec directory or configure `driver_dir`. | +| Snap | Not included. | + +Standalone VM driver tarballs are published with OpenShell releases. Installing +the binary does not select it; VM always requires an explicit driver setting. + Common gateway options: | Gateway TOML option | Description | @@ -254,7 +274,8 @@ For maintainer-level implementation details, refer to the [VM driver README](htt ### Enable the VM Driver -The VM driver is opt-in. Release packages can install `openshell-driver-vm`, but the gateway does not select it unless you configure the driver explicitly. +The VM driver is opt-in. The gateway does not select VM unless you configure +the driver explicitly, even when `openshell-driver-vm` is installed. Enable VM by setting `compute_drivers = ["vm"]` in the gateway TOML file: diff --git a/install.sh b/install.sh index 6cda59f8b..086e2079e 100755 --- a/install.sh +++ b/install.sh @@ -46,7 +46,7 @@ USAGE: curl -fsSL https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh OPTIONS: - --help Print this help message + --help Print this help message. ENVIRONMENT VARIABLES: OPENSHELL_VERSION Release tag to install (default: latest tagged release). @@ -54,7 +54,6 @@ ENVIRONMENT VARIABLES: OPENSHELL_ACK_BREAKING_UPGRADE Set to 1 only after backing up and cleaning up a pre-v0.0.37 installation. - NOTES: When OPENSHELL_VERSION is unset, this resolves the latest tagged release from ${GITHUB_URL}/releases/latest. @@ -682,24 +681,58 @@ patch_homebrew_formula() { } +report_detected_compute_driver() { + _gateway_bin="${OPENSHELL_GATEWAY_BIN:-openshell-gateway}" + if ! _detected_driver="$(as_target_user "$_gateway_bin" config detect-driver)"; then + warn "could not detect an available compute driver; the gateway will report runtime errors when it starts" + return 0 + fi + + case "$_detected_driver" in + podman) + warn "Podman was auto-detected. If you use rootless Podman, configure the gateway to listen on the host bridge:" + info "openshell-gateway config set --bind-address 0.0.0.0:${LOCAL_GATEWAY_PORT}" + info "Restart the gateway service for changes to take effect." + ;; + kubernetes | docker | none) + ;; + *) + warn "gateway returned an unexpected detected compute driver: ${_detected_driver}" + ;; + esac +} + start_user_gateway() { info "restarting openshell-gateway user service as ${TARGET_USER}..." if ! as_target_user systemctl --user daemon-reload; then - info "could not reach the user systemd manager for ${TARGET_USER}" - info "restart the gateway later with: systemctl --user enable openshell-gateway && systemctl --user restart openshell-gateway" - info "then register it with: openshell gateway add https://127.0.0.1:17670 --local --name openshell" - return 0 + dump_local_gateway_diagnostics + gateway_startup_error "could not reload the user systemd manager for ${TARGET_USER}" fi - as_target_user systemctl --user enable openshell-gateway - as_target_user systemctl --user restart openshell-gateway - as_target_user systemctl --user is-active --quiet openshell-gateway + if ! as_target_user systemctl --user enable openshell-gateway; then + dump_local_gateway_diagnostics + gateway_startup_error "could not enable the openshell-gateway user service" + fi + if ! as_target_user systemctl --user restart openshell-gateway; then + dump_local_gateway_diagnostics + gateway_startup_error "could not restart the openshell-gateway user service" + fi + if ! as_target_user systemctl --user is-active --quiet openshell-gateway; then + dump_local_gateway_diagnostics + gateway_startup_error "the openshell-gateway user service is not active" + fi info "registering local gateway as ${TARGET_USER}..." register_local_gateway wait_for_local_gateway_listener wait_for_local_gateway_status + info "gateway service is healthy" +} + +gateway_startup_error() { + _reason="$1" + error "${_reason}. OpenShell package installation succeeded, but the gateway service is not healthy. Review the diagnostics above. If no compute driver is available, install and start Docker or Podman, or install openshell-driver-vm and explicitly select VM. The supervised service will normally retry automatically; restart it if needed." } dump_local_gateway_diagnostics() { @@ -781,7 +814,7 @@ wait_for_local_gateway_listener() { [ -z "$_last_output" ] || printf '%s\n' "$_last_output" >&2 dump_local_gateway_diagnostics - error "local gateway listener did not become reachable at ${_probe_url} within ${_timeout}s" + gateway_startup_error "local gateway listener did not become reachable at ${_probe_url} within ${_timeout}s" } wait_for_local_gateway_status() { @@ -806,7 +839,7 @@ wait_for_local_gateway_status() { [ -z "$_status_output" ] || printf '%s\n' "$_status_output" >&2 dump_local_gateway_diagnostics - error "openshell status did not report connected within ${_timeout}s" + gateway_startup_error "openshell status did not report connected within ${_timeout}s" } remove_local_gateway_registration() { @@ -902,7 +935,8 @@ install_linux_deb() { info "installing ${_deb_file}..." install_deb_package "$_deb_path" - info "installed ${APP_NAME} package from ${RELEASE_TAG}" + info "package installation succeeded: installed ${APP_NAME} from ${RELEASE_TAG}" + report_detected_compute_driver start_user_gateway } @@ -949,7 +983,8 @@ install_linux_rpm() { info "installing ${_rpm_file} and ${_gateway_rpm_file}..." install_rpm_packages "${_tmpdir}/${_rpm_file}" "${_tmpdir}/${_gateway_rpm_file}" - info "installed ${APP_NAME} RPM packages from ${RELEASE_TAG}" + info "package installation succeeded: installed ${APP_NAME} RPM packages from ${RELEASE_TAG}" + report_detected_compute_driver start_user_gateway } @@ -987,16 +1022,15 @@ install_macos_homebrew() { info "installing OpenShell with Homebrew..." as_target_user brew install --formula "$_formula_ref" fi + info "package installation succeeded: installed ${APP_NAME} with Homebrew from ${RELEASE_TAG}" - info "restarting OpenShell Homebrew service..." - if ! as_target_user brew services restart "$_formula_ref"; then - warn "could not restart the OpenShell Homebrew service" - info "restart it later with: brew services restart ${_formula_ref}" - info "then register it with: openshell gateway add https://127.0.0.1:${LOCAL_GATEWAY_PORT} --local --name openshell" - return 0 + _brew_prefix="$(as_target_user brew --prefix 2>/dev/null || true)" + if [ -n "$_brew_prefix" ]; then + OPENSHELL_GATEWAY_BIN="${_brew_prefix}/bin/openshell-gateway" fi + report_detected_compute_driver + restart_homebrew_gateway "$_formula_ref" - _brew_prefix="$(as_target_user brew --prefix 2>/dev/null || true)" if [ -n "$_brew_prefix" ] && [ -x "${_brew_prefix}/bin/openshell" ]; then OPENSHELL_REGISTER_BIN="${_brew_prefix}/bin/openshell" fi @@ -1005,6 +1039,16 @@ install_macos_homebrew() { register_local_gateway wait_for_local_gateway_listener wait_for_local_gateway_status + info "gateway service is healthy" +} + +restart_homebrew_gateway() { + _formula_ref="$1" + info "restarting OpenShell Homebrew service..." + if ! as_target_user brew services restart "$_formula_ref"; then + dump_local_gateway_diagnostics + gateway_startup_error "could not restart the OpenShell Homebrew service" + fi } main() { diff --git a/openshell.spec b/openshell.spec index 3200659d7..c4c02c59e 100644 --- a/openshell.spec +++ b/openshell.spec @@ -53,10 +53,6 @@ BuildRequires: pandoc # Python sub-package build dependencies BuildRequires: python3-devel -# Runtime: container runtime for package-managed gateway sandboxes. -# The gateway auto-detects Podman when the package-managed service starts. -Recommends: podman - %description OpenShell provides safe, sandboxed runtimes for autonomous AI agents. It offers a CLI for managing gateway registrations, sandboxes, and providers with @@ -65,16 +61,14 @@ LLM inference routing. # --- Gateway sub-package --- %package gateway -Summary: OpenShell gateway server with Podman sandbox driver -Requires: podman +Summary: OpenShell gateway server Requires: openssl Requires: %{name} = %{version}-%{release} %description gateway OpenShell gateway server providing the control-plane API for sandbox -lifecycle management. This package installs Podman-oriented defaults in -gateway TOML while leaving compute driver selection to gateway auto-detection -or explicit operator configuration. +lifecycle management. Compute driver selection uses gateway auto-detection or +explicit operator configuration. # --- Python SDK sub-package --- %package -n python3-%{name} @@ -155,8 +149,6 @@ cat > %{buildroot}%{_userunitdir}/%{name}-gateway.service << 'EOF' [Unit] Description=OpenShell Gateway (user) Documentation=https://github.com/NVIDIA/OpenShell -After=podman.socket -Wants=podman.socket [Service] Type=exec diff --git a/python/openshell/release_formula_test.py b/python/openshell/release_formula_test.py index b3ab871ae..d66b42e28 100644 --- a/python/openshell/release_formula_test.py +++ b/python/openshell/release_formula_test.py @@ -57,6 +57,7 @@ def test_generate_homebrew_formula_uses_tagged_macos_driver_asset_without_defaul assert 'bind_address = "127.0.0.1:17670"' not in formula assert '# compute_drivers = ["vm"]' not in formula assert 'run opt_libexec/"openshell-gateway-homebrew-service"' in formula + assert "keep_alive successful_exit: false" in formula assert 'xdg_config_home="${XDG_CONFIG_HOME:-${HOME}/.config}"' in formula assert 'xdg_gateway_config="${xdg_config_home}/openshell/gateway.toml"' in formula assert 'prefix_gateway_config="#{var}/openshell/gateway.toml"' in formula @@ -130,6 +131,8 @@ def test_rpm_spec_uses_gateway_defaults_without_config_helper() -> None: assert "Environment=OPENSHELL_BIND_ADDRESS" not in spec assert "Environment=OPENSHELL_PODMAN_TLS_CA" not in spec assert "ExecStart=/usr/bin/openshell-gateway" in spec + assert "Restart=on-failure" in spec + assert "RestartSec=5" in spec assert "--config" not in spec assert "--db-url" not in spec @@ -149,5 +152,7 @@ def test_deb_user_service_uses_gateway_defaults_without_config_helper() -> None: assert "%S/openshell/tls" not in unit assert "init-gateway-config.sh" not in unit assert "ExecStart=/usr/bin/openshell-gateway" in unit + assert "Restart=on-failure" in unit + assert "RestartSec=5s" in unit assert "--config" not in unit assert "--db-url" not in unit diff --git a/tasks/scripts/test-install-sh.sh b/tasks/scripts/test-install-sh.sh index a1259cf0b..705b39900 100755 --- a/tasks/scripts/test-install-sh.sh +++ b/tasks/scripts/test-install-sh.sh @@ -35,7 +35,7 @@ assert_glibc_preflight_fails() { exit 1 fi - if ! grep -Fq "$expected" "$err"; then + if ! grep -Fq -- "$expected" "$err"; then echo "FAIL: ${name}: missing expected message" >&2 echo "Expected: ${expected}" >&2 echo "Actual:" >&2 @@ -44,6 +44,109 @@ assert_glibc_preflight_fails() { fi } +assert_gateway_failure() { + local name=$1 + local platform=$2 + local action=$3 + + if ( + export PLATFORM="$platform" + TARGET_USER="test-user" + TARGET_HOME="${tmpdir}/home" + + as_target_user() { + if [ "$*" = "$action" ]; then + return 1 + fi + return 0 + } + dump_local_gateway_diagnostics() { + echo "TEST gateway diagnostics" >&2 + } + register_local_gateway() { return 0; } + wait_for_local_gateway_listener() { return 0; } + wait_for_local_gateway_status() { return 0; } + + case "$platform" in + linux) start_user_gateway ;; + darwin) restart_homebrew_gateway "${HOMEBREW_TAP}/${HOMEBREW_FORMULA_NAME}" ;; + esac + ) >"$out" 2>"$err"; then + echo "FAIL: ${name}: expected failure" >&2 + exit 1 + fi + + for expected in \ + "TEST gateway diagnostics" \ + "OpenShell package installation succeeded" \ + "gateway service is not healthy" \ + "install and start Docker or Podman" \ + "supervised service will normally retry automatically"; do + if ! grep -Fq -- "$expected" "$err"; then + echo "FAIL: ${name}: missing expected message: ${expected}" >&2 + cat "$err" >&2 || true + exit 1 + fi + done +} + +assert_gateway_healthy() { + if ! ( + export PLATFORM="linux" + TARGET_USER="test-user" + TARGET_HOME="${tmpdir}/home" + + as_target_user() { return 0; } + register_local_gateway() { return 0; } + wait_for_local_gateway_listener() { return 0; } + wait_for_local_gateway_status() { return 0; } + + start_user_gateway + ) >"$out" 2>"$err"; then + echo "FAIL: healthy gateway service should succeed" >&2 + cat "$err" >&2 || true + exit 1 + fi + + if ! grep -Fq -- "gateway service is healthy" "$err"; then + echo "FAIL: healthy gateway service should report its outcome" >&2 + cat "$err" >&2 || true + exit 1 + fi +} + +assert_detected_driver_guidance() { + local name=$1 + local driver=$2 + local expected=$3 + + if ! ( + OPENSHELL_GATEWAY_BIN="/test/bin/openshell-gateway" + as_target_user() { + if [ "$*" != "/test/bin/openshell-gateway config detect-driver" ]; then + echo "unexpected detection command: $*" >&2 + return 1 + fi + printf '%s\n' "$driver" + } + report_detected_compute_driver + ) >"$out" 2>"$err"; then + echo "FAIL: ${name}" >&2 + cat "$err" >&2 || true + exit 1 + fi + if [ -n "$expected" ] && ! grep -Fq -- "$expected" "$err"; then + echo "FAIL: ${name}: missing expected message: ${expected}" >&2 + cat "$err" >&2 || true + exit 1 + fi + if [ -z "$expected" ] && [ -s "$err" ]; then + echo "FAIL: ${name}: unexpected guidance" >&2 + cat "$err" >&2 || true + exit 1 + fi +} + setup_glibc_227() { export OPENSHELL_TEST_GETCONF_UNAVAILABLE=1 export OPENSHELL_TEST_LDD_OUTPUT="ldd (GNU libc) 2.27" @@ -100,4 +203,54 @@ assert_glibc_preflight_fails \ "OpenShell Linux packages require glibc >= 2.28; detected musl or unsupported libc." \ setup_ldd_musl -echo "install.sh libc preflight tests passed" +assert_detected_driver_guidance \ + "podman detection prints rootless bind guidance" \ + podman \ + "If you use rootless Podman, configure the gateway to listen on the host bridge" + +if ! grep -Fq -- \ + "openshell-gateway config set --bind-address 0.0.0.0:17670" \ + "$err"; then + echo "FAIL: podman detection: missing configuration command" >&2 + cat "$err" >&2 || true + exit 1 +fi +if ! grep -Fq -- "Restart the gateway service for changes to take effect." "$err"; then + echo "FAIL: podman detection: missing restart guidance" >&2 + cat "$err" >&2 || true + exit 1 +fi + +assert_detected_driver_guidance \ + "docker detection does not print bind guidance" \ + docker \ + "" + +assert_detected_driver_guidance \ + "no detected driver does not print bind guidance" \ + none \ + "" + +assert_gateway_healthy + +assert_gateway_failure \ + "systemd enable failure is actionable" \ + linux \ + "systemctl --user enable openshell-gateway" + +assert_gateway_failure \ + "systemd restart failure is actionable" \ + linux \ + "systemctl --user restart openshell-gateway" + +assert_gateway_failure \ + "inactive systemd service is actionable" \ + linux \ + "systemctl --user is-active --quiet openshell-gateway" + +assert_gateway_failure \ + "Homebrew restart failure is actionable" \ + darwin \ + "brew services restart ${HOMEBREW_TAP}/${HOMEBREW_FORMULA_NAME}" + +echo "install.sh tests passed" diff --git a/tasks/scripts/test-packaging-assets.sh b/tasks/scripts/test-packaging-assets.sh index a03f60559..2ade9e780 100755 --- a/tasks/scripts/test-packaging-assets.sh +++ b/tasks/scripts/test-packaging-assets.sh @@ -49,6 +49,8 @@ assert_contains \ assert_contains \ "$service" \ 'ExecStartPre=/usr/bin/openshell-gateway generate-certs --output-dir ${OPENSHELL_LOCAL_TLS_DIR} --server-san host.openshell.internal' +assert_contains "$service" 'Restart=on-failure' +assert_contains "$service" 'RestartSec=5s' assert_not_contains "$service" '%S/openshell/tls' assert_contains \ @@ -57,6 +59,8 @@ assert_contains \ assert_contains \ "$spec" \ 'ExecStartPre=/usr/bin/openshell-gateway generate-certs --output-dir ${OPENSHELL_LOCAL_TLS_DIR} --server-san host.openshell.internal' +assert_contains "$spec" 'Restart=on-failure' +assert_contains "$spec" 'RestartSec=5' assert_not_contains "$spec" '%%S/openshell/tls' echo "packaging asset tests passed"