From b6cc84b2ace5b5f0a007679ce718b361977dfb9a Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Wed, 15 Jul 2026 21:45:58 +0200 Subject: [PATCH 1/2] feat(cli)!: fold gateway metadata into list BREAKING CHANGE: openshell gateway info no longer shows local gateway registration metadata. Use openshell gateway list or openshell gateway list -o json for local registration details. Signed-off-by: Evan Lezar --- crates/openshell-cli/src/main.rs | 22 +--- crates/openshell-cli/src/run.rs | 157 +++++++++++++++++++---------- docs/sandboxes/manage-gateways.mdx | 21 ++-- 3 files changed, 121 insertions(+), 79 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 66484562d4..fddf9db99f 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -366,7 +366,6 @@ const GATEWAY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m \x1b[1mEXAMPLES\x1b[0m $ openshell gateway add http://127.0.0.1:8080 --local $ openshell gateway select my-gateway - $ openshell gateway info $ openshell gateway remove my-gateway "; @@ -1105,18 +1104,11 @@ enum GatewayCommands { name: Option, }, - /// Show gateway registration details. - #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] - Info { - /// Gateway name (defaults to active gateway). - #[arg(long, env = "OPENSHELL_GATEWAY", add = ArgValueCompleter::new(completers::complete_gateway_names))] - name: Option, - }, - /// List registered gateways. /// - /// Prints a table of all registered gateways with their endpoint, type, - /// and authentication mode. The active gateway is marked with `*`. + /// Prints all registered gateways with their endpoint, type, + /// authentication mode, source, and remote registration details. + /// The active gateway is marked with `*`. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] List { /// Output format. @@ -2056,12 +2048,6 @@ async fn main() -> Result<()> { GatewayCommands::Select { name } => { run::gateway_select(name.as_deref(), &cli.gateway)?; } - GatewayCommands::Info { name } => { - let name = name - .or_else(|| resolve_gateway_name(&cli.gateway)) - .unwrap_or_else(|| "openshell".to_string()); - run::gateway_admin_info(&name)?; - } GatewayCommands::List { output } => { run::gateway_list(&cli.gateway, output.as_str())?; } @@ -3445,7 +3431,7 @@ mod tests { for (raw_args, index) in [ (vec!["openshell", "--gateway", "a"], 2), (vec!["openshell", "gateway", "select", "a"], 3), - (vec!["openshell", "gateway", "info", "--name", "a"], 4), + (vec!["openshell", "gateway", "remove", "a"], 3), ] { let mut cmd = Cli::command(); let args: Vec = raw_args.iter().copied().map(Into::into).collect(); diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 5880e21a71..c2f9d067c5 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1356,31 +1356,73 @@ pub fn gateway_list(gateway_flag: &Option, output: &str) -> Result<()> { .max() .unwrap_or(6) .max(6); + let auth_width = gateways + .iter() + .map(|g| gateway_auth_label(&g.metadata).len()) + .max() + .unwrap_or(4) + .max(4); + let remote_labels: Vec> = gateways + .iter() + .map(|g| gateway_remote_label(&g.metadata)) + .collect(); + let show_remote = remote_labels.iter().any(Option::is_some); + let remote_width = remote_labels + .iter() + .filter_map(|label| label.as_ref().map(String::len)) + .max() + .unwrap_or(6) + .max(6); // Print header - println!( - " {:) -> serde_js "source": gateway.source.label(), "auth": gateway_auth_label(metadata), "active": active.as_deref() == Some(&metadata.name), + "is_remote": metadata.is_remote, + "remote_host": &metadata.remote_host, + "resolved_host": &metadata.resolved_host, }) } +fn gateway_remote_label(gateway: &GatewayMetadata) -> Option { + match (&gateway.remote_host, &gateway.resolved_host) { + (Some(remote), Some(resolved)) if remote != resolved => { + Some(format!("{remote} -> {resolved}")) + } + (Some(remote), _) => Some(remote.clone()), + (None, Some(resolved)) => Some(resolved.clone()), + (None, None) => None, + } +} + async fn http_health_check(server: &str, tls: &TlsOptions) -> Result> { let base = server.trim_end_matches('/'); let uri: hyper::Uri = format!("{base}/healthz").parse().into_diagnostic()?; @@ -1522,38 +1578,6 @@ pub fn gateway_remove(name: &str) -> Result<()> { Ok(()) } -/// Show gateway registration details. -pub fn gateway_admin_info(name: &str) -> Result<()> { - let metadata = get_gateway_metadata(name).ok_or_else(|| { - miette::miette!( - "No gateway metadata found for '{name}'.\n\ - Register it first: openshell gateway add --name {name}" - ) - })?; - - println!("{}", "Gateway Info".cyan().bold()); - println!(); - println!(" {} {}", "Gateway:".dimmed(), metadata.name); - println!( - " {} {}", - "Gateway endpoint:".dimmed(), - metadata.gateway_endpoint - ); - - if metadata.is_remote { - if let Some(ref host) = metadata.remote_host { - println!(" {} {host}", "Remote host:".dimmed()); - } else { - println!(" {} External registration", "Type:".dimmed()); - } - if let Some(ref resolved) = metadata.resolved_host { - println!(" {} {resolved}", "Resolved host:".dimmed()); - } - } - - Ok(()) -} - /// Validate system prerequisites for running a gateway. /// /// Checks Docker connectivity and reports the result. Returns exit code 0 @@ -7890,9 +7914,10 @@ mod tests { PolicyGetView, 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_to_json, - gateway_type_label, git_sync_files, http_health_check, import_local_package_mtls_bundle, - inferred_provider_type, mtls_certs_exist_for_gateway, package_managed_tls_dirs, + gateway_auth_label, gateway_env_override_warning, gateway_remote_label, + gateway_select_with, gateway_to_json, gateway_type_label, git_sync_files, + http_health_check, import_local_package_mtls_bundle, inferred_provider_type, + mtls_certs_exist_for_gateway, package_managed_tls_dirs, parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs, parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs, plaintext_gateway_is_remote, policy_revision_to_json, progress_step_from_metadata, @@ -9035,6 +9060,36 @@ mod tests { assert_eq!(json["active"], true); } + #[test] + fn gateway_to_json_includes_remote_registration_details() { + let gateway = ListedGateway { + metadata: GatewayMetadata { + name: "remote-vm".to_string(), + gateway_endpoint: "https://127.0.0.1:17670".to_string(), + is_remote: true, + remote_host: Some("user@gateway-alias".to_string()), + resolved_host: Some("10.0.0.5".to_string()), + auth_mode: Some("mtls".to_string()), + ..Default::default() + }, + source: GatewayMetadataSource::User, + }; + + let json = gateway_to_json(&gateway, &Some("local-vm".to_string())); + + assert_eq!(json["source"], "user"); + assert_eq!(json["type"], "remote"); + assert_eq!(json["auth"], "mtls"); + assert_eq!(json["active"], false); + assert_eq!(json["is_remote"], true); + assert_eq!(json["remote_host"], "user@gateway-alias"); + assert_eq!(json["resolved_host"], "10.0.0.5"); + assert_eq!( + gateway_remote_label(&gateway.metadata).as_deref(), + Some("user@gateway-alias -> 10.0.0.5") + ); + } + #[test] fn gateway_auth_label_defaults_https_gateways_to_mtls() { let gateway = GatewayMetadata { diff --git a/docs/sandboxes/manage-gateways.mdx b/docs/sandboxes/manage-gateways.mdx index ce4496e8d2..283ef0cfb7 100644 --- a/docs/sandboxes/manage-gateways.mdx +++ b/docs/sandboxes/manage-gateways.mdx @@ -84,12 +84,21 @@ The active gateway is the persisted default. The `-g` flag and the `OPENSHELL_GA 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 a non-empty absolute `OPENSHELL_SYSTEM_GATEWAY_DIR` when needed; empty or relative values fall back to `/etc/openshell` and log a warning. 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: +List all registered gateways. The table shows each gateway's endpoint, type, +config source, authentication mode, and remote target when a remote registration +has one: ```shell openshell gateway list ``` +Use structured output when you need the complete local registration metadata, +including `remote_host` and `resolved_host` for remote registrations: + +```shell +openshell gateway list -o json +``` + Switch the active gateway: ```shell @@ -110,13 +119,6 @@ Use `openshell status` for a quick health check: openshell status ``` -Use `openshell gateway info` when you need the registered endpoint, gateway metadata, or compute driver details: - -```shell -openshell gateway info -openshell gateway info --name production -``` - Remove a local CLI registration without stopping the gateway service: ```shell @@ -129,14 +131,13 @@ Check gateway health: ```shell openshell status -openshell gateway info ``` For Docker-backed local gateways, inspect Docker and the gateway process or container started by your local workflow: ```shell openshell doctor check -openshell gateway info +openshell gateway list ``` For Kubernetes gateways, inspect the gateway workload and cluster events: From 9125b06cb903359b51d93f8361bee28ab8449fc0 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Wed, 15 Jul 2026 21:46:21 +0200 Subject: [PATCH 2/2] feat(gateway): add elevated gateway info Signed-off-by: Evan Lezar --- architecture/compute-runtimes.md | 4 + crates/openshell-cli/src/main.rs | 42 +++- crates/openshell-cli/src/run.rs | 238 ++++++++++++++++-- .../tests/ensure_providers_integration.rs | 7 + .../openshell-cli/tests/mtls_integration.rs | 7 + .../tests/provider_commands_integration.rs | 7 + .../sandbox_create_lifecycle_integration.rs | 7 + .../sandbox_name_fallback_integration.rs | 7 + crates/openshell-sdk/tests/client_mock.rs | 11 + crates/openshell-server/src/compute/mod.rs | 46 +++- crates/openshell-server/src/grpc/mod.rs | 51 +++- crates/openshell-server/tests/common/mod.rs | 7 + .../tests/supervisor_relay_integration.rs | 6 + docs/sandboxes/manage-gateways.mdx | 17 +- proto/openshell.proto | 37 +++ 15 files changed, 451 insertions(+), 43 deletions(-) diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index d5d0912e23..e9ef3236b4 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -26,6 +26,10 @@ The capability RPC reports driver identity, version, and the default sandbox image used by the gateway. GPU availability stays driver-local and is validated when a sandbox create request asks for GPU resources. +The gateway records driver identity and version from the startup capability +response. Elevated gateway info reports that initialized driver snapshot instead +of re-querying drivers on each request. + ## Runtime Summary | Runtime | Best fit | Sandbox boundary | Notes | diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index fddf9db99f..b32e7fe9f9 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -240,7 +240,7 @@ const HELP_TEMPLATE: &str = "\ provider: Manage provider configuration \x1b[1mGATEWAY COMMANDS\x1b[0m - gateway: Manage gateway registrations + gateway: Manage gateways status: Show gateway status and information inference: Manage inference configuration doctor: Diagnose gateway issues @@ -366,6 +366,7 @@ const GATEWAY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m \x1b[1mEXAMPLES\x1b[0m $ openshell gateway add http://127.0.0.1:8080 --local $ openshell gateway select my-gateway + $ openshell gateway info $ openshell gateway remove my-gateway "; @@ -523,7 +524,7 @@ enum Commands { // =================================================================== // GATEWAY COMMANDS // =================================================================== - /// Manage gateway registrations. + /// Manage gateways. #[command(alias = "gw", after_help = GATEWAY_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] Gateway { #[command(subcommand)] @@ -1104,6 +1105,14 @@ enum GatewayCommands { name: Option, }, + /// Show elevated live gateway runtime information. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Info { + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + }, + /// List registered gateways. /// /// Prints all registered gateways with their endpoint, type, @@ -2048,6 +2057,15 @@ async fn main() -> Result<()> { GatewayCommands::Select { name } => { run::gateway_select(name.as_deref(), &cli.gateway)?; } + GatewayCommands::Info { output } => { + if let Ok(ctx) = resolve_gateway(&cli.gateway, &cli.gateway_endpoint) { + let mut tls = tls.with_gateway_name(&ctx.name); + apply_auth(&mut tls, &ctx.name); + run::gateway_info(&ctx.name, &ctx.endpoint, &tls, output.as_str()).await?; + } else { + run::gateway_info_not_configured()?; + } + } GatewayCommands::List { output } => { run::gateway_list(&cli.gateway, output.as_str())?; } @@ -3459,6 +3477,26 @@ mod tests { assert!(matches!(cli.command, Some(Commands::Status))); } + #[test] + fn gateway_info_accepts_output_json() { + let cli = Cli::try_parse_from(["openshell", "gateway", "info", "-o", "json"]) + .expect("gateway info -o json should parse"); + + assert!(matches!( + cli.command, + Some(Commands::Gateway { + command: Some(GatewayCommands::Info { + output: OutputFormat::Json + }) + }) + )); + } + + #[test] + fn top_level_info_is_not_a_command() { + assert!(Cli::try_parse_from(["openshell", "info"]).is_err()); + } + #[test] fn hidden_aliases_still_parse() { let cli = Cli::try_parse_from(["openshell", "lg", "sandbox-1"]) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index c2f9d067c5..924edf6f3c 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -40,20 +40,21 @@ use openshell_core::proto::{ DeleteProviderRefreshRequest, DeleteProviderRequest, DeleteSandboxRequest, DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, ExposeServiceRequest, GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, - GetGatewayConfigRequest, GetProviderProfileRequest, GetProviderRefreshStatusRequest, - GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, - GetSandboxPolicyStatusRequest, GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, - HealthRequest, ImportProviderProfilesRequest, LintProviderProfilesRequest, - ListProviderProfilesRequest, ListProvidersRequest, ListSandboxPoliciesRequest, - ListSandboxProvidersRequest, ListSandboxesRequest, ListServicesRequest, PlatformEvent, - PolicySource, PolicyStatus, Provider, ProviderCredentialRefreshStatus, - ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic, - ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, - RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy, - SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetClusterInferenceRequest, - SettingScope, SettingValue, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, - UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, - exec_sandbox_event, setting_value, tcp_forward_init, + GetGatewayConfigRequest, GetGatewayInfoRequest, GetProviderProfileRequest, + GetProviderRefreshStatusRequest, GetProviderRequest, GetSandboxConfigRequest, + GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, GetServiceRequest, + GpuResourceRequirements, HealthRequest, ImportProviderProfilesRequest, + LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, + ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, + ListServicesRequest, PlatformEvent, PolicySource, PolicyStatus, Provider, + ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, ProviderProfile, + ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest, + ResourceRequirements, RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, + SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, ServiceEndpointResponse, + ServiceStatus, SetClusterInferenceRequest, SettingScope, SettingValue, TcpForwardFrame, + TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, UpdateProviderProfilesRequest, + UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value, + tcp_forward_init, }; use openshell_core::settings::{self, SettingValueKind}; use openshell_core::{ObjectId, ObjectName}; @@ -525,6 +526,28 @@ fn print_sandbox_header(sandbox: &Sandbox, display: Option<&ProvisioningDisplay> } } +#[derive(Debug, Clone)] +struct GatewayInfoView { + gateway: String, + server: String, + auth: Option<&'static str>, + status: String, + version: String, + compute_drivers: Vec, +} + +#[derive(Debug, Clone)] +struct ComputeDriverInfoView { + name: String, + capabilities: ComputeDriverCapabilitiesView, +} + +#[derive(Debug, Clone)] +struct ComputeDriverCapabilitiesView { + driver_name: String, + driver_version: String, +} + /// Show gateway status. #[allow(clippy::branches_sharing_code)] pub async fn gateway_status(gateway_name: &str, server: &str, tls: &TlsOptions) -> Result<()> { @@ -582,6 +605,129 @@ pub async fn gateway_status(gateway_name: &str, server: &str, tls: &TlsOptions) Ok(()) } +fn gateway_service_status_name(status: i32) -> &'static str { + match ServiceStatus::try_from(status) { + Ok(ServiceStatus::Healthy) => "healthy", + Ok(ServiceStatus::Degraded) => "degraded", + Ok(ServiceStatus::Unhealthy) => "unhealthy", + Ok(ServiceStatus::Unspecified) | Err(_) => "unknown", + } +} + +/// Show elevated gateway runtime information. +pub async fn gateway_info( + gateway_name: &str, + server: &str, + tls: &TlsOptions, + output: &str, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let info = client + .get_gateway_info(GetGatewayInfoRequest {}) + .await + .map_err(|err| match err.code() { + Code::Unimplemented => { + miette!("gateway info is not supported by this gateway version") + } + Code::PermissionDenied => miette!("gateway info requires admin privileges: {err}"), + _ => miette!("get_gateway_info failed: {err}"), + })? + .into_inner(); + + let view = GatewayInfoView { + gateway: gateway_name.to_string(), + server: server.to_string(), + auth: tls.is_bearer_auth().then_some("bearer"), + status: gateway_service_status_name(info.status).to_string(), + version: info.gateway_version, + compute_drivers: info + .compute_drivers + .into_iter() + .map(|driver| { + let capabilities = driver.capabilities.unwrap_or_default(); + ComputeDriverInfoView { + name: driver.name, + capabilities: ComputeDriverCapabilitiesView { + driver_name: capabilities.driver_name, + driver_version: capabilities.driver_version, + }, + } + }) + .collect(), + }; + + print_gateway_info(&view, output) +} + +pub fn gateway_info_not_configured() -> Result<()> { + Err(miette!( + "No gateway configured.\nRegister a gateway with: openshell gateway add " + )) +} + +fn print_gateway_info(view: &GatewayInfoView, output: &str) -> Result<()> { + if crate::output::print_output_single(output, view, gateway_info_to_json)? { + return Ok(()); + } + + println!("{}", "Gateway Info".cyan().bold()); + println!(); + println!(" {} {}", "Gateway:".dimmed(), view.gateway); + println!(" {} {}", "Server:".dimmed(), view.server); + if view.auth.is_some() { + println!(" {} Edge (bearer token)", "Auth:".dimmed()); + } + println!(" {} {}", "Status:".dimmed(), view.status); + println!(" {} {}", "Version:".dimmed(), view.version); + print_compute_driver_info(&view.compute_drivers); + + Ok(()) +} + +fn print_compute_driver_info(drivers: &[ComputeDriverInfoView]) { + if drivers.is_empty() { + return; + } + + println!(" {}", "Compute drivers:".dimmed()); + for driver in drivers { + println!(" {}", driver.name); + if driver.capabilities.driver_name != driver.name { + println!( + " {} {}", + "Driver name:".dimmed(), + driver.capabilities.driver_name + ); + } + println!( + " {} {}", + "Driver version:".dimmed(), + driver.capabilities.driver_version + ); + } +} + +fn gateway_info_to_json(view: &GatewayInfoView) -> serde_json::Value { + serde_json::json!({ + "gateway": &view.gateway, + "server": &view.server, + "auth": view.auth, + "status": &view.status, + "version": &view.version, + "compute_drivers": view + .compute_drivers + .iter() + .map(|driver| serde_json::json!({ + "name": &driver.name, + "capabilities": { + "driver_name": &driver.capabilities.driver_name, + "driver_version": &driver.capabilities.driver_version, + }, + })) + .collect::>(), + }) +} + /// Set the active gateway. pub fn gateway_use(name: &str) -> Result<()> { // Verify the gateway exists @@ -7911,13 +8057,14 @@ fn format_timestamp_ms(ms: i64) -> String { #[cfg(test)] mod tests { use super::{ - PolicyGetView, ProvisioningStep, TlsOptions, build_sandbox_resource_limits, + ComputeDriverCapabilitiesView, ComputeDriverInfoView, GatewayInfoView, PolicyGetView, + 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_remote_label, - gateway_select_with, gateway_to_json, gateway_type_label, git_sync_files, - http_health_check, import_local_package_mtls_bundle, inferred_provider_type, - mtls_certs_exist_for_gateway, package_managed_tls_dirs, + gateway_auth_label, gateway_env_override_warning, gateway_info_to_json, + gateway_remote_label, gateway_select_with, gateway_to_json, gateway_type_label, + git_sync_files, http_health_check, import_local_package_mtls_bundle, + inferred_provider_type, mtls_certs_exist_for_gateway, package_managed_tls_dirs, parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs, parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs, plaintext_gateway_is_remote, policy_revision_to_json, progress_step_from_metadata, @@ -9090,6 +9237,59 @@ mod tests { ); } + #[test] + fn gateway_info_json_includes_compute_drivers_when_available() { + let view = GatewayInfoView { + gateway: "openshell".to_string(), + server: "https://127.0.0.1:17670".to_string(), + auth: Some("bearer"), + status: "healthy".to_string(), + version: "0.0.75".to_string(), + compute_drivers: vec![ComputeDriverInfoView { + name: "podman".to_string(), + capabilities: ComputeDriverCapabilitiesView { + driver_name: "podman".to_string(), + driver_version: "0.0.75".to_string(), + }, + }], + }; + + let json = gateway_info_to_json(&view); + + assert_eq!(json["gateway"], "openshell"); + assert_eq!(json["status"], "healthy"); + assert_eq!(json["version"], "0.0.75"); + assert_eq!(json["compute_drivers"][0]["name"], "podman"); + assert_eq!( + json["compute_drivers"][0]["capabilities"]["driver_name"], + "podman" + ); + assert_eq!( + json["compute_drivers"][0]["capabilities"]["driver_version"], + "0.0.75" + ); + } + + #[test] + fn gateway_info_json_includes_empty_compute_driver_list() { + let view = GatewayInfoView { + gateway: "openshell".to_string(), + server: "https://127.0.0.1:17670".to_string(), + auth: None, + status: "healthy".to_string(), + version: "0.0.74".to_string(), + compute_drivers: Vec::new(), + }; + + let json = gateway_info_to_json(&view); + + assert!( + json["compute_drivers"] + .as_array() + .is_some_and(Vec::is_empty) + ); + } + #[test] fn gateway_auth_label_defaults_https_gateways_to_mtls() { let gateway = GatewayMetadata { diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 24ab5e4bb7..7f9c59651a 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -86,6 +86,13 @@ impl OpenShell for TestOpenShell { })) } + async fn get_gateway_info( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_sandbox( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 3f51dd6044..58adf86082 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -43,6 +43,13 @@ impl OpenShell for TestOpenShell { })) } + async fn get_gateway_info( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_sandbox( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index d75fe26e20..4a55951e19 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -108,6 +108,13 @@ impl OpenShell for TestOpenShell { })) } + async fn get_gateway_info( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_sandbox( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 3dec8943f3..354b5159f0 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -67,6 +67,13 @@ impl OpenShell for TestOpenShell { })) } + async fn get_gateway_info( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_sandbox( &self, request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index d8f8e695f2..7fe3c5dc59 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -58,6 +58,13 @@ impl OpenShell for TestOpenShell { })) } + async fn get_gateway_info( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_sandbox( &self, _request: tonic::Request, diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index d53873f825..544c5a9b29 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -87,6 +87,17 @@ impl OpenShell for TestOpenShell { })) } + async fn get_gateway_info( + &self, + _: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(proto::GetGatewayInfoResponse { + status: proto::ServiceStatus::Healthy.into(), + gateway_version: "test-1.2.3".to_string(), + compute_drivers: Vec::new(), + })) + } + async fn update_provider_profiles( &self, _: tonic::Request, diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 683ef8e872..a0dd3e37fe 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -60,6 +60,16 @@ type SharedComputeDriver = const DELETE_PHASE_CAS_RETRY_LIMIT: usize = 3; +#[derive(Debug, Clone)] +pub struct ComputeDriverInfoSnapshot { + /// Gateway-selected driver name used for routing and `driver_config` keys. + pub name: String, + /// Driver-reported human-readable name from the startup capability snapshot. + pub driver_name: String, + /// Driver-reported implementation version from the startup capability snapshot. + pub driver_version: String, +} + #[tonic::async_trait] trait ShutdownCleanup: Send + Sync { async fn cleanup_on_shutdown(&self) -> Result<(), String>; @@ -259,7 +269,7 @@ impl ComputeDriver for RemoteComputeDriver { #[derive(Clone)] pub struct ComputeRuntime { driver: SharedComputeDriver, - driver_name: String, + driver_info: ComputeDriverInfoSnapshot, shutdown_cleanup: Option>, startup_resume: Option>, _driver_process: Option>, @@ -307,10 +317,15 @@ impl ComputeRuntime { in_tree = driver_kind.is_some(), "Compute driver connected" ); + let driver_info = ComputeDriverInfoSnapshot { + name: driver_name, + driver_name: capabilities.driver_name, + driver_version: capabilities.driver_version, + }; let default_image = capabilities.default_image; Ok(Self { driver, - driver_name, + driver_info, shutdown_cleanup, startup_resume, _driver_process: driver_process, @@ -456,9 +471,14 @@ impl ComputeRuntime { &self.default_image } + #[must_use] + pub fn driver_info_snapshots(&self) -> &[ComputeDriverInfoSnapshot] { + std::slice::from_ref(&self.driver_info) + } + #[must_use] pub fn driver_kind(&self) -> Option { - self.driver_name.parse().ok() + self.driver_info.name.parse().ok() } #[must_use] @@ -467,8 +487,8 @@ impl ComputeRuntime { } pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), Status> { - let driver_sandbox = - driver_sandbox_from_public(sandbox, &self.driver_name).map_err(|status| *status)?; + let driver_sandbox = driver_sandbox_from_public(sandbox, &self.driver_info.name) + .map_err(|status| *status)?; self.driver .validate_sandbox_create(Request::new(ValidateSandboxCreateRequest { sandbox: Some(driver_sandbox), @@ -483,8 +503,8 @@ impl ComputeRuntime { sandbox_token: Option, ) -> Result { let sandbox_id = sandbox.object_id().to_string(); - let mut driver_sandbox = - driver_sandbox_from_public(&sandbox, &self.driver_name).map_err(|status| *status)?; + let mut driver_sandbox = driver_sandbox_from_public(&sandbox, &self.driver_info.name) + .map_err(|status| *status)?; // Create with MustCreate condition to prevent duplicate creation race self.sandbox_index.update_from_sandbox(&sandbox); @@ -2097,7 +2117,11 @@ impl ComputeDriver for NoopTestDriver { pub async fn new_test_runtime(store: Arc) -> ComputeRuntime { ComputeRuntime { driver: Arc::new(NoopTestDriver), - driver_name: "test".to_string(), + driver_info: ComputeDriverInfoSnapshot { + name: "test".to_string(), + driver_name: "test".to_string(), + driver_version: "test".to_string(), + }, shutdown_cleanup: None, startup_resume: None, _driver_process: None, @@ -2352,7 +2376,11 @@ mod tests { let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); ComputeRuntime { driver, - driver_name: "test-driver".to_string(), + driver_info: ComputeDriverInfoSnapshot { + name: "test-driver".to_string(), + driver_name: "test-driver".to_string(), + driver_version: "test".to_string(), + }, shutdown_cleanup: None, startup_resume, _driver_process: None, diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index a71274e2d0..36f3e64cbb 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -13,18 +13,19 @@ mod validation; use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, ApproveDraftChunkResponse, AttachSandboxProviderRequest, AttachSandboxProviderResponse, - ClearDraftChunksRequest, ClearDraftChunksResponse, ConfigureProviderRefreshRequest, - ConfigureProviderRefreshResponse, CreateProviderRequest, CreateSandboxRequest, - CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderProfileRequest, - DeleteProviderProfileResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, - DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DeleteServiceRequest, DeleteServiceResponse, DetachSandboxProviderRequest, - DetachSandboxProviderResponse, EditDraftChunkRequest, EditDraftChunkResponse, ExecSandboxEvent, - ExecSandboxInput, ExecSandboxRequest, ExposeServiceRequest, GatewayMessage, - GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, - GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderProfileRequest, - GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, GetProviderRequest, - GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, + ClearDraftChunksRequest, ClearDraftChunksResponse, ComputeDriverCapabilities, + ComputeDriverInfo, ConfigureProviderRefreshRequest, ConfigureProviderRefreshResponse, + CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, + DeleteProviderProfileRequest, DeleteProviderProfileResponse, DeleteProviderRefreshRequest, + DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, + DeleteSandboxRequest, DeleteSandboxResponse, DeleteServiceRequest, DeleteServiceResponse, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, EditDraftChunkRequest, + EditDraftChunkResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, + ExposeServiceRequest, GatewayMessage, GetDraftHistoryRequest, GetDraftHistoryResponse, + GetDraftPolicyRequest, GetDraftPolicyResponse, GetGatewayConfigRequest, + GetGatewayConfigResponse, GetGatewayInfoRequest, GetGatewayInfoResponse, + GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetServiceRequest, HealthRequest, HealthResponse, ImportProviderProfilesRequest, @@ -206,6 +207,32 @@ impl OpenShell for OpenShellService { })) } + #[rpc_auth(auth = "bearer", scope = "config:read", role = "admin")] + async fn get_gateway_info( + &self, + _request: Request, + ) -> Result, Status> { + let compute_drivers = self + .state + .compute + .driver_info_snapshots() + .iter() + .map(|driver| ComputeDriverInfo { + name: driver.name.clone(), + capabilities: Some(ComputeDriverCapabilities { + driver_name: driver.driver_name.clone(), + driver_version: driver.driver_version.clone(), + }), + }) + .collect(); + + Ok(Response::new(GetGatewayInfoResponse { + status: ServiceStatus::Healthy.into(), + gateway_version: openshell_core::VERSION.to_string(), + compute_drivers, + })) + } + // --- Sandbox lifecycle --- #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 3934c8af42..a5bfa1057e 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -62,6 +62,13 @@ impl OpenShell for TestOpenShell { })) } + async fn get_gateway_info( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_sandbox( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index bd94d151e5..98c5d99f55 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -111,6 +111,12 @@ impl OpenShell for RelayGateway { ) -> Result, Status> { Err(Status::unimplemented("unused")) } + async fn get_gateway_info( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } async fn create_sandbox( &self, _: tonic::Request, diff --git a/docs/sandboxes/manage-gateways.mdx b/docs/sandboxes/manage-gateways.mdx index 283ef0cfb7..443a65a750 100644 --- a/docs/sandboxes/manage-gateways.mdx +++ b/docs/sandboxes/manage-gateways.mdx @@ -113,12 +113,26 @@ openshell status -g staging ## Inspect Gateway Status -Use `openshell status` for a quick health check: +Use `openshell status` for a live gateway check. It verifies that the CLI can +reach the running gateway, then reports health and the gateway version: ```shell openshell status ``` +Use `openshell gateway info` when you need elevated runtime details such as +initialized compute drivers and driver-reported capability versions: + +```shell +openshell gateway info +``` + +Use structured output when scripting against live runtime info: + +```shell +openshell gateway info -o json +``` + Remove a local CLI registration without stopping the gateway service: ```shell @@ -131,6 +145,7 @@ Check gateway health: ```shell openshell status +openshell gateway info ``` For Docker-backed local gateways, inspect Docker and the gateway process or container started by your local workflow: diff --git a/proto/openshell.proto b/proto/openshell.proto index 776ae66ca6..b437613b04 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -22,6 +22,9 @@ service OpenShell { // Check the health of the service. rpc Health(HealthRequest) returns (HealthResponse); + // Fetch elevated live gateway runtime metadata. + rpc GetGatewayInfo(GetGatewayInfoRequest) returns (GetGatewayInfoResponse); + // Create a new sandbox. rpc CreateSandbox(CreateSandboxRequest) returns (SandboxResponse); @@ -290,6 +293,40 @@ message HealthResponse { string version = 2; } +// Gateway info request. +message GetGatewayInfoRequest {} + +// Gateway info response. +message GetGatewayInfoResponse { + // Service status. + ServiceStatus status = 1; + + // OpenShell gateway binary version. + string gateway_version = 2; + + // Compute driver runtimes initialized by this gateway. Current gateways + // return exactly one entry. + repeated ComputeDriverInfo compute_drivers = 3; +} + +// Info for one initialized compute driver runtime. +message ComputeDriverInfo { + // Gateway-selected driver name used for routing and driver_config keys. + string name = 1; + + // Capabilities reported by the driver during gateway runtime initialization. + ComputeDriverCapabilities capabilities = 2; +} + +// Public compute driver capability snapshot. +message ComputeDriverCapabilities { + // Driver-reported human-readable name from the startup capability snapshot. + string driver_name = 1; + + // Driver-reported implementation version from the startup capability snapshot. + string driver_version = 2; +} + // Public sandbox resource exposed by the OpenShell API. // // This is the canonical gateway-owned view of a sandbox. It merges user intent