Skip to content

Commit 61aa75d

Browse files
committed
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 <alex.lewontin@canonical.com>
1 parent 35d7263 commit 61aa75d

8 files changed

Lines changed: 365 additions & 53 deletions

File tree

crates/openshell-bootstrap/src/metadata.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ pub enum GatewayMetadataSource {
8282
System,
8383
}
8484

85+
impl GatewayMetadataSource {
86+
pub const fn label(self) -> &'static str {
87+
match self {
88+
Self::User => "user",
89+
Self::System => "system",
90+
}
91+
}
92+
}
8593

8694
fn stored_metadata_path(name: &str) -> Result<PathBuf> {
8795
Ok(gateways_dir()?.join(name).join("metadata.json"))

crates/openshell-cli/src/run.rs

Lines changed: 89 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ use hyper_util::{client::legacy::Client, rt::TokioExecutor};
1919
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
2020
use miette::{IntoDiagnostic, Result, WrapErr, miette};
2121
use openshell_bootstrap::{
22-
GatewayMetadata, clear_active_gateway, clear_last_sandbox_if_matches,
23-
extract_host_from_ssh_destination, get_gateway_metadata, list_gateways, load_active_gateway,
24-
remove_gateway_metadata, resolve_ssh_hostname, save_active_gateway, save_last_sandbox,
25-
store_gateway_metadata,
22+
GatewayMetadata, GatewayMetadataSource, clear_active_gateway, clear_last_sandbox_if_matches,
23+
extract_host_from_ssh_destination, gateway_metadata_source, get_gateway_metadata,
24+
list_gateways, load_active_gateway, remove_gateway_metadata, resolve_ssh_hostname,
25+
save_active_gateway, save_last_sandbox, store_gateway_metadata,
2626
};
2727
use openshell_core::progress::{
2828
PROGRESS_ACTIVE_DETAIL_KEY, PROGRESS_ACTIVE_STEP_KEY, PROGRESS_COMPLETE_LABEL_KEY,
@@ -658,6 +658,19 @@ fn gateway_select_column_widths(gateways: &[GatewayMetadata]) -> (usize, usize,
658658

659659
(name_width, endpoint_width, type_width)
660660
}
661+
fn listed_gateway_source(name: &str) -> Result<GatewayMetadataSource> {
662+
gateway_metadata_source(name)?
663+
.ok_or_else(|| miette!("gateway '{name}' disappeared while resolving its config source"))
664+
}
665+
666+
fn listed_gateways_with_source(
667+
gateways: &[GatewayMetadata],
668+
) -> Result<Vec<(&GatewayMetadata, GatewayMetadataSource)>> {
669+
gateways
670+
.iter()
671+
.map(|gateway| Ok((gateway, listed_gateway_source(&gateway.name)?)))
672+
.collect()
673+
}
661674

662675
fn gateway_type_label(gateway: &GatewayMetadata) -> &'static str {
663676
match gateway.auth_mode.as_deref() {
@@ -1291,13 +1304,14 @@ pub fn gateway_logout(name: &str) -> Result<()> {
12911304
/// List all registered gateways.
12921305
pub fn gateway_list(gateway_flag: &Option<String>, output: &str) -> Result<()> {
12931306
let gateways = list_gateways()?;
1307+
let gateway_sources = listed_gateways_with_source(&gateways)?;
12941308
let active = gateway_flag.clone().or_else(load_active_gateway);
12951309

12961310
match output {
12971311
"json" => {
1298-
let items: Vec<serde_json::Value> = gateways
1312+
let items: Vec<serde_json::Value> = gateway_sources
12991313
.iter()
1300-
.map(|g| gateway_to_json(g, &active))
1314+
.map(|(gateway, source)| gateway_to_json(gateway, &active, *source))
13011315
.collect();
13021316
println!(
13031317
"{}",
@@ -1306,9 +1320,9 @@ pub fn gateway_list(gateway_flag: &Option<String>, output: &str) -> Result<()> {
13061320
return Ok(());
13071321
}
13081322
"yaml" => {
1309-
let items: Vec<serde_json::Value> = gateways
1323+
let items: Vec<serde_json::Value> = gateway_sources
13101324
.iter()
1311-
.map(|g| gateway_to_json(g, &active))
1325+
.map(|(gateway, source)| gateway_to_json(gateway, &active, *source))
13121326
.collect();
13131327
print!("{}", serde_yml::to_string(&items).into_diagnostic()?);
13141328
return Ok(());
@@ -1327,7 +1341,6 @@ pub fn gateway_list(gateway_flag: &Option<String>, output: &str) -> Result<()> {
13271341
return Ok(());
13281342
}
13291343

1330-
// Calculate column widths
13311344
let name_width = gateways
13321345
.iter()
13331346
.map(|g| g.name.len())
@@ -1346,25 +1359,33 @@ pub fn gateway_list(gateway_flag: &Option<String>, output: &str) -> Result<()> {
13461359
.max()
13471360
.unwrap_or(4)
13481361
.max(4);
1362+
let source_width = gateway_sources
1363+
.iter()
1364+
.map(|(_, source)| source.label().len())
1365+
.max()
1366+
.unwrap_or(6)
1367+
.max(6);
13491368

1350-
// Print header
13511369
println!(
1352-
" {:<name_width$} {:<endpoint_width$} {:<type_width$} {}",
1370+
" {:<name_width$} {:<endpoint_width$} {:<type_width$} {:<source_width$} {}",
13531371
"NAME".bold(),
13541372
"ENDPOINT".bold(),
13551373
"TYPE".bold(),
1374+
"SOURCE".bold(),
13561375
"AUTH".bold(),
13571376
);
13581377

1359-
// Print rows
1360-
for gateway in &gateways {
1378+
for (gateway, source) in gateway_sources {
13611379
let is_active = active.as_deref() == Some(&gateway.name);
13621380
let marker = if is_active { "*" } else { " " };
13631381
let gw_type = gateway_type_label(gateway);
13641382
let gw_auth = gateway_auth_label(gateway);
13651383
let line = format!(
1366-
"{marker} {:<name_width$} {:<endpoint_width$} {:<type_width$} {gw_auth}",
1367-
gateway.name, gateway.gateway_endpoint, gw_type,
1384+
"{marker} {:<name_width$} {:<endpoint_width$} {:<type_width$} {:<source_width$} {gw_auth}",
1385+
gateway.name,
1386+
gateway.gateway_endpoint,
1387+
gw_type,
1388+
source.label(),
13681389
);
13691390
if is_active {
13701391
println!("{}", line.green());
@@ -1376,11 +1397,16 @@ pub fn gateway_list(gateway_flag: &Option<String>, output: &str) -> Result<()> {
13761397
Ok(())
13771398
}
13781399

1379-
fn gateway_to_json(gateway: &GatewayMetadata, active: &Option<String>) -> serde_json::Value {
1400+
fn gateway_to_json(
1401+
gateway: &GatewayMetadata,
1402+
active: &Option<String>,
1403+
source: GatewayMetadataSource,
1404+
) -> serde_json::Value {
13801405
serde_json::json!({
13811406
"name": gateway.name,
13821407
"endpoint": gateway.gateway_endpoint,
13831408
"type": gateway_type_label(gateway),
1409+
"source": source.label(),
13841410
"auth": gateway_auth_label(gateway),
13851411
"active": active.as_deref() == Some(&gateway.name),
13861412
})
@@ -1458,11 +1484,20 @@ fn remove_gateway_registration(name: &str) {
14581484

14591485
/// Remove a local gateway registration without touching the gateway service.
14601486
pub fn gateway_remove(name: &str) -> Result<()> {
1461-
if get_gateway_metadata(name).is_none() {
1462-
return Err(miette::miette!(
1463-
"No gateway metadata found for '{name}'.\n\
1464-
List available gateways: openshell gateway select"
1465-
));
1487+
match gateway_metadata_source(name)? {
1488+
Some(GatewayMetadataSource::User) => {}
1489+
Some(GatewayMetadataSource::System) => {
1490+
return Err(miette::miette!(
1491+
"Gateway registration '{name}' is installed by the system and cannot be removed from user config.\n\
1492+
Register a per-user gateway with the same name to override it, or select another gateway."
1493+
));
1494+
}
1495+
None => {
1496+
return Err(miette::miette!(
1497+
"No gateway metadata found for '{name}'.\n\
1498+
List available gateways: openshell gateway select"
1499+
));
1500+
}
14661501
}
14671502

14681503
remove_gateway_registration(name);
@@ -6983,19 +7018,21 @@ mod tests {
69837018
ProvisioningDisplay, ProvisioningStep, TlsOptions, build_sandbox_resource_limits,
69847019
dockerfile_sources_supported_for_gateway, format_endpoint, format_gateway_select_header,
69857020
format_gateway_select_items, format_provider_attachment_table, gateway_add,
6986-
gateway_auth_label, gateway_env_override_warning, gateway_select_with, gateway_type_label,
6987-
git_sync_files, http_health_check, image_requests_gpu, import_local_package_mtls_bundle,
6988-
inferred_provider_type, local_upload_path_exists, local_upload_path_is_symlink,
6989-
package_managed_tls_dirs, parse_cli_setting_value, parse_credential_expiry_cli_value,
6990-
parse_credential_expiry_pairs, parse_credential_pairs, plaintext_gateway_is_remote,
6991-
progress_step_from_metadata, provider_profile_allows_refresh_bootstrap,
6992-
provisioning_timeout_message, ready_false_condition_message, refresh_status_header,
6993-
refresh_status_row, resolve_from, sandbox_should_persist, service_expose_status_error,
6994-
service_url_for_gateway,
7021+
gateway_auth_label, gateway_env_override_warning, gateway_select_with, gateway_to_json,
7022+
gateway_type_label, git_sync_files, http_health_check, image_requests_gpu,
7023+
import_local_package_mtls_bundle, inferred_provider_type, local_upload_path_exists,
7024+
local_upload_path_is_symlink, package_managed_tls_dirs, parse_cli_setting_value,
7025+
parse_credential_expiry_cli_value, parse_credential_expiry_pairs, parse_credential_pairs,
7026+
plaintext_gateway_is_remote, progress_step_from_metadata,
7027+
provider_profile_allows_refresh_bootstrap, provisioning_timeout_message,
7028+
ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from,
7029+
sandbox_should_persist, service_expose_status_error, service_url_for_gateway,
69957030
};
69967031
use crate::TEST_ENV_LOCK;
69977032
use hyper::StatusCode;
6998-
use openshell_bootstrap::{load_active_gateway, load_gateway_metadata, store_gateway_metadata};
7033+
use openshell_bootstrap::{
7034+
GatewayMetadataSource, load_active_gateway, load_gateway_metadata, store_gateway_metadata,
7035+
};
69997036
use std::fs;
70007037
use std::io::{Read, Write};
70017038
use std::net::TcpListener;
@@ -7935,6 +7972,27 @@ mod tests {
79357972
assert!(items[1].contains("http://127.0.0.1:8080"));
79367973
}
79377974

7975+
#[test]
7976+
fn gateway_to_json_includes_config_source() {
7977+
let gateway = GatewayMetadata {
7978+
name: "local-vm".to_string(),
7979+
gateway_endpoint: "http://127.0.0.1:17670".to_string(),
7980+
auth_mode: Some("plaintext".to_string()),
7981+
..Default::default()
7982+
};
7983+
7984+
let json = gateway_to_json(
7985+
&gateway,
7986+
&Some("local-vm".to_string()),
7987+
GatewayMetadataSource::System,
7988+
);
7989+
7990+
assert_eq!(json["source"], "system");
7991+
assert_eq!(json["type"], "local");
7992+
assert_eq!(json["auth"], "plaintext");
7993+
assert_eq!(json["active"], true);
7994+
}
7995+
79387996
#[test]
79397997
fn gateway_auth_label_defaults_https_gateways_to_mtls() {
79407998
let gateway = GatewayMetadata {

crates/openshell-tui/src/app.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::collections::HashMap;
55
use std::time::{Duration, Instant};
66

77
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8+
use openshell_bootstrap::GatewayMetadataSource;
89
use openshell_core::auth::EdgeAuthInterceptor;
910
use openshell_core::proto::open_shell_client::OpenShellClient;
1011
use openshell_core::proto::setting_value;
@@ -214,10 +215,21 @@ pub fn display_setting_value(value: &Option<setting_value::Value>) -> String {
214215
// Gateway entry
215216
// ---------------------------------------------------------------------------
216217

218+
#[derive(Debug, Clone, PartialEq, Eq)]
217219
pub struct GatewayEntry {
218220
pub name: String,
219221
pub endpoint: String,
220222
pub is_remote: bool,
223+
pub source: Option<GatewayMetadataSource>,
224+
}
225+
226+
impl GatewayEntry {
227+
pub const fn source_label(&self) -> &'static str {
228+
match self.source {
229+
Some(source) => source.label(),
230+
None => "unknown",
231+
}
232+
}
221233
}
222234

223235
// ---------------------------------------------------------------------------
@@ -2231,3 +2243,40 @@ fn unique_provider_name(base: &str, existing: &[String]) -> String {
22312243
}
22322244
base.to_string()
22332245
}
2246+
2247+
#[cfg(test)]
2248+
mod tests {
2249+
use super::GatewayEntry;
2250+
use openshell_bootstrap::GatewayMetadataSource;
2251+
2252+
#[test]
2253+
fn gateway_entry_source_label_formats_known_sources() {
2254+
let user_gateway = GatewayEntry {
2255+
name: "user-gw".to_string(),
2256+
endpoint: "https://user.example.com".to_string(),
2257+
is_remote: true,
2258+
source: Some(GatewayMetadataSource::User),
2259+
};
2260+
let system_gateway = GatewayEntry {
2261+
name: "system-gw".to_string(),
2262+
endpoint: "http://127.0.0.1:17670".to_string(),
2263+
is_remote: false,
2264+
source: Some(GatewayMetadataSource::System),
2265+
};
2266+
2267+
assert_eq!(user_gateway.source_label(), "user");
2268+
assert_eq!(system_gateway.source_label(), "system");
2269+
}
2270+
2271+
#[test]
2272+
fn gateway_entry_source_label_handles_unknown_source() {
2273+
let gateway = GatewayEntry {
2274+
name: "mystery".to_string(),
2275+
endpoint: "https://mystery.example.com".to_string(),
2276+
is_remote: true,
2277+
source: None,
2278+
};
2279+
2280+
assert_eq!(gateway.source_label(), "unknown");
2281+
}
2282+
}

crates/openshell-tui/src/lib.rs

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use crossterm::terminal::{
1818
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
1919
};
2020
use miette::{IntoDiagnostic, Result};
21+
use openshell_bootstrap::gateway_metadata_source;
2122
use openshell_core::auth::EdgeAuthInterceptor;
2223
use openshell_core::metadata::{ObjectId, ObjectLabels, ObjectName};
2324
use openshell_core::proto::open_shell_client::OpenShellClient;
@@ -458,24 +459,22 @@ fn refresh_gateway_list(app: &mut App) {
458459
if let Ok(gateways) = openshell_bootstrap::list_gateways() {
459460
app.gateways = gateways
460461
.into_iter()
461-
.map(|m| GatewayEntry {
462-
name: m.name,
463-
endpoint: m.gateway_endpoint,
464-
is_remote: m.is_remote,
462+
.map(|metadata| GatewayEntry {
463+
source: gateway_metadata_source(&metadata.name).ok().flatten(),
464+
name: metadata.name,
465+
endpoint: metadata.gateway_endpoint,
466+
is_remote: metadata.is_remote,
465467
})
466468
.collect();
467469

468-
// Keep selection in bounds.
469470
if app.gateway_selected >= app.gateways.len() && !app.gateways.is_empty() {
470471
app.gateway_selected = app.gateways.len() - 1;
471472
}
472473

473-
// If the active gateway appears in the list, move cursor to it on first load.
474-
if let Some(idx) = app.gateways.iter().position(|g| g.name == app.gateway_name) {
475-
// Only snap the cursor when it's still at 0 (initial state).
476-
if app.gateway_selected == 0 {
477-
app.gateway_selected = idx;
478-
}
474+
if let Some(idx) = app.gateways.iter().position(|g| g.name == app.gateway_name)
475+
&& app.gateway_selected == 0
476+
{
477+
app.gateway_selected = idx;
479478
}
480479
}
481480
}

crates/openshell-tui/src/ui/dashboard.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ fn draw_gateway_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
4040
let header = Row::new(vec![
4141
Cell::from(Span::styled(" NAME", t.muted)),
4242
Cell::from(Span::styled("TYPE", t.muted)),
43+
Cell::from(Span::styled("SOURCE", t.muted)),
4344
Cell::from(Span::styled("STATUS", t.muted)),
4445
Cell::from(Span::styled("", t.muted)),
4546
])
@@ -92,6 +93,7 @@ fn draw_gateway_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
9293
Row::new(vec![
9394
name_cell,
9495
Cell::from(Span::styled(type_label, t.muted)),
96+
Cell::from(Span::styled(entry.source_label(), t.muted)),
9597
status_cell,
9698
policy_cell,
9799
])
@@ -107,9 +109,10 @@ fn draw_gateway_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
107109
.padding(Padding::horizontal(1));
108110

109111
let widths = [
110-
Constraint::Percentage(30),
112+
Constraint::Percentage(24),
111113
Constraint::Percentage(10),
112-
Constraint::Percentage(25),
114+
Constraint::Percentage(10),
115+
Constraint::Percentage(21),
113116
Constraint::Percentage(35),
114117
];
115118

crates/openshell-tui/src/ui/mod.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,21 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) {
130130
_ => Span::styled(&app.status_text, t.muted),
131131
};
132132

133+
let active_gateway_source = app
134+
.gateways
135+
.iter()
136+
.find(|gateway| gateway.name == app.gateway_name)
137+
.map_or("unknown", app::GatewayEntry::source_label);
138+
133139
let mut parts: Vec<Span<'_>> = vec![
134140
Span::styled(" >_ OpenShell ", t.accent_bold),
135141
Span::styled(" ALPHA ", t.badge),
136142
Span::styled(" | ", t.muted),
137143
Span::styled("Current Gateway: ", t.text),
138144
Span::styled(&app.gateway_name, t.heading),
139-
Span::styled(" (", t.muted),
145+
Span::styled(" [", t.muted),
146+
Span::styled(active_gateway_source, t.muted),
147+
Span::styled("] (", t.muted),
140148
status_span,
141149
Span::styled(")", t.muted),
142150
Span::styled(" | ", t.muted),

0 commit comments

Comments
 (0)