From b86c21abcbc6158a0a863a90befd84d0aff77456 Mon Sep 17 00:00:00 2001 From: Alex Lewontin Date: Thu, 28 May 2026 18:34:22 -0400 Subject: [PATCH 1/2] feat(bootstrap): add system gateway registry for installer defaults Adds a read-only installer-seeded gateway registry that the CLI consults after per-user gateway config. The registry uses the same layout as per-user config with `active_gateway` at the root and `gateways//metadata.json` beneath it. By default the system config root is `/etc/openshell`, while `OPENSHELL_SYSTEM_GATEWAY_DIR` remains available as an override for packages that need a different location. User-managed gateways continue to shadow installer entries on name collision. Originally-authored-by: Mark Shuttleworth Signed-off-by: Alex Lewontin --- architecture/gateway.md | 13 + crates/openshell-bootstrap/src/edge_token.rs | 6 +- crates/openshell-bootstrap/src/lib.rs | 11 +- crates/openshell-bootstrap/src/metadata.rs | 397 ++++++++++++++++--- crates/openshell-bootstrap/src/oidc_token.rs | 4 +- crates/openshell-bootstrap/src/paths.rs | 101 ++++- docs/sandboxes/manage-gateways.mdx | 2 + 7 files changed, 467 insertions(+), 67 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index 01f377a2d..1a77f009c 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -358,6 +358,19 @@ Driver-specific values that are not part of the inheritance allowlist (e.g. Podman `socket_path`, VM `vcpus`) only come from the driver's own table. +### Package-managed gateway registry + +The CLI reads its active-gateway and per-gateway metadata from +`$XDG_CONFIG_HOME/openshell/`. It also looks for a package-manager owned +system config root at `/etc/openshell`, using the same layout as the per-user +config root: `active_gateway` plus `gateways//metadata.json`. Packages +runtimes that need a different location can override that root with +`OPENSHELL_SYSTEM_GATEWAY_DIR`. The CLI falls back to this system config when +no per-user entry exists; per-user entries shadow system entries on name +collision. System entries are read-only from the CLI, so `gateway remove` +rejects a pure system entry instead of pretending to delete package-manager +owned state. + ## Operational Constraints - Gateway TLS and client certificate distribution are deployment concerns owned diff --git a/crates/openshell-bootstrap/src/edge_token.rs b/crates/openshell-bootstrap/src/edge_token.rs index 7b1892d14..f647cc5e0 100644 --- a/crates/openshell-bootstrap/src/edge_token.rs +++ b/crates/openshell-bootstrap/src/edge_token.rs @@ -7,19 +7,19 @@ //! `$XDG_CONFIG_HOME/openshell/gateways//edge_token`. //! The token is a plain-text JWT string with `0600` permissions. -use crate::paths::gateways_dir; +use crate::paths::user_gateways_dir; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::paths::{ensure_parent_dir_restricted, set_file_owner_only}; use std::path::PathBuf; /// Path to the stored edge auth token for a gateway. pub fn edge_token_path(gateway_name: &str) -> Result { - Ok(gateways_dir()?.join(gateway_name).join("edge_token")) + Ok(user_gateways_dir()?.join(gateway_name).join("edge_token")) } /// Legacy path used before the rename to `edge_token`. fn legacy_token_path(gateway_name: &str) -> Result { - Ok(gateways_dir()?.join(gateway_name).join("cf_token")) + Ok(user_gateways_dir()?.join(gateway_name).join("cf_token")) } /// Store an edge authentication token for a gateway. diff --git a/crates/openshell-bootstrap/src/lib.rs b/crates/openshell-bootstrap/src/lib.rs index 8845f0392..ef773370e 100644 --- a/crates/openshell-bootstrap/src/lib.rs +++ b/crates/openshell-bootstrap/src/lib.rs @@ -8,7 +8,7 @@ pub mod oidc_token; mod metadata; pub mod mtls; -pub mod paths; +mod paths; pub mod pki; #[cfg(test)] @@ -21,8 +21,9 @@ use std::sync::Mutex; pub(crate) static XDG_TEST_LOCK: Mutex<()> = Mutex::new(()); pub use crate::metadata::{ - GatewayMetadata, clear_active_gateway, clear_last_sandbox_if_matches, - extract_host_from_ssh_destination, get_gateway_metadata, list_gateways, load_active_gateway, - load_gateway_metadata, load_last_sandbox, remove_gateway_metadata, resolve_ssh_hostname, - save_active_gateway, save_last_sandbox, store_gateway_metadata, + GatewayMetadata, GatewayMetadataSource, ListedGateway, clear_active_gateway, + clear_last_sandbox_if_matches, extract_host_from_ssh_destination, + gateway_metadata_source, get_gateway_metadata, list_gateways, list_gateways_with_source, + load_active_gateway, load_gateway_metadata, load_last_sandbox, remove_gateway_metadata, + resolve_ssh_hostname, save_active_gateway, save_last_sandbox, store_gateway_metadata, }; diff --git a/crates/openshell-bootstrap/src/metadata.rs b/crates/openshell-bootstrap/src/metadata.rs index 108a99b8a..5fd83c6e5 100644 --- a/crates/openshell-bootstrap/src/metadata.rs +++ b/crates/openshell-bootstrap/src/metadata.rs @@ -1,11 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use crate::paths::{active_gateway_path, gateways_dir, last_sandbox_path}; +use crate::paths::{ + user_active_gateway_path, user_gateways_dir, last_sandbox_path, system_active_gateway_path, + system_gateways_dir, +}; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::paths::ensure_parent_dir_restricted; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Gateway metadata stored for CLI endpoint resolution and authentication. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -70,10 +73,60 @@ pub struct GatewayMetadata { pub vm_driver_state_dir: Option, } -fn stored_metadata_path(name: &str) -> Result { - Ok(gateways_dir()?.join(name).join("metadata.json")) +/// Storage layer that provides a gateway metadata record. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GatewayMetadataSource { + /// Per-user metadata under `$XDG_CONFIG_HOME/openshell/gateways`. + User, + /// Installer-provided metadata under the system gateway registry. + System, } +impl GatewayMetadataSource { + pub const fn label(self) -> &'static str { + match self { + Self::User => "user", + Self::System => "system", + } + } +} + +#[derive(Debug, Clone)] +pub struct ListedGateway { + pub metadata: GatewayMetadata, + pub source: GatewayMetadataSource, +} + +fn user_gateway_metadata_path(name: &str) -> Result { + Ok(user_gateways_dir()?.join(name).join("metadata.json")) +} + +fn system_gateway_metadata_path(name: &str) -> PathBuf { + system_gateways_dir().join(name).join("metadata.json") +} + +fn resolve_gateway_metadata_path(name: &str) -> Result> { + let user = user_gateway_metadata_path(name)?; + if user.exists() { + return Ok(Some((user, GatewayMetadataSource::User))); + } + + let system = system_gateway_metadata_path(name); + if system.exists() { + return Ok(Some((system, GatewayMetadataSource::System))); + } + + Ok(None) +} + +fn parse_gateway_metadata(path: &Path) -> Result { + let contents = std::fs::read_to_string(path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read metadata from {}", path.display()))?; + serde_json::from_str(&contents) + .into_diagnostic() + .wrap_err("failed to parse gateway metadata") +} /// Extract the hostname from an SSH destination string. /// /// Handles formats like: @@ -137,7 +190,7 @@ pub fn resolve_ssh_hostname(host: &str) -> String { } pub fn store_gateway_metadata(name: &str, metadata: &GatewayMetadata) -> Result<()> { - let path = stored_metadata_path(name)?; + let path = user_gateway_metadata_path(name)?; ensure_parent_dir_restricted(&path)?; let contents = serde_json::to_string_pretty(metadata) .into_diagnostic() @@ -148,14 +201,22 @@ pub fn store_gateway_metadata(name: &str, metadata: &GatewayMetadata) -> Result< Ok(()) } +/// Return whether a gateway metadata record would resolve from user or system config. +pub fn gateway_metadata_source(name: &str) -> Result> { + Ok(resolve_gateway_metadata_path(name)?.map(|(_, source)| source)) +} + pub fn load_gateway_metadata(name: &str) -> Result { - let path = stored_metadata_path(name)?; - let contents = std::fs::read_to_string(&path) - .into_diagnostic() - .wrap_err_with(|| format!("failed to read metadata from {}", path.display()))?; - serde_json::from_str(&contents) - .into_diagnostic() - .wrap_err("failed to parse gateway metadata") + let primary = user_gateway_metadata_path(name)?; + let system = system_gateway_metadata_path(name); + let Some((path, _source)) = resolve_gateway_metadata_path(name)? else { + return Err(miette::miette!( + "no metadata found for gateway '{name}' (looked in {} and {})", + primary.display(), + system.display(), + )); + }; + parse_gateway_metadata(&path) } /// Load gateway metadata if available. @@ -165,22 +226,30 @@ pub fn get_gateway_metadata(name: &str) -> Option { /// Save the active gateway name to persistent storage. pub fn save_active_gateway(name: &str) -> Result<()> { - let path = active_gateway_path()?; + let path = user_active_gateway_path()?; ensure_parent_dir_restricted(&path)?; std::fs::write(&path, name) .into_diagnostic() .wrap_err_with(|| format!("failed to write active gateway to {}", path.display()))?; Ok(()) } +fn read_nonempty_trimmed(path: &Path) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + let value = contents.trim(); + (!value.is_empty()).then(|| value.to_string()) +} /// Load the active gateway name from persistent storage. /// -/// Returns `None` if no active gateway has been set. +/// Returns `None` if no active gateway has been set. Falls back to the +/// system-level active gateway file when no per-user selection exists, so +/// installer-provided defaults can take effect on a fresh system. pub fn load_active_gateway() -> Option { - let path = active_gateway_path().ok()?; - let contents = std::fs::read_to_string(&path).ok()?; - let name = contents.trim().to_string(); - if name.is_empty() { None } else { Some(name) } + user_active_gateway_path() + .ok() + .as_deref() + .and_then(read_nonempty_trimmed) + .or_else(|| read_nonempty_trimmed(&system_active_gateway_path())) } /// Save the last-used sandbox name for a gateway to persistent storage. @@ -197,12 +266,13 @@ pub fn save_last_sandbox(gateway: &str, sandbox: &str) -> Result<()> { /// /// Returns `None` if no last sandbox has been set. pub fn load_last_sandbox(gateway: &str) -> Option { - let path = last_sandbox_path(gateway).ok()?; - let contents = std::fs::read_to_string(&path).ok()?; - let name = contents.trim().to_string(); - if name.is_empty() { None } else { Some(name) } + last_sandbox_path(gateway) + .ok() + .as_deref() + .and_then(read_nonempty_trimmed) } + /// Clear the last-used sandbox record for a gateway if it matches the given name. /// /// This should be called after a sandbox is deleted so that subsequent commands @@ -216,41 +286,60 @@ pub fn clear_last_sandbox_if_matches(gateway: &str, sandbox: &str) { } } -/// List all gateways that have stored metadata. -/// -/// Scans `$XDG_CONFIG_HOME/openshell/gateways/` for subdirectories containing -/// `metadata.json` and returns the parsed metadata for each. -pub fn list_gateways() -> Result> { - let dir = gateways_dir()?; - if !dir.exists() { - return Ok(Vec::new()); - } - +/// List all gateways that have stored metadata, along with the config layer +/// that supplied each record. +pub fn list_gateways_with_source() -> Result> { let mut gateways = Vec::new(); - let entries = std::fs::read_dir(&dir) - .into_diagnostic() - .wrap_err_with(|| format!("failed to read directory {}", dir.display()))?; - - for entry in entries { - let entry = entry.into_diagnostic()?; - let path = entry.path(); - // Only consider directories that contain a metadata.json file - if path.is_dir() { - let gateway_name = entry.file_name().to_string_lossy().to_string(); - if let Ok(metadata) = load_gateway_metadata(&gateway_name) { - gateways.push(metadata); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + let mut scan = |dir: PathBuf, source: GatewayMetadataSource| -> Result<()> { + if !dir.exists() { + return Ok(()); + } + let entries = std::fs::read_dir(&dir) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read directory {}", dir.display()))?; + for entry in entries { + let entry = entry.into_diagnostic()?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + if seen.contains(&name) { + continue; + } + let metadata_path = path.join("metadata.json"); + if let Ok(metadata) = parse_gateway_metadata(&metadata_path) { + seen.insert(name); + gateways.push(ListedGateway { metadata, source }); } } - } + Ok(()) + }; + + scan(user_gateways_dir()?, GatewayMetadataSource::User)?; + scan(system_gateways_dir(), GatewayMetadataSource::System)?; - // Sort by name for stable output - gateways.sort_by(|a, b| a.name.cmp(&b.name)); + gateways.sort_by(|a, b| a.metadata.name.cmp(&b.metadata.name)); Ok(gateways) } +/// List all gateways that have stored metadata. +/// +/// Scans `$XDG_CONFIG_HOME/openshell/gateways/` and the system registry under +/// `/etc/openshell/gateways/` (or `OPENSHELL_SYSTEM_GATEWAY_DIR/gateways/` +/// when set). Per-user entries shadow system entries on name collision. +pub fn list_gateways() -> Result> { + Ok(list_gateways_with_source()? + .into_iter() + .map(|gateway| gateway.metadata) + .collect()) +} + /// Remove the active gateway file (used when destroying the active gateway). pub fn clear_active_gateway() -> Result<()> { - let path = active_gateway_path()?; + let path = user_active_gateway_path()?; if path.exists() { std::fs::remove_file(&path) .into_diagnostic() @@ -261,7 +350,7 @@ pub fn clear_active_gateway() -> Result<()> { /// Remove gateway metadata file. pub fn remove_gateway_metadata(name: &str) -> Result<()> { - let path = stored_metadata_path(name)?; + let path = user_gateway_metadata_path(name)?; if path.exists() { std::fs::remove_file(&path) .into_diagnostic() @@ -345,16 +434,22 @@ mod tests { let _guard = crate::XDG_TEST_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let orig = std::env::var("XDG_CONFIG_HOME").ok(); + let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok(); + let orig_sys = std::env::var(crate::paths::SYSTEM_GATEWAY_DIR_ENV).ok(); unsafe { std::env::set_var("XDG_CONFIG_HOME", tmp); + std::env::remove_var(crate::paths::SYSTEM_GATEWAY_DIR_ENV); } f(); unsafe { - match orig { + match orig_xdg { Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), None => std::env::remove_var("XDG_CONFIG_HOME"), } + match orig_sys { + Some(v) => std::env::set_var(crate::paths::SYSTEM_GATEWAY_DIR_ENV, v), + None => std::env::remove_var(crate::paths::SYSTEM_GATEWAY_DIR_ENV), + } } } @@ -437,4 +532,204 @@ mod tests { ); }); } + + // ── system gateway dir fallback ─────────────────────────────────── + + /// Helper: hold the shared XDG test lock, point `XDG_CONFIG_HOME` at + /// `user` and `OPENSHELL_SYSTEM_GATEWAY_DIR` at the system config root, + /// run `f`, then restore both env vars. + #[allow(unsafe_code)] + fn with_tmp_xdg_and_system( + user: &std::path::Path, + system: &std::path::Path, + f: F, + ) { + let _guard = crate::XDG_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok(); + let orig_sys = std::env::var(crate::paths::SYSTEM_GATEWAY_DIR_ENV).ok(); + unsafe { + std::env::set_var("XDG_CONFIG_HOME", user); + std::env::set_var(crate::paths::SYSTEM_GATEWAY_DIR_ENV, system); + } + f(); + unsafe { + match orig_xdg { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + match orig_sys { + Some(v) => std::env::set_var(crate::paths::SYSTEM_GATEWAY_DIR_ENV, v), + None => std::env::remove_var(crate::paths::SYSTEM_GATEWAY_DIR_ENV), + } + } + } + + /// Write a `//metadata.json` file for the given endpoint. + fn write_system_metadata(dir: &std::path::Path, name: &str, endpoint: &str) { + let gw_dir = dir.join(name); + std::fs::create_dir_all(&gw_dir).unwrap(); + let meta = GatewayMetadata { + name: name.to_string(), + gateway_endpoint: endpoint.to_string(), + ..Default::default() + }; + std::fs::write( + gw_dir.join("metadata.json"), + serde_json::to_string(&meta).unwrap(), + ) + .unwrap(); + } + + #[test] + fn load_active_gateway_falls_back_to_system_dir() { + let user = tempfile::tempdir().unwrap(); + let system = tempfile::tempdir().unwrap(); + with_tmp_xdg_and_system(user.path(), system.path(), || { + std::fs::write(system.path().join("active_gateway"), "from-system").unwrap(); + assert_eq!(load_active_gateway(), Some("from-system".to_string())); + }); + } + + #[test] + fn load_active_gateway_prefers_user_over_system() { + let user = tempfile::tempdir().unwrap(); + let system = tempfile::tempdir().unwrap(); + with_tmp_xdg_and_system(user.path(), system.path(), || { + save_active_gateway("from-user").unwrap(); + std::fs::write(system.path().join("active_gateway"), "from-system").unwrap(); + assert_eq!(load_active_gateway(), Some("from-user".to_string())); + }); + } + + #[test] + fn load_gateway_metadata_falls_back_to_system_dir() { + let user = tempfile::tempdir().unwrap(); + let system = tempfile::tempdir().unwrap(); + with_tmp_xdg_and_system(user.path(), system.path(), || { + write_system_metadata( + &system.path().join("gateways"), + "sys-gw", + "unix:///tmp/sys.sock", + ); + let meta = load_gateway_metadata("sys-gw").unwrap(); + assert_eq!(meta.name, "sys-gw"); + assert_eq!(meta.gateway_endpoint, "unix:///tmp/sys.sock"); + }); + } + + #[test] + fn gateway_metadata_source_reports_user_system_and_missing() { + let user = tempfile::tempdir().unwrap(); + let system = tempfile::tempdir().unwrap(); + with_tmp_xdg_and_system(user.path(), system.path(), || { + write_system_metadata( + &system.path().join("gateways"), + "sys-gw", + "unix:///tmp/sys.sock", + ); + assert_eq!( + gateway_metadata_source("sys-gw").unwrap(), + Some(GatewayMetadataSource::System) + ); + + let user_meta = GatewayMetadata { + name: "user-gw".to_string(), + gateway_endpoint: "https://user-endpoint".to_string(), + ..Default::default() + }; + store_gateway_metadata("user-gw", &user_meta).unwrap(); + assert_eq!( + gateway_metadata_source("user-gw").unwrap(), + Some(GatewayMetadataSource::User) + ); + + assert_eq!(gateway_metadata_source("missing").unwrap(), None); + }); + } + + #[test] + fn load_gateway_metadata_error_mentions_both_search_paths() { + let user = tempfile::tempdir().unwrap(); + let system = tempfile::tempdir().unwrap(); + with_tmp_xdg_and_system(user.path(), system.path(), || { + let err = load_gateway_metadata("missing").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("missing"), "expected name in error: {msg}"); + assert!( + msg.contains(user.path().to_str().unwrap()), + "expected user path in error: {msg}" + ); + assert!( + msg.contains(system.path().to_str().unwrap()), + "expected system path in error: {msg}" + ); + }); + } + + #[test] + fn load_gateway_metadata_prefers_user_over_system() { + let user = tempfile::tempdir().unwrap(); + let system = tempfile::tempdir().unwrap(); + with_tmp_xdg_and_system(user.path(), system.path(), || { + let user_meta = GatewayMetadata { + name: "shared".to_string(), + gateway_endpoint: "https://user-endpoint".to_string(), + ..Default::default() + }; + store_gateway_metadata("shared", &user_meta).unwrap(); + write_system_metadata( + &system.path().join("gateways"), + "shared", + "https://system-endpoint", + ); + let meta = load_gateway_metadata("shared").unwrap(); + assert_eq!(meta.gateway_endpoint, "https://user-endpoint"); + }); + } + + #[test] + fn list_gateways_merges_user_and_system() { + let user = tempfile::tempdir().unwrap(); + let system = tempfile::tempdir().unwrap(); + with_tmp_xdg_and_system(user.path(), system.path(), || { + let user_meta = GatewayMetadata { + name: "alpha".to_string(), + gateway_endpoint: "https://alpha".to_string(), + ..Default::default() + }; + store_gateway_metadata("alpha", &user_meta).unwrap(); + write_system_metadata(&system.path().join("gateways"), "beta", "https://beta"); + let gateways = list_gateways_with_source().unwrap(); + assert_eq!(gateways.len(), 2); + assert_eq!(gateways[0].metadata.name, "alpha"); + assert_eq!(gateways[0].source, GatewayMetadataSource::User); + assert_eq!(gateways[1].metadata.name, "beta"); + assert_eq!(gateways[1].source, GatewayMetadataSource::System); + }); + } + + #[test] + fn list_gateways_user_shadows_system_on_collision() { + let user = tempfile::tempdir().unwrap(); + let system = tempfile::tempdir().unwrap(); + with_tmp_xdg_and_system(user.path(), system.path(), || { + let user_meta = GatewayMetadata { + name: "local-vm".to_string(), + gateway_endpoint: "https://user-override".to_string(), + ..Default::default() + }; + store_gateway_metadata("local-vm", &user_meta).unwrap(); + write_system_metadata( + &system.path().join("gateways"), + "local-vm", + "unix:///tmp/sys.sock", + ); + let gateways = list_gateways_with_source().unwrap(); + assert_eq!(gateways.len(), 1); + assert_eq!(gateways[0].metadata.gateway_endpoint, "https://user-override"); + assert_eq!(gateways[0].source, GatewayMetadataSource::User); + }); + } } diff --git a/crates/openshell-bootstrap/src/oidc_token.rs b/crates/openshell-bootstrap/src/oidc_token.rs index 19c6cabaa..d8b0cd193 100644 --- a/crates/openshell-bootstrap/src/oidc_token.rs +++ b/crates/openshell-bootstrap/src/oidc_token.rs @@ -7,7 +7,7 @@ //! `$XDG_CONFIG_HOME/openshell/gateways//oidc_token.json`. //! File permissions are `0600` (owner-only). -use crate::paths::gateways_dir; +use crate::paths::user_gateways_dir; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::paths::{ensure_parent_dir_restricted, set_file_owner_only}; use serde::{Deserialize, Serialize}; @@ -36,7 +36,7 @@ pub struct OidcTokenBundle { /// Path to the stored OIDC token bundle for a gateway. pub fn oidc_token_path(gateway_name: &str) -> Result { - Ok(gateways_dir()?.join(gateway_name).join("oidc_token.json")) + Ok(user_gateways_dir()?.join(gateway_name).join("oidc_token.json")) } /// Store an OIDC token bundle for a gateway. diff --git a/crates/openshell-bootstrap/src/paths.rs b/crates/openshell-bootstrap/src/paths.rs index cd3cb7693..27dd7f343 100644 --- a/crates/openshell-bootstrap/src/paths.rs +++ b/crates/openshell-bootstrap/src/paths.rs @@ -2,34 +2,123 @@ // SPDX-License-Identifier: Apache-2.0 use miette::Result; -use openshell_core::paths::xdg_config_dir; +use openshell_core::paths::openshell_config_dir; use std::path::PathBuf; +/// Env var pointing at a system-level `OpenShell` config root override. +/// +/// Set by installers (snap, deb, systemd unit, dev wrappers) that want +/// to surface deployment-provided gateways without requiring the user to +/// register them. The directory uses the same layout as the per-user config +/// root: `active_gateway` plus `gateways//metadata.json`. CLI behaviour +/// treats it as read-only; all writes go to the per-user XDG location, which +/// shadows system entries on name collision. When unset, `OpenShell` falls +/// back to `/etc/openshell`. +pub(crate) const SYSTEM_GATEWAY_DIR_ENV: &str = "OPENSHELL_SYSTEM_GATEWAY_DIR"; + +const DEFAULT_SYSTEM_CONFIG_DIR: &str = "/etc/openshell"; + /// Path to the file that stores the active gateway name. /// /// Location: `$XDG_CONFIG_HOME/openshell/active_gateway` -pub fn active_gateway_path() -> Result { - Ok(xdg_config_dir()?.join("openshell").join("active_gateway")) +pub fn user_active_gateway_path() -> Result { + Ok(openshell_config_dir()?.join("active_gateway")) } /// Base directory for all gateway metadata files. /// /// Location: `$XDG_CONFIG_HOME/openshell/gateways/` -pub fn gateways_dir() -> Result { - Ok(xdg_config_dir()?.join("openshell").join("gateways")) +pub fn user_gateways_dir() -> Result { + Ok(openshell_config_dir()?.join("gateways")) +} + +/// Read-only system-level `OpenShell` config root. +/// +/// Uses `OPENSHELL_SYSTEM_GATEWAY_DIR` when set; otherwise falls back to +/// `/etc/openshell`. +pub(crate) fn system_config_dir() -> PathBuf { + std::env::var_os(SYSTEM_GATEWAY_DIR_ENV) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_SYSTEM_CONFIG_DIR)) +} + +/// Read-only system-level gateway metadata directory. +pub(crate) fn system_gateways_dir() -> PathBuf { + system_config_dir().join("gateways") +} + +/// Optional system-level active gateway file within the system config root. +pub(crate) fn system_active_gateway_path() -> PathBuf { + system_config_dir().join("active_gateway") } /// Path to the file that stores the last-used sandbox name for a gateway. /// /// Location: `$XDG_CONFIG_HOME/openshell/gateways//last_sandbox` pub fn last_sandbox_path(gateway: &str) -> Result { - Ok(gateways_dir()?.join(gateway).join("last_sandbox")) + Ok(user_gateways_dir()?.join(gateway).join("last_sandbox")) } #[cfg(test)] mod tests { use super::*; + #[test] + #[allow(unsafe_code)] + fn system_config_dir_defaults_to_etc_openshell() { + let _guard = crate::XDG_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let orig_sys = std::env::var(SYSTEM_GATEWAY_DIR_ENV).ok(); + unsafe { + std::env::remove_var(SYSTEM_GATEWAY_DIR_ENV); + } + assert_eq!(system_config_dir(), PathBuf::from("/etc/openshell")); + assert_eq!( + system_gateways_dir(), + PathBuf::from("/etc/openshell/gateways") + ); + assert_eq!( + system_active_gateway_path(), + PathBuf::from("/etc/openshell/active_gateway") + ); + unsafe { + match orig_sys { + Some(v) => std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, v), + None => std::env::remove_var(SYSTEM_GATEWAY_DIR_ENV), + } + } + } + + #[test] + #[allow(unsafe_code)] + fn system_config_dir_prefers_env_override() { + let _guard = crate::XDG_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let tmp = tempfile::tempdir().unwrap(); + let override_dir = tmp.path().join("openshell-system"); + let orig_sys = std::env::var(SYSTEM_GATEWAY_DIR_ENV).ok(); + unsafe { + std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, &override_dir); + } + assert_eq!(system_config_dir(), override_dir); + assert_eq!( + system_gateways_dir(), + tmp.path().join("openshell-system/gateways") + ); + assert_eq!( + system_active_gateway_path(), + tmp.path().join("openshell-system/active_gateway") + ); + unsafe { + match orig_sys { + Some(v) => std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, v), + None => std::env::remove_var(SYSTEM_GATEWAY_DIR_ENV), + } + } + } + #[test] #[allow(unsafe_code)] fn last_sandbox_path_layout() { diff --git a/docs/sandboxes/manage-gateways.mdx b/docs/sandboxes/manage-gateways.mdx index 6cfa39121..62135f8a5 100644 --- a/docs/sandboxes/manage-gateways.mdx +++ b/docs/sandboxes/manage-gateways.mdx @@ -82,6 +82,8 @@ One gateway is always the active gateway. All CLI commands target it by default. The active gateway is the persisted default. The `-g` flag and the `OPENSHELL_GATEWAY` environment variable override it when commands resolve a gateway. If `OPENSHELL_GATEWAY` is set to a different gateway, `openshell gateway select ` still saves the new default and warns that the current shell continues to use the environment value until you unset or update it. +Installers can seed read-only gateway entries for package-managed local services. By default the CLI reads these from `/etc/openshell`, using the same `active_gateway` plus `gateways//metadata.json` layout as per-user config. Packages can override that system config root with `OPENSHELL_SYSTEM_GATEWAY_DIR` when needed. These entries appear in `openshell gateway list` and can be selected like user registrations. `openshell gateway remove` removes only per-user registrations. Register a per-user gateway with the same name when you need to shadow an installer-provided default. + List all registered gateways: ```shell From d1736c73fa7558b48ac1d31f9e1e295342344c09 Mon Sep 17 00:00:00 2001 From: Alex Lewontin Date: Thu, 28 May 2026 18:34:43 -0400 Subject: [PATCH 2/2] feat(cli): show gateway config source in list and term Expose whether a gateway registration comes from user or system config in `openshell gateway list`, the TUI gateway pane, and list JSON output. The CLI also refuses to remove system-managed registrations and the smoke tests cover the new list output. Signed-off-by: Alex Lewontin --- crates/openshell-cli/src/run.rs | 147 +++++++++----- crates/openshell-tui/src/app.rs | 49 +++++ crates/openshell-tui/src/lib.rs | 24 +-- crates/openshell-tui/src/ui/dashboard.rs | 7 +- crates/openshell-tui/src/ui/mod.rs | 10 +- docs/sandboxes/manage-gateways.mdx | 2 +- e2e/rust/tests/cli_smoke.rs | 244 ++++++++++++++++++++++- 7 files changed, 408 insertions(+), 75 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index b92be199e..a8f4e7a51 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -19,10 +19,11 @@ use hyper_util::{client::legacy::Client, rt::TokioExecutor}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use miette::{IntoDiagnostic, Result, WrapErr, miette}; use openshell_bootstrap::{ - GatewayMetadata, clear_active_gateway, clear_last_sandbox_if_matches, - extract_host_from_ssh_destination, get_gateway_metadata, list_gateways, load_active_gateway, - remove_gateway_metadata, resolve_ssh_hostname, save_active_gateway, save_last_sandbox, - store_gateway_metadata, + GatewayMetadata, GatewayMetadataSource, ListedGateway, clear_active_gateway, + clear_last_sandbox_if_matches, extract_host_from_ssh_destination, + gateway_metadata_source, get_gateway_metadata, list_gateways, list_gateways_with_source, + load_active_gateway, remove_gateway_metadata, resolve_ssh_hostname, save_active_gateway, + save_last_sandbox, store_gateway_metadata, }; use openshell_core::progress::{ PROGRESS_ACTIVE_DETAIL_KEY, PROGRESS_ACTIVE_STEP_KEY, PROGRESS_COMPLETE_LABEL_KEY, @@ -940,15 +941,17 @@ pub async fn gateway_add( &derived_name }; - // Fail if a gateway with this name already exists. - if get_gateway_metadata(name).is_some() { - return Err(miette::miette!( - "Gateway '{}' already exists.\n\ - Remove it first with: openshell gateway remove {}\n\ - Or choose a different name with: --name ", - name, - name, - )); + match gateway_metadata_source(name)? { + Some(GatewayMetadataSource::User) => { + return Err(miette::miette!( + "Gateway '{}' already exists.\n\ + Remove it first with: openshell gateway remove {}\n\ + Or choose a different name with: --name ", + name, + name, + )); + } + Some(GatewayMetadataSource::System) | None => {} } // OIDC takes precedence over plaintext/mTLS/edge detection — the user @@ -1290,14 +1293,14 @@ pub fn gateway_logout(name: &str) -> Result<()> { /// List all registered gateways. pub fn gateway_list(gateway_flag: &Option, output: &str) -> Result<()> { - let gateways = list_gateways()?; + let gateways = list_gateways_with_source()?; let active = gateway_flag.clone().or_else(load_active_gateway); match output { "json" => { let items: Vec = gateways .iter() - .map(|g| gateway_to_json(g, &active)) + .map(|gateway| gateway_to_json(gateway, &active)) .collect(); println!( "{}", @@ -1308,7 +1311,7 @@ pub fn gateway_list(gateway_flag: &Option, output: &str) -> Result<()> { "yaml" => { let items: Vec = gateways .iter() - .map(|g| gateway_to_json(g, &active)) + .map(|gateway| gateway_to_json(gateway, &active)) .collect(); print!("{}", serde_yml::to_string(&items).into_diagnostic()?); return Ok(()); @@ -1327,44 +1330,52 @@ pub fn gateway_list(gateway_flag: &Option, output: &str) -> Result<()> { return Ok(()); } - // Calculate column widths let name_width = gateways .iter() - .map(|g| g.name.len()) + .map(|gateway| gateway.metadata.name.len()) .max() .unwrap_or(4) .max(4); let endpoint_width = gateways .iter() - .map(|g| g.gateway_endpoint.len()) + .map(|gateway| gateway.metadata.gateway_endpoint.len()) .max() .unwrap_or(8) .max(8); let type_width = gateways .iter() - .map(|g| gateway_type_label(g).len()) + .map(|gateway| gateway_type_label(&gateway.metadata).len()) .max() .unwrap_or(4) .max(4); + let source_width = gateways + .iter() + .map(|gateway| gateway.source.label().len()) + .max() + .unwrap_or(6) + .max(6); - // Print header println!( - " {:, output: &str) -> Result<()> { Ok(()) } -fn gateway_to_json(gateway: &GatewayMetadata, active: &Option) -> serde_json::Value { +fn gateway_to_json(gateway: &ListedGateway, active: &Option) -> serde_json::Value { + let metadata = &gateway.metadata; serde_json::json!({ - "name": gateway.name, - "endpoint": gateway.gateway_endpoint, - "type": gateway_type_label(gateway), - "auth": gateway_auth_label(gateway), - "active": active.as_deref() == Some(&gateway.name), + "name": metadata.name, + "endpoint": metadata.gateway_endpoint, + "type": gateway_type_label(metadata), + "source": gateway.source.label(), + "auth": gateway_auth_label(metadata), + "active": active.as_deref() == Some(&metadata.name), }) } @@ -1458,11 +1471,20 @@ fn remove_gateway_registration(name: &str) { /// Remove a local gateway registration without touching the gateway service. pub fn gateway_remove(name: &str) -> Result<()> { - if get_gateway_metadata(name).is_none() { - return Err(miette::miette!( - "No gateway metadata found for '{name}'.\n\ - List available gateways: openshell gateway select" - )); + match gateway_metadata_source(name)? { + Some(GatewayMetadataSource::User) => {} + Some(GatewayMetadataSource::System) => { + return Err(miette::miette!( + "Gateway registration '{name}' is installed by the system and cannot be removed from user config.\n\ + Register a per-user gateway with the same name to override it, or select another gateway." + )); + } + None => { + return Err(miette::miette!( + "No gateway metadata found for '{name}'.\n\ + List available gateways: openshell gateway select" + )); + } } remove_gateway_registration(name); @@ -6983,19 +7005,22 @@ mod tests { ProvisioningDisplay, ProvisioningStep, TlsOptions, build_sandbox_resource_limits, dockerfile_sources_supported_for_gateway, format_endpoint, format_gateway_select_header, format_gateway_select_items, format_provider_attachment_table, gateway_add, - gateway_auth_label, gateway_env_override_warning, gateway_select_with, gateway_type_label, - git_sync_files, http_health_check, image_requests_gpu, import_local_package_mtls_bundle, - inferred_provider_type, local_upload_path_exists, local_upload_path_is_symlink, - package_managed_tls_dirs, parse_cli_setting_value, parse_credential_expiry_cli_value, - parse_credential_expiry_pairs, parse_credential_pairs, plaintext_gateway_is_remote, - progress_step_from_metadata, provider_profile_allows_refresh_bootstrap, - provisioning_timeout_message, ready_false_condition_message, refresh_status_header, - refresh_status_row, resolve_from, sandbox_should_persist, service_expose_status_error, - service_url_for_gateway, + gateway_auth_label, gateway_env_override_warning, gateway_select_with, gateway_to_json, + gateway_type_label, git_sync_files, http_health_check, image_requests_gpu, + import_local_package_mtls_bundle, inferred_provider_type, local_upload_path_exists, + local_upload_path_is_symlink, package_managed_tls_dirs, parse_cli_setting_value, + parse_credential_expiry_cli_value, parse_credential_expiry_pairs, parse_credential_pairs, + plaintext_gateway_is_remote, progress_step_from_metadata, + provider_profile_allows_refresh_bootstrap, provisioning_timeout_message, + ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from, + sandbox_should_persist, service_expose_status_error, service_url_for_gateway, }; use crate::TEST_ENV_LOCK; use hyper::StatusCode; - use openshell_bootstrap::{load_active_gateway, load_gateway_metadata, store_gateway_metadata}; + use openshell_bootstrap::{ + GatewayMetadata, GatewayMetadataSource, ListedGateway, load_active_gateway, + load_gateway_metadata, store_gateway_metadata, + }; use std::fs; use std::io::{Read, Write}; use std::net::TcpListener; @@ -7004,8 +7029,6 @@ mod tests { use std::thread; use std::time::{Duration, Instant}; use tonic::Status; - - use openshell_bootstrap::GatewayMetadata; use openshell_core::progress::{ PROGRESS_STEP_PULLING_IMAGE, PROGRESS_STEP_REQUESTING_SANDBOX, PROGRESS_STEP_STARTING_SANDBOX, @@ -7935,6 +7958,26 @@ mod tests { assert!(items[1].contains("http://127.0.0.1:8080")); } + #[test] + fn gateway_to_json_includes_config_source() { + let gateway = ListedGateway { + metadata: GatewayMetadata { + name: "local-vm".to_string(), + gateway_endpoint: "http://127.0.0.1:17670".to_string(), + auth_mode: Some("plaintext".to_string()), + ..Default::default() + }, + source: GatewayMetadataSource::System, + }; + + let json = gateway_to_json(&gateway, &Some("local-vm".to_string())); + + assert_eq!(json["source"], "system"); + assert_eq!(json["type"], "local"); + assert_eq!(json["auth"], "plaintext"); + assert_eq!(json["active"], true); + } + #[test] fn gateway_auth_label_defaults_https_gateways_to_mtls() { let gateway = GatewayMetadata { diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index ba817bcf8..23fd23860 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use openshell_bootstrap::GatewayMetadataSource; use openshell_core::auth::EdgeAuthInterceptor; use openshell_core::proto::open_shell_client::OpenShellClient; use openshell_core::proto::setting_value; @@ -214,10 +215,21 @@ pub fn display_setting_value(value: &Option) -> String { // Gateway entry // --------------------------------------------------------------------------- +#[derive(Debug, Clone, PartialEq, Eq)] pub struct GatewayEntry { pub name: String, pub endpoint: String, pub is_remote: bool, + pub source: Option, +} + +impl GatewayEntry { + pub const fn source_label(&self) -> &'static str { + match self.source { + Some(source) => source.label(), + None => "unknown", + } + } } // --------------------------------------------------------------------------- @@ -2231,3 +2243,40 @@ fn unique_provider_name(base: &str, existing: &[String]) -> String { } base.to_string() } + +#[cfg(test)] +mod tests { + use super::GatewayEntry; + use openshell_bootstrap::GatewayMetadataSource; + + #[test] + fn gateway_entry_source_label_formats_known_sources() { + let user_gateway = GatewayEntry { + name: "user-gw".to_string(), + endpoint: "https://user.example.com".to_string(), + is_remote: true, + source: Some(GatewayMetadataSource::User), + }; + let system_gateway = GatewayEntry { + name: "system-gw".to_string(), + endpoint: "http://127.0.0.1:17670".to_string(), + is_remote: false, + source: Some(GatewayMetadataSource::System), + }; + + assert_eq!(user_gateway.source_label(), "user"); + assert_eq!(system_gateway.source_label(), "system"); + } + + #[test] + fn gateway_entry_source_label_handles_unknown_source() { + let gateway = GatewayEntry { + name: "mystery".to_string(), + endpoint: "https://mystery.example.com".to_string(), + is_remote: true, + source: None, + }; + + assert_eq!(gateway.source_label(), "unknown"); + } +} diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 1969715ce..ed48593b1 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -18,6 +18,7 @@ use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; use miette::{IntoDiagnostic, Result}; +use openshell_bootstrap::list_gateways_with_source; use openshell_core::auth::EdgeAuthInterceptor; use openshell_core::metadata::{ObjectId, ObjectLabels, ObjectName}; use openshell_core::proto::open_shell_client::OpenShellClient; @@ -455,31 +456,30 @@ pub async fn run( /// Refresh the list of known gateways from disk. fn refresh_gateway_list(app: &mut App) { - if let Ok(gateways) = openshell_bootstrap::list_gateways() { + if let Ok(gateways) = list_gateways_with_source() { app.gateways = gateways .into_iter() - .map(|m| GatewayEntry { - name: m.name, - endpoint: m.gateway_endpoint, - is_remote: m.is_remote, + .map(|gateway| GatewayEntry { + source: Some(gateway.source), + name: gateway.metadata.name, + endpoint: gateway.metadata.gateway_endpoint, + is_remote: gateway.metadata.is_remote, }) .collect(); - // Keep selection in bounds. if app.gateway_selected >= app.gateways.len() && !app.gateways.is_empty() { app.gateway_selected = app.gateways.len() - 1; } - // If the active gateway appears in the list, move cursor to it on first load. - if let Some(idx) = app.gateways.iter().position(|g| g.name == app.gateway_name) { - // Only snap the cursor when it's still at 0 (initial state). - if app.gateway_selected == 0 { - app.gateway_selected = idx; - } + if let Some(idx) = app.gateways.iter().position(|g| g.name == app.gateway_name) + && app.gateway_selected == 0 + { + app.gateway_selected = idx; } } } + /// Handle a pending gateway switch requested by the user. async fn handle_gateway_switch(app: &mut App) { let Some(name) = app.pending_gateway_switch.take() else { diff --git a/crates/openshell-tui/src/ui/dashboard.rs b/crates/openshell-tui/src/ui/dashboard.rs index 43ae6a937..985118952 100644 --- a/crates/openshell-tui/src/ui/dashboard.rs +++ b/crates/openshell-tui/src/ui/dashboard.rs @@ -40,6 +40,7 @@ fn draw_gateway_list(frame: &mut Frame<'_>, app: &App, area: Rect) { let header = Row::new(vec![ Cell::from(Span::styled(" NAME", t.muted)), Cell::from(Span::styled("TYPE", t.muted)), + Cell::from(Span::styled("SOURCE", t.muted)), Cell::from(Span::styled("STATUS", t.muted)), Cell::from(Span::styled("", t.muted)), ]) @@ -92,6 +93,7 @@ fn draw_gateway_list(frame: &mut Frame<'_>, app: &App, area: Rect) { Row::new(vec![ name_cell, Cell::from(Span::styled(type_label, t.muted)), + Cell::from(Span::styled(entry.source_label(), t.muted)), status_cell, policy_cell, ]) @@ -107,9 +109,10 @@ fn draw_gateway_list(frame: &mut Frame<'_>, app: &App, area: Rect) { .padding(Padding::horizontal(1)); let widths = [ - Constraint::Percentage(30), + Constraint::Percentage(24), Constraint::Percentage(10), - Constraint::Percentage(25), + Constraint::Percentage(10), + Constraint::Percentage(21), Constraint::Percentage(35), ]; diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 98c8badb5..bbf62a0d1 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -130,13 +130,21 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { _ => Span::styled(&app.status_text, t.muted), }; + let active_gateway_source = app + .gateways + .iter() + .find(|gateway| gateway.name == app.gateway_name) + .map_or("unknown", app::GatewayEntry::source_label); + let mut parts: Vec> = vec![ Span::styled(" >_ OpenShell ", t.accent_bold), Span::styled(" ALPHA ", t.badge), Span::styled(" | ", t.muted), Span::styled("Current Gateway: ", t.text), Span::styled(&app.gateway_name, t.heading), - Span::styled(" (", t.muted), + Span::styled(" [", t.muted), + Span::styled(active_gateway_source, t.muted), + Span::styled("] (", t.muted), status_span, Span::styled(")", t.muted), Span::styled(" | ", t.muted), diff --git a/docs/sandboxes/manage-gateways.mdx b/docs/sandboxes/manage-gateways.mdx index 62135f8a5..4b6a95a1d 100644 --- a/docs/sandboxes/manage-gateways.mdx +++ b/docs/sandboxes/manage-gateways.mdx @@ -82,7 +82,7 @@ One gateway is always the active gateway. All CLI commands target it by default. The active gateway is the persisted default. The `-g` flag and the `OPENSHELL_GATEWAY` environment variable override it when commands resolve a gateway. If `OPENSHELL_GATEWAY` is set to a different gateway, `openshell gateway select ` still saves the new default and warns that the current shell continues to use the environment value until you unset or update it. -Installers can seed read-only gateway entries for package-managed local services. By default the CLI reads these from `/etc/openshell`, using the same `active_gateway` plus `gateways//metadata.json` layout as per-user config. Packages can override that system config root with `OPENSHELL_SYSTEM_GATEWAY_DIR` when needed. These entries appear in `openshell gateway list` and can be selected like user registrations. `openshell gateway remove` removes only per-user registrations. Register a per-user gateway with the same name when you need to shadow an installer-provided default. +Installers can seed read-only gateway entries for package-managed local services. By default the CLI reads these from `/etc/openshell`, using the same `active_gateway` plus `gateways//metadata.json` layout as per-user config. Packages can override that system config root with `OPENSHELL_SYSTEM_GATEWAY_DIR` when needed. These entries appear in `openshell gateway list` and can be selected like user registrations. `openshell gateway list` and `openshell term` label each gateway as `user` or `system` so you can see which config layer owns it. `openshell gateway remove` removes only per-user registrations. Register a per-user gateway with the same name when you need to shadow an installer-provided default. List all registered gateways: diff --git a/e2e/rust/tests/cli_smoke.rs b/e2e/rust/tests/cli_smoke.rs index 265b236c4..a68823e80 100644 --- a/e2e/rust/tests/cli_smoke.rs +++ b/e2e/rust/tests/cli_smoke.rs @@ -7,24 +7,33 @@ //! directly, validating that the restructured command tree parses correctly and //! handles edge cases like missing gateway configuration. +use std::fs; +use std::path::Path; use std::process::Stdio; use openshell_e2e::harness::binary::openshell_cmd; use openshell_e2e::harness::output::strip_ansi; -/// Run `openshell ` with an isolated (empty) config directory so it -/// cannot discover any real gateway. -async fn run_isolated(args: &[&str]) -> (String, i32) { - let tmpdir = tempfile::tempdir().expect("create isolated config dir"); +async fn run_with_config( + config_dir: &Path, + system_dir: Option<&Path>, + args: &[&str], +) -> (String, i32) { let mut cmd = openshell_cmd(); cmd.args(args) - .env("XDG_CONFIG_HOME", tmpdir.path()) - .env("HOME", tmpdir.path()) + .env("XDG_CONFIG_HOME", config_dir) + .env("HOME", config_dir) .env_remove("OPENSHELL_GATEWAY") .env_remove("OPENSHELL_GATEWAY_ENDPOINT") .stdout(Stdio::piped()) .stderr(Stdio::piped()); + if let Some(system_dir) = system_dir { + cmd.env("OPENSHELL_SYSTEM_GATEWAY_DIR", system_dir); + } else { + cmd.env_remove("OPENSHELL_SYSTEM_GATEWAY_DIR"); + } + let output = cmd.output().await.expect("spawn openshell"); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); @@ -33,6 +42,100 @@ async fn run_isolated(args: &[&str]) -> (String, i32) { (combined, code) } +/// Run `openshell ` with an isolated (empty) config directory so it +/// cannot discover any real gateway. +async fn run_isolated(args: &[&str]) -> (String, i32) { + let tmpdir = tempfile::tempdir().expect("create isolated config dir"); + let system_dir = tempfile::tempdir().expect("create isolated system config dir"); + run_with_config(tmpdir.path(), Some(system_dir.path()), args).await +} + +fn write_gateway_metadata( + root: &Path, + name: &str, + endpoint: &str, + gateway_port: u16, + is_remote: bool, + auth_mode: &str, +) { + let gateway_dir = root.join("gateways").join(name); + fs::create_dir_all(&gateway_dir).expect("create gateway dir"); + let metadata = serde_json::json!({ + "name": name, + "gateway_endpoint": endpoint, + "gateway_port": gateway_port, + "is_remote": is_remote, + "auth_mode": auth_mode, + }); + fs::write( + gateway_dir.join("metadata.json"), + serde_json::to_vec_pretty(&metadata).expect("serialize gateway metadata"), + ) + .expect("write gateway metadata"); +} + +fn write_user_gateway_metadata( + config_dir: &Path, + name: &str, + endpoint: &str, + gateway_port: u16, + is_remote: bool, + auth_mode: &str, +) { + write_gateway_metadata( + &config_dir.join("openshell"), + name, + endpoint, + gateway_port, + is_remote, + auth_mode, + ); +} + +fn write_system_gateway_metadata( + system_dir: &Path, + name: &str, + endpoint: &str, + gateway_port: u16, + is_remote: bool, + auth_mode: &str, +) { + write_gateway_metadata( + system_dir, + name, + endpoint, + gateway_port, + is_remote, + auth_mode, + ); +} + +fn write_active_gateway(config_dir: &Path, name: &str) { + let active_path = config_dir.join("openshell").join("active_gateway"); + fs::create_dir_all(active_path.parent().expect("active gateway parent")) + .expect("create active gateway parent"); + fs::write(active_path, format!("{name}\n")).expect("write active gateway"); +} + +fn seed_gateway_sources(config_dir: &Path, system_dir: &Path) { + write_user_gateway_metadata( + config_dir, + "alpha", + "https://alpha.example.com", + 443, + true, + "cloudflare_jwt", + ); + write_system_gateway_metadata( + system_dir, + "beta", + "http://127.0.0.1:17670", + 17670, + false, + "plaintext", + ); +} + // ------------------------------------------------------------------- // Top-level --help shows the restructured command tree // ------------------------------------------------------------------- @@ -84,7 +187,9 @@ async fn sandbox_help_shows_upload_download() { assert_eq!(code, 0, "openshell sandbox --help should exit 0"); let clean = strip_ansi(&output); - for sub in ["upload", "download", "create", "get", "list", "delete", "connect"] { + for sub in [ + "upload", "download", "create", "get", "list", "delete", "connect", + ] { assert!( clean.contains(sub), "expected '{sub}' in sandbox --help output:\n{clean}" @@ -170,3 +275,128 @@ async fn status_without_gateway_prints_friendly_message() { "expected hint to register a gateway:\n{clean}" ); } + +// ------------------------------------------------------------------- +// Gateway list source indicators +// ------------------------------------------------------------------- + +#[tokio::test] +async fn gateway_list_table_shows_user_and_system_sources() { + let config_dir = tempfile::tempdir().expect("create config dir"); + let system_dir = tempfile::tempdir().expect("create system dir"); + seed_gateway_sources(config_dir.path(), system_dir.path()); + write_active_gateway(config_dir.path(), "alpha"); + + let (output, code) = run_with_config( + config_dir.path(), + Some(system_dir.path()), + &["gateway", "list"], + ) + .await; + assert_eq!(code, 0, "gateway list should exit 0:\n{output}"); + + let clean = strip_ansi(&output); + assert!(clean.contains("SOURCE"), "expected SOURCE column:\n{clean}"); + + let alpha_line = clean + .lines() + .find(|line| line.contains("alpha")) + .expect("find alpha row"); + assert!( + alpha_line.contains("user"), + "expected alpha row to show user source:\n{clean}" + ); + + let beta_line = clean + .lines() + .find(|line| line.contains("beta")) + .expect("find beta row"); + assert!( + beta_line.contains("system"), + "expected beta row to show system source:\n{clean}" + ); +} + +#[tokio::test] +async fn gateway_list_json_includes_user_and_system_sources() { + let config_dir = tempfile::tempdir().expect("create config dir"); + let system_dir = tempfile::tempdir().expect("create system dir"); + seed_gateway_sources(config_dir.path(), system_dir.path()); + + let (output, code) = run_with_config( + config_dir.path(), + Some(system_dir.path()), + &["gateway", "list", "-o", "json"], + ) + .await; + assert_eq!(code, 0, "gateway list -o json should exit 0:\n{output}"); + + let items: serde_json::Value = serde_json::from_str(&output).expect("parse gateway list json"); + let items = items.as_array().expect("gateway list json array"); + assert_eq!(items.len(), 2, "expected two gateways in json output"); + + let alpha = items + .iter() + .find(|item| item["name"] == "alpha") + .expect("find alpha entry"); + assert_eq!(alpha["source"], "user"); + + let beta = items + .iter() + .find(|item| item["name"] == "beta") + .expect("find beta entry"); + assert_eq!(beta["source"], "system"); +} + +#[tokio::test] +async fn gateway_add_can_shadow_system_gateway_with_user_registration() { + let config_dir = tempfile::tempdir().expect("create config dir"); + let system_dir = tempfile::tempdir().expect("create system dir"); + write_system_gateway_metadata( + system_dir.path(), + "beta", + "http://127.0.0.1:17670", + 17670, + false, + "plaintext", + ); + + let (add_output, add_code) = run_with_config( + config_dir.path(), + Some(system_dir.path()), + &[ + "gateway", + "add", + "http://127.0.0.1:17671", + "--name", + "beta", + ], + ) + .await; + assert_eq!( + add_code, 0, + "gateway add should allow a user registration to shadow a system gateway:\n{add_output}" + ); + + let (list_output, list_code) = run_with_config( + config_dir.path(), + Some(system_dir.path()), + &["gateway", "list", "-o", "json"], + ) + .await; + assert_eq!( + list_code, 0, + "gateway list -o json should exit 0:\n{list_output}" + ); + + let items: serde_json::Value = + serde_json::from_str(&list_output).expect("parse gateway list json"); + let beta = items + .as_array() + .expect("gateway list json array") + .iter() + .find(|item| item["name"] == "beta") + .expect("find beta entry"); + assert_eq!(beta["source"], "user"); + assert_eq!(beta["endpoint"], "http://127.0.0.1:17671"); +}