From b44bd0b47c137a71968524e1485ca7050553eb2f Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Mon, 13 Jul 2026 08:18:46 -0400 Subject: [PATCH 01/14] feat(workspace): add workspace scoping to all resource handlers and CLI Thread workspace through compute drivers (Docker, Podman, K8s) with converged container naming (openshell-{workspace}--{name}-{id}), workspace labels, and label-based lookup. Fix sandbox-side policy sync to use learned workspace instead of hardcoding empty string. Fix TUI all-workspaces view to use per-row workspace for sandbox actions. Add provider profile to workspace deletion blocking check. Thread workspace through SSH config generation with workspace-qualified host aliases. Signed-off-by: Derek Carr --- crates/openshell-cli/src/completers.rs | 30 +- crates/openshell-cli/src/main.rs | 600 +++++++- crates/openshell-cli/src/run.rs | 906 +++++++++-- crates/openshell-cli/src/ssh.rs | 106 +- .../tests/ensure_providers_integration.rs | 60 +- .../openshell-cli/tests/mtls_integration.rs | 49 + .../tests/provider_commands_integration.rs | 340 ++++- .../sandbox_create_lifecycle_integration.rs | 108 +- .../sandbox_name_fallback_integration.rs | 62 +- crates/openshell-core/src/driver_utils.rs | 3 + crates/openshell-core/src/grpc_client.rs | 74 +- crates/openshell-core/src/lib.rs | 4 +- crates/openshell-core/src/metadata.rs | 166 +- crates/openshell-driver-docker/src/lib.rs | 37 +- crates/openshell-driver-docker/src/tests.rs | 10 +- .../openshell-driver-kubernetes/src/driver.rs | 344 ++++- .../openshell-driver-kubernetes/src/grpc.rs | 17 +- .../openshell-driver-podman/src/container.rs | 23 +- crates/openshell-driver-podman/src/driver.rs | 220 ++- crates/openshell-driver-podman/src/grpc.rs | 80 +- crates/openshell-driver-podman/src/watcher.rs | 27 +- crates/openshell-sandbox/src/lib.rs | 21 + .../postgres/006_add_workspace_column.sql | 13 + .../sqlite/006_add_workspace_column.sql | 13 + .../src/auth/sandbox_methods.rs | 4 +- crates/openshell-server/src/compute/lease.rs | 3 + crates/openshell-server/src/compute/mod.rs | 125 +- crates/openshell-server/src/grpc/auth_rpc.rs | 2 +- crates/openshell-server/src/grpc/mod.rs | 117 +- crates/openshell-server/src/grpc/policy.rs | 775 +++++++--- crates/openshell-server/src/grpc/provider.rs | 1075 +++++++++---- crates/openshell-server/src/grpc/sandbox.rs | 340 ++++- crates/openshell-server/src/grpc/service.rs | 301 +++- .../openshell-server/src/grpc/validation.rs | 2 +- crates/openshell-server/src/grpc/workspace.rs | 744 +++++++++ crates/openshell-server/src/inference.rs | 339 +++-- crates/openshell-server/src/lib.rs | 50 +- crates/openshell-server/src/multiplex.rs | 4 +- .../openshell-server/src/persistence/mod.rs | 114 +- .../src/persistence/postgres.rs | 113 +- .../src/persistence/sqlite.rs | 105 +- .../openshell-server/src/persistence/tests.rs | 184 ++- crates/openshell-server/src/policy_store.rs | 12 +- .../openshell-server/src/provider_refresh.rs | 134 +- .../openshell-server/src/service_routing.rs | 180 ++- crates/openshell-server/src/ssh_sessions.rs | 4 +- .../src/supervisor_session.rs | 2 +- crates/openshell-server/tests/common/mod.rs | 49 + .../tests/supervisor_relay_integration.rs | 45 + crates/openshell-tui/src/app.rs | 84 +- crates/openshell-tui/src/lib.rs | 126 +- crates/openshell-tui/src/ui/mod.rs | 7 + crates/openshell-tui/src/ui/providers.rs | 90 +- crates/openshell-tui/src/ui/sandboxes.rs | 54 +- e2e/rust/Cargo.toml | 5 + e2e/rust/tests/workspace_lifecycle.rs | 174 +++ proto/compute_driver.proto | 3 + proto/datamodel.proto | 15 +- proto/inference.proto | 41 +- proto/openshell.proto | 239 ++- proto/sandbox.proto | 3 + rfc/0011-multi-player-design/README.md | 1334 +++++++++++++++++ 62 files changed, 8703 insertions(+), 1608 deletions(-) create mode 100644 crates/openshell-server/migrations/postgres/006_add_workspace_column.sql create mode 100644 crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql create mode 100644 crates/openshell-server/src/grpc/workspace.rs create mode 100644 e2e/rust/tests/workspace_lifecycle.rs create mode 100644 rfc/0011-multi-player-design/README.md diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index a421b418ae..a419f4bef2 100644 --- a/crates/openshell-cli/src/completers.rs +++ b/crates/openshell-cli/src/completers.rs @@ -11,7 +11,7 @@ use openshell_bootstrap::{list_gateways, load_active_gateway, load_gateway_metad use openshell_core::ObjectName; use openshell_core::auth::EdgeAuthInterceptor; use openshell_core::proto::open_shell_client::OpenShellClient; -use openshell_core::proto::{ListProvidersRequest, ListSandboxesRequest}; +use openshell_core::proto::{ListProvidersRequest, ListSandboxesRequest, ListWorkspacesRequest}; use tonic::service::interceptor::InterceptedService; use tonic::transport::Channel; @@ -39,6 +39,8 @@ pub fn complete_sandbox_names(_prefix: &OsStr) -> Vec { limit: 200, offset: 0, label_selector: String::new(), + workspace: String::new(), + all_workspaces: false, }) .await .ok()?; @@ -62,6 +64,8 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec { .list_providers(ListProvidersRequest { limit: 200, offset: 0, + workspace: String::new(), + all_workspaces: false, }) .await .ok()?; @@ -76,6 +80,30 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec { }) } +/// Complete workspace names by querying the active gateway. +pub fn complete_workspace_names(_prefix: &OsStr) -> Vec { + blocking_complete(async { + let (endpoint, gateway_name) = resolve_active_gateway()?; + let mut client = completion_grpc_client(&endpoint, &gateway_name).await?; + let response = client + .list_workspaces(ListWorkspacesRequest { + limit: 200, + offset: 0, + label_selector: String::new(), + }) + .await + .ok()?; + Some( + response + .into_inner() + .workspaces + .into_iter() + .map(|w| CompletionCandidate::new(w.object_name())) + .collect(), + ) + }) +} + fn resolve_active_gateway() -> Option<(String, String)> { let name = std::env::var("OPENSHELL_GATEWAY") .ok() diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index b32e7fe9f9..554f57bf9b 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -360,6 +360,16 @@ const PROVIDER_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m $ openshell provider delete openai "; +const WORKSPACE_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m + ws + +\x1b[1mEXAMPLES\x1b[0m + $ openshell workspace create --name staging + $ openshell workspace list + $ openshell workspace get staging + $ openshell workspace delete staging +"; + const GATEWAY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m gw @@ -422,6 +432,17 @@ struct Cli { )] gateway_insecure: bool, + /// Workspace scope for resource operations. + #[arg( + long, + global = true, + env = "OPENSHELL_WORKSPACE", + default_value = "default", + help_heading = "GLOBAL FLAGS", + add = ArgValueCompleter::new(completers::complete_workspace_names) + )] + workspace: String, + /// Increase verbosity (-v, -vv, -vvv). #[arg(short, long, action = clap::ArgAction::Count, global = true, help_heading = "GLOBAL FLAGS")] verbose: u8, @@ -521,6 +542,13 @@ enum Commands { command: Option, }, + /// Manage workspaces. + #[command(alias = "ws", after_help = WORKSPACE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] + Workspace { + #[command(subcommand)] + command: Option, + }, + // =================================================================== // GATEWAY COMMANDS // =================================================================== @@ -814,6 +842,10 @@ enum ProviderCommands { /// Output format. #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with = "names")] output: OutputFormat, + + /// List providers across all workspaces. + #[arg(long)] + all_workspaces: bool, }, /// List available provider profiles. @@ -952,6 +984,10 @@ enum ProviderProfileCommands { /// Output format. #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Yaml)] output: OutputFormat, + + /// Target platform-scoped profiles (ignores --workspace). + #[arg(long)] + global: bool, }, /// Import provider profiles from a file or directory. @@ -964,6 +1000,10 @@ enum ProviderProfileCommands { /// Directory containing profile files to import. #[arg(long = "from", value_hint = ValueHint::DirPath)] from: Option, + + /// Import as platform-scoped profiles (ignores --workspace). + #[arg(long)] + global: bool, }, /// Update an existing custom provider profile from a file. @@ -975,6 +1015,10 @@ enum ProviderProfileCommands { /// Profile file to update. #[arg(short = 'f', long = "file", value_hint = ValueHint::FilePath)] file: PathBuf, + + /// Target platform-scoped profile (ignores --workspace). + #[arg(long)] + global: bool, }, /// Validate provider profile files without registering them. @@ -987,6 +1031,10 @@ enum ProviderProfileCommands { /// Directory containing profile files to lint. #[arg(long = "from", value_hint = ValueHint::DirPath)] from: Option, + + /// Lint against platform scope (ignores --workspace). + #[arg(long)] + global: bool, }, /// Delete a custom provider profile. @@ -994,6 +1042,10 @@ enum ProviderProfileCommands { Delete { /// Provider profile id. id: String, + + /// Target platform-scoped profile (ignores --workspace). + #[arg(long)] + global: bool, }, } @@ -1132,7 +1184,7 @@ enum GatewayCommands { #[derive(Subcommand, Debug)] enum InferenceCommands { - /// Set gateway-level inference provider and model. + /// Set workspace-level inference provider and model. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Set { /// Provider name. @@ -1158,7 +1210,7 @@ enum InferenceCommands { timeout: u64, }, - /// Update gateway-level inference configuration (partial update). + /// Update workspace-level inference configuration (partial update). #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Update { /// Provider name (unchanged if omitted). @@ -1182,7 +1234,7 @@ enum InferenceCommands { timeout: Option, }, - /// Get gateway-level inference provider and model. + /// Get workspace-level inference provider and model. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Get { /// Show the system inference route instead of the user-facing route. @@ -1385,6 +1437,10 @@ enum SandboxCommands { /// Output format. #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with_all = ["ids", "names"])] output: OutputFormat, + + /// List sandboxes across all workspaces. + #[arg(long)] + all_workspaces: bool, }, /// Delete a sandbox by name. @@ -1916,6 +1972,10 @@ enum ServiceCommands { /// Number of endpoints to skip. #[arg(long, default_value_t = 0)] offset: u32, + + /// List services across all workspaces. + #[arg(long)] + all_workspaces: bool, }, /// Show one exposed sandbox service endpoint. @@ -1941,6 +2001,108 @@ enum ServiceCommands { }, } +#[derive(Subcommand, Debug)] +enum WorkspaceCommands { + /// Create a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Create { + /// Workspace name (DNS-1123 label: lowercase alphanumeric and hyphens, 1-63 chars). + #[arg(long)] + name: String, + + /// Labels to attach to the workspace (KEY=VALUE). + #[arg(long = "label", value_name = "KEY=VALUE")] + labels: Vec, + }, + + /// Fetch a workspace by name. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Get { + /// Workspace name. + #[arg(add = ArgValueCompleter::new(completers::complete_workspace_names))] + name: String, + }, + + /// List workspaces. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + List { + /// Maximum number of workspaces to return. + #[arg(long, default_value_t = 100)] + limit: u32, + + /// Offset into the workspace list. + #[arg(long, default_value_t = 0)] + offset: u32, + + /// Filter by label selector (e.g. "env=staging"). + #[arg(long)] + label_selector: Option, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + }, + + /// Delete a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Delete { + /// Workspace name(s) to delete. + #[arg(required = true, add = ArgValueCompleter::new(completers::complete_workspace_names))] + names: Vec, + }, + + /// Manage workspace members. + #[command(subcommand, help_template = SUBCOMMAND_HELP_TEMPLATE)] + Member(WorkspaceMemberCommands), +} + +#[derive(Subcommand, Debug)] +enum WorkspaceMemberCommands { + /// Add a member to a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Add { + /// Workspace name. + #[arg(long, add = ArgValueCompleter::new(completers::complete_workspace_names))] + workspace: String, + + /// OIDC subject claim identifying the principal. + #[arg(long)] + subject: String, + + /// Role to assign (user or admin). + #[arg(long)] + role: String, + }, + + /// Remove a member from a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Remove { + /// Workspace name. + #[arg(long, add = ArgValueCompleter::new(completers::complete_workspace_names))] + workspace: String, + + /// OIDC subject claim identifying the principal. + #[arg(long)] + subject: String, + }, + + /// List members of a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + List { + /// Workspace name. + #[arg(long, add = ArgValueCompleter::new(completers::complete_workspace_names))] + workspace: String, + + /// Maximum number of members to return. + #[arg(long, default_value_t = 100)] + limit: u32, + + /// Offset into the member list. + #[arg(long, default_value_t = 0)] + offset: u32, + }, +} + #[tokio::main] #[allow(clippy::large_stack_frames)] // CLI dispatch holds many futures; OK at top level. async fn main() -> Result<()> { @@ -2203,6 +2365,7 @@ async fn main() -> Result<()> { &target_host, target_port, &tls, + &cli.workspace, ) .await?; } @@ -2216,7 +2379,15 @@ async fn main() -> Result<()> { let mut tls = tls.with_gateway_name(&ctx.name); apply_auth(&mut tls, &ctx.name); let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_forward(&ctx.endpoint, &name, &spec, background, &tls).await?; + run::sandbox_forward( + &ctx.endpoint, + &name, + &spec, + background, + &tls, + &cli.workspace, + ) + .await?; if background { eprintln!( "{} Forwarding port {} to sandbox {name} in the background", @@ -2245,24 +2416,42 @@ async fn main() -> Result<()> { target_port, } => { let service = service.unwrap_or_default(); - run::service_expose(&ctx.endpoint, &sandbox, &service, target_port, &tls) - .await?; + run::service_expose( + &ctx.endpoint, + &sandbox, + &service, + target_port, + &cli.workspace, + &tls, + ) + .await?; } ServiceCommands::List { sandbox, limit, offset, + all_workspaces, } => { - run::service_list(&ctx.endpoint, sandbox.as_deref(), limit, offset, &tls) - .await?; + run::service_list( + &ctx.endpoint, + sandbox.as_deref(), + limit, + offset, + &cli.workspace, + all_workspaces, + &tls, + ) + .await?; } ServiceCommands::Get { sandbox, service } => { let service = service.unwrap_or_default(); - run::service_get(&ctx.endpoint, &sandbox, &service, &tls).await?; + run::service_get(&ctx.endpoint, &sandbox, &service, &cli.workspace, &tls) + .await?; } ServiceCommands::Delete { sandbox, service } => { let service = service.unwrap_or_default(); - run::service_delete(&ctx.endpoint, &sandbox, &service, &tls).await?; + run::service_delete(&ctx.endpoint, &sandbox, &service, &cli.workspace, &tls) + .await?; } } } @@ -2289,6 +2478,7 @@ async fn main() -> Result<()> { since.as_deref(), &source, &level, + &cli.workspace, &tls, ) .await?; @@ -2347,13 +2537,22 @@ async fn main() -> Result<()> { yes, wait, timeout, + &cli.workspace, &tls, ) .await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_policy_set(&ctx.endpoint, &name, &policy, wait, timeout, &tls) - .await?; + run::sandbox_policy_set( + &ctx.endpoint, + &name, + &policy, + wait, + timeout, + &cli.workspace, + &tls, + ) + .await?; } } PolicyCommands::Update { @@ -2383,6 +2582,7 @@ async fn main() -> Result<()> { dry_run, wait, timeout, + &cli.workspace, &tls, ) .await?; @@ -2402,6 +2602,7 @@ async fn main() -> Result<()> { rev, view, output.as_str(), + &cli.workspace, &tls, ) .await?; @@ -2413,6 +2614,7 @@ async fn main() -> Result<()> { rev, view, output.as_str(), + &cli.workspace, &tls, ) .await?; @@ -2424,10 +2626,12 @@ async fn main() -> Result<()> { global, } => { if global { - run::sandbox_policy_list_global(&ctx.endpoint, limit, &tls).await?; + run::sandbox_policy_list_global(&ctx.endpoint, limit, &cli.workspace, &tls) + .await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_policy_list(&ctx.endpoint, &name, limit, &tls).await?; + run::sandbox_policy_list(&ctx.endpoint, &name, limit, &cli.workspace, &tls) + .await?; } } PolicyCommands::Delete { global, yes } => { @@ -2436,7 +2640,8 @@ async fn main() -> Result<()> { "sandbox policy delete is not supported; use --global to remove global policy lock" )); } - run::gateway_setting_delete(&ctx.endpoint, "policy", yes, &tls).await?; + run::gateway_setting_delete(&ctx.endpoint, "policy", yes, &cli.workspace, &tls) + .await?; } PolicyCommands::Prove { .. } => unreachable!(), } @@ -2463,7 +2668,8 @@ async fn main() -> Result<()> { run::gateway_settings_get(&ctx.endpoint, json, &tls).await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_settings_get(&ctx.endpoint, &name, json, &tls).await?; + run::sandbox_settings_get(&ctx.endpoint, &name, json, &cli.workspace, &tls) + .await?; } } SettingsCommands::Set { @@ -2474,10 +2680,26 @@ async fn main() -> Result<()> { yes, } => { if global { - run::gateway_setting_set(&ctx.endpoint, &key, &value, yes, &tls).await?; + run::gateway_setting_set( + &ctx.endpoint, + &key, + &value, + yes, + &cli.workspace, + &tls, + ) + .await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_setting_set(&ctx.endpoint, &name, &key, &value, &tls).await?; + run::sandbox_setting_set( + &ctx.endpoint, + &name, + &key, + &value, + &cli.workspace, + &tls, + ) + .await?; } } SettingsCommands::Delete { @@ -2487,10 +2709,18 @@ async fn main() -> Result<()> { yes, } => { if global { - run::gateway_setting_delete(&ctx.endpoint, &key, yes, &tls).await?; + run::gateway_setting_delete(&ctx.endpoint, &key, yes, &cli.workspace, &tls) + .await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_setting_delete(&ctx.endpoint, &name, &key, &tls).await?; + run::sandbox_setting_delete( + &ctx.endpoint, + &name, + &key, + &cli.workspace, + &tls, + ) + .await?; } } } @@ -2508,11 +2738,25 @@ async fn main() -> Result<()> { match draft_cmd { DraftCommands::Get { name, status } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_get(&ctx.endpoint, &name, status.as_deref(), &tls).await?; + run::sandbox_draft_get( + &ctx.endpoint, + &name, + status.as_deref(), + &cli.workspace, + &tls, + ) + .await?; } DraftCommands::Approve { name, chunk_id } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_approve(&ctx.endpoint, &name, &chunk_id, &tls).await?; + run::sandbox_draft_approve( + &ctx.endpoint, + &name, + &chunk_id, + &cli.workspace, + &tls, + ) + .await?; } DraftCommands::Reject { name, @@ -2520,8 +2764,15 @@ async fn main() -> Result<()> { reason, } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_reject(&ctx.endpoint, &name, &chunk_id, &reason, &tls) - .await?; + run::sandbox_draft_reject( + &ctx.endpoint, + &name, + &chunk_id, + &reason, + &cli.workspace, + &tls, + ) + .await?; } DraftCommands::ApproveAll { name, @@ -2532,6 +2783,7 @@ async fn main() -> Result<()> { &ctx.endpoint, &name, include_security_flagged, + &cli.workspace, &tls, ) .await?; @@ -2539,11 +2791,11 @@ async fn main() -> Result<()> { DraftCommands::Clear { name } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_clear(&ctx.endpoint, &name, &tls).await?; + run::sandbox_draft_clear(&ctx.endpoint, &name, &cli.workspace, &tls).await?; } DraftCommands::History { name } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_history(&ctx.endpoint, &name, &tls).await?; + run::sandbox_draft_history(&ctx.endpoint, &name, &cli.workspace, &tls).await?; } } } @@ -2568,7 +2820,14 @@ async fn main() -> Result<()> { } => { let route_name = if system { "sandbox-system" } else { "" }; run::gateway_inference_set( - endpoint, &provider, &model, route_name, no_verify, timeout, &tls, + endpoint, + &provider, + &model, + route_name, + no_verify, + timeout, + &cli.workspace, + &tls, ) .await?; } @@ -2587,13 +2846,14 @@ async fn main() -> Result<()> { route_name, no_verify, timeout, + &cli.workspace, &tls, ) .await?; } InferenceCommands::Get { system } => { let route_name = if system { Some("sandbox-system") } else { None }; - run::gateway_inference_get(endpoint, route_name, &tls).await?; + run::gateway_inference_get(endpoint, route_name, &cli.workspace, &tls).await?; } } } @@ -2714,6 +2974,7 @@ async fn main() -> Result<()> { environment: env_map, approval_mode: &approval_mode, }, + &cli.workspace, &tls, )) .await?; @@ -2747,6 +3008,7 @@ async fn main() -> Result<()> { local, sandbox_dest, &tls, + &cli.workspace, ) .await?; eprintln!("{} Upload complete", "✓".green().bold()); @@ -2758,7 +3020,15 @@ async fn main() -> Result<()> { local.display(), ); } - run::sandbox_sync_up(&ctx.endpoint, &name, local, sandbox_dest, &tls).await?; + run::sandbox_sync_up( + &ctx.endpoint, + &name, + local, + sandbox_dest, + &tls, + &cli.workspace, + ) + .await?; eprintln!("{} Upload complete", "✓".green().bold()); } SandboxCommands::Download { @@ -2771,8 +3041,15 @@ async fn main() -> Result<()> { apply_auth(&mut tls, &ctx.name); let local_dest = dest.as_deref().unwrap_or("."); eprintln!("Downloading sandbox:{sandbox_path} -> {local_dest}"); - run::sandbox_sync_down(&ctx.endpoint, &name, &sandbox_path, local_dest, &tls) - .await?; + run::sandbox_sync_down( + &ctx.endpoint, + &name, + &sandbox_path, + local_dest, + &tls, + &cli.workspace, + ) + .await?; eprintln!("{} Download complete", "✓".green().bold()); } other => { @@ -2788,7 +3065,8 @@ async fn main() -> Result<()> { } SandboxCommands::Get { name, policy_only } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_get(endpoint, &name, policy_only, &tls).await?; + run::sandbox_get(endpoint, &name, policy_only, &cli.workspace, &tls) + .await?; } SandboxCommands::List { limit, @@ -2797,6 +3075,7 @@ async fn main() -> Result<()> { names, selector, output, + all_workspaces, } => { run::sandbox_list( endpoint, @@ -2806,22 +3085,37 @@ async fn main() -> Result<()> { names, selector.as_deref(), output.as_str(), + &cli.workspace, + all_workspaces, &tls, ) .await?; } SandboxCommands::Delete { names, all } => { - run::sandbox_delete(endpoint, &names, all, &tls, &ctx.name).await?; + run::sandbox_delete( + endpoint, + &names, + all, + &cli.workspace, + &tls, + &ctx.name, + ) + .await?; } SandboxCommands::Connect { name, editor } => { let name = resolve_sandbox_name(name, &ctx.name)?; if let Some(editor) = editor.map(Into::into) { run::sandbox_connect_editor( - endpoint, &ctx.name, &name, editor, &tls, + endpoint, + &ctx.name, + &name, + editor, + &tls, + &cli.workspace, ) .await?; } else { - run::sandbox_connect(endpoint, &name, &tls).await?; + run::sandbox_connect(endpoint, &name, &tls, &cli.workspace).await?; } let _ = save_last_sandbox(&ctx.name, &name); } @@ -2853,6 +3147,7 @@ async fn main() -> Result<()> { tty_override, &env_map, &tls, + &cli.workspace, ) .await?; let _ = save_last_sandbox(&ctx.name, &name); @@ -2862,26 +3157,101 @@ async fn main() -> Result<()> { } SandboxCommands::SshConfig { name } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::print_ssh_config(&ctx.name, &name); + run::print_ssh_config(&ctx.name, &name, &cli.workspace); } SandboxCommands::Provider(command) => match command { SandboxProviderCommands::List { name } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_provider_list(endpoint, &name, &tls).await?; + run::sandbox_provider_list(endpoint, &name, &cli.workspace, &tls) + .await?; } SandboxProviderCommands::Attach { name, provider } => { - run::sandbox_provider_attach(endpoint, &name, &provider, &tls) - .await?; + run::sandbox_provider_attach( + endpoint, + &name, + &provider, + &cli.workspace, + &tls, + ) + .await?; } SandboxProviderCommands::Detach { name, provider } => { - run::sandbox_provider_detach(endpoint, &name, &provider, &tls) - .await?; + run::sandbox_provider_detach( + endpoint, + &name, + &provider, + &cli.workspace, + &tls, + ) + .await?; } }, } } } } + + // ----------------------------------------------------------- + // Workspace commands + // ----------------------------------------------------------- + Some(Commands::Workspace { + command: Some(command), + }) => { + let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; + let endpoint = &ctx.endpoint; + let mut tls = tls.with_gateway_name(&ctx.name); + apply_auth(&mut tls, &ctx.name); + + match command { + WorkspaceCommands::Create { name, labels } => { + run::workspace_create(endpoint, &name, &labels, &tls).await?; + } + WorkspaceCommands::Get { name } => { + run::workspace_get(endpoint, &name, &tls).await?; + } + WorkspaceCommands::List { + limit, + offset, + label_selector, + output, + } => { + run::workspace_list( + endpoint, + limit, + offset, + label_selector.as_deref().unwrap_or(""), + output.as_str(), + &tls, + ) + .await?; + } + WorkspaceCommands::Delete { names } => { + run::workspace_delete(endpoint, &names, &tls).await?; + } + WorkspaceCommands::Member(command) => match command { + WorkspaceMemberCommands::Add { + workspace, + subject, + role, + } => { + run::workspace_member_add(endpoint, &workspace, &subject, &role, &tls) + .await?; + } + WorkspaceMemberCommands::Remove { workspace, subject } => { + run::workspace_member_remove(endpoint, &workspace, &subject, &tls).await?; + } + WorkspaceMemberCommands::List { + workspace, + limit, + offset, + } => { + run::workspace_member_list(endpoint, &workspace, limit, offset, &tls) + .await?; + } + }, + } + } + Some(Commands::Provider { command: Some(command), }) => { @@ -2909,6 +3279,7 @@ async fn main() -> Result<()> { from_gcloud_adc, runtime_credentials, &config, + &cli.workspace, &tls, ) .await?; @@ -2922,6 +3293,7 @@ async fn main() -> Result<()> { endpoint, &name, credential_key.as_deref(), + &cli.workspace, &tls, ) .await?; @@ -2946,6 +3318,7 @@ async fn main() -> Result<()> { secret_material_keys: &secret_material_keys, credential_expires_at_ms: credential_expires_at, }, + &cli.workspace, &tls, ) .await?; @@ -2954,60 +3327,110 @@ async fn main() -> Result<()> { name, credential_key, } => { - run::provider_rotate(endpoint, &name, &credential_key, &tls).await?; + run::provider_rotate( + endpoint, + &name, + &credential_key, + &cli.workspace, + &tls, + ) + .await?; } ProviderRefreshCommands::Delete { name, credential_key, } => { - run::provider_refresh_delete(endpoint, &name, &credential_key, &tls) - .await?; + run::provider_refresh_delete( + endpoint, + &name, + &credential_key, + &cli.workspace, + &tls, + ) + .await?; } }, ProviderCommands::Get { name } => { - run::provider_get(endpoint, &name, &tls).await?; + run::provider_get(endpoint, &name, &cli.workspace, &tls).await?; } ProviderCommands::List { limit, offset, names, output, + all_workspaces, } => { - run::provider_list(endpoint, limit, offset, names, output.as_str(), &tls) - .await?; + run::provider_list( + endpoint, + limit, + offset, + names, + output.as_str(), + &cli.workspace, + all_workspaces, + &tls, + ) + .await?; } ProviderCommands::ListProfiles { output } => { - run::provider_list_profiles(endpoint, output.as_str(), &tls).await?; - } - ProviderCommands::Profile(command) => match command { - ProviderProfileCommands::Export { id, output } => { - run::provider_profile_export(endpoint, &id, output.as_str(), &tls).await?; - } - ProviderProfileCommands::Import { file, from } => { - run::provider_profile_import( - endpoint, - file.as_deref(), - from.as_deref(), - &tls, - ) - .await?; - } - ProviderProfileCommands::Update { id, file } => { - run::provider_profile_update(endpoint, &id, &file, &tls).await?; - } - ProviderProfileCommands::Lint { file, from } => { - run::provider_profile_lint( - endpoint, - file.as_deref(), - from.as_deref(), - &tls, - ) + run::provider_list_profiles(endpoint, output.as_str(), &cli.workspace, &tls) .await?; + } + ProviderCommands::Profile(command) => { + let profile_workspace = + |global: bool| -> &str { if global { "" } else { &cli.workspace } }; + match command { + ProviderProfileCommands::Export { id, output, global } => { + run::provider_profile_export( + endpoint, + &id, + output.as_str(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProviderProfileCommands::Import { file, from, global } => { + run::provider_profile_import( + endpoint, + file.as_deref(), + from.as_deref(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProviderProfileCommands::Update { id, file, global } => { + run::provider_profile_update( + endpoint, + &id, + &file, + profile_workspace(global), + &tls, + ) + .await?; + } + ProviderProfileCommands::Lint { file, from, global } => { + run::provider_profile_lint( + endpoint, + file.as_deref(), + from.as_deref(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProviderProfileCommands::Delete { id, global } => { + run::provider_profile_delete( + endpoint, + &id, + profile_workspace(global), + &tls, + ) + .await?; + } } - ProviderProfileCommands::Delete { id } => { - run::provider_profile_delete(endpoint, &id, &tls).await?; - } - }, + } ProviderCommands::Update { name, from_existing, @@ -3022,12 +3445,13 @@ async fn main() -> Result<()> { &credentials, &config, &credential_expires_at, + &cli.workspace, &tls, ) .await?; } ProviderCommands::Delete { names } => { - run::provider_delete(endpoint, &names, &tls).await?; + run::provider_delete(endpoint, &names, &cli.workspace, &tls).await?; } } } @@ -3090,11 +3514,11 @@ async fn main() -> Result<()> { }; let mut tls = tls.with_gateway_name(&g); apply_auth(&mut tls, &g); - run::sandbox_ssh_proxy_by_name(&endpoint, &n, &tls).await?; + run::sandbox_ssh_proxy_by_name(&endpoint, &n, &tls, &cli.workspace).await?; } // Legacy name mode with --server only (no --gateway-name). (_, _, _, Some(srv), None, Some(n)) => { - run::sandbox_ssh_proxy_by_name(&srv, &n, &tls).await?; + run::sandbox_ssh_proxy_by_name(&srv, &n, &tls, &cli.workspace).await?; } _ => { return Err(miette::miette!( @@ -3140,6 +3564,13 @@ async fn main() -> Result<()> { .print_help() .expect("Failed to print help"); } + Some(Commands::Workspace { command: None }) => { + Cli::command() + .find_subcommand_mut("workspace") + .expect("workspace subcommand exists") + .print_help() + .expect("Failed to print help"); + } Some(Commands::Provider { command: None }) => { Cli::command() .find_subcommand_mut("provider") @@ -3850,7 +4281,8 @@ mod tests { Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Export { id, - output: OutputFormat::Yaml + output: OutputFormat::Yaml, + .. })) }) if id == "custom-api" )); @@ -3889,7 +4321,8 @@ mod tests { Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Update { id, - file: _ + file: _, + .. })) }) if id == "custom-api" )); @@ -3901,7 +4334,8 @@ mod tests { delete.command, Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Delete { - id + id, + .. })) }) if id == "custom-api" )); @@ -4853,6 +5287,7 @@ mod tests { sandbox, limit, offset, + .. }), }) => { assert_eq!(sandbox.as_deref(), Some("my-sandbox")); @@ -4872,6 +5307,7 @@ mod tests { sandbox, limit, offset, + .. }), }) => { assert_eq!(sandbox, None); diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 924edf6f3c..48b7695084 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -39,25 +39,24 @@ use openshell_core::proto::{ CreateSandboxRequest, CreateSshSessionRequest, DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, DeleteSandboxRequest, DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, ExposeServiceRequest, - GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, - 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, + GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, + GetInferenceRouteRequest, 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, SetInferenceRouteRequest, 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}; +use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; use openshell_providers::{ ProviderRegistry, ProviderTypeProfile, RealDiscoveryContext, detect_provider_from_command, discover_from_profile, normalize_provider_type, parse_profile_json, parse_profile_yaml, @@ -1916,6 +1915,7 @@ async fn finalize_sandbox_create_session( sandbox_name: &str, persist: bool, session_result: Result<()>, + workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { @@ -1924,7 +1924,7 @@ async fn finalize_sandbox_create_session( } let names = [sandbox_name.to_string()]; - if let Err(err) = sandbox_delete(server, &names, false, tls, gateway).await { + if let Err(err) = sandbox_delete(server, &names, false, workspace, tls, gateway).await { if session_result.is_ok() { return Err(err); } @@ -1991,6 +1991,7 @@ pub async fn sandbox_create( server: &str, gateway_name: &str, config: SandboxCreateConfig<'_>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let SandboxCreateConfig { @@ -2065,6 +2066,7 @@ pub async fn sandbox_create( providers, &inferred_types, auto_providers_override, + workspace, ) .await?; @@ -2098,7 +2100,7 @@ pub async fn sandbox_create( }), name: name.unwrap_or_default().to_string(), labels, - annotations: HashMap::new(), + workspace: workspace.to_string(), }; let response = match client.create_sandbox(request).await { @@ -2143,7 +2145,11 @@ pub async fn sandbox_create( name: sandbox_name.clone(), setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), setting_value: Some(setting), - ..Default::default() + delete_setting: false, + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await { @@ -2392,6 +2398,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2407,6 +2414,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2417,6 +2425,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2434,6 +2443,7 @@ pub async fn sandbox_create( spec, true, // background &effective_tls, + workspace, ) .await?; eprintln!( @@ -2456,6 +2466,7 @@ pub async fn sandbox_create( &sandbox_name, editor, &effective_tls, + workspace, ) .await?; return Ok(()); @@ -2463,12 +2474,14 @@ pub async fn sandbox_create( if command.is_empty() { let connect_result = if persist { - sandbox_connect(&effective_server, &sandbox_name, &effective_tls).await + sandbox_connect(&effective_server, &sandbox_name, &effective_tls, workspace) + .await } else { crate::ssh::sandbox_connect_without_exec( &effective_server, &sandbox_name, &effective_tls, + workspace, ) .await }; @@ -2478,6 +2491,7 @@ pub async fn sandbox_create( &sandbox_name, persist, connect_result, + workspace, &effective_tls, gateway_name, ) @@ -2496,6 +2510,7 @@ pub async fn sandbox_create( command, tty, &effective_tls, + workspace, ) .await } else { @@ -2505,6 +2520,7 @@ pub async fn sandbox_create( command, tty, &effective_tls, + workspace, ) .await }; @@ -2514,6 +2530,7 @@ pub async fn sandbox_create( &sandbox_name, persist, exec_result, + workspace, &effective_tls, gateway_name, ) @@ -2739,6 +2756,7 @@ pub async fn sandbox_sync_command( down: Option<&str>, dest: Option<&str>, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { match (up, down) { (Some(local_path), None) => { @@ -2751,13 +2769,13 @@ pub async fn sandbox_sync_command( } let dest_display = dest.unwrap_or("~"); eprintln!("Syncing {} -> sandbox:{}", local.display(), dest_display); - sandbox_sync_up(server, name, local, dest, tls).await?; + sandbox_sync_up(server, name, local, dest, tls, workspace).await?; eprintln!("{} Sync complete", "✓".green().bold()); } (None, Some(sandbox_path)) => { let local_dest = dest.unwrap_or("."); eprintln!("Syncing sandbox:{sandbox_path} -> {local_dest}"); - sandbox_sync_down(server, name, sandbox_path, local_dest, tls).await?; + sandbox_sync_down(server, name, sandbox_path, local_dest, tls, workspace).await?; eprintln!("{} Sync complete", "✓".green().bold()); } _ => { @@ -2778,6 +2796,7 @@ pub async fn sandbox_get( server: &str, name: &str, policy_only: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -2785,6 +2804,7 @@ pub async fn sandbox_get( let response = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -2913,6 +2933,7 @@ pub async fn sandbox_exec_grpc( tty_override: Option, environment: &HashMap, tls: &TlsOptions, + workspace: &str, ) -> Result { let mut client = grpc_client(server, tls).await?; @@ -2920,6 +2941,7 @@ pub async fn sandbox_exec_grpc( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -3028,11 +3050,12 @@ pub async fn service_forward_tcp( target_host: &str, target_port: u16, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { let (bind_addr, bind_port) = parse_tcp_forward_spec(local, target_port)?; let mut client = grpc_client(server, tls).await?; - let sandbox = fetch_ready_sandbox_for_forward(&mut client, name).await?; + let sandbox = fetch_ready_sandbox_for_forward(&mut client, name, workspace).await?; let listener = tokio::net::TcpListener::bind((bind_addr.as_str(), bind_port)) .await @@ -3062,7 +3085,7 @@ pub async fn service_forward_tcp( } _ = health_check.tick() => { - fetch_ready_sandbox_for_forward(&mut client, name).await?; + fetch_ready_sandbox_for_forward(&mut client, name, workspace).await?; } accepted = listener.accept() => { @@ -3126,10 +3149,12 @@ async fn create_forward_session_token( async fn fetch_ready_sandbox_for_forward( client: &mut crate::tls::GrpcClient, name: &str, + workspace: &str, ) -> Result { let response = match client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await { @@ -3523,6 +3548,8 @@ pub async fn sandbox_list( names_only: bool, label_selector: Option<&str>, output: &str, + workspace: &str, + all_workspaces: bool, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -3532,6 +3559,12 @@ pub async fn sandbox_list( limit, offset, label_selector: label_selector.unwrap_or("").to_string(), + workspace: if all_workspaces { + String::new() + } else { + workspace.to_string() + }, + all_workspaces, }) .await .into_diagnostic()?; @@ -3571,14 +3604,34 @@ pub async fn sandbox_list( .unwrap_or(4) .max(4); let created_width = 19; // "YYYY-MM-DD HH:MM:SS" + let ws_width = if all_workspaces { + sandboxes + .iter() + .map(|s| s.object_workspace().len()) + .max() + .unwrap_or(9) + .max(9) + } else { + 0 + }; // Print header - println!( - "{: phase.to_string(), }; let created = format_epoch_ms(sandbox.metadata.as_ref().map_or(0, |m| m.created_at_ms)); - println!( - "{: serde_json::Value { }) } -pub async fn sandbox_provider_list(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { +pub async fn sandbox_provider_list( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .list_sandbox_providers(ListSandboxProvidersRequest { sandbox_name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -3644,6 +3713,7 @@ pub async fn sandbox_provider_attach( server: &str, name: &str, provider: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -3652,6 +3722,7 @@ pub async fn sandbox_provider_attach( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -3666,6 +3737,7 @@ pub async fn sandbox_provider_attach( sandbox_name: name.to_string(), provider_name: provider.to_string(), expected_resource_version: resource_version, + workspace: workspace.to_string(), }) .await { @@ -3697,6 +3769,7 @@ pub async fn sandbox_provider_detach( server: &str, name: &str, provider: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -3705,6 +3778,7 @@ pub async fn sandbox_provider_detach( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -3719,6 +3793,7 @@ pub async fn sandbox_provider_detach( sandbox_name: name.to_string(), provider_name: provider.to_string(), expected_resource_version: resource_version, + workspace: workspace.to_string(), }) .await { @@ -3811,6 +3886,7 @@ pub async fn sandbox_delete( server: &str, names: &[String], all: bool, + workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { @@ -3823,6 +3899,8 @@ pub async fn sandbox_delete( limit: 1000, offset: 0, label_selector: String::new(), + workspace: workspace.to_string(), + all_workspaces: false, }) .await .into_diagnostic()?; @@ -3851,7 +3929,10 @@ pub async fn sandbox_delete( } let response = client - .delete_sandbox(DeleteSandboxRequest { name: name.clone() }) + .delete_sandbox(DeleteSandboxRequest { + name: name.clone(), + workspace: workspace.to_string(), + }) .await .into_diagnostic()?; @@ -3888,6 +3969,7 @@ pub async fn ensure_required_providers( explicit_names: &[String], inferred_types: &[String], auto_providers_override: Option, + workspace: &str, ) -> Result> { if explicit_names.is_empty() && inferred_types.is_empty() { return Ok(Vec::new()); @@ -3906,7 +3988,12 @@ pub async fn ensure_required_providers( let limit = 100_u32; loop { let response = client - .list_providers(ListProvidersRequest { limit, offset }) + .list_providers(ListProvidersRequest { + limit, + offset, + workspace: workspace.to_string(), + all_workspaces: false, + }) .await .into_diagnostic()?; let providers = response.into_inner().providers; @@ -3943,6 +4030,7 @@ pub async fn ensure_required_providers( auto_providers_override, &mut seen_names, &mut configured_names, + workspace, ) .await?; // Record the type mapping so the inferred-types pass below @@ -3983,6 +4071,7 @@ pub async fn ensure_required_providers( auto_providers_override, &mut seen_names, &mut configured_names, + workspace, ) .await?; } @@ -4004,6 +4093,7 @@ async fn auto_create_provider( auto_providers_override: Option, seen_names: &mut HashSet, configured_names: &mut Vec, + workspace: &str, ) -> Result<()> { eprintln!("Missing provider: {provider_type}"); @@ -4043,7 +4133,7 @@ async fn auto_create_provider( return Ok(()); } - let discovered = discover_existing_provider_data(client, provider_type) + let discovered = discover_existing_provider_data(client, provider_type, workspace) .await .map_err(|err| miette::miette!("failed to discover provider '{provider_type}': {err}"))?; let Some(discovered) = discovered else { @@ -4066,13 +4156,14 @@ async fn auto_create_provider( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.to_string(), }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), credential_expires_at_ms: HashMap::new(), }), + workspace: workspace.to_string(), }; let response = client.create_provider(request).await.map_err(|status| { @@ -4109,13 +4200,14 @@ async fn auto_create_provider( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.to_string(), }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), credential_expires_at_ms: HashMap::new(), }), + workspace: workspace.to_string(), }; match client.create_provider(request).await { @@ -4351,6 +4443,7 @@ pub async fn service_expose( sandbox: &str, service: &str, target_port: u16, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -4360,6 +4453,7 @@ pub async fn service_expose( service: service.to_string(), target_port: u32::from(target_port), domain: true, + workspace: workspace.to_string(), }) .await .map_err(service_expose_status_error)? @@ -4397,6 +4491,8 @@ pub async fn service_list( sandbox: Option<&str>, limit: u32, offset: u32, + workspace: &str, + all_workspaces: bool, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -4405,6 +4501,12 @@ pub async fn service_list( sandbox: sandbox.unwrap_or_default().to_string(), limit, offset, + workspace: if all_workspaces { + String::new() + } else { + workspace.to_string() + }, + all_workspaces, }) .await .map_err(|status| service_status_error("list services", "sandbox:read", status))? @@ -4419,7 +4521,7 @@ pub async fn service_list( return Ok(()); } - print_service_endpoint_table(&response.services, server); + print_service_endpoint_table(&response.services, server, all_workspaces); Ok(()) } @@ -4427,6 +4529,7 @@ pub async fn service_get( server: &str, sandbox: &str, service: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -4434,12 +4537,13 @@ pub async fn service_get( .get_service(GetServiceRequest { sandbox: sandbox.to_string(), service: service.to_string(), + workspace: workspace.to_string(), }) .await .map_err(|status| service_status_error("get service", "sandbox:read", status))? .into_inner(); - print_service_endpoint_table(&[response], server); + print_service_endpoint_table(&[response], server, false); Ok(()) } @@ -4447,6 +4551,7 @@ pub async fn service_delete( server: &str, sandbox: &str, service: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -4454,6 +4559,7 @@ pub async fn service_delete( .delete_service(DeleteServiceRequest { sandbox: sandbox.to_string(), service: service.to_string(), + workspace: workspace.to_string(), }) .await .map_err(|status| service_status_error("delete service", "sandbox:write", status))? @@ -4500,11 +4606,19 @@ fn service_status_error(action: &str, required_scope: &str, status: Status) -> m } } -fn print_service_endpoint_table(services: &[ServiceEndpointResponse], gateway_endpoint: &str) { +fn print_service_endpoint_table( + services: &[ServiceEndpointResponse], + gateway_endpoint: &str, + all_workspaces: bool, +) { let rows = services .iter() .filter_map(|response| { let endpoint = response.endpoint.as_ref()?; + let workspace = endpoint + .metadata + .as_ref() + .map_or("", |m| m.workspace.as_str()); let service = service_display_name(&endpoint.service_name).to_string(); let target = format!("127.0.0.1:{}", endpoint.target_port); let url = if response.url.is_empty() { @@ -4512,7 +4626,13 @@ fn print_service_endpoint_table(services: &[ServiceEndpointResponse], gateway_en } else { service_url_for_gateway(&response.url, gateway_endpoint) }; - Some((endpoint.sandbox_name.clone(), service, target, url)) + Some(( + workspace.to_string(), + endpoint.sandbox_name.clone(), + service, + target, + url, + )) }) .collect::>(); @@ -4520,37 +4640,63 @@ fn print_service_endpoint_table(services: &[ServiceEndpointResponse], gateway_en return; } + let ws_width = if all_workspaces { + rows.iter() + .map(|(ws, _, _, _, _)| ws.len()) + .max() + .unwrap_or(9) + .max(9) + } else { + 0 + }; let sandbox_width = rows .iter() - .map(|(sandbox, _, _, _)| sandbox.len()) + .map(|(_, sandbox, _, _, _)| sandbox.len()) .max() .unwrap_or(7) .max(7); let service_width = rows .iter() - .map(|(_, service, _, _)| service.len()) + .map(|(_, _, service, _, _)| service.len()) .max() .unwrap_or(7) .max(7); let target_width = rows .iter() - .map(|(_, _, target, _)| target.len()) + .map(|(_, _, _, target, _)| target.len()) .max() .unwrap_or(6) .max(6); - println!( - "{: Result<()> { match client .delete_provider(DeleteProviderRequest { name: provider_name.to_string(), + workspace: workspace.to_string(), }) .await { @@ -4723,10 +4871,12 @@ async fn gateway_providers_v2_enabled(client: &mut crate::tls::GrpcClient) -> Re async fn fetch_provider_profile( client: &mut crate::tls::GrpcClient, provider_type: &str, + workspace: &str, ) -> Result { let response = client .get_provider_profile(GetProviderProfileRequest { id: provider_type.to_string(), + workspace: workspace.to_string(), }) .await .map_err(|status| { @@ -4748,9 +4898,10 @@ async fn fetch_provider_profile( async fn discover_existing_provider_data( client: &mut crate::tls::GrpcClient, provider_type: &str, + workspace: &str, ) -> Result> { if gateway_providers_v2_enabled(client).await? { - let profile = fetch_provider_profile(client, provider_type).await?; + let profile = fetch_provider_profile(client, provider_type, workspace).await?; let profile = ProviderTypeProfile::from_proto(&profile); let mut discovered = discover_from_profile(&profile, &RealDiscoveryContext).map_err(|err| { @@ -4821,6 +4972,7 @@ pub async fn provider_create( credentials: &[String], from_gcloud_adc: bool, config: &[String], + workspace: &str, tls: &TlsOptions, ) -> Result<()> { provider_create_with_options( @@ -4832,6 +4984,7 @@ pub async fn provider_create( from_gcloud_adc, false, config, + workspace, tls, ) .await @@ -4847,6 +5000,7 @@ pub async fn provider_create_with_options( from_gcloud_adc: bool, runtime_credentials: bool, config: &[String], + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if from_gcloud_adc && (from_existing || !credentials.is_empty() || runtime_credentials) { @@ -4877,6 +5031,7 @@ pub async fn provider_create_with_options( let response = client .get_provider_profile(GetProviderProfileRequest { id: profile_id.to_string(), + workspace: workspace.to_string(), }) .await; match response { @@ -4929,7 +5084,8 @@ pub async fn provider_create_with_options( let mut config_map = parse_key_value_pairs(config, "--config")?; if from_existing { - let discovered = discover_existing_provider_data(&mut client, &provider_type).await?; + let discovered = + discover_existing_provider_data(&mut client, &provider_type, workspace).await?; let Some(discovered) = discovered else { return Err(miette::miette!( "no existing local credentials/config found for provider type '{provider_type}'" @@ -4953,10 +5109,10 @@ pub async fn provider_create_with_options( } let allows_empty_credentials = if runtime_credentials { provider_profile_allows_empty_credentials( - &fetch_provider_profile(&mut client, &provider_type).await?, + &fetch_provider_profile(&mut client, &provider_type, workspace).await?, ) } else { - fetch_provider_profile(&mut client, &provider_type) + fetch_provider_profile(&mut client, &provider_type, workspace) .await .ok() .is_some_and(|profile| provider_profile_allows_empty_credentials(&profile)) @@ -4991,13 +5147,14 @@ pub async fn provider_create_with_options( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.to_string(), }), r#type: provider_type.clone(), credentials: credential_map, config: config_map, credential_expires_at_ms: HashMap::new(), }), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5027,6 +5184,7 @@ pub async fn provider_create_with_options( "refresh_token".to_string(), ], expires_at_ms: None, + workspace: workspace.to_string(), }) .await { @@ -5035,6 +5193,7 @@ pub async fn provider_create_with_options( &provider_name, "configure", &configure_err, + workspace, ) .await; } @@ -5043,6 +5202,7 @@ pub async fn provider_create_with_options( .rotate_provider_credential(RotateProviderCredentialRequest { provider: provider_name.clone(), credential_key: adc_credential_key, + workspace: workspace.to_string(), }) .await { @@ -5051,6 +5211,7 @@ pub async fn provider_create_with_options( &provider_name, "mint the initial access token for", &rotate_err, + workspace, ) .await; } @@ -5068,11 +5229,17 @@ fn provider_profile_allows_empty_credentials(profile: &ProviderProfile) -> bool ProviderTypeProfile::from_proto(profile).allows_empty_provider_credentials() } -pub async fn provider_get(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { +pub async fn provider_get( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .get_provider(GetProviderRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5171,17 +5338,29 @@ fn provider_to_json(provider: &Provider) -> serde_json::Value { serde_json::Value::Object(obj) } +#[allow(clippy::too_many_arguments)] pub async fn provider_list( server: &str, limit: u32, offset: u32, names_only: bool, output: &str, + workspace: &str, + all_workspaces: bool, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client - .list_providers(ListProvidersRequest { limit, offset }) + .list_providers(ListProvidersRequest { + limit, + offset, + workspace: if all_workspaces { + String::new() + } else { + workspace.to_string() + }, + all_workspaces, + }) .await .into_diagnostic()?; let providers = response.into_inner().providers; @@ -5205,6 +5384,16 @@ pub async fn provider_list( return Ok(()); } + let ws_width = if all_workspaces { + providers + .iter() + .map(|p| p.object_workspace().len()) + .max() + .unwrap_or(9) + .max(9) + } else { + 0 + }; let name_width = providers .iter() .map(|provider| provider.object_name().len()) @@ -5218,33 +5407,61 @@ pub async fn provider_list( .unwrap_or(4) .max(4); - println!( - "{: Result<()> { +pub async fn provider_list_profiles( + server: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .list_provider_profiles(ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5292,9 +5509,10 @@ pub async fn provider_profile_export( server: &str, id: &str, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { - let rendered = provider_profile_export_text(server, id, output, tls).await?; + let rendered = provider_profile_export_text(server, id, output, workspace, tls).await?; if output == "json" { println!("{rendered}"); } else { @@ -5307,11 +5525,15 @@ pub async fn provider_profile_export_text( server: &str, id: &str, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result { let mut client = grpc_client(server, tls).await?; let response = client - .get_provider_profile(GetProviderProfileRequest { id: id.to_string() }) + .get_provider_profile(GetProviderProfileRequest { + id: id.to_string(), + workspace: workspace.to_string(), + }) .await .into_diagnostic()?; let profile = response @@ -5334,6 +5556,7 @@ pub async fn provider_profile_import( server: &str, file: Option<&Path>, from: Option<&Path>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let (items, mut diagnostics) = load_profile_import_items(file, from)?; @@ -5348,7 +5571,10 @@ pub async fn provider_profile_import( let mut client = grpc_client(server, tls).await?; if !items.is_empty() { let response = client - .import_provider_profiles(ImportProviderProfilesRequest { profiles: items }) + .import_provider_profiles(ImportProviderProfilesRequest { + profiles: items, + workspace: workspace.to_string(), + }) .await .into_diagnostic()? .into_inner(); @@ -5375,6 +5601,7 @@ pub async fn provider_profile_update( server: &str, id: &str, file: &Path, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let (mut items, mut diagnostics) = load_profile_import_items(Some(file), None)?; @@ -5397,6 +5624,7 @@ pub async fn provider_profile_update( profile: Some(item), expected_resource_version, id: id.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5416,6 +5644,7 @@ pub async fn provider_profile_lint( server: &str, file: Option<&Path>, from: Option<&Path>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let (items, mut diagnostics) = load_profile_import_items(file, from)?; @@ -5426,7 +5655,10 @@ pub async fn provider_profile_lint( if !items.is_empty() { let mut client = grpc_client(server, tls).await?; let response = client - .lint_provider_profiles(LintProviderProfilesRequest { profiles: items }) + .lint_provider_profiles(LintProviderProfilesRequest { + profiles: items, + workspace: workspace.to_string(), + }) .await .into_diagnostic()? .into_inner(); @@ -5442,10 +5674,18 @@ pub async fn provider_profile_lint( Ok(()) } -pub async fn provider_profile_delete(server: &str, id: &str, tls: &TlsOptions) -> Result<()> { +pub async fn provider_profile_delete( + server: &str, + id: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client - .delete_provider_profile(DeleteProviderProfileRequest { id: id.to_string() }) + .delete_provider_profile(DeleteProviderProfileRequest { + id: id.to_string(), + workspace: workspace.to_string(), + }) .await .into_diagnostic()? .into_inner(); @@ -5461,6 +5701,7 @@ pub async fn provider_refresh_status( server: &str, name: &str, credential_key: Option<&str>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -5468,6 +5709,7 @@ pub async fn provider_refresh_status( .get_provider_refresh_status(GetProviderRefreshStatusRequest { provider: name.to_string(), credential_key: credential_key.unwrap_or_default().to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5518,6 +5760,7 @@ pub struct ProviderRefreshConfigInput<'a> { pub async fn provider_refresh_config( server: &str, input: ProviderRefreshConfigInput<'_>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let strategy = provider_refresh_strategy(input.strategy)?; @@ -5545,6 +5788,7 @@ pub async fn provider_refresh_config( material, secret_material_keys, expires_at_ms: input.credential_expires_at_ms, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5565,6 +5809,7 @@ pub async fn provider_rotate( server: &str, name: &str, credential_key: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -5572,6 +5817,7 @@ pub async fn provider_rotate( .rotate_provider_credential(RotateProviderCredentialRequest { provider: name.to_string(), credential_key: credential_key.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5600,6 +5846,7 @@ pub async fn provider_refresh_delete( server: &str, name: &str, credential_key: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -5607,6 +5854,7 @@ pub async fn provider_refresh_delete( .delete_provider_refresh(DeleteProviderRefreshRequest { provider: name.to_string(), credential_key: credential_key.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5880,6 +6128,7 @@ fn truncate_display(value: &str, max_width: usize) -> String { truncated } +#[allow(clippy::too_many_arguments)] pub async fn provider_update( server: &str, name: &str, @@ -5887,6 +6136,7 @@ pub async fn provider_update( credentials: &[String], config: &[String], credential_expires_at: &[String], + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if from_existing && !credentials.is_empty() { @@ -5906,6 +6156,7 @@ pub async fn provider_update( let existing = client .get_provider(GetProviderRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5914,7 +6165,8 @@ pub async fn provider_update( .ok_or_else(|| miette::miette!("provider '{name}' not found"))?; let provider_type = existing.r#type; - let discovered = discover_existing_provider_data(&mut client, &provider_type).await?; + let discovered = + discover_existing_provider_data(&mut client, &provider_type, workspace).await?; let Some(discovered) = discovered else { return Err(miette::miette!( "no existing local credentials/config found for provider type '{provider_type}'" @@ -5938,7 +6190,7 @@ pub async fn provider_update( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.to_string(), }), r#type: String::new(), credentials: credential_map, @@ -5946,6 +6198,7 @@ pub async fn provider_update( credential_expires_at_ms: HashMap::new(), }), credential_expires_at_ms, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5963,11 +6216,19 @@ pub async fn provider_update( Ok(()) } -pub async fn provider_delete(server: &str, names: &[String], tls: &TlsOptions) -> Result<()> { +pub async fn provider_delete( + server: &str, + names: &[String], + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; for name in names { let response = client - .delete_provider(DeleteProviderRequest { name: name.clone() }) + .delete_provider(DeleteProviderRequest { + name: name.clone(), + workspace: workspace.to_string(), + }) .await .into_diagnostic()?; if response.into_inner().deleted { @@ -5979,6 +6240,334 @@ pub async fn provider_delete(server: &str, names: &[String], tls: &TlsOptions) - Ok(()) } +// --------------------------------------------------------------------------- +// Workspace commands +// --------------------------------------------------------------------------- + +pub async fn workspace_create( + server: &str, + name: &str, + label_args: &[String], + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::CreateWorkspaceRequest; + + let labels = label_args + .iter() + .filter_map(|arg| { + let (k, v) = arg.split_once('=')?; + Some((k.to_string(), v.to_string())) + }) + .collect::>(); + + let mut client = grpc_client(server, tls).await?; + let response = client + .create_workspace(CreateWorkspaceRequest { + name: name.to_string(), + labels, + }) + .await + .into_diagnostic()?; + + let workspace = response + .into_inner() + .workspace + .ok_or_else(|| miette!("workspace missing from response"))?; + + println!( + "{} Created workspace {}", + "✓".green().bold(), + workspace.object_name().bold() + ); + + Ok(()) +} + +pub async fn workspace_get(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { + use openshell_core::proto::GetWorkspaceRequest; + + let mut client = grpc_client(server, tls).await?; + let response = client + .get_workspace(GetWorkspaceRequest { + name: name.to_string(), + }) + .await + .into_diagnostic()?; + + let workspace = response + .into_inner() + .workspace + .ok_or_else(|| miette!("workspace missing from response"))?; + + println!("{}", "Workspace:".cyan().bold()); + println!(); + println!(" {} {}", "Name:".dimmed(), workspace.object_name()); + if let Some(meta) = &workspace.metadata { + println!(" {} {}", "Id:".dimmed(), meta.id); + println!( + " {} {}", + "Resource version:".dimmed(), + meta.resource_version + ); + if meta.created_at_ms != 0 { + println!( + " {} {}", + "Created:".dimmed(), + format_epoch_ms(meta.created_at_ms) + ); + } + if !meta.labels.is_empty() { + println!( + " {} {}", + "Labels:".dimmed(), + meta.labels + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", ") + ); + } + } + + Ok(()) +} + +pub async fn workspace_list( + server: &str, + limit: u32, + offset: u32, + label_selector: &str, + output: &str, + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::ListWorkspacesRequest; + + let mut client = grpc_client(server, tls).await?; + let response = client + .list_workspaces(ListWorkspacesRequest { + limit, + offset, + label_selector: label_selector.to_string(), + }) + .await + .into_diagnostic()?; + let workspaces = response.into_inner().workspaces; + + if crate::output::print_output_collection(output, &workspaces, workspace_to_json)? { + return Ok(()); + } + + if workspaces.is_empty() { + println!("No workspaces found."); + return Ok(()); + } + + let name_width = workspaces + .iter() + .map(|w| w.object_name().len()) + .max() + .unwrap_or(4) + .max(4); + + println!( + "{:>() + .join(", ") + }); + println!( + "{: Result<()> { + use openshell_core::proto::DeleteWorkspaceRequest; + + let mut client = grpc_client(server, tls).await?; + for name in names { + let response = client + .delete_workspace(DeleteWorkspaceRequest { name: name.clone() }) + .await + .into_diagnostic()?; + if response.into_inner().deleted { + println!("{} Deleted workspace {name}", "✓".green().bold()); + } else { + println!("{} Workspace {name} not found", "!".yellow()); + } + } + Ok(()) +} + +pub async fn workspace_member_add( + server: &str, + workspace: &str, + subject: &str, + role: &str, + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::{AddWorkspaceMemberRequest, WorkspaceRole}; + + let role_val = match role.to_lowercase().as_str() { + "user" => WorkspaceRole::User, + "admin" => WorkspaceRole::Admin, + _ => { + return Err(miette!( + "invalid role '{}': must be 'user' or 'admin'", + role + )); + } + }; + + let mut client = grpc_client(server, tls).await?; + let response = client + .add_workspace_member(AddWorkspaceMemberRequest { + workspace: workspace.to_string(), + principal_subject: subject.to_string(), + role: role_val.into(), + }) + .await + .into_diagnostic()?; + + let member = response + .into_inner() + .member + .ok_or_else(|| miette!("member missing from response"))?; + + println!( + "{} Added {} to workspace {} as {}", + "✓".green().bold(), + member.principal_subject.bold(), + workspace.bold(), + role, + ); + + Ok(()) +} + +pub async fn workspace_member_remove( + server: &str, + workspace: &str, + subject: &str, + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::RemoveWorkspaceMemberRequest; + + let mut client = grpc_client(server, tls).await?; + let response = client + .remove_workspace_member(RemoveWorkspaceMemberRequest { + workspace: workspace.to_string(), + principal_subject: subject.to_string(), + }) + .await + .into_diagnostic()?; + + if response.into_inner().removed { + println!( + "{} Removed {} from workspace {}", + "✓".green().bold(), + subject.bold(), + workspace.bold(), + ); + } else { + println!( + "{} Member {} not found in workspace {}", + "!".yellow(), + subject, + workspace, + ); + } + + Ok(()) +} + +pub async fn workspace_member_list( + server: &str, + workspace: &str, + limit: u32, + offset: u32, + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::{ListWorkspaceMembersRequest, WorkspaceRole}; + + let mut client = grpc_client(server, tls).await?; + let response = client + .list_workspace_members(ListWorkspaceMembersRequest { + workspace: workspace.to_string(), + limit, + offset, + }) + .await + .into_diagnostic()?; + let members = response.into_inner().members; + + if members.is_empty() { + println!("No members found in workspace {workspace}."); + return Ok(()); + } + + let subject_width = members + .iter() + .map(|m| m.principal_subject.len()) + .max() + .unwrap_or(7) + .max(7); + + println!("{: "admin", + Ok(WorkspaceRole::User) => "user", + _ => "unknown", + }; + println!("{: serde_json::Value { + let mut obj = serde_json::Map::new(); + if let Some(meta) = &workspace.metadata { + obj.insert("name".to_string(), serde_json::json!(meta.name)); + obj.insert("id".to_string(), serde_json::json!(meta.id)); + obj.insert( + "resource_version".to_string(), + serde_json::json!(meta.resource_version), + ); + if meta.created_at_ms != 0 { + obj.insert( + "created_at".to_string(), + serde_json::json!(format_epoch_ms(meta.created_at_ms)), + ); + } + if !meta.labels.is_empty() { + obj.insert("labels".to_string(), serde_json::json!(meta.labels)); + } + } + serde_json::Value::Object(obj) +} + +#[allow(clippy::too_many_arguments)] pub async fn gateway_inference_set( server: &str, provider_name: &str, @@ -5986,6 +6575,7 @@ pub async fn gateway_inference_set( route_name: &str, no_verify: bool, timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let progress = if std::io::stdout().is_terminal() { @@ -6003,13 +6593,14 @@ pub async fn gateway_inference_set( let mut client = grpc_inference_client(server, tls).await?; let response = client - .set_cluster_inference(SetClusterInferenceRequest { + .set_inference_route(SetInferenceRouteRequest { provider_name: provider_name.to_string(), model_id: model_id.to_string(), route_name: route_name.to_string(), verify: false, no_verify, timeout_secs, + workspace: workspace.to_string(), }) .await; @@ -6023,10 +6614,11 @@ pub async fn gateway_inference_set( let label = if configured.route_name == "sandbox-system" { "System inference configured:" } else { - "Gateway inference configured:" + "Inference configured:" }; println!("{}", label.cyan().bold()); println!(); + println!(" {} {}", "Workspace:".dimmed(), configured.workspace); println!(" {} {}", "Route:".dimmed(), configured.route_name); println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); @@ -6041,6 +6633,7 @@ pub async fn gateway_inference_set( Ok(()) } +#[allow(clippy::too_many_arguments)] pub async fn gateway_inference_update( server: &str, provider_name: Option<&str>, @@ -6048,6 +6641,7 @@ pub async fn gateway_inference_update( route_name: &str, no_verify: bool, timeout_secs: Option, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if provider_name.is_none() && model_id.is_none() && timeout_secs.is_none() { @@ -6060,8 +6654,9 @@ pub async fn gateway_inference_update( // Fetch current config to use as base for the partial update. let current = client - .get_cluster_inference(GetClusterInferenceRequest { + .get_inference_route(GetInferenceRouteRequest { route_name: route_name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6085,13 +6680,14 @@ pub async fn gateway_inference_update( }; let response = client - .set_cluster_inference(SetClusterInferenceRequest { + .set_inference_route(SetInferenceRouteRequest { provider_name: provider.to_string(), model_id: model.to_string(), route_name: route_name.to_string(), verify: false, no_verify, timeout_secs: timeout, + workspace: workspace.to_string(), }) .await; @@ -6105,10 +6701,11 @@ pub async fn gateway_inference_update( let label = if configured.route_name == "sandbox-system" { "System inference updated:" } else { - "Gateway inference updated:" + "Inference updated:" }; println!("{}", label.cyan().bold()); println!(); + println!(" {} {}", "Workspace:".dimmed(), configured.workspace); println!(" {} {}", "Route:".dimmed(), configured.route_name); println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); @@ -6126,6 +6723,7 @@ pub async fn gateway_inference_update( pub async fn gateway_inference_get( server: &str, route_name: Option<&str>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_inference_client(server, tls).await?; @@ -6133,8 +6731,9 @@ pub async fn gateway_inference_get( if let Some(name) = route_name { // Show a single route (--system was specified). let response = client - .get_cluster_inference(GetClusterInferenceRequest { + .get_inference_route(GetInferenceRouteRequest { route_name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -6143,19 +6742,20 @@ pub async fn gateway_inference_get( let label = if name == "sandbox-system" { "System inference:" } else { - "Gateway inference:" + "Inference:" }; println!("{}", label.cyan().bold()); println!(); + println!(" {} {}", "Workspace:".dimmed(), configured.workspace); println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); println!(" {} {}", "Version:".dimmed(), configured.version); print_timeout(configured.timeout_secs); } else { // Show both routes by default. - print_inference_route(&mut client, "Gateway inference", "").await; + print_inference_route(&mut client, "Inference", "", workspace).await; println!(); - print_inference_route(&mut client, "System inference", "sandbox-system").await; + print_inference_route(&mut client, "System inference", "sandbox-system", workspace).await; } Ok(()) } @@ -6164,10 +6764,12 @@ async fn print_inference_route( client: &mut crate::tls::GrpcInferenceClient, label: &str, route_name: &str, + workspace: &str, ) { match client - .get_cluster_inference(GetClusterInferenceRequest { + .get_inference_route(GetInferenceRouteRequest { route_name: route_name.to_string(), + workspace: workspace.to_string(), }) .await { @@ -6175,6 +6777,7 @@ async fn print_inference_route( let configured = response.into_inner(); println!("{}", format!("{label}:").cyan().bold()); println!(); + println!(" {} {}", "Workspace:".dimmed(), configured.workspace); println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); println!(" {} {}", "Version:".dimmed(), configured.version); @@ -6532,6 +7135,7 @@ pub async fn sandbox_policy_set_global( yes: bool, wait: bool, _timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if wait { @@ -6551,7 +7155,9 @@ pub async fn sandbox_policy_set_global( name: String::new(), policy: Some(policy), global: true, - ..Default::default() + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6574,12 +7180,14 @@ pub async fn sandbox_settings_get( server: &str, name: &str, json: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6735,6 +7343,7 @@ pub async fn gateway_setting_set( key: &str, value: &str, yes: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let setting_value = parse_cli_setting_value(key, value)?; @@ -6747,7 +7356,9 @@ pub async fn gateway_setting_set( setting_key: key.to_string(), setting_value: Some(setting_value), global: true, - ..Default::default() + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6768,6 +7379,7 @@ pub async fn sandbox_setting_set( name: &str, key: &str, value: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let setting_value = parse_cli_setting_value(key, value)?; @@ -6778,7 +7390,11 @@ pub async fn sandbox_setting_set( name: name.to_string(), setting_key: key.to_string(), setting_value: Some(setting_value), - ..Default::default() + delete_setting: false, + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6799,6 +7415,7 @@ pub async fn gateway_setting_delete( server: &str, key: &str, yes: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { confirm_global_setting_delete(key, yes)?; @@ -6810,7 +7427,9 @@ pub async fn gateway_setting_delete( setting_key: key.to_string(), delete_setting: true, global: true, - ..Default::default() + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6833,6 +7452,7 @@ pub async fn sandbox_setting_delete( server: &str, name: &str, key: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -6841,7 +7461,10 @@ pub async fn sandbox_setting_delete( name: name.to_string(), setting_key: key.to_string(), delete_setting: true, - ..Default::default() + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6872,6 +7495,7 @@ pub async fn sandbox_policy_set( policy_path: &str, wait: bool, timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let policy = load_sandbox_policy(Some(policy_path))? @@ -6885,6 +7509,7 @@ pub async fn sandbox_policy_set( name: name.to_string(), version: 0, global: false, + workspace: workspace.to_string(), }) .await .ok() @@ -6895,7 +7520,13 @@ pub async fn sandbox_policy_set( .update_config(UpdateConfigRequest { name: name.to_string(), policy: Some(policy), - ..Default::default() + setting_key: String::new(), + setting_value: None, + delete_setting: false, + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -6942,6 +7573,7 @@ pub async fn sandbox_policy_set( name: name.to_string(), version: resp.version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -6997,6 +7629,7 @@ pub async fn sandbox_policy_update( dry_run: bool, wait: bool, timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if dry_run && wait { @@ -7017,6 +7650,7 @@ pub async fn sandbox_policy_update( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7065,7 +7699,8 @@ pub async fn sandbox_policy_update( .update_config(UpdateConfigRequest { name: name.to_string(), merge_operations: plan.merge_operations, - ..Default::default() + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7112,6 +7747,7 @@ pub async fn sandbox_policy_update( name: name.to_string(), version: response.version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7159,6 +7795,7 @@ pub async fn sandbox_policy_get( version: u32, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut stdout = Vec::new(); @@ -7169,6 +7806,7 @@ pub async fn sandbox_policy_get( version, view, output, + workspace, tls, (&mut stdout, &mut stderr), ) @@ -7187,12 +7825,14 @@ pub async fn sandbox_policy_get( } #[doc(hidden)] +#[allow(clippy::too_many_arguments)] pub async fn sandbox_policy_get_to_writer( server: &str, name: &str, version: u32, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, writers: (&mut W, &mut E), ) -> Result<()> @@ -7201,8 +7841,10 @@ where E: Write + Send, { if version == 0 { - return sandbox_policy_get_effective_to_writer(server, name, view, output, tls, writers) - .await; + return sandbox_policy_get_effective_to_writer( + server, name, view, output, workspace, tls, writers, + ) + .await; } let (stdout, stderr) = writers; @@ -7213,6 +7855,7 @@ where name: name.to_string(), version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7280,6 +7923,7 @@ async fn sandbox_policy_get_effective_to_writer( name: &str, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, writers: (&mut W, &mut E), ) -> Result<()> @@ -7293,6 +7937,7 @@ where let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7394,6 +8039,7 @@ pub async fn sandbox_policy_get_global( version: u32, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7403,6 +8049,7 @@ pub async fn sandbox_policy_get_global( name: String::new(), version, global: true, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7531,6 +8178,7 @@ pub async fn sandbox_policy_list( server: &str, name: &str, limit: u32, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7541,6 +8189,7 @@ pub async fn sandbox_policy_list( limit, offset: 0, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7555,7 +8204,12 @@ pub async fn sandbox_policy_list( Ok(()) } -pub async fn sandbox_policy_list_global(server: &str, limit: u32, tls: &TlsOptions) -> Result<()> { +pub async fn sandbox_policy_list_global( + server: &str, + limit: u32, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let resp = client @@ -7564,6 +8218,7 @@ pub async fn sandbox_policy_list_global(server: &str, limit: u32, tls: &TlsOptio limit, offset: 0, global: true, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7619,6 +8274,7 @@ pub async fn sandbox_logs( since: Option<&str>, sources: &[String], level: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7627,6 +8283,7 @@ pub async fn sandbox_logs( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7691,6 +8348,7 @@ pub async fn sandbox_logs( since_ms, sources: source_filter, min_level: level.to_uppercase(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7753,6 +8411,7 @@ pub async fn sandbox_draft_get( server: &str, name: &str, status_filter: Option<&str>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7761,6 +8420,7 @@ pub async fn sandbox_draft_get( .get_draft_policy(GetDraftPolicyRequest { name: name.to_string(), status_filter: status_filter.unwrap_or("").to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7845,6 +8505,7 @@ pub async fn sandbox_draft_approve( server: &str, name: &str, chunk_id: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7853,6 +8514,7 @@ pub async fn sandbox_draft_approve( .approve_draft_chunk(ApproveDraftChunkRequest { name: name.to_string(), chunk_id: chunk_id.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7874,6 +8536,7 @@ pub async fn sandbox_draft_reject( name: &str, chunk_id: &str, reason: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7883,6 +8546,7 @@ pub async fn sandbox_draft_reject( name: name.to_string(), chunk_id: chunk_id.to_string(), reason: reason.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7897,6 +8561,7 @@ pub async fn sandbox_draft_approve_all( server: &str, name: &str, include_security_flagged: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7905,6 +8570,7 @@ pub async fn sandbox_draft_approve_all( .approve_all_draft_chunks(ApproveAllDraftChunksRequest { name: name.to_string(), include_security_flagged, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7922,12 +8588,18 @@ pub async fn sandbox_draft_approve_all( } /// Clear all pending network rules. -pub async fn sandbox_draft_clear(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { +pub async fn sandbox_draft_clear( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .clear_draft_chunks(ClearDraftChunksRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7943,12 +8615,18 @@ pub async fn sandbox_draft_clear(server: &str, name: &str, tls: &TlsOptions) -> } /// Show network rule history. -pub async fn sandbox_draft_history(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { +pub async fn sandbox_draft_history( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .get_draft_history(GetDraftHistoryRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -10001,7 +10679,7 @@ mod tests { resource_version: 42, created_at_ms: 1_234_567_890_000, labels, - annotations: std::collections::HashMap::new(), + workspace: String::new(), }; let provider = Provider { diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index a5f7b8bcd8..cfae779df8 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -76,6 +76,7 @@ async fn ssh_session_config( server: &str, name: &str, tls: &TlsOptions, + workspace: &str, ) -> Result { let mut client = grpc_client(server, tls).await?; @@ -83,6 +84,7 @@ async fn ssh_session_config( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -255,8 +257,9 @@ async fn sandbox_connect_with_mode( name: &str, tls: &TlsOptions, replace_process: bool, + workspace: &str, ) -> Result<()> { - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; let mut command = ssh_base_command(&session.proxy_command); command @@ -278,16 +281,22 @@ async fn sandbox_connect_with_mode( } /// Connect to a sandbox via SSH. -pub async fn sandbox_connect(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { - sandbox_connect_with_mode(server, name, tls, true).await +pub async fn sandbox_connect( + server: &str, + name: &str, + tls: &TlsOptions, + workspace: &str, +) -> Result<()> { + sandbox_connect_with_mode(server, name, tls, true, workspace).await } pub(crate) async fn sandbox_connect_without_exec( server: &str, name: &str, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { - sandbox_connect_with_mode(server, name, tls, false).await + sandbox_connect_with_mode(server, name, tls, false, workspace).await } pub async fn sandbox_connect_editor( @@ -296,12 +305,14 @@ pub async fn sandbox_connect_editor( name: &str, editor: Editor, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { // Verify the sandbox exists before writing SSH config / launching the editor. let mut client = grpc_client(server, tls).await?; client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -309,8 +320,8 @@ pub async fn sandbox_connect_editor( .sandbox .ok_or_else(|| miette::miette!("sandbox not found: {name}"))?; - let host_alias = host_alias(name); - install_ssh_config(gateway, name)?; + let host_alias = host_alias(name, workspace); + install_ssh_config(gateway, name, workspace)?; launch_editor(editor, &host_alias)?; eprintln!( "{} Opened {} for sandbox {}", @@ -331,10 +342,11 @@ pub async fn sandbox_forward( spec: &ForwardSpec, background: bool, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { openshell_core::forward::check_port_available(spec)?; - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; let mut command = TokioCommand::from(ssh_base_command(&session.proxy_command)); command @@ -528,12 +540,13 @@ async fn sandbox_exec_with_mode( tty: bool, tls: &TlsOptions, replace_process: bool, + workspace: &str, ) -> Result<()> { if command.is_empty() { return Err(miette::miette!("no command provided")); } - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; let mut ssh = ssh_base_command(&session.proxy_command); if tty { @@ -572,8 +585,9 @@ pub async fn sandbox_exec( command: &[String], tty: bool, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { - sandbox_exec_with_mode(server, name, command, tty, tls, true).await + sandbox_exec_with_mode(server, name, command, tty, tls, true, workspace).await } pub(crate) async fn sandbox_exec_without_exec( @@ -582,8 +596,9 @@ pub(crate) async fn sandbox_exec_without_exec( command: &[String], tty: bool, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { - sandbox_exec_with_mode(server, name, command, tty, tls, false).await + sandbox_exec_with_mode(server, name, command, tty, tls, false, workspace).await } /// What to pack into the tar archive streamed to the sandbox. @@ -754,8 +769,9 @@ async fn ssh_tar_upload( dest_dir: Option<&str>, source: UploadSource, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; // When no explicit destination is given, use the unescaped `$HOME` shell // variable so the remote shell resolves it at runtime. @@ -948,6 +964,7 @@ pub async fn sandbox_sync_up_files( local_path: &Path, dest: Option<&str>, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { if files.is_empty() { return Ok(()); @@ -962,6 +979,7 @@ pub async fn sandbox_sync_up_files( archive_prefix: file_list_archive_prefix(local_path), }, tls, + workspace, ) .await } @@ -979,6 +997,7 @@ pub async fn sandbox_sync_up( local_path: &Path, sandbox_path: Option<&str>, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { // When an explicit destination is given and looks like a file path (does // not end with '/'), split into parent directory + target basename so that @@ -1005,6 +1024,7 @@ pub async fn sandbox_sync_up( tar_name: target_name.into(), }, tls, + workspace, ) .await; } @@ -1032,6 +1052,7 @@ pub async fn sandbox_sync_up( tar_name, }, tls, + workspace, ) .await } @@ -1139,9 +1160,10 @@ pub async fn sandbox_sync_down( sandbox_path: &str, dest: &str, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { let sandbox_path = validate_sandbox_source_path(sandbox_path)?; - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; let sandbox_path = resolve_sandbox_source_path(&session, &sandbox_path).await?; let kind = probe_sandbox_source_kind(&session, &sandbox_path).await?; @@ -1417,8 +1439,13 @@ fn grpc_server_from_ssh_gateway_url(gateway_url: &str) -> Result { /// and sandbox name instead of pre-created gateway/token credentials. It is /// suitable for use as an SSH `ProxyCommand` in `~/.ssh/config` because it /// creates a fresh session on every invocation. -pub async fn sandbox_ssh_proxy_by_name(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { - let session = ssh_session_config(server, name, tls).await?; +pub async fn sandbox_ssh_proxy_by_name( + server: &str, + name: &str, + tls: &TlsOptions, + workspace: &str, +) -> Result<()> { + let session = ssh_session_config(server, name, tls, workspace).await?; sandbox_ssh_proxy( &session.gateway_url, &session.sandbox_id, @@ -1428,20 +1455,21 @@ pub async fn sandbox_ssh_proxy_by_name(server: &str, name: &str, tls: &TlsOption .await } -fn host_alias(name: &str) -> String { - format!("openshell-{name}") +fn host_alias(name: &str, workspace: &str) -> String { + format!("openshell-{name}.{workspace}") } -fn render_ssh_config(gateway: &str, name: &str) -> String { +fn render_ssh_config(gateway: &str, name: &str, workspace: &str) -> String { let exe = std::env::current_exe().expect("failed to resolve OpenShell executable"); let exe = shell_escape(&exe.to_string_lossy()); let proxy_cmd = format!( - "{exe} ssh-proxy --gateway-name {} --name {}", + "{exe} ssh-proxy --gateway-name {} --name {} --workspace {}", shell_escape(gateway), shell_escape(name), + shell_escape(workspace), ); - let host_alias = host_alias(name); + let host_alias = host_alias(name, workspace); format!( "Host {host_alias}\n User sandbox\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n GlobalKnownHostsFile /dev/null\n LogLevel ERROR\n ServerAliveInterval 15\n ServerAliveCountMax 3\n ProxyCommand {proxy_cmd}\n" ) @@ -1567,7 +1595,7 @@ fn upsert_host_block(contents: &str, alias: &str, block: &str) -> String { rendered } -pub fn install_ssh_config(gateway: &str, name: &str) -> Result { +pub fn install_ssh_config(gateway: &str, name: &str, workspace: &str) -> Result { let managed_config = openshell_ssh_config_path()?; let main_config = user_ssh_config_path()?; ensure_openshell_include(&main_config, &managed_config)?; @@ -1576,8 +1604,8 @@ pub fn install_ssh_config(gateway: &str, name: &str) -> Result { openshell_core::paths::create_dir_restricted(parent)?; } - let alias = host_alias(name); - let block = render_ssh_config(gateway, name); + let alias = host_alias(name, workspace); + let block = render_ssh_config(gateway, name, workspace); let contents = fs::read_to_string(&managed_config).unwrap_or_default(); let updated = upsert_host_block(&contents, &alias, &block); fs::write(&managed_config, updated) @@ -1624,8 +1652,8 @@ fn launch_editor_command(binary: &str, label: &str, remote_target: &str) -> Resu /// The `ProxyCommand` uses `--gateway-name` so that `ssh-proxy` resolves the /// gateway endpoint and TLS certificates from the gateway metadata directory /// (`~/.config/openshell/gateways//mtls/`). -pub fn print_ssh_config(gateway: &str, name: &str) { - print!("{}", render_ssh_config(gateway, name)); +pub fn print_ssh_config(gateway: &str, name: &str, workspace: &str) { + print!("{}", render_ssh_config(gateway, name, workspace)); } #[cfg(test)] @@ -1674,8 +1702,8 @@ mod tests { let user_config = ssh_dir.join("config"); fs::write(&user_config, "Host personal\n HostName example.com\n").unwrap(); - let managed_path = install_ssh_config("openshell", "demo").unwrap(); - install_ssh_config("openshell", "demo").unwrap(); + let managed_path = install_ssh_config("openshell", "demo", "default").unwrap(); + install_ssh_config("openshell", "demo", "default").unwrap(); let main_contents = fs::read_to_string(&user_config).unwrap(); assert!(main_contents.contains("Host personal")); @@ -1686,7 +1714,12 @@ mod tests { assert!(include_idx < host_idx); let managed_contents = fs::read_to_string(&managed_path).unwrap(); - assert_eq!(managed_contents.matches("Host openshell-demo").count(), 1); + assert_eq!( + managed_contents + .matches("Host openshell-demo.default") + .count(), + 1 + ); assert!(managed_contents.contains("ProxyCommand")); unsafe { @@ -1701,6 +1734,25 @@ mod tests { } } + #[test] + fn render_ssh_config_includes_workspace_in_proxy_command() { + let config = render_ssh_config("my-gw", "demo", "beta"); + assert!( + config.contains("Host openshell-demo.beta"), + "host alias should be workspace-qualified: {config}" + ); + assert!( + config.contains("--workspace beta"), + "ProxyCommand should include --workspace: {config}" + ); + } + + #[test] + fn host_alias_includes_workspace() { + assert_eq!(host_alias("demo", "default"), "openshell-demo.default"); + assert_eq!(host_alias("demo", "beta"), "openshell-demo.beta"); + } + #[test] fn launch_editor_returns_friendly_error_when_binary_missing() { let err = launch_editor_command( diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 7f9c59651a..75035d04b1 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -63,7 +63,7 @@ impl TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), @@ -357,7 +357,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), @@ -592,6 +592,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } // ── test server fixture ────────────────────────────────────────────── @@ -672,6 +721,7 @@ async fn explicit_provider_name_passes_through_when_it_exists() { &["nvidia".to_string()], &[], Some(true), // --auto-providers (should not matter here) + "default", ) .await .expect("should succeed"); @@ -700,6 +750,7 @@ async fn explicit_provider_name_auto_creates_when_valid_type() { &["nvidia".to_string()], &[], Some(true), // --auto-providers to skip interactive prompt + "default", ) .await .expect("should auto-create the provider"); @@ -733,6 +784,7 @@ async fn explicit_provider_name_errors_for_unrecognised_name() { &["my-custom-thing".to_string()], &[], Some(true), + "default", ) .await .expect_err("should fail for unrecognised provider name"); @@ -764,6 +816,7 @@ async fn inferred_type_auto_creates_provider() { &[], &["claude-code".to_string()], Some(true), // --auto-providers + "default", ) .await .expect("should auto-create the inferred provider"); @@ -793,6 +846,7 @@ async fn no_auto_providers_skips_missing_explicit_provider() { &["nvidia".to_string()], &[], Some(false), // --no-auto-providers + "default", ) .await .expect("should succeed with empty list"); @@ -828,6 +882,7 @@ async fn explicit_and_inferred_providers_combined() { &["nvidia".to_string()], &["claude-code".to_string()], Some(true), + "default", ) .await .expect("should create both providers"); @@ -859,6 +914,7 @@ async fn explicit_and_inferred_deduplicates() { &["nvidia".to_string()], &["nvidia".to_string()], Some(true), + "default", ) .await .expect("should succeed"); diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 58adf86082..622e3c1170 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -483,6 +483,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } async fn run_server( diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 4a55951e19..b28df6f012 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -136,7 +136,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 1, - annotations: HashMap::new(), + workspace: String::new(), }), spec: None, status: None, @@ -617,7 +617,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), @@ -996,6 +996,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } /// Test fixture: TLS-enabled server with matching client certs. @@ -1077,17 +1126,27 @@ async fn provider_cli_run_functions_support_full_crud_flow() { &["API_KEY=abc".to_string()], false, &["profile=dev".to_string()], + "default", &ts.tls, ) .await .expect("provider create"); - run::provider_get(&ts.endpoint, "my-claude", &ts.tls) + run::provider_get(&ts.endpoint, "my-claude", "default", &ts.tls) .await .expect("provider get"); - run::provider_list(&ts.endpoint, 100, 0, false, "table", &ts.tls) - .await - .expect("provider list"); + run::provider_list( + &ts.endpoint, + 100, + 0, + false, + "table", + "default", + false, + &ts.tls, + ) + .await + .expect("provider list"); run::provider_update( &ts.endpoint, @@ -1096,12 +1155,13 @@ async fn provider_cli_run_functions_support_full_crud_flow() { &["API_KEY=rotated".to_string()], &["profile=prod".to_string()], &[], + "default", &ts.tls, ) .await .expect("provider update"); - run::provider_delete(&ts.endpoint, &["my-claude".to_string()], &ts.tls) + run::provider_delete(&ts.endpoint, &["my-claude".to_string()], "default", &ts.tls) .await .expect("provider delete"); } @@ -1110,7 +1170,7 @@ async fn provider_cli_run_functions_support_full_crud_flow() { async fn provider_list_profiles_cli_uses_profile_browsing_rpc() { let ts = run_server().await; - run::provider_list_profiles(&ts.endpoint, "table", &ts.tls) + run::provider_list_profiles(&ts.endpoint, "table", "default", &ts.tls) .await .expect("provider list-profiles"); } @@ -1128,19 +1188,34 @@ async fn provider_list_json_output() { &["ANTHROPIC_API_KEY=test-key".to_string()], false, &["region=us-west".to_string()], + "default", &ts.tls, ) .await .expect("provider create"); // Test JSON output (verifies it doesn't error) - run::provider_list(&ts.endpoint, 100, 0, false, "json", &ts.tls) - .await - .expect("provider list json should succeed"); + run::provider_list( + &ts.endpoint, + 100, + 0, + false, + "json", + "default", + false, + &ts.tls, + ) + .await + .expect("provider list json should succeed"); - run::provider_delete(&ts.endpoint, &["test-provider".to_string()], &ts.tls) - .await - .expect("provider delete"); + run::provider_delete( + &ts.endpoint, + &["test-provider".to_string()], + "default", + &ts.tls, + ) + .await + .expect("provider delete"); } #[tokio::test] @@ -1156,19 +1231,34 @@ async fn provider_list_yaml_output() { &["ANTHROPIC_API_KEY=test-key".to_string()], false, &["region=us-west".to_string()], + "default", &ts.tls, ) .await .expect("provider create"); // Test YAML output (verifies it doesn't error) - run::provider_list(&ts.endpoint, 100, 0, false, "yaml", &ts.tls) - .await - .expect("provider list yaml should succeed"); + run::provider_list( + &ts.endpoint, + 100, + 0, + false, + "yaml", + "default", + false, + &ts.tls, + ) + .await + .expect("provider list yaml should succeed"); - run::provider_delete(&ts.endpoint, &["test-provider".to_string()], &ts.tls) - .await - .expect("provider delete"); + run::provider_delete( + &ts.endpoint, + &["test-provider".to_string()], + "default", + &ts.tls, + ) + .await + .expect("provider delete"); } #[tokio::test] @@ -1176,9 +1266,18 @@ async fn provider_list_json_empty() { let ts = run_server().await; // Test JSON output with no providers (verifies it doesn't error on empty list) - run::provider_list(&ts.endpoint, 100, 0, false, "json", &ts.tls) - .await - .expect("provider list json empty should succeed"); + run::provider_list( + &ts.endpoint, + 100, + 0, + false, + "json", + "default", + false, + &ts.tls, + ) + .await + .expect("provider list json empty should succeed"); } #[tokio::test] @@ -1193,6 +1292,7 @@ async fn provider_refresh_cli_run_functions_wire_requests() { &["MS_GRAPH_ACCESS_TOKEN=token".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -1209,6 +1309,7 @@ async fn provider_refresh_cli_run_functions_wire_requests() { secret_material_keys: &["client_secret".to_string()], credential_expires_at_ms: Some(1_767_225_600_000), }, + "default", &ts.tls, ) .await @@ -1217,16 +1318,29 @@ async fn provider_refresh_cli_run_functions_wire_requests() { &ts.endpoint, "my-graph", Some("MS_GRAPH_ACCESS_TOKEN"), + "default", &ts.tls, ) .await .expect("provider refresh status"); - run::provider_rotate(&ts.endpoint, "my-graph", "MS_GRAPH_ACCESS_TOKEN", &ts.tls) - .await - .expect("provider refresh rotate"); - run::provider_refresh_delete(&ts.endpoint, "my-graph", "MS_GRAPH_ACCESS_TOKEN", &ts.tls) - .await - .expect("provider refresh delete"); + run::provider_rotate( + &ts.endpoint, + "my-graph", + "MS_GRAPH_ACCESS_TOKEN", + "default", + &ts.tls, + ) + .await + .expect("provider refresh rotate"); + run::provider_refresh_delete( + &ts.endpoint, + "my-graph", + "MS_GRAPH_ACCESS_TOKEN", + "default", + &ts.tls, + ) + .await + .expect("provider refresh delete"); let requests = ts.state.refresh_requests.lock().await.clone(); assert_eq!( @@ -1267,6 +1381,7 @@ async fn provider_refresh_configure_reads_secret_material_from_env_off_argv() { &["GOOGLE_CHAT_ACCESS_TOKEN=pending".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -1285,6 +1400,7 @@ async fn provider_refresh_configure_reads_secret_material_from_env_off_argv() { secret_material_keys: &[], credential_expires_at_ms: None, }, + "default", &ts.tls, ) .await @@ -1326,6 +1442,7 @@ async fn provider_refresh_configure_rejects_key_supplied_via_both_material_and_e secret_material_keys: &[], credential_expires_at_ms: None, }, + "default", &ts.tls, ) .await @@ -1355,6 +1472,7 @@ async fn provider_refresh_configure_fails_closed_when_secret_material_env_is_uns secret_material_keys: &[], credential_expires_at_ms: None, }, + "default", &ts.tls, ) .await @@ -1397,6 +1515,7 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() false, true, &[], + "default", &ts.tls, ) .await @@ -1437,6 +1556,7 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_ &[], false, &[], + "default", &ts.tls, ) .await @@ -1464,26 +1584,51 @@ async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results &["GITHUB_TOKEN=ghp-test".to_string()], false, &[], + "default", &ts.tls, ) .await .expect("provider create"); - run::sandbox_provider_attach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls) - .await - .expect("sandbox provider attach"); - run::sandbox_provider_attach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls) - .await - .expect("sandbox provider attach is idempotent"); - run::sandbox_provider_list(&ts.endpoint, "dev-sandbox", &ts.tls) + run::sandbox_provider_attach( + &ts.endpoint, + "dev-sandbox", + "work-github", + "default", + &ts.tls, + ) + .await + .expect("sandbox provider attach"); + run::sandbox_provider_attach( + &ts.endpoint, + "dev-sandbox", + "work-github", + "default", + &ts.tls, + ) + .await + .expect("sandbox provider attach is idempotent"); + run::sandbox_provider_list(&ts.endpoint, "dev-sandbox", "default", &ts.tls) .await .expect("sandbox provider list"); - run::sandbox_provider_detach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls) - .await - .expect("sandbox provider detach"); - run::sandbox_provider_detach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls) - .await - .expect("sandbox provider detach is idempotent"); + run::sandbox_provider_detach( + &ts.endpoint, + "dev-sandbox", + "work-github", + "default", + &ts.tls, + ) + .await + .expect("sandbox provider detach"); + run::sandbox_provider_detach( + &ts.endpoint, + "dev-sandbox", + "work-github", + "default", + &ts.tls, + ) + .await + .expect("sandbox provider detach is idempotent"); let requests = ts.state.sandbox_provider_requests.lock().await.clone(); assert_eq!( @@ -1519,10 +1664,15 @@ async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results async fn sandbox_provider_attach_cli_surfaces_server_errors() { let ts = run_server().await; - let err = - run::sandbox_provider_attach(&ts.endpoint, "dev-sandbox", "missing-provider", &ts.tls) - .await - .expect_err("missing provider should fail"); + let err = run::sandbox_provider_attach( + &ts.endpoint, + "dev-sandbox", + "missing-provider", + "default", + &ts.tls, + ) + .await + .expect_err("missing provider should fail"); assert!( err.to_string().contains("provider not found"), @@ -1563,14 +1713,14 @@ binaries: [/usr/bin/custom] ) .unwrap(); - run::provider_profile_lint(&ts.endpoint, Some(&profile_path), None, &ts.tls) + run::provider_profile_lint(&ts.endpoint, Some(&profile_path), None, "default", &ts.tls) .await .expect("profile lint"); - run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, &ts.tls) + run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, "default", &ts.tls) .await .expect("profile import"); let exported_yaml = - run::provider_profile_export_text(&ts.endpoint, "custom-api", "yaml", &ts.tls) + run::provider_profile_export_text(&ts.endpoint, "custom-api", "yaml", "default", &ts.tls) .await .expect("profile export text"); assert!(exported_yaml.contains("resource_version: 1")); @@ -1581,9 +1731,15 @@ binaries: [/usr/bin/custom] ) .replace("host: api.custom.example", "host: api.updated.example"); std::fs::write(&profile_path, updated_yaml).unwrap(); - run::provider_profile_update(&ts.endpoint, "custom-api", &profile_path, &ts.tls) - .await - .expect("profile update"); + run::provider_profile_update( + &ts.endpoint, + "custom-api", + &profile_path, + "default", + &ts.tls, + ) + .await + .expect("profile update"); assert_eq!( ts.state .profiles @@ -1594,10 +1750,10 @@ binaries: [/usr/bin/custom] .map(|endpoint| endpoint.host.as_str()), Some("api.updated.example") ); - run::provider_profile_export(&ts.endpoint, "custom-api", "yaml", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-api", "yaml", "default", &ts.tls) .await .expect("profile export"); - run::provider_list_profiles(&ts.endpoint, "json", &ts.tls) + run::provider_list_profiles(&ts.endpoint, "json", "default", &ts.tls) .await .expect("provider list-profiles json"); run::provider_create( @@ -1608,6 +1764,7 @@ binaries: [/usr/bin/custom] &["CUSTOM_API_KEY=abc".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -1623,10 +1780,15 @@ binaries: [/usr/bin/custom] .expect("custom provider should be stored"); assert_eq!(provider.r#type, "custom-api"); - run::provider_delete(&ts.endpoint, &["custom-provider".to_string()], &ts.tls) - .await - .expect("custom provider delete"); - run::provider_profile_delete(&ts.endpoint, "custom-api", &ts.tls) + run::provider_delete( + &ts.endpoint, + &["custom-provider".to_string()], + "default", + &ts.tls, + ) + .await + .expect("custom provider delete"); + run::provider_profile_delete(&ts.endpoint, "custom-api", "default", &ts.tls) .await .expect("profile delete"); } @@ -1662,6 +1824,7 @@ async fn provider_create_from_existing_uses_profile_discovery_when_v2_enabled() &[], false, &[], + "default", &ts.tls, ) .await @@ -1695,6 +1858,7 @@ async fn provider_create_from_existing_uses_registry_discovery_when_v2_disabled( &[], false, &[], + "default", &ts.tls, ) .await @@ -1738,6 +1902,7 @@ async fn provider_create_from_existing_vertex_discovers_credentials_and_config_w &[], false, &[], + "default", &ts.tls, ) .await @@ -1793,6 +1958,7 @@ async fn provider_create_from_existing_requires_profile_when_v2_enabled() { &[], false, &[], + "default", &ts.tls, ) .await @@ -1836,6 +2002,7 @@ async fn provider_create_from_existing_fails_when_profile_discovery_finds_nothin &[], false, &[], + "default", &ts.tls, ) .await @@ -1892,9 +2059,18 @@ async fn provider_update_from_existing_uses_profile_discovery_when_v2_enabled() ); let _env = EnvVarGuard::set(&[("CUSTOM_UPDATE_DISCOVERY_API_KEY", "updated-profile-secret")]); - run::provider_update(&ts.endpoint, "custom-update", true, &[], &[], &[], &ts.tls) - .await - .expect("profile-backed provider update --from-existing"); + run::provider_update( + &ts.endpoint, + "custom-update", + true, + &[], + &[], + &[], + "default", + &ts.tls, + ) + .await + .expect("profile-backed provider update --from-existing"); let provider = ts .state @@ -1943,14 +2119,14 @@ binaries: [/usr/bin/yaml-client] .unwrap(); std::fs::write(dir.path().join("notes.txt"), "ignored").unwrap(); - run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), &ts.tls) + run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), "default", &ts.tls) .await .expect("profile import --from"); - run::provider_profile_export(&ts.endpoint, "custom-yaml", "yaml", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-yaml", "yaml", "default", &ts.tls) .await .expect("custom-yaml should be imported"); - run::provider_profile_export(&ts.endpoint, "custom-json", "json", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-json", "json", "default", &ts.tls) .await .expect("custom-json should be imported"); } @@ -1990,7 +2166,7 @@ binaries: ) .unwrap(); - run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, &ts.tls) + run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, "default", &ts.tls) .await .expect("profile import"); @@ -2000,6 +2176,7 @@ binaries: let profile = client .get_provider_profile(openshell_core::proto::GetProviderProfileRequest { id: "advanced-api".to_string(), + workspace: String::new(), }) .await .expect("get provider profile") @@ -2034,15 +2211,16 @@ endpoints: .unwrap(); std::fs::write(dir.path().join("broken.yaml"), "id: [\n").unwrap(); - let err = run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), &ts.tls) - .await - .expect_err("profile import --from should fail on parse errors"); + let err = + run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), "default", &ts.tls) + .await + .expect_err("profile import --from should fail on parse errors"); assert!( err.to_string().contains("provider profile import failed"), "unexpected error: {err}" ); - run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", "default", &ts.tls) .await .expect_err("valid profiles should not be partially imported after local parse errors"); } @@ -2065,7 +2243,7 @@ endpoints: .unwrap(); std::fs::write(dir.path().join("broken.yaml"), "id: [\n").unwrap(); - let err = run::provider_profile_lint(&ts.endpoint, None, Some(dir.path()), &ts.tls) + let err = run::provider_profile_lint(&ts.endpoint, None, Some(dir.path()), "default", &ts.tls) .await .expect_err("profile lint --from should fail on parse errors"); assert!( @@ -2073,7 +2251,7 @@ endpoints: "unexpected error: {err}" ); - run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", "default", &ts.tls) .await .expect_err("lint should not import valid profiles"); } @@ -2090,6 +2268,7 @@ async fn provider_create_rejects_key_only_credentials_without_local_env_value() &["INVALID_PAIR".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2115,6 +2294,7 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() { &["NAV_GENERIC_TEST_KEY".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2126,6 +2306,7 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() { let response = client .get_provider(GetProviderRequest { name: "my-generic".to_string(), + workspace: String::new(), }) .await .expect("get provider should succeed") @@ -2150,6 +2331,7 @@ async fn provider_create_rejects_combined_from_existing_and_credentials() { &["API_KEY=abc".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2174,6 +2356,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_from_existing() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2199,6 +2382,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_credentials() { &["GOOGLE_VERTEX_AI_TOKEN=token".to_string()], true, &[], + "default", &ts.tls, ) .await @@ -2225,6 +2409,7 @@ async fn provider_create_rejects_empty_env_var_for_key_only_credential() { &["NAV_EMPTY_ENV_KEY".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2250,6 +2435,7 @@ async fn provider_create_supports_nvidia_type_with_nvidia_api_key() { &["NVIDIA_API_KEY".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2261,6 +2447,7 @@ async fn provider_create_supports_nvidia_type_with_nvidia_api_key() { let response = client .get_provider(GetProviderRequest { name: "my-nvidia".to_string(), + workspace: String::new(), }) .await .expect("get provider should succeed") @@ -2302,6 +2489,7 @@ async fn provider_create_from_gcloud_adc_happy_path() { &[], // no explicit credentials; refresh bootstrap covers it true, // from_gcloud_adc &[], + "default", &ts.tls, ) .await @@ -2387,6 +2575,7 @@ async fn provider_create_from_gcloud_adc_rejects_service_account() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2424,6 +2613,7 @@ async fn provider_create_from_gcloud_adc_missing_file() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2457,6 +2647,7 @@ async fn provider_create_from_gcloud_adc_rejects_wrong_provider_type_before_cred &[], true, &[], + "default", &ts.tls, ) .await @@ -2494,6 +2685,7 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_refresh_config &[], true, &[], + "default", &ts.tls, ) .await @@ -2544,6 +2736,7 @@ async fn provider_create_from_gcloud_adc_warn_path_keeps_provider_when_rollback_ &[], true, &[], + "default", &ts.tls, ) .await @@ -2592,6 +2785,7 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_initial_rotate &[], true, &[], + "default", &ts.tls, ) .await @@ -2632,6 +2826,7 @@ async fn provider_create_from_existing_vertex_config_only_reports_missing_vertex &[], false, &[], + "default", &ts.tls, ) .await @@ -2678,6 +2873,7 @@ async fn provider_create_from_gcloud_adc_with_config_keys() { "VERTEX_AI_PROJECT_ID=my-gcp-project".to_string(), "VERTEX_AI_REGION=us-east1".to_string(), ], + "default", &ts.tls, ) .await @@ -2752,6 +2948,7 @@ async fn provider_create_from_gcloud_adc_missing_refresh_token() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2794,6 +2991,7 @@ async fn provider_create_from_gcloud_adc_missing_client_secret() { &[], true, &[], + "default", &ts.tls, ) .await diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 354b5159f0..bde4fd502c 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -94,7 +94,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), ..Sandbox::default() }; @@ -116,7 +116,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), ..Sandbox::default() }; @@ -377,7 +377,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), ..Sandbox::default() }; @@ -665,6 +665,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } struct TestServer { @@ -1139,6 +1188,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1171,6 +1221,7 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1238,6 +1289,7 @@ async fn sandbox_create_sends_driver_config_json() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1299,6 +1351,7 @@ async fn sandbox_create_sends_gpu_default_request() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1333,6 +1386,7 @@ async fn sandbox_create_sends_gpu_count_request() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1368,6 +1422,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { tty_override: Some(true), ..test_config() }, + "default", &tls, ) .await @@ -1413,6 +1468,7 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1454,6 +1510,7 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1487,6 +1544,7 @@ async fn sandbox_create_times_out_when_only_logs_arrive() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1517,6 +1575,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1551,6 +1610,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { tty_override: Some(true), ..test_config() }, + "default", &tls, ) .await @@ -1584,6 +1644,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1619,6 +1680,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1646,9 +1708,16 @@ async fn sandbox_forward_background_tracks_owned_child_when_pid_discovery_fails( drop(listener); let spec = openshell_core::forward::ForwardSpec::new(forward_port); - run::sandbox_forward(&server.endpoint, "owned-forward", &spec, true, &tls) - .await - .expect("background forward should track the owned SSH child without PID discovery"); + run::sandbox_forward( + &server.endpoint, + "owned-forward", + &spec, + true, + &tls, + "default", + ) + .await + .expect("background forward should track the owned SSH child without PID discovery"); let record = openshell_core::forward::read_forward_pid("owned-forward", forward_port) .expect("owned background forward should write a PID file"); @@ -1676,9 +1745,16 @@ async fn sandbox_forward_foreground_fails_when_ssh_exits_before_listener_opens() drop(listener); let spec = openshell_core::forward::ForwardSpec::new(forward_port); - let err = run::sandbox_forward(&server.endpoint, "foreground-forward", &spec, false, &tls) - .await - .expect_err("foreground forward should fail when ssh exits before listener readiness"); + let err = run::sandbox_forward( + &server.endpoint, + "foreground-forward", + &spec, + false, + &tls, + "default", + ) + .await + .expect_err("foreground forward should fail when ssh exits before listener readiness"); let msg = format!("{err}"); assert!( msg.contains("ssh exited before local forward listener opened"), @@ -1699,9 +1775,16 @@ async fn sandbox_forward_background_terminates_owned_child_when_listener_never_o drop(listener); let spec = openshell_core::forward::ForwardSpec::new(forward_port); - let err = run::sandbox_forward(&server.endpoint, "unreachable-forward", &spec, true, &tls) - .await - .expect_err("background forward should fail when the listener never opens"); + let err = run::sandbox_forward( + &server.endpoint, + "unreachable-forward", + &spec, + true, + &tls, + "default", + ) + .await + .expect_err("background forward should fail when the listener never opens"); let msg = format!("{err}"); assert!( msg.contains("ssh process started but local forward listener was not reachable"), @@ -1758,6 +1841,7 @@ async fn sandbox_create_sends_environment_variables() { ]), ..test_config() }, + "default", &tls, ) .await diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 7fe3c5dc59..dc62797ecb 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -86,7 +86,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: String::new(), }), ..Default::default() }), @@ -569,6 +569,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } struct TestServer { @@ -636,7 +685,7 @@ async fn run_server() -> TestServer { async fn sandbox_get_sends_correct_name() { let ts = run_server().await; - run::sandbox_get(&ts.endpoint, "my-sandbox", false, &ts.tls) + run::sandbox_get(&ts.endpoint, "my-sandbox", false, "default", &ts.tls) .await .expect("sandbox_get should succeed"); @@ -653,7 +702,7 @@ async fn sandbox_get_sends_correct_name() { async fn sandbox_get_policy_only_round_trip() { let ts = run_server().await; - run::sandbox_get(&ts.endpoint, "my-sandbox", true, &ts.tls) + run::sandbox_get(&ts.endpoint, "my-sandbox", true, "default", &ts.tls) .await .expect("sandbox_get with policy_only should succeed"); @@ -679,7 +728,7 @@ async fn sandbox_get_with_persisted_last_sandbox() { assert_eq!(resolved, "persisted-sb"); // Call sandbox_get with the resolved name. - run::sandbox_get(&ts.endpoint, &resolved, false, &ts.tls) + run::sandbox_get(&ts.endpoint, &resolved, false, "default", &ts.tls) .await .expect("sandbox_get should succeed"); @@ -703,6 +752,7 @@ async fn policy_get_full_json_cli_prints_policy_payload() { 0, run::PolicyGetView::Full, "json", + "default", &ts.tls, (&mut stdout, &mut stderr), ) @@ -751,6 +801,7 @@ async fn policy_get_base_json_cli_prints_round_trippable_policy_payload() { 0, run::PolicyGetView::Base, "json", + "default", &ts.tls, (&mut stdout, &mut stderr), ) @@ -792,6 +843,7 @@ async fn policy_get_explicit_revision_uses_stored_policy_status() { 3, run::PolicyGetView::Full, "json", + "default", &ts.tls, (&mut stdout, &mut stderr), ) @@ -829,7 +881,7 @@ async fn explicit_name_takes_precedence_over_persisted() { // Persist one name, but supply a different one explicitly. save_last_sandbox("my-cluster", "old-sandbox").expect("save should succeed"); - run::sandbox_get(&ts.endpoint, "explicit-sandbox", false, &ts.tls) + run::sandbox_get(&ts.endpoint, "explicit-sandbox", false, "default", &ts.tls) .await .expect("sandbox_get should succeed"); diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 9e4411b2a1..e5eba760d8 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -27,6 +27,9 @@ pub const LABEL_SANDBOX_NAME: &str = "openshell.ai/sandbox-name"; /// Container/pod label carrying the sandbox namespace. pub const LABEL_SANDBOX_NAMESPACE: &str = "openshell.ai/sandbox-namespace"; +/// Container/pod label carrying the sandbox workspace. +pub const LABEL_SANDBOX_WORKSPACE: &str = "openshell.ai/sandbox-workspace"; + // --------------------------------------------------------------------------- /// Path to the sandbox supervisor binary inside the container image. diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 373635ae15..8923dbe012 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -542,6 +542,36 @@ mod auth_tests { } } +#[cfg(test)] +mod workspace_tests { + use super::*; + + #[test] + fn cached_client_workspace_defaults_to_empty_before_poll() { + let client_ws: Arc> = Arc::new(tokio::sync::OnceCell::new()); + assert_eq!(client_ws.get().cloned().unwrap_or_default(), ""); + } + + #[test] + fn cached_client_workspace_returns_learned_value() { + let client_ws: Arc> = Arc::new(tokio::sync::OnceCell::new()); + let _ = client_ws.set("beta".to_string()); + assert_eq!(client_ws.get().cloned().unwrap_or_default(), "beta"); + } + + #[test] + fn cached_client_workspace_is_set_once() { + let client_ws: Arc> = Arc::new(tokio::sync::OnceCell::new()); + let _ = client_ws.set("beta".to_string()); + let _ = client_ws.set("gamma".to_string()); + assert_eq!( + client_ws.get().cloned().unwrap_or_default(), + "beta", + "workspace should not change after first poll" + ); + } +} + /// Connect to the `OpenShell` server. async fn connect(endpoint: &str) -> Result> { let channel = connect_channel(endpoint).await?; @@ -618,12 +648,19 @@ async fn sync_policy_with_client( client: &mut OpenShellClient, sandbox: &str, policy: &ProtoSandboxPolicy, + workspace: &str, ) -> Result<()> { client .update_config(UpdateConfigRequest { name: sandbox.to_string(), policy: Some(policy.clone()), - ..Default::default() + setting_key: String::new(), + setting_value: None, + delete_setting: false, + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic() @@ -641,6 +678,7 @@ pub async fn discover_and_sync_policy( sandbox_id: &str, sandbox: &str, discovered_policy: &ProtoSandboxPolicy, + workspace: &str, ) -> Result { debug!( endpoint = %endpoint, @@ -652,7 +690,7 @@ pub async fn discover_and_sync_policy( let mut client = connect(endpoint).await?; // Sync the discovered policy to the gateway. - sync_policy_with_client(&mut client, sandbox, discovered_policy).await?; + sync_policy_with_client(&mut client, sandbox, discovered_policy, workspace).await?; // Re-fetch from the gateway to get the canonical version/hash. fetch_policy_with_client(&mut client, sandbox_id) @@ -666,10 +704,15 @@ pub async fn discover_and_sync_policy( /// /// Used by the supervisor to push baseline-path-enriched policies so the /// gateway stores the effective policy users see via `openshell sandbox get`. -pub async fn sync_policy(endpoint: &str, sandbox: &str, policy: &ProtoSandboxPolicy) -> Result<()> { +pub async fn sync_policy( + endpoint: &str, + sandbox: &str, + policy: &ProtoSandboxPolicy, + workspace: &str, +) -> Result<()> { debug!(endpoint = %endpoint, sandbox = %sandbox, "Syncing enriched policy to gateway"); let mut client = connect(endpoint).await?; - sync_policy_with_client(&mut client, sandbox, policy).await + sync_policy_with_client(&mut client, sandbox, policy, workspace).await } /// Sync an enriched policy and return the authoritative revision snapshot. @@ -678,9 +721,10 @@ pub async fn sync_policy_and_fetch_snapshot( sandbox_id: &str, sandbox: &str, policy: &ProtoSandboxPolicy, + workspace: &str, ) -> Result { let mut client = connect(endpoint).await?; - sync_policy_with_client(&mut client, sandbox, policy).await?; + sync_policy_with_client(&mut client, sandbox, policy, workspace).await?; fetch_settings_snapshot_with_client(&mut client, sandbox_id).await } @@ -720,6 +764,7 @@ pub async fn fetch_provider_environment( #[derive(Clone)] pub struct CachedOpenShellClient { client: OpenShellClient, + workspace: Arc>, } /// Settings poll result returned by [`CachedOpenShellClient::poll_settings`]. @@ -735,6 +780,8 @@ pub struct SettingsPollResult { /// When `policy_source` is `Global`, the version of the global policy revision. pub global_policy_version: u32, pub provider_env_revision: u64, + /// Workspace the sandbox belongs to. + pub workspace: String, } fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> SettingsPollResult { @@ -748,6 +795,7 @@ fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> Settin settings: inner.settings, global_policy_version: inner.global_policy_version, provider_env_revision: inner.provider_env_revision, + workspace: inner.workspace, } } @@ -762,7 +810,10 @@ impl CachedOpenShellClient { pub async fn connect(endpoint: &str) -> Result { debug!(endpoint = %endpoint, "Connecting openshell gRPC client for policy polling"); let client = connect(endpoint).await?; - Ok(Self { client }) + Ok(Self { + client, + workspace: Arc::new(tokio::sync::OnceCell::new()), + }) } /// Get a clone of the underlying tonic client for direct RPC calls. @@ -781,7 +832,14 @@ impl CachedOpenShellClient { .await .into_diagnostic()?; - Ok(settings_poll_result(response.into_inner())) + let result = settings_poll_result(response.into_inner()); + let _ = self.workspace.set(result.workspace.clone()); + Ok(result) + } + + /// Returns the workspace learned from the server, or empty if not yet polled. + pub fn workspace(&self) -> String { + self.workspace.get().cloned().unwrap_or_default() } /// Submit denial summaries and/or agent-authored proposals for policy analysis. @@ -807,6 +865,7 @@ impl CachedOpenShellClient { proposed_chunks, network_activity_summaries, analysis_mode: analysis_mode.to_string(), + workspace: self.workspace(), }) .await .into_diagnostic()?; @@ -829,6 +888,7 @@ impl CachedOpenShellClient { .get_draft_policy(GetDraftPolicyRequest { name: sandbox_name.to_string(), status_filter: status_filter.to_string(), + workspace: self.workspace(), }) .await .into_diagnostic()?; diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index bb61e1f56f..cd16646b86 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -45,7 +45,9 @@ pub use config::{ MtlsAuthConfig, OidcConfig, TlsConfig, }; pub use error::{ComputeDriverError, Error, Result}; -pub use metadata::{GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion}; +pub use metadata::{ + GetResourceVersion, ObjectId, ObjectLabels, ObjectName, ObjectWorkspace, SetResourceVersion, +}; /// Build version string derived from git metadata. /// diff --git a/crates/openshell-core/src/metadata.rs b/crates/openshell-core/src/metadata.rs index af26f73ae0..5a48b8b37c 100644 --- a/crates/openshell-core/src/metadata.rs +++ b/crates/openshell-core/src/metadata.rs @@ -7,7 +7,7 @@ use crate::proto::{ InferenceRoute, ObjectForTest, Provider, Sandbox, SandboxStatus, ServiceEndpoint, SshSession, - StoredProviderCredentialRefreshState, StoredProviderProfile, + StoredProviderCredentialRefreshState, StoredProviderProfile, Workspace, WorkspaceMember, }; use std::collections::HashMap; @@ -36,6 +36,12 @@ pub trait GetResourceVersion { fn get_resource_version(&self) -> u64; } +/// Provides access to the object's workspace for persistence scoping. +pub trait ObjectWorkspace { + fn object_workspace(&self) -> &str; + fn requires_workspace() -> bool; +} + // Implementations for Sandbox impl ObjectId for Sandbox { fn object_id(&self) -> &str { @@ -69,6 +75,15 @@ impl GetResourceVersion for Sandbox { } } +impl ObjectWorkspace for Sandbox { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + impl Sandbox { pub fn phase(&self) -> i32 { self.status.as_ref().map_or(0, |s| s.phase) @@ -89,6 +104,49 @@ impl Sandbox { } } +// Implementations for Workspace +impl ObjectId for Workspace { + fn object_id(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.id.as_str()) + } +} + +impl ObjectName for Workspace { + fn object_name(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.name.as_str()) + } +} + +impl ObjectLabels for Workspace { + fn object_labels(&self) -> Option> { + self.metadata.as_ref().map(|m| m.labels.clone()) + } +} + +impl SetResourceVersion for Workspace { + fn set_resource_version(&mut self, version: u64) { + if let Some(meta) = self.metadata.as_mut() { + meta.resource_version = version; + } + } +} + +impl GetResourceVersion for Workspace { + fn get_resource_version(&self) -> u64 { + self.metadata.as_ref().map_or(0, |m| m.resource_version) + } +} + +impl ObjectWorkspace for Workspace { + #[allow(clippy::unnecessary_literal_bound)] + fn object_workspace(&self) -> &str { + "" + } + fn requires_workspace() -> bool { + false + } +} + // Implementations for Provider impl ObjectId for Provider { fn object_id(&self) -> &str { @@ -122,6 +180,15 @@ impl GetResourceVersion for Provider { } } +impl ObjectWorkspace for Provider { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for StoredProviderProfile impl ObjectId for StoredProviderProfile { fn object_id(&self) -> &str { @@ -155,6 +222,15 @@ impl GetResourceVersion for StoredProviderProfile { } } +impl ObjectWorkspace for StoredProviderProfile { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + false + } +} + // Implementations for StoredProviderCredentialRefreshState impl ObjectId for StoredProviderCredentialRefreshState { fn object_id(&self) -> &str { @@ -188,6 +264,15 @@ impl GetResourceVersion for StoredProviderCredentialRefreshState { } } +impl ObjectWorkspace for StoredProviderCredentialRefreshState { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + false + } +} + // Implementations for SshSession impl ObjectId for SshSession { fn object_id(&self) -> &str { @@ -221,6 +306,15 @@ impl GetResourceVersion for SshSession { } } +impl ObjectWorkspace for SshSession { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for ServiceEndpoint impl ObjectId for ServiceEndpoint { fn object_id(&self) -> &str { @@ -254,6 +348,15 @@ impl GetResourceVersion for ServiceEndpoint { } } +impl ObjectWorkspace for ServiceEndpoint { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for InferenceRoute impl ObjectId for InferenceRoute { fn object_id(&self) -> &str { @@ -287,6 +390,57 @@ impl GetResourceVersion for InferenceRoute { } } +impl ObjectWorkspace for InferenceRoute { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + +// Implementations for WorkspaceMember +impl ObjectId for WorkspaceMember { + fn object_id(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.id.as_str()) + } +} + +impl ObjectName for WorkspaceMember { + fn object_name(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.name.as_str()) + } +} + +impl ObjectLabels for WorkspaceMember { + fn object_labels(&self) -> Option> { + self.metadata.as_ref().map(|m| m.labels.clone()) + } +} + +impl SetResourceVersion for WorkspaceMember { + fn set_resource_version(&mut self, version: u64) { + if let Some(meta) = self.metadata.as_mut() { + meta.resource_version = version; + } + } +} + +impl GetResourceVersion for WorkspaceMember { + fn get_resource_version(&self) -> u64 { + self.metadata.as_ref().map_or(0, |m| m.resource_version) + } +} + +impl ObjectWorkspace for WorkspaceMember { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for ObjectForTest (test-only proto type) impl ObjectId for ObjectForTest { fn object_id(&self) -> &str { @@ -318,3 +472,13 @@ impl GetResourceVersion for ObjectForTest { 0 } } + +impl ObjectWorkspace for ObjectForTest { + #[allow(clippy::unnecessary_literal_bound)] + fn object_workspace(&self) -> &str { + "" + } + fn requires_workspace() -> bool { + false + } +} diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 06d575a1e1..90f24f51f7 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -25,7 +25,8 @@ use openshell_core::config::{ use openshell_core::driver_mounts; use openshell_core::driver_utils::{ LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, - LABEL_SANDBOX_NAMESPACE, SUPERVISOR_IMAGE_BINARY_PATH, supervisor_image_should_refresh, + LABEL_SANDBOX_NAMESPACE, LABEL_SANDBOX_WORKSPACE, SUPERVISOR_IMAGE_BINARY_PATH, + supervisor_image_should_refresh, }; use openshell_core::gpu::{ CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, driver_gpu_requirements, @@ -1535,6 +1536,7 @@ fn pending_sandbox_snapshot( conditions: vec![condition], deleting, }), + workspace: sandbox.workspace.clone(), } } @@ -2292,6 +2294,10 @@ fn build_container_create_body_with_gpu_devices( ); labels.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone()); labels.insert(LABEL_SANDBOX_NAME.to_string(), sandbox.name.clone()); + labels.insert( + LABEL_SANDBOX_WORKSPACE.to_string(), + sandbox.workspace.clone(), + ); // The list/get/find paths filter by `config.sandbox_namespace`, so use // the same value here. `DriverSandbox.namespace` is unset on the request // path (the gateway elides it), and using it would produce containers @@ -2723,6 +2729,10 @@ fn sandbox_from_container_summary( .get(LABEL_SANDBOX_NAMESPACE) .cloned() .unwrap_or_default(); + let workspace = labels + .get(LABEL_SANDBOX_WORKSPACE) + .cloned() + .unwrap_or_default(); let supervisor_connected = readiness.is_supervisor_connected(&id); Some(DriverSandbox { @@ -2735,6 +2745,7 @@ fn sandbox_from_container_summary( &name, supervisor_connected, )), + workspace, }) } @@ -2908,24 +2919,26 @@ const CONTAINER_NAME_PREFIX: &str = "openshell-"; fn container_name_for_sandbox(sandbox: &DriverSandbox) -> String { let id_suffix = sanitize_docker_name(&sandbox.id); + let workspace = sanitize_docker_name(&sandbox.workspace); let name = sanitize_docker_name(&sandbox.name); + + // Format: openshell-{workspace}--{name}-{id} + // The workspace and id are never truncated — they ensure uniqueness. + // Only the sandbox name portion is truncated when the total exceeds + // MAX_CONTAINER_NAME_LEN. + if name.is_empty() { - let mut base = format!("{CONTAINER_NAME_PREFIX}{id_suffix}"); - // The prefix is always < MAX_CONTAINER_NAME_LEN. Truncate the id - // suffix only if the sandbox id itself is pathologically long. + let mut base = format!("{CONTAINER_NAME_PREFIX}{workspace}---{id_suffix}"); if base.len() > MAX_CONTAINER_NAME_LEN { base.truncate(MAX_CONTAINER_NAME_LEN); } - return base; + return trim_container_name_tail(base); } - // Reserve space for the prefix and the `-` tail so the id - // suffix — which is what makes the name unique between sandboxes that - // share a human-readable prefix — is never truncated away. - let reserved = CONTAINER_NAME_PREFIX.len() + 1 + id_suffix.len(); + // Reserve space for fixed parts: prefix + workspace + "--" + "-" + id + let reserved = CONTAINER_NAME_PREFIX.len() + workspace.len() + 2 + 1 + id_suffix.len(); if reserved >= MAX_CONTAINER_NAME_LEN { - // Pathological sandbox id. Fall back to `` and truncate. - let mut base = format!("{CONTAINER_NAME_PREFIX}{id_suffix}"); + let mut base = format!("{CONTAINER_NAME_PREFIX}{workspace}---{id_suffix}"); base.truncate(MAX_CONTAINER_NAME_LEN); return trim_container_name_tail(base); } @@ -2936,7 +2949,7 @@ fn container_name_for_sandbox(sandbox: &DriverSandbox) -> String { } else { name }; - format!("{CONTAINER_NAME_PREFIX}{truncated_name}-{id_suffix}") + format!("{CONTAINER_NAME_PREFIX}{workspace}--{truncated_name}-{id_suffix}") } /// Docker container names may not end with `-`, `.`, or `_`. Truncation can diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 594308bce5..14525e7dc0 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -46,6 +46,7 @@ fn test_sandbox() -> DriverSandbox { sandbox_token: String::new(), }), status: None, + workspace: String::new(), } } @@ -1980,6 +1981,7 @@ fn container_name_preserves_id_suffix_for_long_names() { namespace: "default".to_string(), spec: None, status: None, + workspace: "default".to_string(), }; let second = DriverSandbox { id: "sbx-second-0987654321".to_string(), @@ -2005,15 +2007,19 @@ fn container_name_preserves_id_suffix_for_long_names() { } #[test] -fn container_name_empty_sandbox_name_uses_id_only() { +fn container_name_empty_sandbox_name_uses_workspace_and_id() { let sandbox = DriverSandbox { id: "sbx-abc".to_string(), name: String::new(), namespace: "default".to_string(), spec: None, status: None, + workspace: "default".to_string(), }; - assert_eq!(container_name_for_sandbox(&sandbox), "openshell-sbx-abc",); + assert_eq!( + container_name_for_sandbox(&sandbox), + "openshell-default---sbx-abc", + ); } #[test] diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 5315d30409..6ed8df179b 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -20,7 +20,8 @@ use kube::runtime::watcher::{self, Event}; use kube::{Client, Error as KubeError}; use openshell_core::driver_mounts; use openshell_core::driver_utils::{ - LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, SUPERVISOR_IMAGE_BINARY_PATH, + LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, + LABEL_SANDBOX_WORKSPACE, SUPERVISOR_IMAGE_BINARY_PATH, }; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::progress::{ @@ -684,9 +685,9 @@ impl KubernetesComputeDriver { Ok(()) } - pub async fn get_sandbox(&self, name: &str) -> Result, String> { + pub async fn get_sandbox(&self, sandbox_id: &str) -> Result, String> { info!( - sandbox_name = %name, + sandbox_id = %sandbox_id, namespace = %self.config.namespace, "Fetching sandbox from Kubernetes" ); @@ -694,15 +695,19 @@ impl KubernetesComputeDriver { let agent_sandbox_api = self .supported_agent_sandbox_api(self.client.clone()) .await?; - match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.get(name)).await { - Ok(Ok(obj)) => sandbox_from_object(&self.config.namespace, obj).map(Some), - Ok(Err(KubeError::Api(err))) if err.code == 404 => { - debug!(sandbox_name = %name, "Sandbox not found in Kubernetes"); - Ok(None) - } + let selector = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let lp = ListParams::default().labels(&selector); + match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.list(&lp)).await { + Ok(Ok(list)) => list.items.into_iter().next().map_or_else( + || { + debug!(sandbox_id = %sandbox_id, "Sandbox not found in Kubernetes"); + Ok(None) + }, + |obj| sandbox_from_object(&self.config.namespace, obj).map(|(_, s)| Some(s)), + ), Ok(Err(err)) => { warn!( - sandbox_name = %name, + sandbox_id = %sandbox_id, error = %err, "Failed to fetch sandbox from Kubernetes" ); @@ -710,7 +715,7 @@ impl KubernetesComputeDriver { } Err(_elapsed) => { warn!( - sandbox_name = %name, + sandbox_id = %sandbox_id, timeout_secs = KUBE_API_TIMEOUT.as_secs(), "Timed out fetching sandbox from Kubernetes" ); @@ -738,10 +743,10 @@ impl KubernetesComputeDriver { .await { Ok(Ok(list)) => { - let mut sandboxes = list + let mut sandboxes: Vec = list .items .into_iter() - .map(|obj| sandbox_from_object(&self.config.namespace, obj)) + .map(|obj| sandbox_from_object(&self.config.namespace, obj).map(|(_, s)| s)) .collect::, _>>()?; sandboxes.sort_by(|left, right| { left.name @@ -832,32 +837,25 @@ impl KubernetesComputeDriver { }; validate_sidecar_proxy_identity(¶ms)?; - let data = sandbox_to_k8s_spec(sandbox.spec.as_ref(), ¶ms) - .map_err(KubernetesDriverError::InvalidArgument)?; - let mut obj = DynamicObject::new(name, &agent_sandbox_api.resource); + let kube_name = kube_resource_name(&sandbox.workspace, name); + let mut obj = DynamicObject::new(&kube_name, &agent_sandbox_api.resource); // Copy only the SCC-related annotations onto the Sandbox CR for // traceability. Copying the full namespace annotation map exposes // unrelated cluster metadata and can fail with oversized annotations. - let scc_annotations: BTreeMap = [ + let mut annotations = sandbox_annotations(sandbox); + for key in [ crate::config::ANNOTATION_SCC_UID_RANGE, crate::config::ANNOTATION_SCC_SUPPLEMENTAL_GROUPS, - ] - .iter() - .filter_map(|key| { - ns_annotations - .get(*key) - .map(|v| ((*key).to_string(), v.clone())) - }) - .collect(); + ] { + if let Some(v) = ns_annotations.get(key) { + annotations.insert(key.to_string(), v.clone()); + } + } obj.metadata = ObjectMeta { - name: Some(name.to_string()), + name: Some(kube_name), namespace: Some(self.config.namespace.clone()), labels: Some(sandbox_labels(sandbox)), - annotations: if scc_annotations.is_empty() { - None - } else { - Some(scc_annotations) - }, + annotations: Some(annotations), ..Default::default() }; @@ -900,9 +898,9 @@ impl KubernetesComputeDriver { } } - pub async fn delete_sandbox(&self, name: &str) -> Result { + pub async fn delete_sandbox(&self, sandbox_id: &str) -> Result { info!( - sandbox_name = %name, + sandbox_id = %sandbox_id, namespace = %self.config.namespace, "Deleting sandbox from Kubernetes" ); @@ -910,23 +908,65 @@ impl KubernetesComputeDriver { let agent_sandbox_api = self .supported_agent_sandbox_api(self.client.clone()) .await?; + let selector = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let lp = ListParams::default().labels(&selector); + let kube_name = match tokio::time::timeout( + KUBE_API_TIMEOUT, + agent_sandbox_api.api.list(&lp), + ) + .await + { + Ok(Ok(list)) => { + if let Some(obj) = list.items.into_iter().next() { + match obj.metadata.name { + Some(name) => name, + None => return Ok(false), + } + } else { + debug!(sandbox_id = %sandbox_id, "Sandbox not found in Kubernetes (already deleted)"); + return Ok(false); + } + } + Ok(Err(err)) => { + warn!( + sandbox_id = %sandbox_id, + error = %err, + "Failed to list sandbox for deletion from Kubernetes" + ); + return Err(err.to_string()); + } + Err(_elapsed) => { + warn!( + sandbox_id = %sandbox_id, + timeout_secs = KUBE_API_TIMEOUT.as_secs(), + "Timed out listing sandbox for deletion from Kubernetes" + ); + return Err(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )); + } + }; + match tokio::time::timeout( KUBE_API_TIMEOUT, - agent_sandbox_api.api.delete(name, &DeleteParams::default()), + agent_sandbox_api + .api + .delete(&kube_name, &DeleteParams::default()), ) .await { Ok(Ok(_response)) => { - info!(sandbox_name = %name, "Sandbox deleted from Kubernetes"); + info!(sandbox_id = %sandbox_id, "Sandbox deleted from Kubernetes"); Ok(true) } Ok(Err(KubeError::Api(err))) if err.code == 404 => { - debug!(sandbox_name = %name, "Sandbox not found in Kubernetes (already deleted)"); + debug!(sandbox_id = %sandbox_id, "Sandbox not found in Kubernetes (already deleted)"); Ok(false) } Ok(Err(err)) => { warn!( - sandbox_name = %name, + sandbox_id = %sandbox_id, error = %err, "Failed to delete sandbox from Kubernetes" ); @@ -934,7 +974,7 @@ impl KubernetesComputeDriver { } Err(_elapsed) => { warn!( - sandbox_name = %name, + sandbox_id = %sandbox_id, timeout_secs = KUBE_API_TIMEOUT.as_secs(), "Timed out deleting sandbox from Kubernetes" ); @@ -946,13 +986,14 @@ impl KubernetesComputeDriver { } } - pub async fn sandbox_exists(&self, name: &str) -> Result { + pub async fn sandbox_exists(&self, sandbox_id: &str) -> Result { let agent_sandbox_api = self .supported_agent_sandbox_api(self.client.clone()) .await?; - match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.get(name)).await { - Ok(Ok(_)) => Ok(true), - Ok(Err(KubeError::Api(err))) if err.code == 404 => Ok(false), + let selector = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let lp = ListParams::default().labels(&selector); + match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.list(&lp)).await { + Ok(Ok(list)) => Ok(!list.items.is_empty()), Ok(Err(err)) => Err(err.to_string()), Err(_elapsed) => Err(format!( "timed out after {}s waiting for Kubernetes API", @@ -983,8 +1024,8 @@ impl KubernetesComputeDriver { result = sandbox_stream.try_next() => match result { Ok(Some(Event::Applied(obj))) => { match sandbox_from_object(&namespace, obj) { - Ok(sandbox) => { - update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &sandbox); + Ok((kube_name, sandbox)) => { + update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &kube_name, &sandbox); let event = WatchSandboxesEvent { payload: Some(watch_sandboxes_event::Payload::Sandbox( WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } @@ -1024,8 +1065,8 @@ impl KubernetesComputeDriver { Ok(Some(Event::Restarted(objs))) => { for obj in objs { match sandbox_from_object(&namespace, obj) { - Ok(sandbox) => { - update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &sandbox); + Ok((kube_name, sandbox)) => { + update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &kube_name, &sandbox); let event = WatchSandboxesEvent { payload: Some(watch_sandboxes_event::Payload::Sandbox( WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } @@ -1110,9 +1151,18 @@ fn validate_gpu_request( Ok(()) } +fn kube_resource_name(workspace: &str, name: &str) -> String { + format!("{workspace}--{name}") +} + fn sandbox_labels(sandbox: &Sandbox) -> BTreeMap { let mut labels = BTreeMap::new(); labels.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone()); + labels.insert(LABEL_SANDBOX_NAME.to_string(), sandbox.name.clone()); + labels.insert( + LABEL_SANDBOX_WORKSPACE.to_string(), + sandbox.workspace.clone(), + ); labels.insert( LABEL_MANAGED_BY.to_string(), LABEL_MANAGED_BY_VALUE.to_string(), @@ -1120,24 +1170,48 @@ fn sandbox_labels(sandbox: &Sandbox) -> BTreeMap { labels } +fn sandbox_annotations(sandbox: &Sandbox) -> BTreeMap { + let mut annotations = BTreeMap::new(); + annotations.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone()); + annotations.insert(LABEL_SANDBOX_NAME.to_string(), sandbox.name.clone()); + annotations.insert( + LABEL_SANDBOX_WORKSPACE.to_string(), + sandbox.workspace.clone(), + ); + annotations +} + fn sandbox_id_from_object(obj: &DynamicObject) -> Result { + if let Some(annotations) = obj.metadata.annotations.as_ref() + && let Some(id) = annotations.get(LABEL_SANDBOX_ID) + { + return Ok(id.clone()); + } if let Some(labels) = obj.metadata.labels.as_ref() && let Some(id) = labels.get(LABEL_SANDBOX_ID) { return Ok(id.clone()); } - - let name = obj.metadata.name.clone().unwrap_or_default(); - if let Some(id) = name.strip_prefix("sandbox-") { - return Ok(id.to_string()); - } - Err("sandbox id not found on object".to_string()) } -fn sandbox_from_object(namespace: &str, obj: DynamicObject) -> Result { +fn annotation_or_label(obj: &DynamicObject, key: &str) -> Option { + obj.metadata + .annotations + .as_ref() + .and_then(|a| a.get(key)) + .or_else(|| obj.metadata.labels.as_ref().and_then(|l| l.get(key))) + .cloned() +} + +/// Returns `(kube_resource_name, DriverSandbox)`. +fn sandbox_from_object(namespace: &str, obj: DynamicObject) -> Result<(String, Sandbox), String> { let id = sandbox_id_from_object(&obj)?; - let name = obj.metadata.name.clone().unwrap_or_default(); + let kube_name = obj.metadata.name.clone().unwrap_or_default(); + let name = + annotation_or_label(&obj, LABEL_SANDBOX_NAME).ok_or("sandbox name not found on object")?; + let workspace = annotation_or_label(&obj, LABEL_SANDBOX_WORKSPACE) + .ok_or("sandbox workspace not found on object")?; let namespace = obj .metadata .namespace @@ -1145,22 +1219,27 @@ fn sandbox_from_object(namespace: &str, obj: DynamicObject) -> Result, agent_pod_to_id: &mut std::collections::HashMap, + kube_name: &str, sandbox: &Sandbox, ) { - if !sandbox.name.is_empty() { - sandbox_name_to_id.insert(sandbox.name.clone(), sandbox.id.clone()); + if !kube_name.is_empty() { + sandbox_name_to_id.insert(kube_name.to_string(), sandbox.id.clone()); } if let Some(status) = sandbox.status.as_ref() && !status.instance_id.is_empty() @@ -5625,4 +5704,143 @@ mod tests { let storage = &vct[0]["spec"]["resources"]["requests"]["storage"]; assert_eq!(storage, DEFAULT_WORKSPACE_STORAGE_SIZE); } + + #[test] + fn kube_resource_name_qualifies_with_workspace() { + assert_eq!(kube_resource_name("alpha", "work"), "alpha--work"); + assert_eq!( + kube_resource_name("default", "my-sandbox"), + "default--my-sandbox" + ); + } + + #[test] + fn kube_resource_name_different_workspaces_produce_different_names() { + let alpha = kube_resource_name("alpha", "work"); + let beta = kube_resource_name("beta", "work"); + assert_ne!(alpha, beta); + } + + #[test] + fn sandbox_from_object_reads_annotations() { + let obj = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("alpha--work".to_string()), + namespace: Some("default".to_string()), + annotations: Some(BTreeMap::from([ + (LABEL_SANDBOX_ID.to_string(), "uuid-123".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "work".to_string()), + (LABEL_SANDBOX_WORKSPACE.to_string(), "alpha".to_string()), + ])), + labels: Some(BTreeMap::from([ + (LABEL_SANDBOX_ID.to_string(), "uuid-123".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "work".to_string()), + (LABEL_SANDBOX_WORKSPACE.to_string(), "alpha".to_string()), + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + ])), + ..Default::default() + }, + data: serde_json::json!({}), + }; + + let (kube_name, sandbox) = sandbox_from_object("default", obj).unwrap(); + assert_eq!(kube_name, "alpha--work"); + assert_eq!(sandbox.name, "work"); + assert_eq!(sandbox.workspace, "alpha"); + assert_eq!(sandbox.id, "uuid-123"); + } + + #[test] + fn sandbox_from_object_falls_back_to_labels() { + let obj = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("alpha--work".to_string()), + namespace: Some("default".to_string()), + annotations: None, + labels: Some(BTreeMap::from([ + (LABEL_SANDBOX_ID.to_string(), "uuid-456".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "work".to_string()), + (LABEL_SANDBOX_WORKSPACE.to_string(), "alpha".to_string()), + ])), + ..Default::default() + }, + data: serde_json::json!({}), + }; + + let (_, sandbox) = sandbox_from_object("default", obj).unwrap(); + assert_eq!(sandbox.name, "work"); + assert_eq!(sandbox.workspace, "alpha"); + assert_eq!(sandbox.id, "uuid-456"); + } + + #[test] + fn sandbox_from_object_errors_without_workspace() { + let obj = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("work".to_string()), + namespace: Some("default".to_string()), + labels: Some(BTreeMap::from([ + (LABEL_SANDBOX_ID.to_string(), "uuid-789".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "work".to_string()), + ])), + ..Default::default() + }, + data: serde_json::json!({}), + }; + + let result = sandbox_from_object("default", obj); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("workspace")); + } + + #[test] + fn sandbox_labels_includes_workspace_and_name() { + let sandbox = Sandbox { + id: "uuid-1".to_string(), + name: "work".to_string(), + workspace: "alpha".to_string(), + ..Default::default() + }; + let labels = sandbox_labels(&sandbox); + assert_eq!(labels.get(LABEL_SANDBOX_ID).unwrap(), "uuid-1"); + assert_eq!(labels.get(LABEL_SANDBOX_NAME).unwrap(), "work"); + assert_eq!(labels.get(LABEL_SANDBOX_WORKSPACE).unwrap(), "alpha"); + assert_eq!( + labels.get(LABEL_MANAGED_BY).unwrap(), + LABEL_MANAGED_BY_VALUE + ); + } + + #[test] + fn sandbox_annotations_stores_authoritative_values() { + let sandbox = Sandbox { + id: "uuid-2".to_string(), + name: "dev".to_string(), + workspace: "beta".to_string(), + ..Default::default() + }; + let annotations = sandbox_annotations(&sandbox); + assert_eq!(annotations.get(LABEL_SANDBOX_ID).unwrap(), "uuid-2"); + assert_eq!(annotations.get(LABEL_SANDBOX_NAME).unwrap(), "dev"); + assert_eq!(annotations.get(LABEL_SANDBOX_WORKSPACE).unwrap(), "beta"); + } + + #[test] + fn sandbox_id_from_object_errors_without_label() { + let obj = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("some-name".to_string()), + ..Default::default() + }, + data: serde_json::json!({}), + }; + assert!(sandbox_id_from_object(&obj).is_err()); + } } diff --git a/crates/openshell-driver-kubernetes/src/grpc.rs b/crates/openshell-driver-kubernetes/src/grpc.rs index eced74f57b..fccfa9464b 100644 --- a/crates/openshell-driver-kubernetes/src/grpc.rs +++ b/crates/openshell-driver-kubernetes/src/grpc.rs @@ -57,23 +57,17 @@ impl ComputeDriver for ComputeDriverService { request: Request, ) -> Result, Status> { let request = request.into_inner(); - if request.sandbox_name.is_empty() { - return Err(Status::invalid_argument("sandbox_name is required")); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); } let sandbox = self .driver - .get_sandbox(&request.sandbox_name) + .get_sandbox(&request.sandbox_id) .await .map_err(Status::internal)? .ok_or_else(|| Status::not_found("sandbox not found"))?; - if !request.sandbox_id.is_empty() && request.sandbox_id != sandbox.id { - return Err(Status::failed_precondition( - "sandbox_id did not match the fetched sandbox", - )); - } - Ok(Response::new(GetSandboxResponse { sandbox: Some(sandbox), })) @@ -120,9 +114,12 @@ impl ComputeDriver for ComputeDriverService { request: Request, ) -> Result, Status> { let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } let deleted = self .driver - .delete_sandbox(&request.sandbox_name) + .delete_sandbox(&request.sandbox_id) .await .map_err(Status::internal)?; Ok(Response::new(DeleteSandboxResponse { deleted })) diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index e4a0b79189..0ee803af3d 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -42,13 +42,15 @@ pub const LABEL_SANDBOX_ID: &str = "openshell.sandbox-id"; pub const LABEL_SANDBOX_NAME: &str = "openshell.sandbox-name"; /// Label key for the sandbox namespace. pub const LABEL_SANDBOX_NAMESPACE: &str = "openshell.sandbox-namespace"; +/// Label key for the sandbox workspace. +pub const LABEL_SANDBOX_WORKSPACE: &str = "openshell.sandbox-workspace"; /// Label applied to all managed containers. pub const LABEL_MANAGED: &str = "openshell.managed"; /// Label filter string for list/event queries. pub const LABEL_MANAGED_FILTER: &str = "openshell.managed=true"; /// Container name prefix to avoid collisions with user containers. -const CONTAINER_PREFIX: &str = "openshell-sandbox-"; +const CONTAINER_PREFIX: &str = "openshell-"; /// Volume name prefix. const VOLUME_PREFIX: &str = "openshell-sandbox-"; @@ -144,10 +146,12 @@ fn default_true() -> bool { true } -/// Build a Podman container name from the sandbox name. +/// Build a Podman container name from the sandbox workspace, name, and ID. +/// +/// Format: `openshell-{workspace}--{name}-{id}` #[must_use] -pub fn container_name(sandbox_name: &str) -> String { - format!("{CONTAINER_PREFIX}{sandbox_name}") +pub fn container_name(workspace: &str, name: &str, id: &str) -> String { + format!("{CONTAINER_PREFIX}{workspace}--{name}-{id}") } /// Build the workspace volume name from the sandbox ID. @@ -464,6 +468,7 @@ fn build_labels(sandbox: &DriverSandbox) -> BTreeMap { labels.insert(LABEL_SANDBOX_ID.into(), sandbox.id.clone()); labels.insert(LABEL_SANDBOX_NAME.into(), sandbox.name.clone()); labels.insert(LABEL_SANDBOX_NAMESPACE.into(), sandbox.namespace.clone()); + labels.insert(LABEL_SANDBOX_WORKSPACE.into(), sandbox.workspace.clone()); labels.insert(LABEL_MANAGED.into(), "true".into()); labels @@ -832,7 +837,7 @@ pub fn build_container_spec_with_token_and_gpu_devices( gpu_device_ids: Option<&[String]>, ) -> Result { let image = resolve_image(sandbox, config); - let name = container_name(&sandbox.name); + let name = container_name(&sandbox.workspace, &sandbox.name, &sandbox.id); let vol = volume_name(&sandbox.id); let env = build_env(sandbox, config, image); @@ -1276,8 +1281,11 @@ mod tests { } #[test] - fn container_name_is_prefixed() { - assert_eq!(container_name("my-sandbox"), "openshell-sandbox-my-sandbox"); + fn container_name_is_workspace_qualified() { + assert_eq!( + container_name("default", "my-sandbox", "abc-123"), + "openshell-default--my-sandbox-abc-123" + ); } #[test] @@ -1772,6 +1780,7 @@ mod tests { namespace: String::new(), spec: None, status: None, + workspace: String::new(), } } diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index cf639fa265..b606443c98 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -22,7 +22,7 @@ use openshell_core::proto::compute::v1::{ use std::path::Path; use std::sync::Arc; use std::time::Duration; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; impl From for ComputeDriverError { fn from(value: PodmanApiError) -> Self { @@ -62,12 +62,12 @@ struct ValidatedPodmanSandbox<'a> { gpu_requirements: Option<&'a GpuResourceRequirements>, } -/// Construct and validate a container name from a sandbox name. +/// Construct and validate a container name from a sandbox. /// -/// Combines the prefix with the sandbox name and validates the result -/// against Podman's naming rules before any resources are created. -fn validated_container_name(sandbox_name: &str) -> Result { - let name = container::container_name(sandbox_name); +/// Combines the prefix with workspace, name, and ID, then validates the +/// result against Podman's naming rules before any resources are created. +fn validated_container_name(sandbox: &DriverSandbox) -> Result { + let name = container::container_name(&sandbox.workspace, &sandbox.name, &sandbox.id); crate::client::validate_name(&name) .map_err(|e| ComputeDriverError::Precondition(e.to_string()))?; Ok(name) @@ -451,7 +451,7 @@ impl PodmanComputeDriver { // Validate the composed container name early, before creating any // resources (volume), so we don't leave orphans when the name is // invalid. - let name = validated_container_name(&sandbox.name)?; + let name = validated_container_name(sandbox)?; let validated = self.validated_sandbox_create(sandbox).await?; let vol_name = container::volume_name(&sandbox.id); @@ -593,10 +593,29 @@ impl PodmanComputeDriver { Ok(()) } + /// Find the Podman container name for a sandbox by its ID using label lookup. + async fn find_container_name_by_id( + &self, + sandbox_id: &str, + ) -> Result, ComputeDriverError> { + let filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let entries = self + .client + .list_containers(&filter) + .await + .map_err(ComputeDriverError::from)?; + Ok(entries.first().and_then(|e| e.names.first().cloned())) + } + /// Stop a sandbox container without deleting it. - pub async fn stop_sandbox(&self, sandbox_name: &str) -> Result<(), ComputeDriverError> { - let name = validated_container_name(sandbox_name)?; - info!(sandbox_name = %sandbox_name, container = %name, "Stopping sandbox container"); + pub async fn stop_sandbox(&self, sandbox_id: &str) -> Result<(), ComputeDriverError> { + let name = self + .find_container_name_by_id(sandbox_id) + .await? + .ok_or_else(|| { + ComputeDriverError::Precondition("sandbox container not found".into()) + })?; + info!(sandbox_id = %sandbox_id, container = %name, "Stopping sandbox container"); self.client .stop_container(&name, self.config.stop_timeout_secs) @@ -605,52 +624,24 @@ impl PodmanComputeDriver { } /// Delete a sandbox container and its workspace volume. - pub async fn delete_sandbox( - &self, - sandbox_id: &str, - sandbox_name: &str, - ) -> Result { + pub async fn delete_sandbox(&self, sandbox_id: &str) -> Result { if sandbox_id.is_empty() { return Err(ComputeDriverError::Precondition( "sandbox id is required".into(), )); } - let name = validated_container_name(sandbox_name)?; - info!( - sandbox_id = %sandbox_id, - sandbox_name = %sandbox_name, - container = %name, - "Deleting sandbox container" - ); - // Use the request's stable sandbox ID as the source of truth for - // cleanup. Inspect is only used as a best-effort cross-check so - // cleanup still works if the container is already gone or mislabeled. - match self.client.inspect_container(&name).await { - Ok(inspect) => match inspect.config.labels.get(LABEL_SANDBOX_ID) { - Some(label_id) if label_id != sandbox_id => { - warn!( - sandbox_id = %sandbox_id, - sandbox_name = %sandbox_name, - container = %name, - label_sandbox_id = %label_id, - "Container label sandbox ID did not match delete request; cleaning up using request sandbox_id" - ); - } - None => { - warn!( - sandbox_id = %sandbox_id, - sandbox_name = %sandbox_name, - container = %name, - "Container missing '{}' label; cleaning up using request sandbox_id", - LABEL_SANDBOX_ID, - ); - } - Some(_) => {} - }, - Err(PodmanApiError::NotFound(_)) => {} - Err(e) => return Err(ComputeDriverError::from(e)), - } + let Some(name) = self.find_container_name_by_id(sandbox_id).await? else { + debug!(sandbox_id = %sandbox_id, "Sandbox container not found (already deleted)"); + let vol = container::volume_name(sandbox_id); + if let Err(e) = self.client.remove_volume(&vol).await { + warn!(sandbox_id = %sandbox_id, volume = %vol, error = %e, "Failed to remove workspace volume"); + } + cleanup_sandbox_token_secret(&self.client, &container::token_secret_name(sandbox_id)) + .await; + return Ok(false); + }; + info!(sandbox_id = %sandbox_id, container = %name, "Deleting sandbox container"); // Stop (best-effort). let _ = self @@ -659,7 +650,7 @@ impl PodmanComputeDriver { .await; // Remove container. If NotFound, the container was removed between - // inspect and here (TOCTOU race); proceed with volume cleanup + // list and here (TOCTOU race); proceed with volume cleanup // since the workspace volume is idempotent to remove. let container_existed = match self.client.remove_container(&name).await { Ok(()) => true, @@ -672,7 +663,6 @@ impl PodmanComputeDriver { if let Err(e) = self.client.remove_volume(&vol).await { warn!( sandbox_id = %sandbox_id, - sandbox_name = %sandbox_name, volume = %vol, error = %e, "Failed to remove workspace volume" @@ -684,25 +674,40 @@ impl PodmanComputeDriver { } /// Check whether a sandbox container exists. - pub async fn sandbox_exists(&self, sandbox_name: &str) -> Result { - let name = container::container_name(sandbox_name); - match self.client.inspect_container(&name).await { - Ok(_) => Ok(true), - Err(PodmanApiError::NotFound(_)) => Ok(false), - Err(e) => Err(ComputeDriverError::from(e)), - } + pub async fn sandbox_exists(&self, sandbox_id: &str) -> Result { + let filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let entries = self + .client + .list_containers(&filter) + .await + .map_err(ComputeDriverError::from)?; + Ok(!entries.is_empty()) } - /// Fetch a single sandbox by name. + /// Fetch a single sandbox by ID. pub async fn get_sandbox( &self, - sandbox_name: &str, + sandbox_id: &str, ) -> Result, ComputeDriverError> { - let name = container::container_name(sandbox_name); - match self.client.inspect_container(&name).await { - Ok(inspect) => Ok(driver_sandbox_from_inspect(&inspect)), - Err(PodmanApiError::NotFound(_)) => Ok(None), - Err(e) => Err(ComputeDriverError::from(e)), + let filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let entries = self + .client + .list_containers(&filter) + .await + .map_err(ComputeDriverError::from)?; + let Some(entry) = entries.first() else { + return Ok(None); + }; + if entry.state == "running" { + Ok(self + .client + .inspect_container(&entry.id) + .await + .ok() + .and_then(|inspect| driver_sandbox_from_inspect(&inspect)) + .or_else(|| driver_sandbox_from_list_entry(entry))) + } else { + Ok(driver_sandbox_from_list_entry(entry)) } } @@ -1312,6 +1317,7 @@ mod tests { ..Default::default() }), status: None, + workspace: String::new(), } } @@ -1470,24 +1476,22 @@ mod tests { } #[tokio::test] - async fn delete_sandbox_cleans_up_with_request_id_when_container_is_already_gone() { + async fn delete_sandbox_cleans_up_volume_when_container_is_already_gone() { let sandbox_id = "sandbox-123"; - let sandbox_name = "demo"; - let container_name = container::container_name(sandbox_name); let volume_name = container::volume_name(sandbox_id); let (socket_path, request_log, handle) = spawn_podman_stub( "delete-not-found", vec![ - StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), - StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), - StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), + // list_containers returns empty (container already gone) + StubResponse::new(StatusCode::OK, "[]"), + // remove_volume StubResponse::new(StatusCode::NO_CONTENT, ""), ], ); let driver = test_driver(socket_path.clone()); let deleted = driver - .delete_sandbox(sandbox_id, sandbox_name) + .delete_sandbox(sandbox_id) .await .expect("delete should succeed"); @@ -1497,67 +1501,48 @@ mod tests { .lock() .expect("request log lock should not be poisoned") .clone(); + assert!(requests[0].contains("/libpod/containers/json")); assert_eq!( - requests, - vec![ - format!( - "GET {}", - api_path(&format!("/libpod/containers/{container_name}/json")) - ), - format!( - "POST {}", - api_path(&format!( - "/libpod/containers/{container_name}/stop?timeout=10" - )) - ), - format!( - "DELETE {}", - api_path(&format!( - "/libpod/containers/{container_name}?force=true&v=true" - )) - ), - format!( - "DELETE {}", - api_path(&format!("/libpod/volumes/{volume_name}")) - ), - ] + requests[1], + format!( + "DELETE {}", + api_path(&format!("/libpod/volumes/{volume_name}")) + ) ); let _ = fs::remove_file(socket_path); } #[tokio::test] - async fn delete_sandbox_uses_request_id_when_container_label_disagrees() { + async fn delete_sandbox_finds_container_by_label_and_removes() { let sandbox_id = "sandbox-request-id"; - let sandbox_name = "demo"; - let container_name = container::container_name(sandbox_name); + let container_name = "openshell-default--demo-sandbox-request-id"; let volume_name = container::volume_name(sandbox_id); - let inspect_body = serde_json::json!({ + let list_body = serde_json::json!([{ "Id": "container-id", - "Name": format!("/{container_name}"), - "State": { - "Status": "running", - "Running": true - }, - "Config": { - "Labels": { - LABEL_SANDBOX_ID: "sandbox-label-id" - } + "Names": [container_name], + "State": "running", + "Labels": { + LABEL_SANDBOX_ID: sandbox_id } - }) + }]) .to_string(); let (socket_path, request_log, handle) = spawn_podman_stub( - "delete-mismatch", + "delete-label-lookup", vec![ - StubResponse::new(StatusCode::OK, inspect_body), + // list_containers by label + StubResponse::new(StatusCode::OK, list_body), + // stop_container StubResponse::new(StatusCode::NO_CONTENT, ""), + // remove_container StubResponse::new(StatusCode::NO_CONTENT, ""), + // remove_volume StubResponse::new(StatusCode::NO_CONTENT, ""), ], ); let driver = test_driver(socket_path.clone()); let deleted = driver - .delete_sandbox(sandbox_id, sandbox_name) + .delete_sandbox(sandbox_id) .await .expect("delete should succeed"); @@ -1567,12 +1552,15 @@ mod tests { .lock() .expect("request log lock should not be poisoned") .clone(); + assert!(requests[0].contains("/libpod/containers/json")); + assert!(requests[1].contains(&format!("/libpod/containers/{container_name}/stop"))); + assert!(requests[2].contains(&format!("/libpod/containers/{container_name}"))); assert_eq!( - requests[3..], - [format!( + requests[3], + format!( "DELETE {}", api_path(&format!("/libpod/volumes/{volume_name}")) - )] + ) ); let _ = fs::remove_file(socket_path); } diff --git a/crates/openshell-driver-podman/src/grpc.rs b/crates/openshell-driver-podman/src/grpc.rs index 4840ee2810..1f7cce89a4 100644 --- a/crates/openshell-driver-podman/src/grpc.rs +++ b/crates/openshell-driver-podman/src/grpc.rs @@ -60,23 +60,17 @@ impl ComputeDriver for ComputeDriverService { request: Request, ) -> Result, Status> { let request = request.into_inner(); - if request.sandbox_name.is_empty() { - return Err(Status::invalid_argument("sandbox_name is required")); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); } let sandbox = self .driver - .get_sandbox(&request.sandbox_name) + .get_sandbox(&request.sandbox_id) .await .map_err(Status::from)? .ok_or_else(|| Status::not_found("sandbox not found"))?; - if !request.sandbox_id.is_empty() && request.sandbox_id != sandbox.id { - return Err(Status::failed_precondition( - "sandbox_id did not match the fetched sandbox", - )); - } - Ok(Response::new(GetSandboxResponse { sandbox: Some(sandbox), })) @@ -110,11 +104,11 @@ impl ComputeDriver for ComputeDriverService { request: Request, ) -> Result, Status> { let request = request.into_inner(); - if request.sandbox_name.is_empty() { - return Err(Status::invalid_argument("sandbox_name is required")); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); } self.driver - .stop_sandbox(&request.sandbox_name) + .stop_sandbox(&request.sandbox_id) .await .map_err(Status::from)?; Ok(Response::new(StopSandboxResponse {})) @@ -128,12 +122,9 @@ impl ComputeDriver for ComputeDriverService { if request.sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); } - if request.sandbox_name.is_empty() { - return Err(Status::invalid_argument("sandbox_name is required")); - } let deleted = self .driver - .delete_sandbox(&request.sandbox_id, &request.sandbox_name) + .delete_sandbox(&request.sandbox_id) .await .map_err(Status::from)?; Ok(Response::new(DeleteSandboxResponse { deleted })) @@ -190,24 +181,6 @@ mod tests { format!("/v5.0.0{path}") } - #[tokio::test] - async fn delete_sandbox_rejects_missing_sandbox_name() { - let service = test_service(unique_socket_path("missing-name")); - - let err = ComputeDriver::delete_sandbox( - &service, - Request::new(DeleteSandboxRequest { - sandbox_id: "sandbox-123".to_string(), - sandbox_name: String::new(), - }), - ) - .await - .expect_err("missing sandbox_name should fail"); - - assert_eq!(err.code(), tonic::Code::InvalidArgument); - assert_eq!(err.message(), "sandbox_name is required"); - } - #[tokio::test] async fn delete_sandbox_rejects_missing_sandbox_id() { let service = test_service(unique_socket_path("missing-id")); @@ -229,15 +202,13 @@ mod tests { #[tokio::test] async fn delete_sandbox_forwards_request_sandbox_id_to_driver_cleanup() { let sandbox_id = "sandbox-abc"; - let sandbox_name = "demo"; - let container_name = container::container_name(sandbox_name); let volume_name = container::volume_name(sandbox_id); let (socket_path, request_log, handle) = spawn_podman_stub( "forward-id", vec![ - StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), - StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), - StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), + // list_containers returns empty (container already gone) + StubResponse::new(StatusCode::OK, "[]"), + // remove_volume StubResponse::new(StatusCode::NO_CONTENT, ""), ], ); @@ -247,7 +218,7 @@ mod tests { &service, Request::new(DeleteSandboxRequest { sandbox_id: sandbox_id.to_string(), - sandbox_name: sandbox_name.to_string(), + sandbox_name: "demo".to_string(), }), ) .await @@ -263,30 +234,13 @@ mod tests { .lock() .expect("request log lock should not be poisoned") .clone(); + assert!(requests[0].contains("/libpod/containers/json")); assert_eq!( - requests, - vec![ - format!( - "GET {}", - api_path(&format!("/libpod/containers/{container_name}/json")) - ), - format!( - "POST {}", - api_path(&format!( - "/libpod/containers/{container_name}/stop?timeout=10" - )) - ), - format!( - "DELETE {}", - api_path(&format!( - "/libpod/containers/{container_name}?force=true&v=true" - )) - ), - format!( - "DELETE {}", - api_path(&format!("/libpod/volumes/{volume_name}")) - ), - ] + requests[1], + format!( + "DELETE {}", + api_path(&format!("/libpod/volumes/{volume_name}")) + ) ); let _ = std::fs::remove_file(socket_path); } diff --git a/crates/openshell-driver-podman/src/watcher.rs b/crates/openshell-driver-podman/src/watcher.rs index 54606ea442..6babdaee04 100644 --- a/crates/openshell-driver-podman/src/watcher.rs +++ b/crates/openshell-driver-podman/src/watcher.rs @@ -7,7 +7,9 @@ use crate::client::{ ContainerInspect, ContainerListEntry, ContainerState, HealthState, PodmanApiError, PodmanClient, PodmanEvent, }; -use crate::container::{LABEL_MANAGED_FILTER, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, short_id}; +use crate::container::{ + LABEL_MANAGED_FILTER, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_WORKSPACE, short_id, +}; use futures::Stream; use openshell_core::ComputeDriverError; use openshell_core::proto::compute::v1::{ @@ -215,9 +217,16 @@ async fn map_podman_event( .get(LABEL_SANDBOX_NAME) .cloned() .unwrap_or_default(); + let workspace = event + .actor + .attributes + .get(LABEL_SANDBOX_WORKSPACE) + .cloned() + .unwrap_or_default(); Some(sandbox_event(build_driver_sandbox( sandbox_id.clone(), sandbox_name, + workspace, String::new(), short_id(container_id), DriverCondition { @@ -247,6 +256,7 @@ async fn map_podman_event( fn build_driver_sandbox( sandbox_id: String, sandbox_name: String, + workspace: String, instance_name: String, instance_id: String, condition: DriverCondition, @@ -265,6 +275,7 @@ fn build_driver_sandbox( conditions: vec![condition], deleting, }), + workspace, } } @@ -277,6 +288,12 @@ pub fn driver_sandbox_from_inspect(inspect: &ContainerInspect) -> Option Option Option ( @@ -316,6 +339,7 @@ pub fn driver_sandbox_from_list_entry(entry: &ContainerListEntry) -> Option Result { + pub async fn delete_sandbox(&self, workspace: &str, name: &str) -> Result { let _guard = self.sync_lock.lock().await; // Resolve sandbox ID from name let sandbox = self .store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; @@ -718,6 +720,7 @@ impl ComputeRuntime { Sandbox::object_type(), &id, &name, + sandbox.object_workspace(), &sandbox.encode_to_vec(), labels_json.as_deref(), WriteCondition::MatchResourceVersion(expected_resource_version), @@ -774,7 +777,7 @@ impl ComputeRuntime { let records = self .store - .list(Sandbox::object_type(), 1000, 0) + .list_by_type(Sandbox::object_type(), 1000, 0) .await .map_err(|e| e.to_string())?; @@ -1074,7 +1077,7 @@ impl ComputeRuntime { let records = self .store - .list(Sandbox::object_type(), 500, 0) + .list_by_type(Sandbox::object_type(), 500, 0) .await .map_err(|e| e.to_string())?; @@ -1172,6 +1175,11 @@ impl ComputeRuntime { if supervisor_promoted { ensure_supervisor_ready_status(&mut status, &sandbox_name); } + let workspace = if incoming.workspace.is_empty() { + "default".to_string() + } else { + incoming.workspace.clone() + }; let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: incoming.id.clone(), @@ -1179,7 +1187,7 @@ impl ComputeRuntime { created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace, }), spec: None, status, @@ -1191,6 +1199,7 @@ impl ComputeRuntime { Sandbox::object_type(), &incoming.id, sandbox.object_name(), + sandbox.object_workspace(), &sandbox.encode_to_vec(), None, WriteCondition::MustCreate, @@ -1396,7 +1405,7 @@ impl ComputeRuntime { if let Err(e) = self .store - .delete_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, sandbox.object_name()) + .delete_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, "", sandbox.object_name()) .await { warn!( @@ -1409,7 +1418,11 @@ impl ComputeRuntime { } async fn cleanup_sandbox_ssh_sessions(&self, sandbox_id: &str) { - if let Ok(records) = self.store.list(SshSession::object_type(), 1000, 0).await { + if let Ok(records) = self + .store + .list_by_type(SshSession::object_type(), 1000, 0) + .await + { for record in records { if let Ok(session) = SshSession::decode(record.payload.as_slice()) && session.sandbox_id == sandbox_id @@ -1582,6 +1595,7 @@ fn driver_sandbox_from_public( .map(|spec| driver_sandbox_spec_from_public(spec, driver_name)) .transpose()?, status: sandbox.status.as_ref().map(driver_status_from_public), + workspace: sandbox.object_workspace().to_string(), }) } @@ -2415,7 +2429,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), ..Default::default() }; @@ -2431,7 +2445,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), sandbox_id: sandbox_id.to_string(), token: format!("token-{id}"), @@ -2845,6 +2859,7 @@ mod tests { }], deleting: false, }), + workspace: "default".to_string(), }) .await .unwrap(); @@ -2887,6 +2902,7 @@ mod tests { namespace: "default".to_string(), spec: None, status: None, + workspace: "default".to_string(), }) .await .unwrap(); @@ -2935,6 +2951,7 @@ mod tests { "Starting", "VM is starting", ))), + workspace: "default".to_string(), }) .await .unwrap(); @@ -3054,6 +3071,7 @@ mod tests { }], deleting: false, }), + workspace: "default".to_string(), }], current_sandboxes: vec![DriverSandbox { id: "sb-1".to_string(), @@ -3074,6 +3092,7 @@ mod tests { }], deleting: false, }), + workspace: "default".to_string(), }], })) .await; @@ -3122,6 +3141,7 @@ mod tests { "DependenciesNotReady", "Pod exists with phase: Pending; Service Exists", ))), + workspace: "default".to_string(), }], current_sandboxes: vec![DriverSandbox { id: "sb-1".to_string(), @@ -3135,6 +3155,7 @@ mod tests { message: "Pod is Ready".to_string(), last_transition_time: String::new(), })), + workspace: "default".to_string(), }], })) .await; @@ -3176,6 +3197,7 @@ mod tests { }], deleting: false, }), + workspace: "default".to_string(), }], ..Default::default() })) @@ -3214,6 +3236,7 @@ mod tests { SANDBOX_SETTINGS_OBJECT_TYPE, "settings-sb-1", sandbox.object_name(), + "", br#"{"revision":1,"settings":{}}"#, None, ) @@ -3246,7 +3269,7 @@ mod tests { assert!( runtime .store - .get_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, sandbox.object_name()) + .get_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, "", sandbox.object_name()) .await .unwrap() .is_none() @@ -3521,7 +3544,12 @@ mod tests { runtime.validate_sandbox_create(&sandbox).await.unwrap(); runtime.create_sandbox(sandbox, None).await.unwrap(); - assert!(runtime.delete_sandbox("uds-sandbox").await.unwrap()); + assert!( + runtime + .delete_sandbox("default", "uds-sandbox") + .await + .unwrap() + ); let calls = driver.calls(); assert_eq!(calls.len(), 4, "unexpected calls: {calls:?}"); @@ -3577,7 +3605,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }); let created = runtime.create_sandbox(sandbox, None).await.unwrap(); @@ -3653,7 +3681,7 @@ mod tests { .expect("should have one successful creation"); let retrieved = runtime .store - .get_message_by_name::("test-concurrent") + .get_message_by_name::("default", "test-concurrent") .await .unwrap(); assert!( @@ -3666,4 +3694,75 @@ mod tests { "retrieved sandbox should match created sandbox" ); } + + #[test] + fn driver_sandbox_from_public_populates_workspace() { + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-1".to_string(), + name: "work".to_string(), + workspace: "alpha".to_string(), + ..Default::default() + }), + ..Default::default() + }; + let driver_sb = driver_sandbox_from_public(&sandbox, "kubernetes").unwrap(); + assert_eq!(driver_sb.workspace, "alpha"); + assert_eq!(driver_sb.name, "work"); + assert_eq!(driver_sb.id, "sb-1"); + } + + #[tokio::test] + async fn apply_sandbox_update_uses_workspace_from_driver() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + runtime + .apply_sandbox_update(DriverSandbox { + id: "sb-w1".to_string(), + name: "work".to_string(), + namespace: "default".to_string(), + spec: None, + status: Some(make_driver_status(make_driver_condition( + "DependenciesNotReady", + "Provisioning", + ))), + workspace: "team-ml".to_string(), + }) + .await + .unwrap(); + + let stored = runtime + .store + .get_message::("sb-w1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.object_workspace(), "team-ml"); + } + + #[tokio::test] + async fn apply_sandbox_update_defaults_workspace_when_empty() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + runtime + .apply_sandbox_update(DriverSandbox { + id: "sb-w2".to_string(), + name: "work".to_string(), + namespace: "default".to_string(), + spec: None, + status: Some(make_driver_status(make_driver_condition( + "DependenciesNotReady", + "Provisioning", + ))), + workspace: String::new(), + }) + .await + .unwrap(); + + let stored = runtime + .store + .get_message::("sb-w2") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.object_workspace(), "default"); + } } diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index 944b9b3ed7..cd8edf63a0 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -200,7 +200,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::default(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 36f3e64cbb..efd7fe602d 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -9,41 +9,45 @@ pub mod provider; mod sandbox; mod service; mod validation; +pub mod workspace; use openshell_core::proto::{ - ApproveAllDraftChunksRequest, ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, - ApproveDraftChunkResponse, AttachSandboxProviderRequest, AttachSandboxProviderResponse, - ClearDraftChunksRequest, ClearDraftChunksResponse, ComputeDriverCapabilities, - ComputeDriverInfo, ConfigureProviderRefreshRequest, ConfigureProviderRefreshResponse, + AddWorkspaceMemberRequest, AddWorkspaceMemberResponse, ApproveAllDraftChunksRequest, + ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, ApproveDraftChunkResponse, + AttachSandboxProviderRequest, AttachSandboxProviderResponse, ClearDraftChunksRequest, + ClearDraftChunksResponse, ConfigureProviderRefreshRequest, ConfigureProviderRefreshResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, - DeleteProviderProfileRequest, DeleteProviderProfileResponse, DeleteProviderRefreshRequest, - DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, - DeleteSandboxRequest, DeleteSandboxResponse, DeleteServiceRequest, DeleteServiceResponse, + CreateWorkspaceRequest, CreateWorkspaceResponse, DeleteProviderProfileRequest, + DeleteProviderProfileResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, + DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, + DeleteServiceRequest, DeleteServiceResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, 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, + GetGatewayConfigResponse, GetProviderProfileRequest, GetProviderRefreshStatusRequest, + GetProviderRefreshStatusResponse, GetProviderRequest, GetSandboxConfigRequest, + GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, + GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, - GetServiceRequest, HealthRequest, HealthResponse, ImportProviderProfilesRequest, - ImportProviderProfilesResponse, IssueSandboxTokenRequest, IssueSandboxTokenResponse, - LintProviderProfilesRequest, LintProviderProfilesResponse, ListProviderProfilesRequest, - ListProviderProfilesResponse, ListProvidersRequest, ListProvidersResponse, - ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, ListSandboxProvidersRequest, - ListSandboxProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ListServicesRequest, - ListServicesResponse, ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, - PushSandboxLogsResponse, RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, - RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, ReportPolicyStatusRequest, - ReportPolicyStatusResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, - RotateProviderCredentialRequest, RotateProviderCredentialResponse, SandboxResponse, - SandboxStreamEvent, ServiceEndpointResponse, ServiceStatus, SubmitPolicyAnalysisRequest, - SubmitPolicyAnalysisResponse, SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, - UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, - UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, - WatchSandboxRequest, open_shell_server::OpenShell, + GetServiceRequest, GetWorkspaceRequest, GetWorkspaceResponse, HealthRequest, HealthResponse, + ImportProviderProfilesRequest, ImportProviderProfilesResponse, IssueSandboxTokenRequest, + IssueSandboxTokenResponse, LintProviderProfilesRequest, LintProviderProfilesResponse, + ListProviderProfilesRequest, ListProviderProfilesResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, + ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest, + ListSandboxesResponse, ListServicesRequest, ListServicesResponse, ListWorkspaceMembersRequest, + ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, + ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, + RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, RejectDraftChunkRequest, + RejectDraftChunkResponse, RelayFrame, RemoveWorkspaceMemberRequest, + RemoveWorkspaceMemberResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse, + RevokeSshSessionRequest, RevokeSshSessionResponse, RotateProviderCredentialRequest, + RotateProviderCredentialResponse, SandboxResponse, SandboxStreamEvent, ServiceEndpointResponse, + ServiceStatus, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, SupervisorMessage, + TcpForwardFrame, UndoDraftChunkRequest, UndoDraftChunkResponse, UpdateConfigRequest, + UpdateConfigResponse, UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, + UpdateProviderRequest, WatchSandboxRequest, open_shell_server::OpenShell, }; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -698,6 +702,64 @@ impl OpenShell for OpenShellService { crate::supervisor_session::handle_relay_stream(&self.state.supervisor_sessions, request) .await } + + // --- Workspace management --- + + #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] + async fn create_workspace( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_create_workspace(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:read", role = "user")] + async fn get_workspace( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_get_workspace(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:read", role = "user")] + async fn list_workspaces( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_list_workspaces(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] + async fn delete_workspace( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_delete_workspace(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] + async fn add_workspace_member( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_add_workspace_member(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] + async fn remove_workspace_member( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_remove_workspace_member(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:read", role = "user")] + async fn list_workspace_members( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_list_workspace_members(&self.state, request).await + } } // --------------------------------------------------------------------------- @@ -725,6 +787,7 @@ pub mod test_support { .await .unwrap(), ); + crate::ensure_default_workspace(&store).await.unwrap(); let compute = new_test_runtime(store.clone()).await; Arc::new(ServerState::new( Config::new(None).with_database_url("sqlite::memory:?cache=shared"), diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 6fccf1eade..d2782a581b 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -12,11 +12,10 @@ use crate::ServerState; use crate::auth::principal::Principal; -use crate::persistence::{DraftChunkRecord, ObjectId, ObjectName, ObjectType, PolicyRecord, Store}; -use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt}; -use crate::provider_profile_sources::EffectiveProviderProfileCatalog; -#[cfg(test)] -use crate::provider_profile_sources::ProviderProfileSources; +use crate::persistence::{ + DraftChunkRecord, ObjectId, ObjectName, ObjectType, ObjectWorkspace, PolicyRecord, Store, +}; +use crate::policy_store::PolicyStoreExt; use openshell_core::net::is_internal_ip; use openshell_core::proto::policy_merge_operation; use openshell_core::proto::setting_value; @@ -522,14 +521,14 @@ fn run_prover_findings( /// with the merged policy the prover validates against. async fn build_credential_set_for_sandbox_with_catalog( store: &Store, - catalog: &EffectiveProviderProfileCatalog, + workspace: &str, provider_names: &[String], ) -> Result { let mut credentials = Vec::new(); for name in provider_names { let Some(provider) = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? else { @@ -538,16 +537,28 @@ async fn build_credential_set_for_sandbox_with_catalog( }; let provider_type = provider.r#type.trim(); - let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); - let Some(profile) = - super::provider::get_provider_type_profile_with_catalog(catalog, profile_id) - else { - warn!( - provider_name = %name, - provider_type, - "provider type has no profile; skipping credential entry" - ); - continue; + let profile = if let Some(canonical_type) = normalize_provider_type(provider_type) { + let Some(profile) = get_default_profile(canonical_type) else { + warn!( + provider_name = %name, + provider_type, + "legacy provider type has no profile; skipping credential entry" + ); + continue; + }; + profile.clone() + } else { + let Some(profile) = + super::provider::get_provider_type_profile(store, workspace, provider_type).await? + else { + warn!( + provider_name = %name, + provider_type, + "provider type has no profile; skipping credential entry" + ); + continue; + }; + profile }; let target_hosts: Vec = profile @@ -881,6 +892,7 @@ async fn self_reject_mechanistic_if_already_covered( /// manual. async fn resolve_proposal_approval_mode( store: &Store, + workspace: &str, sandbox_name: &str, ) -> Result<(bool, &'static str), Status> { let global = load_global_settings(store).await?; @@ -890,7 +902,7 @@ async fn resolve_proposal_approval_mode( return Ok((value == "auto", "gateway")); } - let sandbox = load_sandbox_settings(store, sandbox_name).await?; + let sandbox = load_sandbox_settings(store, workspace, sandbox_name).await?; if let Some(StoredSettingValue::String(value)) = sandbox.settings.get(settings::PROPOSAL_APPROVAL_MODE_KEY) { @@ -903,6 +915,7 @@ async fn resolve_proposal_approval_mode( async fn auto_approve_chunk( state: &Arc, sandbox_id: &str, + workspace: &str, sandbox_name: &str, chunk_id: &str, source: &str, @@ -930,7 +943,8 @@ async fn auto_approve_chunk( return Ok(()); } - let (version, hash) = merge_chunk_into_policy(state.store.as_ref(), sandbox_id, &chunk).await?; + let (version, hash) = + merge_chunk_into_policy(state.store.as_ref(), sandbox_id, workspace, &chunk).await?; let chunk_summary = summarize_draft_chunk_rule(&chunk)?; let now_ms = current_time_ms(); @@ -979,7 +993,7 @@ async fn auto_approve_chunk( // agent-authored proposal validation slice. async fn current_effective_policy_for_sandbox( state: &ServerState, - catalog: &EffectiveProviderProfileCatalog, + workspace: &str, sandbox: &Sandbox, sandbox_id: &str, ) -> Result { @@ -1016,12 +1030,9 @@ async fn current_effective_policy_for_sandbox( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); - let provider_layers = profile_provider_policy_layers_with_catalog( - state.store.as_ref(), - catalog, - &provider_names, - ) - .await?; + let provider_layers = + profile_provider_policy_layers(state.store.as_ref(), workspace, &provider_names) + .await?; if !provider_layers.is_empty() { policy = compose_effective_policy(&policy, &provider_layers); } @@ -1167,11 +1178,12 @@ async fn persist_existing_policy_projection( async fn resolve_sandbox_by_name_for_principal( store: &Store, + workspace: &str, principal: &Principal, name: &str, ) -> Result { let sandbox = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; @@ -1218,6 +1230,7 @@ pub(super) async fn handle_get_sandbox_config( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + let workspace = sandbox.object_workspace().to_string(); let sandbox_provider_names = sandbox .spec .as_ref() @@ -1271,7 +1284,7 @@ pub(super) async fn handle_get_sandbox_config( if let Err(e) = state .store - .put_policy_revision(&policy_id, &sandbox_id, 1, &payload, &hash) + .put_policy_revision(&policy_id, &sandbox_id, &workspace, 1, &payload, &hash) .await { warn!( @@ -1303,7 +1316,7 @@ pub(super) async fn handle_get_sandbox_config( let global_settings = load_global_settings(state.store.as_ref()).await?; let sandbox_settings = - load_sandbox_settings(state.store.as_ref(), sandbox.object_name()).await?; + load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?; let providers_v2_enabled = bool_setting_enabled(&global_settings, settings::PROVIDERS_V2_ENABLED_KEY)?; @@ -1329,9 +1342,9 @@ pub(super) async fn handle_get_sandbox_config( && !matches!(policy_source, PolicySource::Global) && let Some(source_policy) = policy.as_ref() { - let provider_layers = profile_provider_policy_layers_with_catalog( + let provider_layers = profile_provider_policy_layers( state.store.as_ref(), - &provider_profile_catalog, + &workspace, &sandbox_provider_names, ) .await?; @@ -1344,12 +1357,9 @@ pub(super) async fn handle_get_sandbox_config( let settings = merge_effective_settings(&global_settings, &sandbox_settings)?; let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source); - let provider_env_revision = compute_provider_env_revision_with_catalog( - state.store.as_ref(), - &provider_profile_catalog, - &sandbox_provider_names, - ) - .await?; + let provider_env_revision = + compute_provider_env_revision(state.store.as_ref(), &workspace, &sandbox_provider_names) + .await?; Ok(Response::new(GetSandboxConfigResponse { policy, @@ -1360,12 +1370,14 @@ pub(super) async fn handle_get_sandbox_config( policy_source: policy_source.into(), global_policy_version, provider_env_revision, + workspace, })) } #[cfg(test)] async fn compute_provider_env_revision( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result { let catalog = ProviderProfileSources::with_default_sources() @@ -1385,7 +1397,7 @@ pub(super) async fn compute_provider_env_revision_with_catalog( for provider_name in provider_names { hasher.update(provider_name.as_bytes()); match store - .get_by_name(Provider::object_type(), provider_name) + .get_by_name(Provider::object_type(), workspace, provider_name) .await .map_err(|e| { Status::internal(format!("fetch provider '{provider_name}' failed: {e}")) @@ -1398,7 +1410,8 @@ pub(super) async fn compute_provider_env_revision_with_catalog( Status::internal(format!("decode provider '{provider_name}' failed: {e}")) })?; hasher.update(provider.r#type.as_bytes()); - hash_provider_profile_revision(catalog, &provider.r#type, &mut hasher); + hash_provider_profile_revision(store, workspace, &provider.r#type, &mut hasher) + .await?; let mut credential_keys: Vec<_> = provider.credentials.keys().collect(); credential_keys.sort(); @@ -1424,18 +1437,47 @@ pub(super) async fn compute_provider_env_revision_with_catalog( )?)) } -fn hash_provider_profile_revision( - catalog: &EffectiveProviderProfileCatalog, +async fn hash_provider_profile_revision( + store: &Store, + workspace: &str, provider_type: &str, hasher: &mut Sha256, -) { - let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); - catalog.hash_profile_revision(profile_id, hasher); +) -> Result<(), Status> { + if let Some(profile) = get_default_profile(provider_type) { + hasher.update(b"builtin-profile"); + hasher.update(profile.to_proto().encode_to_vec()); + return Ok(()); + } + + hasher.update(b"custom-profile"); + match store + .get_by_name( + openshell_core::proto::StoredProviderProfile::object_type(), + workspace, + provider_type, + ) + .await + .map_err(|e| { + Status::internal(format!( + "fetch provider profile '{provider_type}' failed: {e}" + )) + })? { + Some(record) => { + hasher.update(record.id.as_bytes()); + hasher.update(record.updated_at_ms.to_le_bytes()); + hasher.update(record.payload.as_slice()); + } + None => { + hasher.update(b"missing"); + } + } + Ok(()) } #[cfg(test)] async fn profile_provider_policy_layers( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result, Status> { let catalog = ProviderProfileSources::with_default_sources() @@ -1453,22 +1495,34 @@ async fn profile_provider_policy_layers_with_catalog( for name in provider_names { let provider = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; let provider_type = provider.r#type.trim(); - let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); - let Some(profile) = - super::provider::get_provider_type_profile_with_catalog(catalog, profile_id) - else { - warn!( - provider_name = %name, - provider_type, - "provider type has no profile; skipping provider policy layer" - ); - continue; + let profile = if let Some(canonical_type) = normalize_provider_type(provider_type) { + let Some(profile) = get_default_profile(canonical_type) else { + warn!( + provider_name = %name, + provider_type, + "legacy provider type has no profile; skipping provider policy layer" + ); + continue; + }; + profile.clone() + } else { + let Some(profile) = + super::provider::get_provider_type_profile(store, workspace, provider_type).await? + else { + warn!( + provider_name = %name, + provider_type, + "provider type has no profile; skipping provider policy layer" + ); + continue; + }; + profile }; let rule_name = openshell_policy::provider_rule_name(provider.object_name()); @@ -1517,25 +1571,18 @@ pub(super) async fn handle_get_sandbox_provider_environment( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + let workspace = sandbox.object_workspace().to_string(); let spec = sandbox .spec .ok_or_else(|| Status::internal("sandbox has no spec"))?; let provider_names = spec.providers; - let provider_profile_catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) - .await?; - let provider_env_revision = compute_provider_env_revision_with_catalog( + let provider_env_revision = + compute_provider_env_revision(state.store.as_ref(), &workspace, &provider_names).await?; + let provider_environment = super::provider::resolve_provider_environment( state.store.as_ref(), - &provider_profile_catalog, - &provider_names, - ) - .await?; - let provider_environment = super::provider::resolve_provider_environment_with_catalog( - state.store.as_ref(), - &provider_profile_catalog, + &workspace, &provider_names, ) .await?; @@ -1583,11 +1630,13 @@ async fn handle_update_config_inner( sandbox_caller: bool, ) -> Result, Status> { let req = request.into_inner(); - validate_annotations(&req.annotations, "annotations")?; + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if sandbox_caller { validate_sandbox_caller_update(&req)?; resolve_sandbox_by_name_for_principal( state.store.as_ref(), + &workspace, principal .as_ref() .expect("sandbox_caller implies principal"), @@ -1683,6 +1732,7 @@ async fn handle_update_config_inner( .put_policy_revision( &policy_id, GLOBAL_POLICY_SANDBOX_ID, + "", next_version, &payload, &hash, @@ -1789,7 +1839,7 @@ async fn handle_update_config_inner( // Resolve sandbox by name. let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -1816,12 +1866,14 @@ async fn handle_update_config_inner( } let mut sandbox_settings = - load_sandbox_settings(state.store.as_ref(), sandbox.object_name()).await?; + load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()) + .await?; let removed = sandbox_settings.settings.remove(key).is_some(); if removed { sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1); save_sandbox_settings( state.store.as_ref(), + &workspace, sandbox.object_name(), &sandbox_settings, ) @@ -1859,12 +1911,13 @@ async fn handle_update_config_inner( let stored = proto_setting_to_stored(key, setting)?; let mut sandbox_settings = - load_sandbox_settings(state.store.as_ref(), sandbox.object_name()).await?; + load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?; let changed = upsert_setting_value(&mut sandbox_settings.settings, key, stored); if changed { sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1); save_sandbox_settings( state.store.as_ref(), + &workspace, sandbox.object_name(), &sandbox_settings, ) @@ -1911,6 +1964,7 @@ async fn handle_update_config_inner( let (version, hash, updated_sandbox) = apply_merge_operations_with_retry( state.store.as_ref(), &sandbox_id, + &workspace, spec.policy.as_ref(), &merge_ops, Some(&atomic_context), @@ -2105,6 +2159,47 @@ async fn handle_update_config_inner( ); } + let latest = state + .store + .get_latest_policy(&sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?; + + let payload = new_policy.encode_to_vec(); + let hash = deterministic_policy_hash(&new_policy); + + if let Some(ref current) = latest + && current.policy_hash == hash + { + return Ok(Response::new(UpdateConfigResponse { + version: u32::try_from(current.version).unwrap_or(0), + policy_hash: hash, + settings_revision: 0, + deleted: false, + })); + } + + let next_version = latest.map_or(1, |r| r.version + 1); + let policy_id = uuid::Uuid::new_v4().to_string(); + + state + .store + .put_policy_revision( + &policy_id, + &sandbox_id, + &workspace, + next_version, + &payload, + &hash, + ) + .await + .map_err(|e| Status::internal(format!("persist policy revision failed: {e}")))?; + + let _ = state + .store + .supersede_older_policies(&sandbox_id, next_version) + .await; + state.sandbox_watch_bus.notify(&sandbox_id); info!( @@ -2133,6 +2228,11 @@ pub(super) async fn handle_get_sandbox_policy_status( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = if req.global { + String::new() + } else { + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await? + }; let (policy_id, active_version) = if req.global { (GLOBAL_POLICY_SANDBOX_ID.to_string(), 0_u32) @@ -2142,7 +2242,7 @@ pub(super) async fn handle_get_sandbox_policy_status( } let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2184,6 +2284,11 @@ pub(super) async fn handle_list_sandbox_policies( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = if req.global { + String::new() + } else { + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await? + }; let policy_id = if req.global { GLOBAL_POLICY_SANDBOX_ID.to_string() @@ -2193,7 +2298,7 @@ pub(super) async fn handle_list_sandbox_policies( } let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2305,6 +2410,8 @@ pub(super) async fn handle_get_sandbox_logs( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let _workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); } @@ -2419,12 +2526,19 @@ pub(super) async fn handle_submit_policy_analysis( .cloned() .ok_or_else(|| Status::unauthenticated("missing principal"))?; let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } - let sandbox = - resolve_sandbox_by_name_for_principal(state.store.as_ref(), &principal, &req.name).await?; + let sandbox = resolve_sandbox_by_name_for_principal( + state.store.as_ref(), + &workspace, + &principal, + &req.name, + ) + .await?; let sandbox_id = sandbox.object_id().to_string(); for summary in &req.network_activity_summaries { state @@ -2451,17 +2565,8 @@ pub(super) async fn handle_submit_policy_analysis( // case for the common single-chunk submission shape. If real workloads // surface a problem with batches that interact across chunks, the right // fix is to recompute baseline after each successful auto-approve. - let provider_profile_catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) - .await?; - let current_policy = current_effective_policy_for_sandbox( - state, - &provider_profile_catalog, - &sandbox, - &sandbox_id, - ) - .await?; + let current_policy = + current_effective_policy_for_sandbox(state, &workspace, &sandbox, &sandbox_id).await?; // Auto-approval is an opt-in behavior, sourced from the settings model // (sandbox or gateway scope) so it can be flipped on a running sandbox @@ -2469,7 +2574,8 @@ pub(super) async fn handle_submit_policy_analysis( // exact "auto") preserves OpenShell's default-deny posture: every // proposal lands in `pending` for a human reviewer. let (auto_approve_enabled, resolved_from) = - resolve_proposal_approval_mode(state.store.as_ref(), sandbox.object_name()).await?; + resolve_proposal_approval_mode(state.store.as_ref(), &workspace, sandbox.object_name()) + .await?; // The credential set is stable across all chunks in this batch, so build // it once. v1 captures presence only — no scope modeling — so the prover @@ -2480,9 +2586,9 @@ pub(super) async fn handle_submit_policy_analysis( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); - let credential_set = build_credential_set_for_sandbox_with_catalog( + let credential_set = build_credential_set_for_sandbox( state.store.as_ref(), - &provider_profile_catalog, + &workspace, &provider_names_for_creds, ) .await?; @@ -2598,7 +2704,7 @@ pub(super) async fn handle_submit_policy_analysis( .then(|| crate::policy_store::observation_dedup_key(&record)); let effective_id = state .store - .put_draft_chunk(&record, dedup_key.as_deref()) + .put_draft_chunk(&record, dedup_key.as_deref(), &workspace) .await .map_err(|e| Status::internal(format!("persist draft chunk failed: {e}")))?; accepted += 1; @@ -2652,6 +2758,7 @@ pub(super) async fn handle_submit_policy_analysis( && let Err(err) = auto_approve_chunk( state, &sandbox_id, + &workspace, sandbox.object_name(), &effective_id, &req.analysis_mode, @@ -2699,12 +2806,19 @@ pub(super) async fn handle_get_draft_policy( .cloned() .ok_or_else(|| Status::unauthenticated("missing principal"))?; let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } - let sandbox = - resolve_sandbox_by_name_for_principal(state.store.as_ref(), &principal, &req.name).await?; + let sandbox = resolve_sandbox_by_name_for_principal( + state.store.as_ref(), + &workspace, + &principal, + &req.name, + ) + .await?; let sandbox_id = sandbox.object_id().to_string(); let status_filter = if req.status_filter.is_empty() { @@ -2763,6 +2877,8 @@ async fn handle_approve_draft_chunk_inner( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2774,7 +2890,7 @@ async fn handle_approve_draft_chunk_inner( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2807,7 +2923,7 @@ async fn handle_approve_draft_chunk_inner( ); let (version, hash) = - merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, &chunk).await?; + merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, &workspace, &chunk).await?; let chunk_summary = summarize_draft_chunk_rule(&chunk)?; let now_ms = current_time_ms(); @@ -2863,6 +2979,8 @@ async fn handle_reject_draft_chunk_inner( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2872,7 +2990,7 @@ async fn handle_reject_draft_chunk_inner( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2908,7 +3026,8 @@ async fn handle_reject_draft_chunk_inner( if was_approved { require_no_global_policy(state).await?; - let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &chunk).await?; + let (version, hash) = + remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?; emit_gateway_policy_audit_log( &sandbox_id, sandbox.object_name(), @@ -2960,6 +3079,8 @@ async fn handle_approve_all_draft_chunks_inner( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2968,7 +3089,7 @@ async fn handle_approve_all_draft_chunks_inner( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -3019,7 +3140,7 @@ async fn handle_approve_all_draft_chunks_inner( ); let (version, hash) = - merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, chunk).await?; + merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, &workspace, chunk).await?; last_version = version; last_hash = hash; let chunk_summary = summarize_draft_chunk_rule(chunk)?; @@ -3081,6 +3202,8 @@ pub(super) async fn handle_edit_draft_chunk( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -3093,7 +3216,7 @@ pub(super) async fn handle_edit_draft_chunk( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -3145,6 +3268,8 @@ async fn handle_undo_draft_chunk_inner( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -3154,7 +3279,7 @@ async fn handle_undo_draft_chunk_inner( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -3184,7 +3309,7 @@ async fn handle_undo_draft_chunk_inner( "UndoDraftChunk: removing rule from active policy" ); - let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &chunk).await?; + let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?; // Clear any prior rejection_reason on the way back to "pending" so an // agent reading the chunk via policy.local cannot see a stale guidance @@ -3230,13 +3355,15 @@ pub(super) async fn handle_clear_draft_chunks( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -3266,13 +3393,15 @@ pub(super) async fn handle_get_draft_history( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -3742,6 +3871,7 @@ struct AtomicPolicyWriteContext<'a> { async fn apply_merge_operations_with_retry( store: &Store, sandbox_id: &str, + workspace: &str, baseline_policy: Option<&ProtoSandboxPolicy>, operations: &[PolicyMergeOp], atomic_context: Option<&AtomicPolicyWriteContext<'_>>, @@ -3783,35 +3913,21 @@ async fn apply_merge_operations_with_retry( let next_version = latest.as_ref().map_or(1, |record| record.version + 1); let policy_id = uuid::Uuid::new_v4().to_string(); - let write_result = if let Some(context) = atomic_context { - store - .put_policy_revision_atomic(&AtomicPolicyRevisionWrite { - id: policy_id, - sandbox_id: sandbox_id.to_string(), - version: next_version, - policy_payload: payload, - policy_hash: hash.clone(), - provenance: context.provenance.clone(), - expected_resource_version: context.expected_resource_version, - annotations: context.annotations.clone(), - backfill_policy: None, - }) - .await - .map(Some) - } else { - store - .put_policy_revision(&policy_id, sandbox_id, next_version, &payload, &hash) - .await - .map(|()| None) - }; - - match write_result { - Ok(updated_sandbox) => { - if atomic_context.is_none() { - let _ = store - .supersede_older_policies(sandbox_id, next_version) - .await; - } + match store + .put_policy_revision( + &policy_id, + sandbox_id, + workspace, + next_version, + &payload, + &hash, + ) + .await + { + Ok(()) => { + let _ = store + .supersede_older_policies(sandbox_id, next_version) + .await; if attempt > 1 { info!( @@ -3852,6 +3968,7 @@ async fn apply_merge_operations_with_retry( pub(super) async fn merge_chunk_into_policy( store: &Store, sandbox_id: &str, + workspace: &str, chunk: &DraftChunkRecord, ) -> Result<(i64, String), Status> { let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) @@ -3861,19 +3978,19 @@ pub(super) async fn merge_chunk_into_policy( rule, }]; validate_merge_operations_for_server(&operations)?; - apply_merge_operations_with_retry(store, sandbox_id, None, &operations, None) - .await - .map(|(version, hash, _)| (version, hash)) + apply_merge_operations_with_retry(store, sandbox_id, workspace, None, &operations).await } async fn remove_chunk_from_policy( state: &ServerState, sandbox_id: &str, + workspace: &str, chunk: &DraftChunkRecord, ) -> Result<(i64, String), Status> { apply_merge_operations_with_retry( state.store.as_ref(), sandbox_id, + workspace, None, &[PolicyMergeOp::RemoveBinary { rule_name: chunk.rule_name.clone(), @@ -3978,7 +4095,7 @@ fn upsert_setting_value( } pub(super) async fn load_global_settings(store: &Store) -> Result { - load_settings_record(store, GLOBAL_SETTINGS_OBJECT_TYPE, GLOBAL_SETTINGS_NAME).await + load_settings_record(store, GLOBAL_SETTINGS_OBJECT_TYPE, "", GLOBAL_SETTINGS_NAME).await } pub(super) async fn save_global_settings( @@ -3988,6 +4105,7 @@ pub(super) async fn save_global_settings( save_settings_record( store, GLOBAL_SETTINGS_OBJECT_TYPE, + "", GLOBAL_SETTINGS_NAME, settings, ) @@ -3996,32 +4114,41 @@ pub(super) async fn save_global_settings( pub(super) async fn load_sandbox_settings( store: &Store, + workspace: &str, sandbox_name: &str, ) -> Result { - load_settings_record(store, SANDBOX_SETTINGS_OBJECT_TYPE, sandbox_name).await + load_settings_record(store, SANDBOX_SETTINGS_OBJECT_TYPE, workspace, sandbox_name).await } pub(super) async fn save_sandbox_settings( store: &Store, + workspace: &str, sandbox_name: &str, settings: &StoredSettings, ) -> Result<(), Status> { - save_settings_record(store, SANDBOX_SETTINGS_OBJECT_TYPE, sandbox_name, settings).await + save_settings_record( + store, + SANDBOX_SETTINGS_OBJECT_TYPE, + workspace, + sandbox_name, + settings, + ) + .await } async fn load_settings_record( store: &Store, object_type: &str, + workspace: &str, name: &str, ) -> Result { let record = store - .get_by_name(object_type, name) + .get_by_name(object_type, workspace, name) .await .map_err(|e| Status::internal(format!("fetch settings failed: {e}")))?; if let Some(record) = record { let mut settings = serde_json::from_slice::(&record.payload) .map_err(|e| Status::internal(format!("decode settings payload failed: {e}")))?; - // Populate resource_version from database record for CAS settings.resource_version = record.resource_version; Ok(settings) } else { @@ -4032,6 +4159,7 @@ async fn load_settings_record( async fn save_settings_record( store: &Store, object_type: &str, + workspace: &str, name: &str, settings: &StoredSettings, ) -> Result<(), Status> { @@ -4041,13 +4169,10 @@ async fn save_settings_record( .map_err(|e| Status::internal(format!("encode settings payload failed: {e}")))?; let (id, condition) = if settings.resource_version == 0 { - // Create new settings (resource_version 0 means never persisted) (uuid::Uuid::new_v4().to_string(), WriteCondition::MustCreate) } else { - // Update existing with CAS on the version from when it was loaded - // Fetch the record to get the stable ID let existing = store - .get_by_name(object_type, name) + .get_by_name(object_type, workspace, name) .await .map_err(|e| Status::internal(format!("fetch settings for CAS failed: {e}")))? .ok_or_else(|| Status::not_found("settings disappeared since load"))?; @@ -4058,9 +4183,8 @@ async fn save_settings_record( ) }; - // Single-attempt CAS write store - .put_if(object_type, &id, name, &payload, None, condition) + .put_if(object_type, &id, name, workspace, &payload, None, condition) .await .map_err(|e| match e { crate::persistence::PersistenceError::Conflict { .. } => { @@ -4328,7 +4452,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4362,7 +4486,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4395,7 +4519,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4431,7 +4555,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4446,6 +4570,7 @@ mod tests { Request::new(GetDraftPolicyRequest { name: "sandbox-b".to_string(), status_filter: String::new(), + workspace: "default".to_string(), }), "sb-a", ); @@ -4497,6 +4622,7 @@ mod tests { Request::new(GetDraftPolicyRequest { name: "missing-sandbox".to_string(), status_filter: String::new(), + workspace: "default".to_string(), }), "sb-a", ); @@ -4519,7 +4645,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4600,7 +4726,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4627,7 +4753,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: std::iter::once(("GITHUB_TOKEN".to_string(), "ghp-test".to_string())) @@ -4671,7 +4797,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(policy), @@ -4917,9 +5043,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["custom-provider".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["custom-provider".to_string()]) + .await + .unwrap(); assert!(layers.is_empty()); } @@ -4939,7 +5066,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "generic".to_string(), @@ -4962,9 +5089,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["custom-provider".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["custom-provider".to_string()]) + .await + .unwrap(); assert_eq!(layers.len(), 1); assert_eq!(layers[0].rule.endpoints[0].host, "backdoor.example"); @@ -4986,7 +5114,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), @@ -5023,9 +5151,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["work-custom".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["work-custom".to_string()]) + .await + .unwrap(); assert_eq!(layers.len(), 1); assert_eq!(layers[0].rule_name, "_provider_work_custom"); @@ -5053,7 +5182,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), @@ -5076,9 +5205,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["work-custom".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["work-custom".to_string()]) + .await + .unwrap(); assert_eq!(layers.len(), 1); assert_eq!(layers[0].rule.endpoints[0].host, "api.custom.example"); @@ -5092,9 +5222,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["work-github".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["work-github".to_string()]) + .await + .unwrap(); assert_eq!(layers.len(), 1); assert_eq!(layers[0].rule_name, "_provider_work_github"); @@ -5321,7 +5452,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), profile: Some(ProviderProfile { id: "custom-policy".to_string(), @@ -5374,7 +5505,7 @@ mod tests { let mut updated_profile = stored_profile("api.after.example").profile.unwrap(); updated_profile.resource_version = state .store - .get_message_by_name::("custom-policy") + .get_message_by_name::("default", "custom-policy") .await .unwrap() .unwrap() @@ -5391,6 +5522,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-policy".to_string(), + workspace: "default".to_string(), })), ) .await @@ -5415,7 +5547,7 @@ mod tests { let persisted_provider: Provider = state .store - .get_message_by_name("work-custom") + .get_message_by_name::("default", "work-custom") .await .unwrap() .unwrap(); @@ -5607,7 +5739,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), profile: Some(ProviderProfile { id: "custom-token".to_string(), @@ -5651,10 +5783,13 @@ mod tests { .await .unwrap(); - let first = - compute_provider_env_revision(state.store.as_ref(), &["work-custom-token".to_string()]) - .await - .unwrap(); + let first = compute_provider_env_revision( + state.store.as_ref(), + "default", + &["work-custom-token".to_string()], + ) + .await + .unwrap(); tokio::time::sleep(Duration::from_millis(2)).await; let mut rotated_profile = token_grant_profile("https://auth.example.com/rotated-token") @@ -5662,7 +5797,7 @@ mod tests { .unwrap(); rotated_profile.resource_version = state .store - .get_message_by_name::("custom-token") + .get_message_by_name::("default", "custom-token") .await .unwrap() .unwrap() @@ -5679,15 +5814,19 @@ mod tests { }), expected_resource_version: 0, id: "custom-token".to_string(), + workspace: "default".to_string(), })), ) .await .unwrap(); - let second = - compute_provider_env_revision(state.store.as_ref(), &["work-custom-token".to_string()]) - .await - .unwrap(); + let second = compute_provider_env_revision( + state.store.as_ref(), + "default", + &["work-custom-token".to_string()], + ) + .await + .unwrap(); assert_ne!( first, second, @@ -5745,6 +5884,7 @@ mod tests { sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), })), ) .await @@ -5781,6 +5921,7 @@ mod tests { sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), }), ) .await @@ -5866,6 +6007,7 @@ mod tests { discovery: None, }), }], + workspace: "default".to_string(), }), ) .await @@ -5908,6 +6050,7 @@ mod tests { sandbox_name: "custom-attach-lifecycle".to_string(), provider_name: "work-custom".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), })), ) .await @@ -5947,6 +6090,7 @@ mod tests { sandbox_name: "custom-attach-lifecycle".to_string(), provider_name: "work-custom".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), }), ) .await @@ -6011,7 +6155,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(sandbox_policy), @@ -6101,7 +6245,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -6154,7 +6298,7 @@ mod tests { /// settings model, mirroring what `openshell settings set /// proposal_approval_mode ` would do at runtime. async fn seed_sandbox_approval_mode(state: &Arc, sandbox_name: &str, mode: &str) { - let mut settings = load_sandbox_settings(state.store.as_ref(), sandbox_name) + let mut settings = load_sandbox_settings(state.store.as_ref(), "default", sandbox_name) .await .unwrap(); settings.settings.insert( @@ -6162,7 +6306,7 @@ mod tests { StoredSettingValue::String(mode.to_string()), ); settings.revision = settings.revision.wrapping_add(1); - save_sandbox_settings(state.store.as_ref(), sandbox_name, &settings) + save_sandbox_settings(state.store.as_ref(), "default", sandbox_name, &settings) .await .unwrap(); } @@ -6206,7 +6350,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -6263,6 +6407,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6281,6 +6426,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -6293,6 +6439,7 @@ mod tests { &state, Request::new(GetDraftHistoryRequest { name: sandbox_name.clone(), + workspace: "default".to_string(), }), ) .await @@ -6310,6 +6457,7 @@ mod tests { limit: 10, offset: 0, global: false, + workspace: "default".to_string(), }), ) .await @@ -6323,6 +6471,7 @@ mod tests { Request::new(UndoDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -6336,6 +6485,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6348,6 +6498,7 @@ mod tests { &state, Request::new(GetDraftHistoryRequest { name: sandbox_name.clone(), + workspace: "default".to_string(), }), ) .await @@ -6363,6 +6514,7 @@ mod tests { limit: 10, offset: 0, global: false, + workspace: "default".to_string(), }), ) .await @@ -6376,6 +6528,7 @@ mod tests { &state, Request::new(ClearDraftChunksRequest { name: sandbox_name.clone(), + workspace: "default".to_string(), }), ) .await @@ -6388,6 +6541,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6397,7 +6551,10 @@ mod tests { let history_after_clear = handle_get_draft_history( &state, - Request::new(GetDraftHistoryRequest { name: sandbox_name }), + Request::new(GetDraftHistoryRequest { + name: sandbox_name, + workspace: "default".to_string(), + }), ) .await .unwrap() @@ -6422,7 +6579,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -6471,6 +6628,7 @@ mod tests { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: guidance.to_string(), + workspace: "default".to_string(), }), ) .await @@ -6481,6 +6639,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6519,7 +6678,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6585,6 +6744,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6635,7 +6795,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6694,6 +6854,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6769,6 +6930,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6839,7 +7001,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6896,6 +7058,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6937,7 +7100,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6997,6 +7160,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7043,7 +7207,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7098,6 +7262,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7137,7 +7302,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7192,6 +7357,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7223,7 +7389,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7277,6 +7443,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7311,7 +7478,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7366,6 +7533,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7399,7 +7567,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7458,6 +7626,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7492,7 +7661,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7606,7 +7775,7 @@ mod tests { }; state .store - .put_draft_chunk(&chunk, None) + .put_draft_chunk(&chunk, None, "default") .await .expect("draft chunk should persist"); @@ -7615,6 +7784,7 @@ mod tests { with_user(Request::new(ApproveDraftChunkRequest { name: sandbox_name.to_string(), chunk_id: chunk.id.clone(), + workspace: "default".to_string(), })), ) .await @@ -7667,7 +7837,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7721,6 +7891,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7764,7 +7935,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7818,6 +7989,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7850,7 +8022,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7904,6 +8076,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7945,7 +8118,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), profile: Some(ProviderProfile { id: "custom-api".to_string(), @@ -7985,7 +8158,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -8048,6 +8221,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8108,7 +8282,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -8216,6 +8390,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8295,7 +8470,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -8364,6 +8539,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8385,6 +8561,7 @@ mod tests { name: sandbox_name, chunk_id: second.accepted_chunk_ids[0].clone(), reason: "redraft test".to_string(), + workspace: "default".to_string(), }), ) .await @@ -8410,7 +8587,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -8466,6 +8643,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8775,7 +8953,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -8822,6 +9000,7 @@ mod tests { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: "scope too broad".to_string(), + workspace: "default".to_string(), }), ) .await @@ -8832,6 +9011,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -8842,6 +9022,7 @@ mod tests { Request::new(UndoDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -8852,6 +9033,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8890,7 +9072,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -8907,7 +9089,7 @@ mod tests { created_at_ms: 1_000_001, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -8958,6 +9140,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_a.object_name().to_string(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8971,6 +9154,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: other_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -8983,6 +9167,7 @@ mod tests { name: other_name.clone(), chunk_id: chunk_id.clone(), reason: "wrong sandbox".to_string(), + workspace: "default".to_string(), }), ) .await @@ -8995,6 +9180,7 @@ mod tests { name: other_name.clone(), chunk_id: chunk_id.clone(), proposed_rule: Some(proposed_rule.clone()), + workspace: "default".to_string(), }), ) .await @@ -9006,6 +9192,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: sandbox_a.object_name().to_string(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -9016,6 +9203,7 @@ mod tests { Request::new(UndoDraftChunkRequest { name: other_name, chunk_id, + workspace: "default".to_string(), }), ) .await @@ -9234,7 +9422,7 @@ mod tests { rejection_reason: String::new(), }; - let (version, _) = merge_chunk_into_policy(&store, &chunk.sandbox_id, &chunk) + let (version, _) = merge_chunk_into_policy(&store, &chunk.sandbox_id, "default", &chunk) .await .unwrap(); @@ -9288,6 +9476,7 @@ mod tests { .put_policy_revision( "p-seed", sandbox_id, + "default", 1, &initial_policy.encode_to_vec(), "seed-hash", @@ -9330,7 +9519,7 @@ mod tests { rejection_reason: String::new(), }; - let (version, _) = merge_chunk_into_policy(&store, sandbox_id, &chunk) + let (version, _) = merge_chunk_into_policy(&store, sandbox_id, "default", &chunk) .await .unwrap(); assert_eq!(version, 2); @@ -9389,6 +9578,7 @@ mod tests { .put_policy_revision( "p-seed", sandbox_id, + "default", 1, &initial_policy.encode_to_vec(), "seed-hash", @@ -9431,7 +9621,7 @@ mod tests { rejection_reason: String::new(), }; - let (version, _) = merge_chunk_into_policy(&store, sandbox_id, &chunk) + let (version, _) = merge_chunk_into_policy(&store, sandbox_id, "default", &chunk) .await .unwrap(); assert_eq!(version, 2); @@ -9476,6 +9666,7 @@ mod tests { .put_policy_revision( "p-seed", sandbox_id, + "default", 1, &initial_policy.encode_to_vec(), "seed-hash", @@ -9511,8 +9702,8 @@ mod tests { }]; let (left, right) = tokio::join!( - apply_merge_operations_with_retry(&store, sandbox_id, None, &add_allow, None), - apply_merge_operations_with_retry(&store, sandbox_id, None, &add_deny, None), + apply_merge_operations_with_retry(&store, sandbox_id, "default", None, &add_allow), + apply_merge_operations_with_retry(&store, sandbox_id, "default", None, &add_deny), ); let mut versions = vec![left.unwrap().0, right.unwrap().0]; @@ -10182,7 +10373,9 @@ mod tests { #[tokio::test] async fn sandbox_settings_load_returns_default_when_empty() { let store = test_store().await; - let settings = load_sandbox_settings(&store, "nonexistent").await.unwrap(); + let settings = load_sandbox_settings(&store, "default", "nonexistent") + .await + .unwrap(); assert!(settings.settings.is_empty()); assert_eq!(settings.revision, 0); } @@ -10226,11 +10419,13 @@ mod tests { StoredSettingValue::String("auto".to_string()), ); settings.revision = 3; - save_sandbox_settings(&store, sandbox_name, &settings) + save_sandbox_settings(&store, "default", sandbox_name, &settings) .await .unwrap(); - let loaded = load_sandbox_settings(&store, sandbox_name).await.unwrap(); + let loaded = load_sandbox_settings(&store, "default", sandbox_name) + .await + .unwrap(); assert_eq!(loaded.revision, 3); assert_eq!( loaded.settings.get(settings::PROPOSAL_APPROVAL_MODE_KEY), @@ -10395,7 +10590,9 @@ mod tests { assert!(!loaded.settings.contains_key("log_level")); let sandbox_name = "test-sandbox"; - let mut sandbox_settings = load_sandbox_settings(&store, sandbox_name).await.unwrap(); + let mut sandbox_settings = load_sandbox_settings(&store, "default", sandbox_name) + .await + .unwrap(); let changed = upsert_setting_value( &mut sandbox_settings.settings, "log_level", @@ -10403,17 +10600,115 @@ mod tests { ); assert!(changed); sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1); - save_sandbox_settings(&store, sandbox_name, &sandbox_settings) + save_sandbox_settings(&store, "default", sandbox_name, &sandbox_settings) .await .unwrap(); - let reloaded = load_sandbox_settings(&store, sandbox_name).await.unwrap(); + let reloaded = load_sandbox_settings(&store, "default", sandbox_name) + .await + .unwrap(); assert_eq!( reloaded.settings.get("log_level"), Some(&StoredSettingValue::String("debug".to_string())), ); } + #[tokio::test] + async fn sandbox_settings_are_workspace_isolated() { + let store = test_store().await; + let sandbox_name = "work"; + + let mut alpha_settings = StoredSettings::default(); + alpha_settings.settings.insert( + settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), + StoredSettingValue::String("auto".to_string()), + ); + alpha_settings.revision = 1; + save_sandbox_settings(&store, "alpha", sandbox_name, &alpha_settings) + .await + .unwrap(); + + let mut beta_settings = StoredSettings::default(); + beta_settings.settings.insert( + settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), + StoredSettingValue::String("manual".to_string()), + ); + beta_settings.revision = 1; + save_sandbox_settings(&store, "beta", sandbox_name, &beta_settings) + .await + .unwrap(); + + let alpha_loaded = load_sandbox_settings(&store, "alpha", sandbox_name) + .await + .unwrap(); + let beta_loaded = load_sandbox_settings(&store, "beta", sandbox_name) + .await + .unwrap(); + + assert_eq!( + alpha_loaded + .settings + .get(settings::PROPOSAL_APPROVAL_MODE_KEY), + Some(&StoredSettingValue::String("auto".to_string())), + ); + assert_eq!( + beta_loaded + .settings + .get(settings::PROPOSAL_APPROVAL_MODE_KEY), + Some(&StoredSettingValue::String("manual".to_string())), + ); + } + + #[tokio::test] + async fn get_sandbox_config_returns_workspace() { + use openshell_core::proto::{SandboxPhase, SandboxSpec}; + let state = test_server_state().await; + + for (ws, sb_id, sb_name) in [("alpha", "sb-alpha", "work"), ("beta", "sb-beta", "work")] { + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: sb_id.to_string(), + name: sb_name.to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + workspace: ws.to_string(), + }), + spec: Some(SandboxSpec { + policy: None, + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Provisioning as i32); + state.store.put_message(&sandbox).await.unwrap(); + } + + let alpha_req = with_sandbox( + Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-alpha".to_string(), + }), + "sb-alpha", + ); + let alpha_resp = handle_get_sandbox_config(&state, alpha_req) + .await + .unwrap() + .into_inner(); + assert_eq!(alpha_resp.workspace, "alpha"); + + let beta_req = with_sandbox( + Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-beta".to_string(), + }), + "sb-beta", + ); + let beta_resp = handle_get_sandbox_config(&state, beta_req) + .await + .unwrap() + .into_inner(); + assert_eq!(beta_resp.workspace, "beta"); + } + #[test] fn validate_registered_setting_key_rejects_policy() { let err = validate_registered_setting_key("policy").unwrap_err(); @@ -10517,7 +10812,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, // No policy yet - will be backfilled @@ -10532,7 +10827,7 @@ mod tests { // Fetch the sandbox to get its current resource_version let current = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -10552,7 +10847,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: current_version, - annotations: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -10565,7 +10860,7 @@ mod tests { // Verify the resource_version incremented and policy was backfilled let updated_sandbox = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -11180,7 +11475,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -11194,7 +11489,7 @@ mod tests { let current = state .store - .get_message_by_name::("sync-strip") + .get_message_by_name::("default", "sync-strip") .await .unwrap() .unwrap(); @@ -11234,7 +11529,7 @@ mod tests { let updated_sandbox = state .store - .get_message_by_name::("sync-strip") + .get_message_by_name::("default", "sync-strip") .await .unwrap() .unwrap(); @@ -11284,7 +11579,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -11299,7 +11594,7 @@ mod tests { // Get current version let current = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -11319,7 +11614,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: 99, // stale version - annotations: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -11337,7 +11632,7 @@ mod tests { // Verify the sandbox was not modified (policy still None) let unchanged = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -11376,7 +11671,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -11391,7 +11686,7 @@ mod tests { // All three clients fetch the sandbox and see the same version let initial = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -11415,7 +11710,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: initial_version, - annotations: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -11448,7 +11743,7 @@ mod tests { // Final sandbox should have resource_version = initial_version + 1 and policy backfilled let final_sandbox = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index aa4b3ab195..8aaa2ff69f 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -70,17 +70,7 @@ impl ProviderEnvironment { #[cfg(test)] pub(super) async fn create_provider_record( store: &Store, - provider: Provider, -) -> Result { - let catalog = ProviderProfileSources::with_default_sources() - .snapshot_catalog(store) - .await?; - create_provider_record_with_catalog(store, &catalog, provider).await -} - -pub(super) async fn create_provider_record_with_catalog( - store: &Store, - catalog: &EffectiveProviderProfileCatalog, + workspace: &str, mut provider: Provider, ) -> Result { use crate::persistence::{ObjectName, current_time_ms}; @@ -94,7 +84,7 @@ pub(super) async fn create_provider_record_with_catalog( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: workspace.to_string(), }); } @@ -115,7 +105,7 @@ pub(super) async fn create_provider_record_with_catalog( return Err(Status::invalid_argument("provider.type is required")); } if provider.credentials.is_empty() - && !provider_type_allows_empty_credentials(catalog, &provider.r#type) + && !provider_type_allows_empty_credentials(store, workspace, &provider.r#type).await? { return Err(Status::invalid_argument( "provider.credentials must not be empty", @@ -138,6 +128,7 @@ pub(super) async fn create_provider_record_with_catalog( Provider::object_type(), &provider_id, provider.object_name(), + workspace, &provider.encode_to_vec(), None, WriteCondition::MustCreate, @@ -161,13 +152,17 @@ pub(super) async fn create_provider_record_with_catalog( Ok(redact_provider_credentials(provider)) } -pub(super) async fn get_provider_record(store: &Store, name: &str) -> Result { +pub(super) async fn get_provider_record( + store: &Store, + workspace: &str, + name: &str, +) -> Result { if name.is_empty() { return Err(Status::invalid_argument("name is required")); } store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found")) @@ -176,11 +171,12 @@ pub(super) async fn get_provider_record(store: &Store, name: &str) -> Result Result, Status> { let providers: Vec = store - .list_messages(limit, offset) + .list_messages(workspace, limit, offset) .await .map_err(|e| Status::internal(format!("list providers failed: {e}")))?; @@ -193,6 +189,7 @@ pub(super) async fn list_provider_records( #[cfg(test)] pub(super) async fn update_provider_record( store: &Store, + workspace: &str, provider: Provider, ) -> Result { let catalog = ProviderProfileSources::with_default_sources() @@ -217,7 +214,7 @@ pub(super) async fn update_provider_record_with_catalog( // Resolve provider ID from name for CAS update let existing = store - .get_message_by_name::(provider.object_name()) + .get_message_by_name::(workspace, provider.object_name()) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))?; @@ -259,8 +256,7 @@ pub(super) async fn update_provider_record_with_catalog( // #1347. super::validation::validate_object_metadata(candidate.metadata.as_ref(), "provider")?; validate_provider_mutable_fields(&candidate)?; - validate_provider_update_against_attached_sandboxes_with_catalog(store, catalog, &candidate) - .await?; + validate_provider_update_against_attached_sandboxes(store, workspace, &candidate).await?; // Serialize labels for storage let labels_map = candidate.object_labels(); @@ -282,6 +278,7 @@ pub(super) async fn update_provider_record_with_catalog( Provider::object_type(), candidate.object_id(), candidate.object_name(), + workspace, &candidate.encode_to_vec(), labels_json.as_deref(), WriteCondition::MatchResourceVersion(cas_version), @@ -311,20 +308,24 @@ pub(super) async fn update_provider_record_with_catalog( Ok(redact_provider_credentials(candidate)) } -pub(super) async fn delete_provider_record(store: &Store, name: &str) -> Result { +pub(super) async fn delete_provider_record( + store: &Store, + workspace: &str, + name: &str, +) -> Result { if name.is_empty() { return Err(Status::invalid_argument("name is required")); } let Some(provider) = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? else { return Ok(false); }; - let blocking_sandboxes = sandboxes_using_provider(store, name).await?; + let blocking_sandboxes = sandboxes_using_provider(store, workspace, name).await?; if !blocking_sandboxes.is_empty() { return Err(Status::failed_precondition(format!( "provider '{name}' is attached to sandbox(es): {}", @@ -336,7 +337,7 @@ pub(super) async fn delete_provider_record(store: &Store, name: &str) -> Result< .await?; store - .delete_by_name(Provider::object_type(), name) + .delete_by_name(Provider::object_type(), workspace, name) .await .map_err(|e| Status::internal(format!("delete provider failed: {e}"))) } @@ -346,7 +347,7 @@ pub(super) async fn delete_provider_record(store: &Store, name: &str) -> Result< /// value in the output, `None` skips it. /// /// This is the shared pagination kernel used by all sandbox-scan helpers. -async fn scan_sandboxes(store: &Store, mut f: F) -> Result, Status> +async fn scan_sandboxes(store: &Store, workspace: &str, mut f: F) -> Result, Status> where F: FnMut(Sandbox) -> Option, { @@ -354,7 +355,7 @@ where let mut offset = 0u32; loop { let records = store - .list(Sandbox::object_type(), 1000, offset) + .list(Sandbox::object_type(), workspace, 1000, offset) .await .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))?; if records.is_empty() { @@ -379,10 +380,11 @@ where async fn sandboxes_using_provider( store: &Store, + workspace: &str, provider_name: &str, ) -> Result, Status> { let provider_name = provider_name.to_string(); - let mut names = scan_sandboxes(store, |sandbox| { + let mut names = scan_sandboxes(store, workspace, |sandbox| { let spec = sandbox.spec.as_ref()?; if spec.providers.iter().any(|n| n == &provider_name) { Some(sandbox.object_name().to_string()) @@ -398,10 +400,11 @@ async fn sandboxes_using_provider( async fn sandboxes_using_provider_records( store: &Store, + workspace: &str, provider_name: &str, ) -> Result, Status> { let provider_name = provider_name.to_string(); - scan_sandboxes(store, |sandbox| { + scan_sandboxes(store, workspace, |sandbox| { let spec = sandbox.spec.as_ref()?; if spec.providers.iter().any(|n| n == &provider_name) { Some(sandbox) @@ -464,6 +467,7 @@ fn merge_i64_map( #[cfg(test)] pub(super) async fn resolve_provider_environment( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result { let catalog = ProviderProfileSources::with_default_sources() @@ -484,13 +488,13 @@ pub(super) async fn resolve_provider_environment_with_catalog( let mut env = std::collections::HashMap::new(); let mut expires = std::collections::HashMap::new(); let now_ms = crate::persistence::current_time_ms(); - validate_provider_environment_keys_unique_at(store, catalog, provider_names, None, now_ms) + validate_provider_environment_keys_unique_at(store, workspace, provider_names, None, now_ms) .await?; let registry = openshell_providers::ProviderRegistry::new(); for name in provider_names { let provider = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; @@ -538,12 +542,7 @@ pub(super) async fn resolve_provider_environment_with_catalog( Ok(ProviderEnvironment { environment: env, credential_expires_at_ms: expires, - dynamic_credentials: resolve_dynamic_credentials_with_catalog( - store, - catalog, - provider_names, - ) - .await?, + dynamic_credentials: resolve_dynamic_credentials(store, workspace, provider_names).await?, }) } @@ -554,7 +553,7 @@ pub(super) async fn resolve_provider_environment_with_catalog( /// host, port, endpoint path, and provider credential identity. pub(super) async fn resolve_dynamic_credentials_with_catalog( store: &Store, - catalog: &EffectiveProviderProfileCatalog, + workspace: &str, provider_names: &[String], ) -> Result, Status> { if provider_names.is_empty() { @@ -565,7 +564,7 @@ pub(super) async fn resolve_dynamic_credentials_with_catalog( for provider_name in provider_names { let provider = store - .get_message_by_name::(provider_name) + .get_message_by_name::(workspace, provider_name) .await .map_err(|e| { Status::internal(format!("failed to fetch provider '{provider_name}': {e}")) @@ -576,7 +575,7 @@ pub(super) async fn resolve_dynamic_credentials_with_catalog( let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = get_provider_type_profile_with_catalog(catalog, profile_id) else { + let Some(profile) = get_provider_type_profile(store, workspace, profile_id).await? else { continue; }; @@ -865,6 +864,7 @@ fn endpoint_path_matches(pattern: &str, path: &str) -> bool { pub async fn validate_provider_environment_keys_unique( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result<(), Status> { let catalog = ProviderProfileSources::with_default_sources() @@ -880,7 +880,7 @@ pub async fn validate_provider_environment_keys_unique_with_catalog( ) -> Result<(), Status> { validate_provider_environment_keys_unique_at( store, - catalog, + workspace, provider_names, None, crate::persistence::current_time_ms(), @@ -890,7 +890,7 @@ pub async fn validate_provider_environment_keys_unique_with_catalog( pub async fn validate_provider_credential_key_available_for_attached_sandboxes_with_catalog( store: &Store, - catalog: &EffectiveProviderProfileCatalog, + workspace: &str, provider: &Provider, credential_key: &str, ) -> Result<(), Status> { @@ -900,12 +900,12 @@ pub async fn validate_provider_credential_key_available_for_attached_sandboxes_w .entry(credential_key.to_string()) .or_insert_with(|| "pending".to_string()); candidate.credential_expires_at_ms.remove(credential_key); - validate_provider_update_against_attached_sandboxes_with_catalog(store, catalog, &candidate) - .await + validate_provider_update_against_attached_sandboxes(store, workspace, &candidate).await } pub async fn validate_provider_update_against_attached_sandboxes( store: &Store, + workspace: &str, provider: &Provider, ) -> Result<(), Status> { let catalog = ProviderProfileSources::with_default_sources() @@ -921,14 +921,14 @@ pub async fn validate_provider_update_against_attached_sandboxes_with_catalog( provider: &Provider, ) -> Result<(), Status> { let provider_name = provider.object_name().to_string(); - for sandbox in sandboxes_using_provider_records(store, &provider_name).await? { + for sandbox in sandboxes_using_provider_records(store, workspace, &provider_name).await? { let sandbox_name = sandbox.object_name().to_string(); let Some(spec) = sandbox.spec.as_ref() else { continue; }; validate_provider_environment_keys_unique_at( store, - catalog, + workspace, &spec.providers, Some(provider), crate::persistence::current_time_ms(), @@ -946,7 +946,7 @@ pub async fn validate_provider_update_against_attached_sandboxes_with_catalog( async fn validate_provider_environment_keys_unique_at( store: &Store, - catalog: &EffectiveProviderProfileCatalog, + workspace: &str, provider_names: &[String], candidate_provider: Option<&Provider>, now_ms: i64, @@ -957,7 +957,7 @@ async fn validate_provider_environment_keys_unique_at( let provider = match candidate_provider { Some(candidate) if candidate.object_name() == name.as_str() => candidate.clone(), _ => store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? .ok_or_else(|| { @@ -976,9 +976,8 @@ async fn validate_provider_environment_keys_unique_at( seen.insert(key, provider_name.clone()); } } - dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( - catalog, &provider, - )); + dynamic_bindings + .extend(dynamic_token_grant_bindings_for_provider(store, workspace, &provider).await?); } validate_dynamic_token_grant_bindings_unambiguous(&dynamic_bindings)?; Ok(()) @@ -994,14 +993,15 @@ struct DynamicTokenGrantBinding { score: u32, } -fn dynamic_token_grant_bindings_for_provider_with_catalog( - catalog: &EffectiveProviderProfileCatalog, +async fn dynamic_token_grant_bindings_for_provider( + store: &Store, + workspace: &str, provider: &Provider, ) -> Vec { let provider_name = provider.object_name().to_string(); let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = get_provider_type_profile_with_catalog(catalog, profile_id) else { - return Vec::new(); + let Some(profile) = get_provider_type_profile(store, workspace, profile_id).await? else { + return Ok(Vec::new()); }; dynamic_token_grant_bindings_for_profile(&provider_name, &profile.to_proto()) } @@ -1228,7 +1228,9 @@ pub(super) async fn handle_create_provider( request: Request, ) -> Result, Status> { let req = request.into_inner(); - let Some(provider) = req.provider else { + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let Some(mut provider) = req.provider else { emit_provider_lifecycle( "custom", LifecycleOperation::Create, @@ -1236,13 +1238,11 @@ pub(super) async fn handle_create_provider( ); return Err(Status::invalid_argument("provider is required")); }; + if let Some(metadata) = provider.metadata.as_mut() { + metadata.workspace.clone_from(&workspace); + } let provider_type = provider.r#type.clone(); - let catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) - .await?; - let result = - create_provider_record_with_catalog(state.store.as_ref(), &catalog, provider).await; + let result = create_provider_record(state.store.as_ref(), &workspace, provider).await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -1269,8 +1269,10 @@ pub(super) async fn handle_get_provider( state: &Arc, request: Request, ) -> Result, Status> { - let name = request.into_inner().name; - let provider = get_provider_record(state.store.as_ref(), &name).await?; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let provider = get_provider_record(state.store.as_ref(), &workspace, &req.name).await?; Ok(Response::new(ProviderResponse { provider: Some(provider), @@ -1282,8 +1284,25 @@ pub(super) async fn handle_list_providers( request: Request, ) -> Result, Status> { let request = request.into_inner(); + if request.all_workspaces && !request.workspace.is_empty() { + return Err(Status::invalid_argument( + "all_workspaces and workspace are mutually exclusive", + )); + } let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); - let providers = list_provider_records(state.store.as_ref(), limit, request.offset).await?; + + let providers = if request.all_workspaces { + let all: Vec = state + .store + .list_all_messages(limit, request.offset) + .await + .map_err(|e| Status::internal(format!("list providers failed: {e}")))?; + all.into_iter().map(redact_provider_credentials).collect() + } else { + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; + list_provider_records(state.store.as_ref(), &workspace, limit, request.offset).await? + }; Ok(Response::new(ListProvidersResponse { providers })) } @@ -1293,14 +1312,13 @@ pub(super) async fn handle_list_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE) as usize; let offset = request.offset as usize; - let catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) - .await?; - let profiles = catalog - .list_profiles() + let mut profiles = merged_provider_profiles(state.store.as_ref(), &workspace).await?; + profiles.sort_by(|left, right| left.id.cmp(&right.id)); + let profiles = profiles .into_iter() .skip(offset) .take(limit) @@ -1313,15 +1331,15 @@ pub(super) async fn handle_get_provider_profile( state: &Arc, request: Request, ) -> Result, Status> { - let id = request.into_inner().id; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let id = req.id; let id = normalize_profile_id_request(&id)?; - let catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) - .await?; - let profile = catalog - .get_profile(&id) - .ok_or_else(|| Status::not_found("provider profile not found"))?; + let profile = get_provider_type_profile(state.store.as_ref(), &workspace, &id) + .await? + .ok_or_else(|| Status::not_found("provider profile not found"))? + .to_proto(); Ok(Response::new(ProviderProfileResponse { profile: Some(profile), @@ -1333,21 +1351,19 @@ pub(super) async fn handle_import_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - let catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) - .await?; diagnostics - .extend(profile_conflict_diagnostics(state.store.as_ref(), &catalog, &profiles).await?); + .extend(profile_conflict_diagnostics(state.store.as_ref(), &workspace, &profiles).await?); diagnostics.extend(validate_profile_set(&profiles)); if !has_errors(&diagnostics) { diagnostics.extend( profile_attached_sandbox_diagnostics( state.store.as_ref(), - &catalog, + &workspace, &profiles, "import", ) @@ -1365,13 +1381,14 @@ pub(super) async fn handle_import_provider_profiles( let mut imported = Vec::with_capacity(profiles.len()); for (_, profile) in profiles { - let mut stored = stored_provider_profile(profile.to_proto()); + let mut stored = stored_provider_profile_for_workspace(profile.to_proto(), &workspace); let result = state .store .put_if( StoredProviderProfile::object_type(), stored.object_id(), stored.object_name(), + &workspace, &stored.encode_to_vec(), None, WriteCondition::MustCreate, @@ -1400,6 +1417,8 @@ pub(super) async fn handle_update_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let items = request.profile.into_iter().collect::>(); let (profiles, mut diagnostics) = profiles_from_import_items(&items); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); @@ -1410,7 +1429,7 @@ pub(super) async fn handle_update_provider_profiles( .snapshot_catalog(state.store.as_ref()) .await?; diagnostics.extend( - profile_update_target_diagnostics(state.store.as_ref(), &catalog, &profiles, &target_id) + profile_update_target_diagnostics(state.store.as_ref(), &workspace, &profiles, &target_id) .await?, ); diagnostics.extend(validate_profile_set(&profiles)); @@ -1436,7 +1455,7 @@ pub(super) async fn handle_update_provider_profiles( diagnostics.extend( profile_attached_sandbox_diagnostics( state.store.as_ref(), - &catalog, + &workspace, &profiles, "update", ) @@ -1459,7 +1478,7 @@ pub(super) async fn handle_update_provider_profiles( .ok_or_else(|| Status::internal("validated provider profile update is missing"))?; let mut stored = state .store - .get_message_by_name::(&target_id) + .get_message_by_name::(&workspace, &target_id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? .ok_or_else(|| Status::not_found("provider profile not found"))?; @@ -1488,6 +1507,7 @@ pub(super) async fn handle_update_provider_profiles( StoredProviderProfile::object_type(), stored.object_id(), stored.object_name(), + &workspace, &stored.encode_to_vec(), labels_json.as_deref(), WriteCondition::MatchResourceVersion(expected_resource_version), @@ -1514,12 +1534,7 @@ pub(super) async fn handle_lint_provider_profiles( let request = request.into_inner(); let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); - let catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) - .await?; - diagnostics - .extend(profile_conflict_diagnostics(state.store.as_ref(), &catalog, &profiles).await?); + diagnostics.extend(profile_conflict_diagnostics(state.store.as_ref(), "", &profiles).await?); diagnostics.extend(validate_profile_set(&profiles)); let valid = !has_errors(&diagnostics); @@ -1533,7 +1548,10 @@ pub(super) async fn handle_delete_provider_profile( state: &Arc, request: Request, ) -> Result, Status> { - let id = request.into_inner().id; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let id = req.id; let id = normalize_profile_id_request(&id)?; let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let catalog = state @@ -1548,14 +1566,14 @@ pub(super) async fn handle_delete_provider_profile( let existing = state .store - .get_message_by_name::(&id) + .get_message_by_name::(&workspace, &id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))?; if existing.is_none() { return Err(Status::not_found("provider profile not found")); } - let blocking_sandboxes = sandboxes_using_profile(state.store.as_ref(), &id).await?; + let blocking_sandboxes = sandboxes_using_profile(state.store.as_ref(), &workspace, &id).await?; if !blocking_sandboxes.is_empty() { return Err(Status::failed_precondition(format!( "provider profile '{id}' is in use by sandboxes: {}", @@ -1565,27 +1583,50 @@ pub(super) async fn handle_delete_provider_profile( let deleted = state .store - .delete_by_name(StoredProviderProfile::object_type(), &id) + .delete_by_name(StoredProviderProfile::object_type(), &workspace, &id) .await .map_err(|e| Status::internal(format!("delete provider profile failed: {e}")))?; Ok(Response::new(DeleteProviderProfileResponse { deleted })) } -pub(super) fn get_provider_type_profile_with_catalog( - catalog: &EffectiveProviderProfileCatalog, +pub(super) async fn get_provider_type_profile( + store: &Store, + workspace: &str, id: &str, -) -> Option { - catalog.get_type_profile(id) +) -> Result, Status> { + let Some(id) = normalize_profile_id(id) else { + return Ok(None); + }; + if let Some(profile) = get_default_profile(&id) { + return Ok(Some(profile.clone())); + } + let profile = store + .get_message_by_name::(workspace, &id) + .await + .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? + .and_then(|stored| { + let resource_version = stored_profile_resource_version(&stored); + stored.profile.map(|profile| { + ProviderTypeProfile::from_proto(&profile_response_payload( + profile, + resource_version, + )) + }) + }); + Ok(profile) } -fn provider_refresh_defaults( - catalog: &EffectiveProviderProfileCatalog, +async fn provider_refresh_defaults( + store: &Store, + workspace: &str, provider: &Provider, credential_key: &str, -) -> Option { - let profile = get_provider_type_profile_with_catalog(catalog, &provider.r#type)?; - profile +) -> Result, Status> { + let Some(profile) = get_provider_type_profile(store, workspace, &provider.r#type).await? else { + return Ok(None); + }; + Ok(profile .credentials .iter() .find(|credential| { @@ -1623,14 +1664,48 @@ fn validate_refresh_material( Ok(()) } -fn provider_type_allows_empty_credentials( - catalog: &EffectiveProviderProfileCatalog, +async fn provider_type_allows_empty_credentials( + store: &Store, + workspace: &str, provider_type: &str, -) -> bool { - let Some(profile) = get_provider_type_profile_with_catalog(catalog, provider_type) else { - return false; +) -> Result { + let Some(profile) = get_provider_type_profile(store, workspace, provider_type).await? else { + return Ok(false); }; - profile.allows_empty_provider_credentials() + Ok(profile.allows_empty_provider_credentials()) +} + +async fn merged_provider_profiles( + store: &Store, + workspace: &str, +) -> Result, Status> { + let mut profiles = default_profiles().to_vec(); + profiles.extend( + custom_provider_profiles(store, workspace) + .await? + .into_iter() + .filter_map(|stored| { + let resource_version = stored_profile_resource_version(&stored); + stored.profile.map(|profile| { + ProviderTypeProfile::from_proto(&profile_response_payload( + profile, + resource_version, + )) + }) + }), + ); + Ok(profiles) +} + +async fn custom_provider_profiles( + store: &Store, + workspace: &str, +) -> Result, Status> { + let profiles: Vec = store + .list_messages(workspace, 10_000, 0) + .await + .map_err(|e| Status::internal(format!("list provider profiles failed: {e}")))?; + Ok(profiles) } fn normalize_profile_id_request(id: &str) -> Result { @@ -1684,7 +1759,7 @@ fn add_empty_profile_set_diagnostic( async fn profile_conflict_diagnostics( store: &Store, - catalog: &EffectiveProviderProfileCatalog, + workspace: &str, profiles: &[(String, ProviderTypeProfile)], ) -> Result, Status> { let mut diagnostics = Vec::new(); @@ -1705,7 +1780,7 @@ async fn profile_conflict_diagnostics( continue; } if store - .get_message_by_name::(&id) + .get_message_by_name::(workspace, &id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? .is_some() @@ -1724,7 +1799,7 @@ async fn profile_conflict_diagnostics( async fn profile_update_target_diagnostics( store: &Store, - catalog: &EffectiveProviderProfileCatalog, + workspace: &str, profiles: &[(String, ProviderTypeProfile)], target_id: &str, ) -> Result, Status> { @@ -1758,7 +1833,7 @@ async fn profile_update_target_diagnostics( return Ok(diagnostics); } if store - .get_message_by_name::(target_id) + .get_message_by_name::(workspace, target_id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? .is_none() @@ -1792,7 +1867,7 @@ async fn profile_update_target_diagnostics( async fn profile_attached_sandbox_diagnostics( store: &Store, - catalog: &EffectiveProviderProfileCatalog, + workspace: &str, profiles: &[(String, ProviderTypeProfile)], operation: &str, ) -> Result, Status> { @@ -1808,7 +1883,7 @@ async fn profile_attached_sandbox_diagnostics( return Ok(Vec::new()); } - let sandboxes = scan_sandboxes(store, |sandbox| { + let sandboxes = scan_sandboxes(store, workspace, |sandbox| { sandbox .spec .as_ref() @@ -1825,7 +1900,7 @@ async fn profile_attached_sandbox_diagnostics( for provider_name in &spec.providers { let Some(provider) = store - .get_message_by_name::(provider_name) + .get_message_by_name::(workspace, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? else { @@ -1843,9 +1918,9 @@ async fn profile_attached_sandbox_diagnostics( imported_profiles_used.push(used); } } else { - bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( - catalog, &provider, - )); + bindings.extend( + dynamic_token_grant_bindings_for_provider(store, workspace, &provider).await?, + ); } } @@ -1871,6 +1946,51 @@ async fn profile_attached_sandbox_diagnostics( Ok(diagnostics) } +fn stored_provider_profile_for_workspace( + profile: ProviderProfile, + workspace: &str, +) -> StoredProviderProfile { + use crate::persistence::current_time_ms; + let now_ms = current_time_ms(); + let profile = profile_storage_payload(profile); + StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: uuid::Uuid::new_v4().to_string(), + name: profile.id.clone(), + created_at_ms: now_ms, + labels: std::collections::HashMap::new(), + resource_version: 0, + workspace: workspace.to_string(), + }), + profile: Some(profile), + } +} + +#[cfg(test)] +fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { + stored_provider_profile_for_workspace(profile, "default") +} + +fn profile_storage_payload(mut profile: ProviderProfile) -> ProviderProfile { + profile.resource_version = 0; + profile +} + +fn profile_response_payload( + mut profile: ProviderProfile, + resource_version: u64, +) -> ProviderProfile { + profile.resource_version = resource_version; + profile +} + +fn stored_profile_resource_version(stored: &StoredProviderProfile) -> u64 { + stored + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version) +} + fn proto_diagnostic(diagnostic: ProfileValidationDiagnostic) -> ProviderProfileDiagnostic { ProviderProfileDiagnostic { source: diagnostic.source, @@ -1887,10 +2007,14 @@ fn has_errors(diagnostics: &[ProfileValidationDiagnostic]) -> bool { .any(|diagnostic| diagnostic.severity == "error") } -async fn sandboxes_using_profile(store: &Store, profile_id: &str) -> Result, Status> { +async fn sandboxes_using_profile( + store: &Store, + workspace: &str, + profile_id: &str, +) -> Result, Status> { // Collect all sandboxes that reference at least one provider — pagination // is handled by `scan_sandboxes`; the async provider lookup happens below. - let candidates = scan_sandboxes(store, |sandbox| { + let candidates = scan_sandboxes(store, workspace, |sandbox| { let has_providers = sandbox .spec .as_ref() @@ -1904,7 +2028,7 @@ async fn sandboxes_using_profile(store: &Store, profile_id: &str) -> Result(provider_name) + .get_message_by_name::(workspace, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? else { @@ -1926,6 +2050,8 @@ pub(super) async fn handle_update_provider( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; let Some(mut provider) = req.provider else { emit_provider_lifecycle( "custom", @@ -1938,12 +2064,7 @@ pub(super) async fn handle_update_provider( provider .credential_expires_at_ms .extend(req.credential_expires_at_ms); - let catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) - .await?; - let result = - update_provider_record_with_catalog(state.store.as_ref(), &catalog, provider).await; + let result = update_provider_record(state.store.as_ref(), &workspace, provider).await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -1971,12 +2092,14 @@ pub(super) async fn handle_get_provider_refresh_status( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; if request.provider.trim().is_empty() { return Err(Status::invalid_argument("provider is required")); } let provider = state .store - .get_message_by_name::(&request.provider) + .get_message_by_name::(&workspace, &request.provider) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; @@ -1990,6 +2113,7 @@ pub(super) async fn handle_get_provider_refresh_status( } else { crate::provider_refresh::get_refresh_state( state.store.as_ref(), + &workspace, provider.object_id(), request.credential_key.trim(), ) @@ -2011,6 +2135,8 @@ pub(super) async fn handle_configure_provider_refresh( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2093,7 +2219,7 @@ pub(super) async fn handle_configure_provider_refresh( let provider = state .store - .get_message_by_name::(provider_name) + .get_message_by_name::(&workspace, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; @@ -2103,12 +2229,14 @@ pub(super) async fn handle_configure_provider_refresh( .await?; validate_provider_credential_key_available_for_attached_sandboxes_with_catalog( state.store.as_ref(), - &catalog, + &workspace, &provider, credential_key, ) .await?; - let refresh_defaults = provider_refresh_defaults(&catalog, &provider, credential_key); + let refresh_defaults = + provider_refresh_defaults(state.store.as_ref(), &workspace, &provider, credential_key) + .await?; validate_refresh_material(&request.material, refresh_defaults.as_ref())?; let material_scopes = crate::provider_refresh::material_scopes(&request.material); let token_url = refresh_defaults @@ -2151,6 +2279,7 @@ pub(super) async fn handle_configure_provider_refresh( } let existing_refresh_state = crate::provider_refresh::get_refresh_state( state.store.as_ref(), + &workspace, provider.object_id(), credential_key, ) @@ -2163,6 +2292,7 @@ pub(super) async fn handle_configure_provider_refresh( }); let mut state_record = crate::provider_refresh::new_refresh_state( &provider, + &workspace, credential_key, crate::provider_refresh::NewRefreshStateConfig { strategy, @@ -2189,7 +2319,7 @@ pub(super) async fn handle_configure_provider_refresh( created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: String::new(), }), r#type: String::new(), credentials: std::collections::HashMap::new(), @@ -2199,7 +2329,7 @@ pub(super) async fn handle_configure_provider_refresh( expires_at_ms, )]), }; - update_provider_record_with_catalog(state.store.as_ref(), &catalog, updated).await?; + update_provider_record(state.store.as_ref(), &workspace, updated).await?; } Ok(Response::new(ConfigureProviderRefreshResponse { @@ -2214,6 +2344,8 @@ pub(super) async fn handle_rotate_provider_credential( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2224,6 +2356,7 @@ pub(super) async fn handle_rotate_provider_credential( } let refresh_state = crate::provider_refresh::refresh_provider_credential( state.store.as_ref(), + &workspace, provider_name, credential_key, ) @@ -2241,6 +2374,8 @@ pub(super) async fn handle_delete_provider_refresh( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2251,18 +2386,20 @@ pub(super) async fn handle_delete_provider_refresh( } let provider = state .store - .get_message_by_name::(provider_name) + .get_message_by_name::(&workspace, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; let existing_refresh_state = crate::provider_refresh::get_refresh_state( state.store.as_ref(), + &workspace, provider.object_id(), credential_key, ) .await?; let deleted_refresh_state = crate::provider_refresh::delete_refresh_state( state.store.as_ref(), + &workspace, provider.object_id(), credential_key, ) @@ -2289,7 +2426,7 @@ pub(super) async fn handle_delete_provider_refresh( created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: String::new(), }), r#type: String::new(), credentials: std::collections::HashMap::new(), @@ -2299,7 +2436,7 @@ pub(super) async fn handle_delete_provider_refresh( 0, )]), }; - update_provider_record_with_catalog(state.store.as_ref(), &catalog, updated).await?; + update_provider_record(state.store.as_ref(), &workspace, updated).await?; } Ok(Response::new(DeleteProviderRefreshResponse { @@ -2311,9 +2448,12 @@ pub(super) async fn handle_delete_provider( state: &Arc, request: Request, ) -> Result, Status> { - let name = request.into_inner().name; - let provider_profile = provider_profile_for_name(state.store.as_ref(), &name).await; - let result = delete_provider_record(state.store.as_ref(), &name).await; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let name = req.name; + let provider_profile = provider_profile_for_name(state.store.as_ref(), &workspace, &name).await; + let result = delete_provider_record(state.store.as_ref(), &workspace, &name).await; match result { Ok(deleted) => { let outcome = TelemetryOutcome::from_success(deleted); @@ -2352,9 +2492,13 @@ fn emit_provider_profile_lifecycle( openshell_core::telemetry::emit_provider_lifecycle(operation, outcome, provider_profile); } -async fn provider_profile_for_name(store: &Store, name: &str) -> Option { +async fn provider_profile_for_name( + store: &Store, + workspace: &str, + name: &str, +) -> Option { store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .ok() .flatten() @@ -2556,6 +2700,7 @@ mod tests { profile: Some(profile), source: format!("{id}.yaml"), }], + workspace: "default".to_string(), }), ) .await @@ -2569,6 +2714,7 @@ mod tests { ) -> Provider { create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -2576,7 +2722,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), @@ -2599,6 +2745,7 @@ mod tests { let err = validate_provider_environment_keys_unique( store, + "default", &["provider-a".to_string(), "provider-b".to_string()], ) .await @@ -2627,6 +2774,7 @@ mod tests { validate_provider_environment_keys_unique( store, + "default", &["provider-default".to_string(), "provider-admin".to_string()], ) .await @@ -2642,6 +2790,7 @@ mod tests { create_empty_token_grant_provider(store, "provider-existing", "grant-existing").await; create_provider_record( store, + "default", provider_with_values("provider-candidate", "grant-new"), ) .await @@ -2654,7 +2803,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2684,6 +2833,7 @@ mod tests { profile: Some(profile), source: "grant-new.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -2711,6 +2861,7 @@ mod tests { profile: Some(custom_profile("guarded-import")), source: "guarded-import.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -2743,7 +2894,7 @@ mod tests { state.store.put_message(&stored).await.unwrap(); let before: StoredProviderProfile = state .store - .get_message_by_name("custom-api") + .get_message_by_name("default", "custom-api") .await .unwrap() .unwrap(); @@ -2765,6 +2916,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2779,7 +2931,7 @@ mod tests { ); let after: StoredProviderProfile = state .store - .get_message_by_name("custom-api") + .get_message_by_name("default", "custom-api") .await .unwrap() .unwrap(); @@ -2809,6 +2961,7 @@ mod tests { }), expected_resource_version: 0, id: "github".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2830,6 +2983,7 @@ mod tests { }), expected_resource_version: 0, id: "missing-custom".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2861,6 +3015,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2883,6 +3038,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2910,7 +3066,7 @@ mod tests { .unwrap(); let profile_a_version = state .store - .get_message_by_name::("profile-a") + .get_message_by_name::("default", "profile-a") .await .unwrap() .unwrap() @@ -2931,6 +3087,7 @@ mod tests { }), expected_resource_version: 0, id: "profile-a".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2946,7 +3103,7 @@ mod tests { })); let stored_b: StoredProviderProfile = state .store - .get_message_by_name("profile-b") + .get_message_by_name("default", "profile-b") .await .unwrap() .unwrap(); @@ -2970,7 +3127,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2986,7 +3143,7 @@ mod tests { let mut profile = custom_profile("grant-updated"); profile.resource_version = store - .get_message_by_name::("grant-updated") + .get_message_by_name::("default", "grant-updated") .await .unwrap() .unwrap() @@ -3011,6 +3168,7 @@ mod tests { }), expected_resource_version: 0, id: "grant-updated".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3033,7 +3191,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: [ @@ -3124,6 +3282,7 @@ mod tests { profile: Some(profile), source: format!("{id}.yaml"), }], + workspace: "default".to_string(), }), ) .await @@ -3183,6 +3342,7 @@ mod tests { Request::new(ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: "default".to_string(), }), ) .await @@ -3229,6 +3389,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "github".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3246,6 +3407,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "generic".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3263,6 +3425,7 @@ mod tests { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3277,6 +3440,7 @@ mod tests { Request::new(ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: "default".to_string(), }), ) .await @@ -3293,6 +3457,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3313,6 +3478,7 @@ mod tests { profile: Some(custom_profile("github")), source: "github.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3340,6 +3506,7 @@ mod tests { profile: Some(custom_profile("custom-llm")), source: "custom-llm.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3353,6 +3520,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "custom-llm".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3383,6 +3551,7 @@ mod tests { source: "case.yaml".to_string(), }, ], + workspace: "default".to_string(), }), ) .await @@ -3410,6 +3579,7 @@ mod tests { profile: Some(custom_profile("alex-api")), source: "alex-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3419,6 +3589,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: " Alex-API ".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3432,6 +3603,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: " Alex-API ".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3460,6 +3632,7 @@ mod tests { source: "bulk-two.yaml".to_string(), }, ], + workspace: "default".to_string(), }), ) .await @@ -3477,7 +3650,10 @@ mod tests { for id in ["bulk-one", "bulk-two"] { let missing = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { id: id.to_string() }), + Request::new(GetProviderProfileRequest { + id: id.to_string(), + workspace: "default".to_string(), + }), ) .await .unwrap_err(); @@ -3526,6 +3702,7 @@ mod tests { }), source: "advanced-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3538,6 +3715,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "advanced-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3581,6 +3759,7 @@ mod tests { source: "lint-two.yaml".to_string(), }, ], + workspace: "default".to_string(), }), ) .await @@ -3597,7 +3776,10 @@ mod tests { for id in ["lint-one", "lint-two"] { let missing = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { id: id.to_string() }), + Request::new(GetProviderProfileRequest { + id: id.to_string(), + workspace: "default".to_string(), + }), ) .await .unwrap_err(); @@ -3615,6 +3797,7 @@ mod tests { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3624,6 +3807,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: "github".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3632,6 +3816,7 @@ mod tests { create_provider_record( state.store.as_ref(), + "default", provider_with_values("custom-provider", "custom-api"), ) .await @@ -3645,7 +3830,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["custom-provider".to_string()], @@ -3660,6 +3845,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3674,6 +3860,7 @@ mod tests { import_test_graph_refresh_profile(&state).await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3681,7 +3868,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3710,6 +3897,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: Some(expires_at_ms), + workspace: "default".to_string(), }), ) .await @@ -3724,6 +3912,7 @@ mod tests { Request::new(GetProviderRefreshStatusRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3734,7 +3923,7 @@ mod tests { let provider = state .store - .get_message_by_name::("msgraph") + .get_message_by_name::("default", "msgraph") .await .unwrap() .expect("provider"); @@ -3750,6 +3939,7 @@ mod tests { Request::new(DeleteProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3762,6 +3952,7 @@ mod tests { Request::new(GetProviderRefreshStatusRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3771,7 +3962,7 @@ mod tests { let provider_after_delete = state .store - .get_message_by_name::("msgraph") + .get_message_by_name::("default", "msgraph") .await .unwrap() .expect("provider"); @@ -3920,6 +4111,7 @@ mod tests { let state = test_server_state().await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3927,7 +4119,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -3960,6 +4152,7 @@ mod tests { ]), secret_material_keys: vec!["private_key".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -3984,6 +4177,7 @@ mod tests { import_test_graph_refresh_profile(&state).await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3991,7 +4185,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -4020,6 +4214,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: Some(refresh_expires_at_ms), + workspace: "default".to_string(), }), ) .await @@ -4028,6 +4223,7 @@ mod tests { let manual_expires_at_ms = refresh_expires_at_ms + 60_000; update_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4035,7 +4231,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4054,6 +4250,7 @@ mod tests { Request::new(DeleteProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4063,7 +4260,7 @@ mod tests { let provider_after_delete = state .store - .get_message_by_name::("msgraph") + .get_message_by_name::("default", "msgraph") .await .unwrap() .expect("provider"); @@ -4081,6 +4278,7 @@ mod tests { import_test_graph_refresh_profile(&state).await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4088,7 +4286,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -4104,6 +4302,7 @@ mod tests { .unwrap(); create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4111,7 +4310,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(("OTHER_TOKEN".to_string(), "other".to_string())) @@ -4131,7 +4330,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -4155,6 +4354,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4176,6 +4376,7 @@ mod tests { for name in ["first-graph", "second-graph"] { create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4183,7 +4384,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: HashMap::new(), @@ -4203,7 +4404,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["first-graph".to_string(), "second-graph".to_string()], @@ -4227,6 +4428,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4245,6 +4447,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4263,6 +4466,7 @@ mod tests { import_test_graph_refresh_profile(&state).await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4270,7 +4474,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -4302,6 +4506,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4318,6 +4523,7 @@ mod tests { material: HashMap::from([("tenant_id".to_string(), "tenant".to_string())]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4331,6 +4537,7 @@ mod tests { let state = test_server_state().await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4338,7 +4545,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -4366,6 +4573,7 @@ mod tests { material: HashMap::new(), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4394,6 +4602,7 @@ mod tests { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -4403,6 +4612,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4414,6 +4624,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4437,6 +4648,7 @@ mod tests { &task_state, Request::new(DeleteProviderProfileRequest { id: "guarded-delete".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4463,7 +4675,7 @@ mod tests { let store = test_store().await; let created = provider_with_values("gitlab-local", "gitlab"); - let persisted = create_provider_record(&store, created.clone()) + let persisted = create_provider_record(&store, "default", created.clone()) .await .unwrap(); assert_eq!(persisted.object_name(), "gitlab-local"); @@ -4471,18 +4683,25 @@ mod tests { assert!(!persisted.object_id().is_empty()); let provider_id = persisted.object_id().to_string(); - let duplicate_err = create_provider_record(&store, created).await.unwrap_err(); + let duplicate_err = create_provider_record(&store, "default", created) + .await + .unwrap_err(); assert_eq!(duplicate_err.code(), Code::AlreadyExists); - let loaded = get_provider_record(&store, "gitlab-local").await.unwrap(); + let loaded = get_provider_record(&store, "default", "gitlab-local") + .await + .unwrap(); assert_eq!(loaded.object_id(), provider_id); - let listed = list_provider_records(&store, 100, 0).await.unwrap(); + let listed = list_provider_records(&store, "default", 100, 0) + .await + .unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].object_name(), "gitlab-local"); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4490,7 +4709,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -4517,7 +4736,7 @@ mod tests { Some(&"REDACTED".to_string()), ); let stored: Provider = store - .get_message_by_name("gitlab-local") + .get_message_by_name("default", "gitlab-local") .await .unwrap() .unwrap(); @@ -4535,17 +4754,17 @@ mod tests { ); assert_eq!(updated.config.get("region"), Some(&"us-west".to_string())); - let deleted = delete_provider_record(&store, "gitlab-local") + let deleted = delete_provider_record(&store, "default", "gitlab-local") .await .unwrap(); assert!(deleted); - let deleted_again = delete_provider_record(&store, "gitlab-local") + let deleted_again = delete_provider_record(&store, "default", "gitlab-local") .await .unwrap(); assert!(!deleted_again); - let missing = get_provider_record(&store, "gitlab-local") + let missing = get_provider_record(&store, "default", "gitlab-local") .await .unwrap_err(); assert_eq!(missing.code(), Code::NotFound); @@ -4557,6 +4776,7 @@ mod tests { let provider = create_provider_record( &store, + "default", Provider { credential_expires_at_ms: HashMap::from([("API_TOKEN".to_string(), 123_456)]), ..provider_with_values("gitlab-local", "gitlab") @@ -4566,6 +4786,7 @@ mod tests { .unwrap(); let refresh_state = crate::provider_refresh::new_refresh_state( &provider, + "default", "API_TOKEN", crate::provider_refresh::NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::External, @@ -4586,7 +4807,7 @@ mod tests { .await .unwrap(); - let deleted = delete_provider_record(&store, "gitlab-local") + let deleted = delete_provider_record(&store, "default", "gitlab-local") .await .unwrap(); assert!(deleted); @@ -4602,9 +4823,13 @@ mod tests { async fn delete_provider_rejects_attached_provider() { let store = test_store().await; - create_provider_record(&store, provider_with_values("gitlab-local", "gitlab")) - .await - .unwrap(); + create_provider_record( + &store, + "default", + provider_with_values("gitlab-local", "gitlab"), + ) + .await + .unwrap(); store .put_message(&Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -4613,7 +4838,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["gitlab-local".to_string()], @@ -4624,7 +4849,7 @@ mod tests { .await .unwrap(); - let err = delete_provider_record(&store, "gitlab-local") + let err = delete_provider_record(&store, "default", "gitlab-local") .await .unwrap_err(); assert_eq!(err.code(), Code::FailedPrecondition); @@ -4641,7 +4866,9 @@ mod tests { // Create provider and verify resource_version: 1 in response let created = provider_with_values("test-provider", "openai"); - let persisted = create_provider_record(&store, created).await.unwrap(); + let persisted = create_provider_record(&store, "default", created) + .await + .unwrap(); assert_eq!( persisted.metadata.as_ref().unwrap().resource_version, 1, @@ -4651,6 +4878,7 @@ mod tests { // Update provider and verify resource_version: 2 in response let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4658,7 +4886,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -4681,6 +4909,7 @@ mod tests { // Update again and verify resource_version: 3 let updated_again = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4688,7 +4917,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -4716,6 +4945,7 @@ mod tests { let create_missing_type = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4723,7 +4953,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4737,6 +4967,7 @@ mod tests { let create_missing_credentials = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4744,7 +4975,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "gitlab".to_string(), credentials: HashMap::new(), @@ -4807,12 +5038,14 @@ mod tests { }), source: "delegated-refresh-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await .unwrap(); let delegated_refresh_bootstrap_provider = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4820,7 +5053,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "delegated-refresh-api".to_string(), credentials: HashMap::new(), @@ -4844,12 +5077,14 @@ mod tests { profile: Some(mixed_required_profile), source: "mixed-required-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await .unwrap(); let mixed_required_empty = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4857,7 +5092,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "mixed-required-api".to_string(), credentials: HashMap::new(), @@ -4881,12 +5116,14 @@ mod tests { profile: Some(optional_static_profile), source: "optional-static-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await .unwrap(); let optional_static_empty = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4894,7 +5131,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "optional-static-api".to_string(), credentials: HashMap::new(), @@ -4908,6 +5145,7 @@ mod tests { let vertex_empty = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4915,7 +5153,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: HashMap::new(), @@ -4927,14 +5165,17 @@ mod tests { .unwrap(); assert!(vertex_empty.credentials.is_empty()); - let get_err = get_provider_record(store, "").await.unwrap_err(); + let get_err = get_provider_record(store, "default", "").await.unwrap_err(); assert_eq!(get_err.code(), Code::InvalidArgument); - let delete_err = delete_provider_record(store, "").await.unwrap_err(); + let delete_err = delete_provider_record(store, "default", "") + .await + .unwrap_err(); assert_eq!(delete_err.code(), Code::InvalidArgument); let update_missing_err = update_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4942,7 +5183,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4960,10 +5201,13 @@ mod tests { let store = test_store().await; let created = provider_with_values("noop-test", "nvidia"); - let persisted = create_provider_record(&store, created).await.unwrap(); + let persisted = create_provider_record(&store, "default", created) + .await + .unwrap(); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4971,7 +5215,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4996,7 +5240,7 @@ mod tests { ); assert_eq!(updated.config.get("region"), Some(&"us-west".to_string())); let stored: Provider = store - .get_message_by_name("noop-test") + .get_message_by_name("default", "noop-test") .await .unwrap() .unwrap(); @@ -5008,10 +5252,13 @@ mod tests { let store = test_store().await; let created = provider_with_values("delete-key-test", "openai"); - create_provider_record(&store, created).await.unwrap(); + create_provider_record(&store, "default", created) + .await + .unwrap(); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5019,7 +5266,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: String::new(), credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(), @@ -5043,7 +5290,7 @@ mod tests { ); assert!(!updated.config.contains_key("region")); let stored: Provider = store - .get_message_by_name("delete-key-test") + .get_message_by_name("default", "delete-key-test") .await .unwrap() .unwrap(); @@ -5060,10 +5307,13 @@ mod tests { let store = test_store().await; let created = provider_with_values("type-preserve-test", "anthropic"); - create_provider_record(&store, created).await.unwrap(); + create_provider_record(&store, "default", created) + .await + .unwrap(); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5071,7 +5321,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), @@ -5090,10 +5340,13 @@ mod tests { let store = test_store().await; let created = provider_with_values("type-change-test", "nvidia"); - create_provider_record(&store, created).await.unwrap(); + create_provider_record(&store, "default", created) + .await + .unwrap(); let err = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5101,7 +5354,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: HashMap::new(), @@ -5121,11 +5374,14 @@ mod tests { let store = test_store().await; let created = provider_with_values("validate-merge-test", "gitlab"); - create_provider_record(&store, created).await.unwrap(); + create_provider_record(&store, "default", created) + .await + .unwrap(); let oversized_key = "K".repeat(MAX_MAP_KEY_LEN + 1); let err = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5133,7 +5389,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: String::new(), credentials: std::iter::once((oversized_key, "value".to_string())).collect(), @@ -5162,7 +5418,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: oversized_type.clone(), credentials: std::iter::once(("API_TOKEN".to_string(), "old".to_string())).collect(), @@ -5173,6 +5429,7 @@ mod tests { let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5180,7 +5437,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: String::new(), credentials: std::iter::once(("API_TOKEN".to_string(), "new".to_string())) @@ -5198,7 +5455,9 @@ mod tests { #[tokio::test] async fn resolve_provider_env_empty_list_returns_empty() { let store = test_store().await; - let result = resolve_provider_environment(&store, &[]).await.unwrap(); + let result = resolve_provider_environment(&store, "default", &[]) + .await + .unwrap(); assert!(result.is_empty()); } @@ -5212,7 +5471,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "claude".to_string(), credentials: [ @@ -5228,9 +5487,11 @@ mod tests { .collect(), credential_expires_at_ms: HashMap::new(), }; - create_provider_record(&store, provider).await.unwrap(); + create_provider_record(&store, "default", provider) + .await + .unwrap(); - let result = resolve_provider_environment(&store, &["claude-local".to_string()]) + let result = resolve_provider_environment(&store, "default", &["claude-local".to_string()]) .await .unwrap(); assert_eq!(result.get("ANTHROPIC_API_KEY"), Some(&"sk-abc".to_string())); @@ -5243,14 +5504,16 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", provider_with_values("static-provider", "unprofiled-static-api"), ) .await .unwrap(); - let result = resolve_provider_environment(&store, &["static-provider".to_string()]) - .await - .unwrap(); + let result = + resolve_provider_environment(&store, "default", &["static-provider".to_string()]) + .await + .unwrap(); assert_eq!(result.get("API_TOKEN"), Some(&"token-123".to_string())); assert!(result.dynamic_credentials.is_empty()); @@ -5267,7 +5530,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "test".to_string(), credentials: [ @@ -5284,11 +5547,14 @@ mod tests { .into_iter() .collect(), }; - create_provider_record(&store, provider).await.unwrap(); - - let result = resolve_provider_environment(&store, &["expiring-provider".to_string()]) + create_provider_record(&store, "default", provider) .await .unwrap(); + + let result = + resolve_provider_environment(&store, "default", &["expiring-provider".to_string()]) + .await + .unwrap(); assert_eq!(result.get("FRESH_TOKEN"), Some(&"fresh".to_string())); assert!(!result.contains_key("STALE_TOKEN")); assert_eq!( @@ -5300,7 +5566,7 @@ mod tests { #[tokio::test] async fn resolve_provider_env_unknown_name_returns_error() { let store = test_store().await; - let err = resolve_provider_environment(&store, &["nonexistent".to_string()]) + let err = resolve_provider_environment(&store, "default", &["nonexistent".to_string()]) .await .unwrap_err(); assert_eq!(err.code(), Code::FailedPrecondition); @@ -5317,7 +5583,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "test".to_string(), credentials: [ @@ -5330,11 +5596,14 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), }; - create_provider_record(&store, provider).await.unwrap(); - - let result = resolve_provider_environment(&store, &["test-provider".to_string()]) + create_provider_record(&store, "default", provider) .await .unwrap(); + + let result = + resolve_provider_environment(&store, "default", &["test-provider".to_string()]) + .await + .unwrap(); assert_eq!(result.get("VALID_KEY"), Some(&"value".to_string())); assert!(!result.contains_key("nested.api_key")); assert!(!result.contains_key("bad-key")); @@ -5345,6 +5614,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5352,7 +5622,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -5368,6 +5638,7 @@ mod tests { .unwrap(); create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5375,7 +5646,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(("GITLAB_TOKEN".to_string(), "glpat-xyz".to_string())) @@ -5389,6 +5660,7 @@ mod tests { let result = resolve_provider_environment( &store, + "default", &["claude-local".to_string(), "gitlab-local".to_string()], ) .await @@ -5402,6 +5674,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5409,7 +5682,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "claude".to_string(), credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) @@ -5422,6 +5695,7 @@ mod tests { .unwrap(); create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5429,7 +5703,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -5446,6 +5720,7 @@ mod tests { let err = resolve_provider_environment( &store, + "default", &["provider-a".to_string(), "provider-b".to_string()], ) .await @@ -5461,6 +5736,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5468,7 +5744,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5491,7 +5767,7 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-local".to_string()]) + let result = resolve_provider_environment(&store, "default", &["vertex-local".to_string()]) .await .unwrap(); @@ -5536,6 +5812,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5543,7 +5820,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5565,9 +5842,10 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-bootstrap".to_string()]) - .await - .unwrap(); + let result = + resolve_provider_environment(&store, "default", &["vertex-bootstrap".to_string()]) + .await + .unwrap(); assert!(!result.contains_key("GOOGLE_SERVICE_ACCOUNT_KEY")); assert_eq!( @@ -5581,6 +5859,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5588,7 +5867,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5603,9 +5882,10 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-no-config".to_string()]) - .await - .unwrap(); + let result = + resolve_provider_environment(&store, "default", &["vertex-no-config".to_string()]) + .await + .unwrap(); // Static flags still present. assert!(!result.contains_key("CLAUDE_CODE_USE_VERTEX")); @@ -5630,6 +5910,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5637,7 +5918,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5662,9 +5943,10 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-collision".to_string()]) - .await - .unwrap(); + let result = + resolve_provider_environment(&store, "default", &["vertex-collision".to_string()]) + .await + .unwrap(); // Credential value wins over the injected static value. assert_eq!( @@ -5678,6 +5960,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5685,7 +5968,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) @@ -5697,7 +5980,7 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["openai-local".to_string()]) + let result = resolve_provider_environment(&store, "default", &["openai-local".to_string()]) .await .unwrap(); @@ -5717,6 +6000,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5724,7 +6008,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -5740,6 +6024,7 @@ mod tests { .unwrap(); create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5747,7 +6032,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "google-drive".to_string(), credentials: std::iter::once(( @@ -5768,7 +6053,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], @@ -5780,6 +6065,7 @@ mod tests { let err = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5787,7 +6073,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: String::new(), credentials: std::iter::once(( @@ -5815,6 +6101,7 @@ mod tests { create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5822,7 +6109,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -5844,7 +6131,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["my-claude".to_string()], @@ -5861,7 +6148,7 @@ mod tests { .unwrap() .unwrap(); let spec = loaded.spec.unwrap(); - let env = resolve_provider_environment(&store, &spec.providers) + let env = resolve_provider_environment(&store, "default", &spec.providers) .await .unwrap(); @@ -5881,7 +6168,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec::default()), status: None, @@ -5895,7 +6182,7 @@ mod tests { .unwrap() .unwrap(); let spec = loaded.spec.unwrap(); - let env = resolve_provider_environment(&store, &spec.providers) + let env = resolve_provider_environment(&store, "default", &spec.providers) .await .unwrap(); @@ -5917,7 +6204,7 @@ mod tests { // Create a valid provider let provider = provider_with_values("test-validate-provider", "test-type"); - let created = create_provider_record(&store, provider.clone()) + let created = create_provider_record(&store, "default", provider.clone()) .await .unwrap(); @@ -5929,7 +6216,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: String::new(), // Empty type is ignored in update credentials: HashMap::new(), @@ -5943,7 +6230,7 @@ mod tests { "oversized-key-value".to_string(), ); - let result = update_provider_record(&store, update_req).await; + let result = update_provider_record(&store, "default", update_req).await; // Update should fail with InvalidArgument due to oversized key assert!(result.is_err(), "update with invalid data should fail"); @@ -5961,7 +6248,7 @@ mod tests { // Verify database still contains the ORIGINAL valid provider (not the invalid one) let stored = store - .get_message_by_name::("test-validate-provider") + .get_message_by_name::("default", "test-validate-provider") .await .unwrap() .expect("provider should still exist"); @@ -5993,11 +6280,17 @@ mod tests { // Spawn two concurrent creation attempts for the same provider let store1 = store.clone(); let provider1 = provider.clone(); - let handle1 = tokio::spawn(async move { create_provider_record(&store1, provider1).await }); + let handle1 = + tokio::spawn( + async move { create_provider_record(&store1, "default", provider1).await }, + ); let store2 = store.clone(); let provider2 = provider.clone(); - let handle2 = tokio::spawn(async move { create_provider_record(&store2, provider2).await }); + let handle2 = + tokio::spawn( + async move { create_provider_record(&store2, "default", provider2).await }, + ); // Wait for both to complete let result1 = handle1.await.unwrap(); @@ -6029,7 +6322,7 @@ mod tests { .find_map(Result::ok) .expect("should have one successful creation"); let retrieved = store - .get_message_by_name::("test-concurrent-provider") + .get_message_by_name::("default", "test-concurrent-provider") .await .unwrap(); assert!( @@ -6056,6 +6349,7 @@ mod tests { &state, Request::new(CreateProviderRequest { provider: Some(provider.clone()), + workspace: "default".to_string(), }), ) .await @@ -6064,7 +6358,7 @@ mod tests { // Fetch the provider to get its current resource_version let current = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -6083,6 +6377,7 @@ mod tests { Request::new(UpdateProviderRequest { provider: Some(updated_provider.clone()), credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -6124,6 +6419,7 @@ mod tests { &state, Request::new(CreateProviderRequest { provider: Some(provider.clone()), + workspace: "default".to_string(), }), ) .await @@ -6132,7 +6428,7 @@ mod tests { // Fetch the current state let current = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -6151,6 +6447,7 @@ mod tests { Request::new(UpdateProviderRequest { provider: Some(stale_provider), credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -6167,7 +6464,7 @@ mod tests { // Verify the provider was not modified let unchanged = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -6191,6 +6488,7 @@ mod tests { &state, Request::new(CreateProviderRequest { provider: Some(provider.clone()), + workspace: "default".to_string(), }), ) .await @@ -6199,7 +6497,7 @@ mod tests { // All three clients fetch the provider and see the same version let initial = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -6221,6 +6519,7 @@ mod tests { Request::new(UpdateProviderRequest { provider: Some(updated), credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -6253,7 +6552,7 @@ mod tests { // Final provider should have exactly 1 new credential key and resource_version = initial_version + 1 let final_provider = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -6277,7 +6576,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "google-cloud".to_string(), credentials: HashMap::new(), @@ -6380,7 +6679,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: "github".to_string(), credentials: HashMap::new(), @@ -6394,4 +6693,238 @@ mod tests { "non-GCP provider should not inject any env vars" ); } + + #[tokio::test] + async fn provider_crud_is_workspace_isolated() { + use openshell_core::proto::{ + CreateProviderRequest, CreateWorkspaceRequest, DeleteProviderRequest, + GetProviderRequest, ListProvidersRequest, + }; + + let state = test_server_state().await; + + // Create a second workspace "beta". + crate::grpc::workspace::handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "beta".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + // Create same-named provider in each workspace via handlers. + let make_provider = || Provider { + metadata: None, + r#type: "custom".to_string(), + credentials: HashMap::from([("TOKEN".to_string(), "secret".to_string())]), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + }; + + let created_default = handle_create_provider( + &state, + Request::new(CreateProviderRequest { + provider: Some({ + let mut p = make_provider(); + p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "shared-name".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: String::new(), + }); + p + }), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + let default_id = created_default + .provider + .as_ref() + .unwrap() + .object_id() + .to_string(); + + let created_beta = handle_create_provider( + &state, + Request::new(CreateProviderRequest { + provider: Some({ + let mut p = make_provider(); + p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "shared-name".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: String::new(), + }); + p + }), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + let beta_id = created_beta + .provider + .as_ref() + .unwrap() + .object_id() + .to_string(); + + assert_ne!(default_id, beta_id); + + // Get in each workspace returns the correct provider. + let got = handle_get_provider( + &state, + Request::new(GetProviderRequest { + name: "shared-name".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.provider.as_ref().unwrap().object_id(), default_id); + + let got = handle_get_provider( + &state, + Request::new(GetProviderRequest { + name: "shared-name".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.provider.as_ref().unwrap().object_id(), beta_id); + + // List is workspace-scoped. + let listed = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.providers.len(), 1); + assert_eq!(listed.providers[0].object_id(), default_id); + + let listed = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: "beta".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.providers.len(), 1); + assert_eq!(listed.providers[0].object_id(), beta_id); + + // Delete in "default" does not affect "beta". + let deleted = handle_delete_provider( + &state, + Request::new(DeleteProviderRequest { + name: "shared-name".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(deleted.deleted); + + let listed = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert!(listed.providers.is_empty()); + + let got = handle_get_provider( + &state, + Request::new(GetProviderRequest { + name: "shared-name".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.provider.as_ref().unwrap().object_id(), beta_id); + + // all_workspaces returns providers from all workspaces. + // Re-create the "default" provider. + handle_create_provider( + &state, + Request::new(CreateProviderRequest { + provider: Some({ + let mut p = make_provider(); + p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "provider-d".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: String::new(), + }); + p + }), + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let listed = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: String::new(), + all_workspaces: true, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.providers.len(), 2); + + // all_workspaces with non-empty workspace is rejected. + let err = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: true, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } } diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 8e2f6d25c5..b03f9d431e 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -27,7 +27,7 @@ use openshell_core::telemetry::{ LifecycleOperation, LifecycleResource, SandboxTemplateSource, TelemetryComputeDriver, TelemetryOutcome, }; -use openshell_core::{ObjectId, ObjectName}; +use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; use prost::Message; use std::net::IpAddr; use std::pin::Pin; @@ -135,6 +135,9 @@ async fn handle_create_sandbox_inner( } crate::grpc::validation::validate_annotations(&request.annotations, "annotations")?; + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; + let _sandbox_sync_guard = if spec.providers.is_empty() { None } else { @@ -145,12 +148,13 @@ async fn handle_create_sandbox_inner( for name in &spec.providers { state .store - .get_message_by_name::(name) + .get_message_by_name::(&workspace, name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; } - validate_provider_environment_keys_unique(state.store.as_ref(), &spec.providers).await?; + validate_provider_environment_keys_unique(state.store.as_ref(), &workspace, &spec.providers) + .await?; // Ensure the template always carries the resolved image. let mut spec = spec; @@ -183,7 +187,7 @@ async fn handle_create_sandbox_inner( created_at_ms: now_ms, labels: request.labels.clone(), resource_version: 0, - annotations: request.annotations.clone(), + workspace, }), spec: Some(spec), status: None, @@ -238,14 +242,16 @@ pub(super) async fn handle_get_sandbox( state: &Arc, request: Request, ) -> Result, Status> { - let name = request.into_inner().name; - if name.is_empty() { + let req = request.into_inner(); + if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; let sandbox = state .store - .get_message_by_name::(&name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; @@ -260,21 +266,48 @@ pub(super) async fn handle_list_sandboxes( request: Request, ) -> Result, Status> { let request = request.into_inner(); + if request.all_workspaces && !request.workspace.is_empty() { + return Err(Status::invalid_argument( + "all_workspaces and workspace are mutually exclusive", + )); + } let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); - let sandboxes: Vec = if request.label_selector.is_empty() { + let sandboxes: Vec = if request.all_workspaces { + if !request.label_selector.is_empty() { + return Err(Status::invalid_argument( + "label_selector is not supported with all_workspaces", + )); + } state .store - .list_messages(limit, request.offset) + .list_all_messages(limit, request.offset) .await .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))? } else { - crate::grpc::validation::validate_label_selector(&request.label_selector)?; - state - .store - .list_messages_with_selector(&request.label_selector, limit, request.offset) - .await - .map_err(|e| Status::internal(format!("list sandboxes with selector failed: {e}")))? + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; + if request.label_selector.is_empty() { + state + .store + .list_messages(&workspace, limit, request.offset) + .await + .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))? + } else { + crate::grpc::validation::validate_label_selector(&request.label_selector)?; + state + .store + .list_messages_with_selector( + &workspace, + &request.label_selector, + limit, + request.offset, + ) + .await + .map_err(|e| { + Status::internal(format!("list sandboxes with selector failed: {e}")) + })? + } }; Ok(Response::new(ListSandboxesResponse { sandboxes })) @@ -284,8 +317,11 @@ pub(super) async fn handle_list_sandbox_providers( state: &Arc, request: Request, ) -> Result, Status> { - let sandbox = sandbox_by_name(state, &request.into_inner().sandbox_name).await?; - let providers = providers_for_sandbox(state, &sandbox).await?; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let sandbox = sandbox_by_name(state, &workspace, &req.sandbox_name).await?; + let providers = providers_for_sandbox(state, &sandbox, &workspace).await?; Ok(Response::new(ListSandboxProvidersResponse { providers })) } @@ -294,6 +330,8 @@ pub(super) async fn handle_attach_sandbox_provider( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -308,7 +346,7 @@ pub(super) async fn handle_attach_sandbox_provider( ))); } - get_provider_record(state.store.as_ref(), &request.provider_name) + get_provider_record(state.store.as_ref(), &workspace, &request.provider_name) .await .map_err(|err| { if err.code() == tonic::Code::NotFound { @@ -322,7 +360,7 @@ pub(super) async fn handle_attach_sandbox_provider( })?; let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - let sandbox = sandbox_by_name(state, &request.sandbox_name).await?; + let sandbox = sandbox_by_name(state, &workspace, &request.sandbox_name).await?; let sandbox_id = sandbox .metadata .as_ref() @@ -358,8 +396,12 @@ pub(super) async fn handle_attach_sandbox_provider( candidate_spec.providers.push(request.provider_name.clone()); } validate_sandbox_spec(&request.sandbox_name, &candidate_spec)?; - validate_provider_environment_keys_unique(state.store.as_ref(), &candidate_spec.providers) - .await?; + validate_provider_environment_keys_unique( + state.store.as_ref(), + &workspace, + &candidate_spec.providers, + ) + .await?; let provider_name = request.provider_name.clone(); let attached = Arc::new(AtomicBool::new(false)); @@ -408,6 +450,8 @@ pub(super) async fn handle_detach_sandbox_provider( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -422,7 +466,7 @@ pub(super) async fn handle_detach_sandbox_provider( } let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - let sandbox = sandbox_by_name(state, &request.sandbox_name).await?; + let sandbox = sandbox_by_name(state, &workspace, &request.sandbox_name).await?; let sandbox_id = sandbox .metadata .as_ref() @@ -499,19 +543,22 @@ async fn handle_delete_sandbox_inner( state: &Arc, request: Request, ) -> Result, Status> { - let name = request.into_inner().name; + let req = request.into_inner(); + let name = req.name; if name.is_empty() { return Err(Status::invalid_argument("name is required")); } + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; let sandbox_id = state .store - .get_message_by_name::(&name) + .get_message_by_name::(&workspace, &name) .await .ok() .flatten() .map(|sandbox| sandbox.object_id().to_string()); - let deleted = state.compute.delete_sandbox(&name).await?; + let deleted = state.compute.delete_sandbox(&workspace, &name).await?; if deleted && let Some(sandbox_id) = sandbox_id { state.telemetry.end_sandbox_session(&sandbox_id); } @@ -519,14 +566,18 @@ async fn handle_delete_sandbox_inner( Ok(Response::new(DeleteSandboxResponse { deleted })) } -async fn sandbox_by_name(state: &Arc, name: &str) -> Result { +async fn sandbox_by_name( + state: &Arc, + workspace: &str, + name: &str, +) -> Result { if name.is_empty() { return Err(Status::invalid_argument("sandbox_name is required")); } state .store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found")) @@ -535,6 +586,7 @@ async fn sandbox_by_name(state: &Arc, name: &str) -> Result, sandbox: &Sandbox, + workspace: &str, ) -> Result, Status> { let provider_names = sandbox .spec @@ -544,7 +596,7 @@ async fn providers_for_sandbox( let mut providers = Vec::with_capacity(provider_names.len()); for name in provider_names { - let provider = get_provider_record(state.store.as_ref(), name) + let provider = get_provider_record(state.store.as_ref(), workspace, name) .await .map_err(|err| { if err.code() == tonic::Code::NotFound { @@ -1363,7 +1415,7 @@ pub(super) async fn handle_create_ssh_session( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: sandbox.object_workspace().to_string(), }), sandbox_id: req.sandbox_id.clone(), token: token.clone(), @@ -1381,6 +1433,7 @@ pub(super) async fn handle_create_ssh_session( SshSession::object_type(), &token, session.object_name(), + "", &session.encode_to_vec(), None, WriteCondition::MustCreate, @@ -1439,6 +1492,7 @@ pub(super) async fn handle_revoke_ssh_session( SshSession::object_type(), session.object_id(), session.object_name(), + "", &session.encode_to_vec(), None, WriteCondition::MatchResourceVersion(resource_version), @@ -2309,7 +2363,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: std::iter::once((credential_key.to_string(), "secret".to_string())) @@ -2327,7 +2381,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::iter::once(("team".to_string(), "agents".to_string())).collect(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(openshell_core::proto::SandboxSpec { log_level: "debug".to_string(), @@ -2362,6 +2416,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2371,7 +2426,7 @@ mod tests { assert!(response.attached); let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -2405,6 +2460,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2414,7 +2470,7 @@ mod tests { assert!(!response.attached); let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2446,6 +2502,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2455,7 +2512,7 @@ mod tests { assert!(response.detached); let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2470,6 +2527,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2496,6 +2554,7 @@ mod tests { &state, Request::new(ListSandboxProvidersRequest { sandbox_name: "work".to_string(), + workspace: String::new(), }), ) .await @@ -2525,6 +2584,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "missing".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2698,7 +2758,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), - annotations: HashMap::new(), + workspace: String::new(), }), ) .await @@ -2731,7 +2791,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), - annotations: HashMap::new(), + workspace: String::new(), }), ) .await @@ -2830,7 +2890,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), - annotations: HashMap::new(), + workspace: String::new(), }), ) .await @@ -2880,6 +2940,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "provider-b".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2926,6 +2987,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "provider-31".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2935,7 +2997,7 @@ mod tests { assert!(response.attached); let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2980,6 +3042,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "provider-32".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2991,7 +3054,7 @@ mod tests { // Verify sandbox was not modified let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -3026,6 +3089,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -3052,6 +3116,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -3203,7 +3268,7 @@ mod tests { // Fetch the sandbox to get its current resource_version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3216,6 +3281,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, + workspace: String::new(), }), ) .await @@ -3227,7 +3293,7 @@ mod tests { // Verify the resource_version incremented let updated_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3254,7 +3320,7 @@ mod tests { // Get current version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3267,6 +3333,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, + workspace: String::new(), }), ) .await @@ -3284,7 +3351,7 @@ mod tests { // Verify the sandbox was not modified let unchanged_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3316,7 +3383,7 @@ mod tests { // Fetch the sandbox to get its current resource_version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3329,6 +3396,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, + workspace: String::new(), }), ) .await @@ -3340,7 +3408,7 @@ mod tests { // Verify the resource_version incremented let updated_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3367,7 +3435,7 @@ mod tests { // Get current version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3380,6 +3448,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, + workspace: String::new(), }), ) .await @@ -3397,7 +3466,7 @@ mod tests { // Verify the sandbox was not modified let unchanged_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3440,7 +3509,7 @@ mod tests { // All three clients fetch the sandbox and see version 1 let initial_version = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -3460,6 +3529,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: format!("provider-{i}"), expected_resource_version: initial_version, + workspace: String::new(), }), ) .await @@ -3496,7 +3566,7 @@ mod tests { // Final sandbox should have exactly 1 provider and resource_version = initial_version + 1 let final_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3506,4 +3576,176 @@ mod tests { initial_version + 1 ); } + + #[tokio::test] + async fn sandbox_crud_is_workspace_isolated() { + use crate::persistence::ObjectType; + use openshell_core::proto::{ + CreateWorkspaceRequest, GetSandboxRequest, ListSandboxesRequest, + }; + + let state = test_server_state().await; + + // Create a second workspace "beta". + crate::grpc::workspace::handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "beta".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + // Seed a sandbox named "shared-name" in each workspace. + let mut sbx_default = test_sandbox("shared-name", Vec::new()); + sbx_default.metadata.as_mut().unwrap().id = "sbx-default-id".to_string(); + sbx_default.metadata.as_mut().unwrap().workspace = "default".to_string(); + state.store.put_message(&sbx_default).await.unwrap(); + + let mut sbx_beta = test_sandbox("shared-name", Vec::new()); + sbx_beta.metadata.as_mut().unwrap().id = "sbx-beta-id".to_string(); + sbx_beta.metadata.as_mut().unwrap().workspace = "beta".to_string(); + state.store.put_message(&sbx_beta).await.unwrap(); + + // Get in "default" returns the default sandbox. + let got = handle_get_sandbox( + &state, + Request::new(GetSandboxRequest { + name: "shared-name".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.sandbox.as_ref().unwrap().object_id(), "sbx-default-id"); + + // Get in "beta" returns the beta sandbox. + let got = handle_get_sandbox( + &state, + Request::new(GetSandboxRequest { + name: "shared-name".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.sandbox.as_ref().unwrap().object_id(), "sbx-beta-id"); + + // List in "default" returns 1 sandbox. + let listed = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.sandboxes.len(), 1); + assert_eq!(listed.sandboxes[0].object_id(), "sbx-default-id",); + + // List in "beta" returns 1 sandbox. + let listed = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: "beta".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.sandboxes.len(), 1); + assert_eq!(listed.sandboxes[0].object_id(), "sbx-beta-id"); + + // Delete in "default" (via store) does not affect "beta". + state + .store + .delete_by_name(Sandbox::object_type(), "default", "shared-name") + .await + .unwrap(); + + // "default" now has 0 sandboxes. + let listed = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert!(listed.sandboxes.is_empty()); + + // "beta" still has its sandbox. + let got = handle_get_sandbox( + &state, + Request::new(GetSandboxRequest { + name: "shared-name".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.sandbox.as_ref().unwrap().object_id(), "sbx-beta-id"); + + // all_workspaces returns sandboxes from all workspaces. + // Re-create the "default" sandbox so both workspaces have one. + state + .store + .put( + Sandbox::object_type(), + "sbx-default-2", + "sandbox-d", + "default", + &Sandbox::default().encode_to_vec(), + None, + ) + .await + .unwrap(); + let listed = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: String::new(), + all_workspaces: true, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.sandboxes.len(), 2); + + // all_workspaces with non-empty workspace is rejected. + let err = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: "default".to_string(), + all_workspaces: true, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + } } diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 01d8dbfe8e..31e3389b4b 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -4,12 +4,12 @@ use std::collections::HashMap; use std::sync::Arc; -use openshell_core::ObjectId; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ DeleteServiceRequest, DeleteServiceResponse, ExposeServiceRequest, GetServiceRequest, ListServicesRequest, ListServicesResponse, Sandbox, ServiceEndpoint, ServiceEndpointResponse, }; +use openshell_core::{ObjectId, ObjectWorkspace}; use prost::Message as _; use tonic::{Request, Response, Status}; use uuid::Uuid; @@ -26,6 +26,8 @@ pub(super) async fn handle_expose_service( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; if req.target_port == 0 || req.target_port > u32::from(u16::MAX) { @@ -34,7 +36,7 @@ pub(super) async fn handle_expose_service( let sandbox = state .store - .get_message_by_name::(&req.sandbox) + .get_message_by_name::(&workspace, &req.sandbox) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -45,7 +47,7 @@ pub(super) async fn handle_expose_service( // Fetch existing endpoint to determine create vs. update path let existing = state .store - .get_message_by_name::(&key) + .get_message_by_name::(&workspace, &key) .await .map_err(|e| Status::internal(format!("fetch endpoint failed: {e}")))?; @@ -87,7 +89,7 @@ pub(super) async fn handle_expose_service( created_at_ms, labels: HashMap::from([("sandbox".to_string(), req.sandbox.clone())]), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.clone(), }), sandbox_id: sandbox.object_id().to_string(), sandbox_name: req.sandbox.clone(), @@ -103,6 +105,7 @@ pub(super) async fn handle_expose_service( ServiceEndpoint::object_type(), &id, &key, + &workspace, &endpoint.encode_to_vec(), Some(&labels_json), condition, @@ -115,7 +118,7 @@ pub(super) async fn handle_expose_service( meta.resource_version = result.resource_version; } - let url = service_routing::endpoint_url(&state.config, &req.sandbox, &req.service) + let url = service_routing::endpoint_url(&state.config, &workspace, &req.sandbox, &req.service) .unwrap_or_default(); service_routing::emit_service_endpoint_config_event(&endpoint, &url, created); @@ -130,10 +133,12 @@ pub(super) async fn handle_get_service( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; - let endpoint = get_service_endpoint(state, &req.sandbox, &req.service) + let endpoint = get_service_endpoint(state, &workspace, &req.sandbox, &req.service) .await? .ok_or_else(|| Status::not_found("service endpoint not found"))?; @@ -145,18 +150,42 @@ pub(super) async fn handle_list_services( request: Request, ) -> Result, Status> { let req = request.into_inner(); + if req.all_workspaces && !req.workspace.is_empty() { + return Err(Status::invalid_argument( + "all_workspaces and workspace are mutually exclusive", + )); + } if !req.sandbox.is_empty() { validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; } let limit = super::clamp_limit(req.limit, 100, super::MAX_PAGE_SIZE); - let endpoints: Vec = if req.sandbox.is_empty() { - state.store.list_messages(limit, req.offset).await + let endpoints: Vec = if req.all_workspaces { + if !req.sandbox.is_empty() { + return Err(Status::invalid_argument( + "sandbox filter is not supported with all_workspaces", + )); + } + state.store.list_all_messages(limit, req.offset).await } else { - state - .store - .list_messages_with_selector(&format!("sandbox={}", req.sandbox), limit, req.offset) - .await + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + if req.sandbox.is_empty() { + state + .store + .list_messages(&workspace, limit, req.offset) + .await + } else { + state + .store + .list_messages_with_selector( + &workspace, + &format!("sandbox={}", req.sandbox), + limit, + req.offset, + ) + .await + } } .map_err(|e| Status::internal(format!("list endpoints failed: {e}")))?; @@ -173,10 +202,12 @@ pub(super) async fn handle_delete_service( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; - let endpoint = get_service_endpoint(state, &req.sandbox, &req.service).await?; + let endpoint = get_service_endpoint(state, &workspace, &req.sandbox, &req.service).await?; let Some(endpoint) = endpoint else { return Ok(Response::new(DeleteServiceResponse { deleted: false })); }; @@ -184,7 +215,7 @@ pub(super) async fn handle_delete_service( let key = service_routing::endpoint_key(&req.sandbox, &req.service); let deleted = state .store - .delete_by_name(ServiceEndpoint::object_type(), &key) + .delete_by_name(ServiceEndpoint::object_type(), &workspace, &key) .await .map_err(|e| Status::internal(format!("delete endpoint failed: {e}")))?; @@ -197,13 +228,14 @@ pub(super) async fn handle_delete_service( async fn get_service_endpoint( state: &Arc, + workspace: &str, sandbox: &str, service: &str, ) -> Result, Status> { let key = service_routing::endpoint_key(sandbox, service); state .store - .get_message_by_name::(&key) + .get_message_by_name::(workspace, &key) .await .map_err(|e| Status::internal(format!("fetch endpoint failed: {e}"))) } @@ -212,8 +244,10 @@ fn service_endpoint_response( state: &Arc, endpoint: ServiceEndpoint, ) -> ServiceEndpointResponse { + let workspace = endpoint.object_workspace(); let url = service_routing::endpoint_url( &state.config, + workspace, &endpoint.sandbox_name, &endpoint.service_name, ) @@ -287,7 +321,7 @@ mod tests { created_at_ms: 1_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(openshell_core::proto::SandboxSpec::default()), ..Default::default() @@ -333,6 +367,7 @@ mod tests { service: "web".to_string(), target_port: 8080, domain: true, + workspace: "default".to_string(), }), ) .await @@ -346,6 +381,8 @@ mod tests { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, + workspace: "default".to_string(), + all_workspaces: false, }), ) .await @@ -362,6 +399,7 @@ mod tests { Request::new(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -374,6 +412,7 @@ mod tests { Request::new(DeleteServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -386,6 +425,7 @@ mod tests { Request::new(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -398,6 +438,8 @@ mod tests { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, + workspace: "default".to_string(), + all_workspaces: false, }), ) .await @@ -421,6 +463,7 @@ mod tests { service: "web".to_string(), target_port: 8080, domain: true, + workspace: "default".to_string(), }), ) .await @@ -435,6 +478,7 @@ mod tests { service: "web".to_string(), target_port: 9090, domain: true, + workspace: "default".to_string(), }), ) .await @@ -459,6 +503,8 @@ mod tests { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, + workspace: "default".to_string(), + all_workspaces: false, }), ) .await @@ -480,6 +526,7 @@ mod tests { service: "web".to_string(), target_port: 7070, domain: true, + workspace: "default".to_string(), }), ) .await @@ -495,6 +542,7 @@ mod tests { service: "web".to_string(), target_port: 8080, domain: true, + workspace: "default".to_string(), }), ) .await @@ -509,6 +557,7 @@ mod tests { service: "web".to_string(), target_port: 9090, domain: true, + workspace: "default".to_string(), }), ) .await @@ -531,6 +580,7 @@ mod tests { Request::new(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -543,4 +593,223 @@ mod tests { ); assert_ne!(port, 7070, "port should not be the original value"); } + + #[tokio::test] + async fn service_crud_is_workspace_isolated() { + use openshell_core::proto::{ + CreateWorkspaceRequest, DeleteServiceRequest, GetServiceRequest, ListServicesRequest, + }; + + let state = test_server_state().await; + + // Create a second workspace "beta". + crate::grpc::workspace::handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "beta".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + // Seed a sandbox named "my-sandbox" in each workspace. + seed_sandbox(&state, "my-sandbox").await; + + let mut sbx_beta = Sandbox { + metadata: Some(ObjectMeta { + id: "sandbox-my-sandbox-beta".to_string(), + name: "my-sandbox".to_string(), + created_at_ms: 1_000, + labels: HashMap::new(), + resource_version: 0, + workspace: "beta".to_string(), + }), + spec: Some(openshell_core::proto::SandboxSpec::default()), + ..Default::default() + }; + sbx_beta.set_phase(SandboxPhase::Ready as i32); + state.store.put_message(&sbx_beta).await.unwrap(); + + // Expose same service name on the same sandbox name in each workspace. + handle_expose_service( + &state, + Request::new(ExposeServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + target_port: 8080, + domain: true, + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + handle_expose_service( + &state, + Request::new(ExposeServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + target_port: 9090, + domain: true, + workspace: "beta".to_string(), + }), + ) + .await + .unwrap(); + + // Get in "default" returns port 8080. + let got = handle_get_service( + &state, + Request::new(GetServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.endpoint.as_ref().unwrap().target_port, 8080); + + // Get in "beta" returns port 9090. + let got = handle_get_service( + &state, + Request::new(GetServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.endpoint.as_ref().unwrap().target_port, 9090); + + // List in each workspace returns 1 service. + let listed = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: "my-sandbox".to_string(), + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.services.len(), 1); + assert_eq!( + listed.services[0].endpoint.as_ref().unwrap().target_port, + 8080 + ); + + let listed = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: "my-sandbox".to_string(), + limit: 100, + offset: 0, + workspace: "beta".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.services.len(), 1); + assert_eq!( + listed.services[0].endpoint.as_ref().unwrap().target_port, + 9090 + ); + + // Delete in "default" does not affect "beta". + let deleted = handle_delete_service( + &state, + Request::new(DeleteServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(deleted.deleted); + + let listed = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: "my-sandbox".to_string(), + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert!(listed.services.is_empty()); + + let got = handle_get_service( + &state, + Request::new(GetServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.endpoint.as_ref().unwrap().target_port, 9090); + + // all_workspaces returns services from all workspaces. + // Re-create the "default" service. + handle_expose_service( + &state, + Request::new(ExposeServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "api".to_string(), + target_port: 3000, + domain: true, + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let listed = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: String::new(), + limit: 100, + offset: 0, + workspace: String::new(), + all_workspaces: true, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.services.len(), 2); + + // all_workspaces with non-empty workspace is rejected. + let err = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: String::new(), + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: true, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + } } diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 8f4242af17..49bd48754a 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -1180,7 +1180,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), r#type: provider_type.to_string(), credentials, diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs new file mode 100644 index 0000000000..d70ff92979 --- /dev/null +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -0,0 +1,744 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Workspace lifecycle handlers. + +#![allow(clippy::result_large_err)] // gRPC handlers return Result, Status> + +use std::sync::Arc; + +use openshell_core::ObjectName; +use openshell_core::proto::datamodel::v1::ObjectMeta; +use openshell_core::proto::{ + AddWorkspaceMemberRequest, AddWorkspaceMemberResponse, CreateWorkspaceRequest, + CreateWorkspaceResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, GetWorkspaceRequest, + GetWorkspaceResponse, InferenceRoute, ListWorkspaceMembersRequest, + ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, Provider, + RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, Sandbox, ServiceEndpoint, + StoredProviderProfile, Workspace, WorkspaceMember, WorkspaceRole, +}; +use prost::Message; +use tonic::{Request, Response, Status}; + +use crate::ServerState; +use crate::persistence::{ObjectType, WriteCondition, current_time_ms}; + +use super::{MAX_PAGE_SIZE, clamp_limit}; + +pub const WORKSPACE_OBJECT_TYPE: &str = "workspace"; +pub const DEFAULT_WORKSPACE_NAME: &str = "default"; +const MAX_WORKSPACE_MEMBERS: u32 = 1000; + +impl ObjectType for Workspace { + fn object_type() -> &'static str { + WORKSPACE_OBJECT_TYPE + } +} + +impl ObjectType for WorkspaceMember { + fn object_type() -> &'static str { + "workspace_member" + } +} + +fn validate_workspace_name(name: &str) -> Result<(), Status> { + if name.is_empty() { + return Err(Status::invalid_argument("workspace name is required")); + } + if name.len() > 63 { + return Err(Status::invalid_argument( + "workspace name must be 63 characters or fewer", + )); + } + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(Status::invalid_argument( + "workspace name must contain only lowercase alphanumeric characters or hyphens", + )); + } + if name.starts_with('-') || name.ends_with('-') { + return Err(Status::invalid_argument( + "workspace name must not start or end with a hyphen", + )); + } + Ok(()) +} + +/// Resolve and validate a workspace name from a request field. +/// +/// Empty strings are normalized to `"default"`. The workspace must exist in the +/// store; returns `NOT_FOUND` if it doesn't. +pub async fn resolve_workspace( + store: &crate::persistence::Store, + workspace: &str, +) -> Result { + let name = if workspace.is_empty() { + DEFAULT_WORKSPACE_NAME.to_string() + } else { + workspace.to_string() + }; + + let exists: Option = store + .get_message_by_name("", &name) + .await + .map_err(|e| Status::internal(format!("workspace lookup failed: {e}")))?; + + if exists.is_none() { + return Err(Status::not_found(format!("workspace '{name}' not found"))); + } + + Ok(name) +} + +pub(super) async fn handle_create_workspace( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + validate_workspace_name(&req.name)?; + + let now_ms = current_time_ms(); + let workspace_id = uuid::Uuid::new_v4().to_string(); + + let workspace = Workspace { + metadata: Some(ObjectMeta { + id: workspace_id.clone(), + name: req.name, + created_at_ms: now_ms, + labels: req.labels, + resource_version: 0, + workspace: String::new(), + }), + }; + + super::validation::validate_object_metadata(workspace.metadata.as_ref(), "workspace")?; + + let result = state + .store + .put_if( + Workspace::object_type(), + &workspace_id, + workspace.object_name(), + "", + &workspace.encode_to_vec(), + None, + WriteCondition::MustCreate, + ) + .await + .map_err(|e| { + if matches!( + e, + crate::persistence::PersistenceError::UniqueViolation { .. } + ) { + Status::already_exists("workspace already exists") + } else { + Status::internal(format!("persist workspace failed: {e}")) + } + })?; + + let mut workspace = workspace; + if let Some(metadata) = workspace.metadata.as_mut() { + metadata.resource_version = result.resource_version; + } + + Ok(Response::new(CreateWorkspaceResponse { + workspace: Some(workspace), + })) +} + +pub(super) async fn handle_get_workspace( + state: &Arc, + request: Request, +) -> Result, Status> { + let name = request.into_inner().name; + if name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } + + let workspace: Workspace = state + .store + .get_message_by_name("", &name) + .await + .map_err(|e| Status::internal(format!("fetch workspace failed: {e}")))? + .ok_or_else(|| Status::not_found("workspace not found"))?; + + Ok(Response::new(GetWorkspaceResponse { + workspace: Some(workspace), + })) +} + +pub(super) async fn handle_list_workspaces( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + let limit = clamp_limit(req.limit, 100, MAX_PAGE_SIZE); + + let workspaces: Vec = if req.label_selector.is_empty() { + state + .store + .list_messages("", limit, req.offset) + .await + .map_err(|e| Status::internal(format!("list workspaces failed: {e}")))? + } else { + state + .store + .list_messages_with_selector("", &req.label_selector, limit, req.offset) + .await + .map_err(|e| Status::internal(format!("list workspaces failed: {e}")))? + }; + + Ok(Response::new(ListWorkspacesResponse { workspaces })) +} + +pub(super) async fn handle_delete_workspace( + state: &Arc, + request: Request, +) -> Result, Status> { + let name = request.into_inner().name; + if name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } + if name == DEFAULT_WORKSPACE_NAME { + return Err(Status::failed_precondition( + "the default workspace cannot be deleted", + )); + } + + let mut blocking = Vec::new(); + for (object_type, label) in [ + (Sandbox::object_type(), "sandbox"), + (Provider::object_type(), "provider"), + (StoredProviderProfile::object_type(), "provider profile"), + (ServiceEndpoint::object_type(), "service"), + (InferenceRoute::object_type(), "inference route"), + ] { + let records = state + .store + .list(object_type, &name, 1, 0) + .await + .map_err(|e| Status::internal(format!("resource check failed: {e}")))?; + if !records.is_empty() { + blocking.push(label); + } + } + if !blocking.is_empty() { + return Err(Status::failed_precondition(format!( + "workspace '{}' still contains resources: {}", + name, + blocking.join(", ") + ))); + } + + // Clean up membership records before deleting the workspace itself. + let members: Vec = state + .store + .list_messages(&name, MAX_WORKSPACE_MEMBERS, 0) + .await + .map_err(|e| Status::internal(format!("list workspace members failed: {e}")))?; + for member in &members { + if let Some(meta) = &member.metadata { + state + .store + .delete(WorkspaceMember::object_type(), &meta.id) + .await + .map_err(|e| { + Status::internal(format!( + "delete workspace member '{}' failed: {e}", + meta.name + )) + })?; + } + } + + let deleted = state + .store + .delete_by_name(Workspace::object_type(), "", &name) + .await + .map_err(|e| Status::internal(format!("delete workspace failed: {e}")))?; + + Ok(Response::new(DeleteWorkspaceResponse { deleted })) +} + +pub(super) async fn handle_add_workspace_member( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + let workspace = resolve_workspace(&state.store, &req.workspace).await?; + + if req.principal_subject.is_empty() { + return Err(Status::invalid_argument("principal_subject is required")); + } + + let role = WorkspaceRole::try_from(req.role).unwrap_or(WorkspaceRole::Unspecified); + if role == WorkspaceRole::Unspecified { + return Err(Status::invalid_argument( + "role must be USER or ADMIN, not UNSPECIFIED", + )); + } + + let existing: Vec = state + .store + .list_messages(&workspace, MAX_WORKSPACE_MEMBERS, 0) + .await + .map_err(|e| Status::internal(format!("list workspace members failed: {e}")))?; + if existing.len() >= MAX_WORKSPACE_MEMBERS as usize { + return Err(Status::resource_exhausted(format!( + "workspace has reached the maximum of {MAX_WORKSPACE_MEMBERS} members" + ))); + } + + let member_id = uuid::Uuid::new_v4().to_string(); + let now_ms = current_time_ms(); + + let member = WorkspaceMember { + metadata: Some(ObjectMeta { + id: member_id.clone(), + name: req.principal_subject.clone(), + created_at_ms: now_ms, + labels: std::collections::HashMap::new(), + resource_version: 0, + workspace: workspace.clone(), + }), + principal_subject: req.principal_subject, + role: req.role, + }; + + let result = state + .store + .put_if( + WorkspaceMember::object_type(), + &member_id, + member.object_name(), + &workspace, + &member.encode_to_vec(), + None, + WriteCondition::MustCreate, + ) + .await + .map_err(|e| { + if matches!( + e, + crate::persistence::PersistenceError::UniqueViolation { .. } + ) { + Status::already_exists("member already exists in this workspace") + } else { + Status::internal(format!("persist workspace member failed: {e}")) + } + })?; + + let mut member = member; + if let Some(metadata) = member.metadata.as_mut() { + metadata.resource_version = result.resource_version; + } + + Ok(Response::new(AddWorkspaceMemberResponse { + member: Some(member), + })) +} + +pub(super) async fn handle_remove_workspace_member( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + let workspace = resolve_workspace(&state.store, &req.workspace).await?; + + if req.principal_subject.is_empty() { + return Err(Status::invalid_argument("principal_subject is required")); + } + + let removed = state + .store + .delete_by_name( + WorkspaceMember::object_type(), + &workspace, + &req.principal_subject, + ) + .await + .map_err(|e| Status::internal(format!("remove workspace member failed: {e}")))?; + + Ok(Response::new(RemoveWorkspaceMemberResponse { removed })) +} + +pub(super) async fn handle_list_workspace_members( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + let workspace = resolve_workspace(&state.store, &req.workspace).await?; + + let limit = clamp_limit(req.limit, 100, MAX_PAGE_SIZE); + + let members: Vec = state + .store + .list_messages(&workspace, limit, req.offset) + .await + .map_err(|e| Status::internal(format!("list workspace members failed: {e}")))?; + + Ok(Response::new(ListWorkspaceMembersResponse { members })) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + use openshell_core::proto::datamodel::v1::ObjectMeta; + use tonic::{Code, Request}; + + use crate::grpc::test_support::test_server_state; + + #[tokio::test] + async fn delete_workspace_blocked_by_resources() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "ephemeral".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let sbx = Sandbox { + metadata: Some(ObjectMeta { + id: "sbx-eph-1".to_string(), + name: "blocker".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + workspace: "ephemeral".to_string(), + }), + ..Default::default() + }; + state.store.put_message(&sbx).await.unwrap(); + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "ephemeral".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message().contains("sandbox"), + "error should name the blocking resource type: {}", + err.message() + ); + + state + .store + .delete_by_name(Sandbox::object_type(), "ephemeral", "blocker") + .await + .unwrap(); + + let resp = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "ephemeral".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.deleted); + } + + #[tokio::test] + async fn delete_workspace_blocked_by_provider_profiles() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "profiles-ws".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let profile = StoredProviderProfile { + metadata: Some(ObjectMeta { + id: "prof-1".to_string(), + name: "my-profile".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + workspace: "profiles-ws".to_string(), + }), + ..Default::default() + }; + state.store.put_message(&profile).await.unwrap(); + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "profiles-ws".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message().contains("provider profile"), + "error should name provider profile as blocking: {}", + err.message() + ); + + state + .store + .delete_by_name( + StoredProviderProfile::object_type(), + "profiles-ws", + "my-profile", + ) + .await + .unwrap(); + + let resp = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "profiles-ws".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.deleted); + } + + #[tokio::test] + async fn delete_default_workspace_rejected() { + let state = test_server_state().await; + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "default".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + } + + #[tokio::test] + async fn add_and_list_workspace_members() { + let state = test_server_state().await; + + let resp = handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "alice@example.com".to_string(), + role: WorkspaceRole::Admin.into(), + }), + ) + .await + .unwrap() + .into_inner(); + + let member = resp.member.unwrap(); + assert_eq!(member.principal_subject, "alice@example.com"); + assert_eq!(member.role, i32::from(WorkspaceRole::Admin)); + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "bob@example.com".to_string(), + role: WorkspaceRole::User.into(), + }), + ) + .await + .unwrap(); + + let list = handle_list_workspace_members( + &state, + Request::new(ListWorkspaceMembersRequest { + workspace: "default".to_string(), + limit: 100, + offset: 0, + }), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(list.members.len(), 2); + } + + #[tokio::test] + async fn remove_workspace_member() { + let state = test_server_state().await; + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "charlie@example.com".to_string(), + role: WorkspaceRole::User.into(), + }), + ) + .await + .unwrap(); + + let resp = handle_remove_workspace_member( + &state, + Request::new(RemoveWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "charlie@example.com".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.removed); + + let list = handle_list_workspace_members( + &state, + Request::new(ListWorkspaceMembersRequest { + workspace: "default".to_string(), + limit: 100, + offset: 0, + }), + ) + .await + .unwrap() + .into_inner(); + + assert!(list.members.is_empty()); + } + + #[tokio::test] + async fn add_duplicate_member_rejected() { + let state = test_server_state().await; + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "dave@example.com".to_string(), + role: WorkspaceRole::User.into(), + }), + ) + .await + .unwrap(); + + let err = handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "dave@example.com".to_string(), + role: WorkspaceRole::Admin.into(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::AlreadyExists); + } + + #[tokio::test] + async fn delete_workspace_cleans_up_members() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "cleanup-test".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "cleanup-test".to_string(), + principal_subject: "alice@example.com".to_string(), + role: WorkspaceRole::Admin.into(), + }), + ) + .await + .unwrap(); + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "cleanup-test".to_string(), + principal_subject: "bob@example.com".to_string(), + role: WorkspaceRole::User.into(), + }), + ) + .await + .unwrap(); + + let list = handle_list_workspace_members( + &state, + Request::new(ListWorkspaceMembersRequest { + workspace: "cleanup-test".to_string(), + limit: 100, + offset: 0, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(list.members.len(), 2); + + let resp = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "cleanup-test".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.deleted); + + // Membership records should have been cleaned up. + let remaining: Vec = state + .store + .list_messages("cleanup-test", 100, 0) + .await + .unwrap(); + assert!( + remaining.is_empty(), + "expected 0 orphaned members, found {}", + remaining.len() + ); + } + + #[test] + fn validate_workspace_name_accepts_single_hyphens() { + validate_workspace_name("my-workspace").unwrap(); + } + + #[test] + fn validate_workspace_name_rejects_uppercase() { + let err = validate_workspace_name("MyWorkspace").unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_workspace_name_rejects_leading_hyphen() { + let err = validate_workspace_name("-workspace").unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } +} diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index 2b58bd15cd..761d589e8f 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -3,16 +3,16 @@ #![allow(clippy::result_large_err)] // gRPC handlers return Result, Status> -use openshell_core::ObjectId; use openshell_core::inference::{ VERTEX_AI_PROJECT_ID_KEY, VERTEX_AI_PUBLISHER_KEY, VERTEX_AI_REGION_KEY, }; use openshell_core::proto::{ - ClusterInferenceConfig, GetClusterInferenceRequest, GetClusterInferenceResponse, - GetInferenceBundleRequest, GetInferenceBundleResponse, InferenceRoute, Provider, ResolvedRoute, - SetClusterInferenceRequest, SetClusterInferenceResponse, ValidatedEndpoint, + GetInferenceBundleRequest, GetInferenceBundleResponse, GetInferenceRouteRequest, + GetInferenceRouteResponse, InferenceRoute, InferenceRouteConfig, Provider, ResolvedRoute, + Sandbox, SetInferenceRouteRequest, SetInferenceRouteResponse, ValidatedEndpoint, inference_server::Inference, }; +use openshell_core::{ObjectId, ObjectWorkspace}; use openshell_providers::normalize_provider_type; use openshell_router::config::ResolvedRoute as RouterResolvedRoute; use openshell_router::{ValidationFailureKind, verify_backend_endpoint}; @@ -68,26 +68,43 @@ impl Inference for InferenceService { &self, request: Request, ) -> Result, Status> { - authorize_inference_bundle( + let sandbox_id = authorize_inference_bundle( request .extensions() .get::(), )?; - resolve_inference_bundle(self.state.store.as_ref()) + let sandbox: Sandbox = self + .state + .store + .get_message::(&sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found(format!("sandbox '{sandbox_id}' not found")))?; + let workspace = sandbox.object_workspace(); + let workspace = if workspace.is_empty() { + "default" + } else { + workspace + }; + resolve_inference_bundle(self.state.store.as_ref(), workspace) .await .map(Response::new) } #[rpc_auth(auth = "bearer", scope = "inference:write", role = "admin")] - async fn set_cluster_inference( + async fn set_inference_route( &self, - request: Request, - ) -> Result, Status> { + request: Request, + ) -> Result, Status> { let req = request.into_inner(); + let workspace = + crate::grpc::workspace::resolve_workspace(self.state.store.as_ref(), &req.workspace) + .await?; let route_name = effective_route_name(&req.route_name)?; let verify = !req.no_verify; - let route = upsert_cluster_inference_route( + let route = upsert_inference_route( self.state.store.as_ref(), + &workspace, route_name, &req.provider_name, &req.model_id, @@ -102,7 +119,7 @@ impl Inference for InferenceService { .as_ref() .ok_or_else(|| Status::internal("managed route missing config"))?; - Ok(Response::new(SetClusterInferenceResponse { + Ok(Response::new(SetInferenceRouteResponse { provider_name: config.provider_name.clone(), model_id: config.model_id.clone(), version: route.route.version, @@ -110,25 +127,29 @@ impl Inference for InferenceService { validation_performed: !route.validation.is_empty(), validated_endpoints: route.validation, timeout_secs: config.timeout_secs, + workspace, })) } #[rpc_auth(auth = "bearer", scope = "inference:read", role = "user")] - async fn get_cluster_inference( + async fn get_inference_route( &self, - request: Request, - ) -> Result, Status> { + request: Request, + ) -> Result, Status> { let req = request.into_inner(); + let workspace = + crate::grpc::workspace::resolve_workspace(self.state.store.as_ref(), &req.workspace) + .await?; let route_name = effective_route_name(&req.route_name)?; let route = self .state .store - .get_message_by_name::(route_name) + .get_message_by_name::(&workspace, route_name) .await .map_err(|e| Status::internal(format!("fetch route failed: {e}")))? .ok_or_else(|| { Status::not_found(format!( - "inference route '{route_name}' is not configured; run 'openshell inference set --provider --model '" + "inference route '{route_name}' is not configured in workspace '{workspace}'; run 'openshell inference set --provider --model '" )) })?; @@ -143,18 +164,20 @@ impl Inference for InferenceService { )); } - Ok(Response::new(GetClusterInferenceResponse { + Ok(Response::new(GetInferenceRouteResponse { provider_name: config.provider_name.clone(), model_id: config.model_id.clone(), version: route.version, route_name: route_name.to_string(), timeout_secs: config.timeout_secs, + workspace, })) } } -async fn upsert_cluster_inference_route( +async fn upsert_inference_route( store: &Store, + workspace: &str, route_name: &str, provider_name: &str, model_id: &str, @@ -169,11 +192,13 @@ async fn upsert_cluster_inference_route( } let provider = store - .get_message_by_name::(provider_name) + .get_message_by_name::(workspace, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| { - Status::failed_precondition(format!("provider '{provider_name}' not found")) + Status::failed_precondition(format!( + "provider '{provider_name}' not found in workspace '{workspace}'" + )) })?; let resolved = resolve_provider_route(&provider, model_id)?; @@ -183,18 +208,16 @@ async fn upsert_cluster_inference_route( Vec::new() }; - let config = build_cluster_inference_config(&provider, model_id, timeout_secs); + let config = build_inference_route_config(&provider, model_id, timeout_secs); - // Fetch existing route to determine create vs. update path let existing = store - .get_message_by_name::(route_name) + .get_message_by_name::(workspace, route_name) .await .map_err(|e| Status::internal(format!("fetch route failed: {e}")))?; let now_ms = current_time_ms(); let (id, metadata, new_version, condition) = if let Some(existing) = existing { - // Update path: preserve metadata, increment version, use CAS let resource_version = existing.metadata.as_ref().map_or(0, |m| m.resource_version); ( existing.object_id().to_string(), @@ -203,7 +226,6 @@ async fn upsert_cluster_inference_route( WriteCondition::MatchResourceVersion(resource_version), ) } else { - // Create path: new metadata, version 1, use MustCreate let new_id = uuid::Uuid::new_v4().to_string(); let new_metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: new_id.clone(), @@ -211,7 +233,7 @@ async fn upsert_cluster_inference_route( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: workspace.to_string(), }); (new_id, new_metadata, 1, WriteCondition::MustCreate) }; @@ -222,15 +244,14 @@ async fn upsert_cluster_inference_route( version: new_version, }; - // Ensure metadata is valid (defense in depth - should always be true for server-constructed metadata) crate::grpc::validate_object_metadata(route.metadata.as_ref(), "inference_route")?; - // Single-attempt CAS write: fails with ABORTED on concurrent modification store .put_if( InferenceRoute::object_type(), &id, route_name, + workspace, &route.encode_to_vec(), None, condition, @@ -241,12 +262,12 @@ async fn upsert_cluster_inference_route( Ok(UpsertedInferenceRoute { route, validation }) } -fn build_cluster_inference_config( +fn build_inference_route_config( provider: &Provider, model_id: &str, timeout_secs: u64, -) -> ClusterInferenceConfig { - ClusterInferenceConfig { +) -> InferenceRouteConfig { + InferenceRouteConfig { provider_name: provider.object_name().to_string(), model_id: model_id.to_string(), timeout_secs, @@ -876,9 +897,9 @@ fn find_provider_config_value(provider: &Provider, preferred_keys: &[&str]) -> O fn authorize_inference_bundle( principal: Option<&crate::auth::principal::Principal>, -) -> Result<(), Status> { +) -> Result { match principal { - Some(crate::auth::principal::Principal::Sandbox(_)) => Ok(()), + Some(crate::auth::principal::Principal::Sandbox(s)) => Ok(s.sandbox_id.clone()), Some(crate::auth::principal::Principal::User(_)) => Err(Status::permission_denied( "GetInferenceBundle requires a sandbox principal", )), @@ -888,13 +909,16 @@ fn authorize_inference_bundle( } } -/// Resolve the inference bundle (all managed routes + revision hash). -async fn resolve_inference_bundle(store: &Store) -> Result { +/// Resolve the inference bundle for a workspace (all managed routes + revision hash). +async fn resolve_inference_bundle( + store: &Store, + workspace: &str, +) -> Result { let mut routes = Vec::new(); - if let Some(r) = resolve_route_by_name(store, CLUSTER_INFERENCE_ROUTE_NAME).await? { + if let Some(r) = resolve_route_by_name(store, workspace, CLUSTER_INFERENCE_ROUTE_NAME).await? { routes.push(r); } - if let Some(r) = resolve_route_by_name(store, SANDBOX_SYSTEM_ROUTE_NAME).await? { + if let Some(r) = resolve_route_by_name(store, workspace, SANDBOX_SYSTEM_ROUTE_NAME).await? { routes.push(r); } @@ -933,10 +957,11 @@ async fn resolve_inference_bundle(store: &Store) -> Result Result, Status> { let route = store - .get_message_by_name::(route_name) + .get_message_by_name::(workspace, route_name) .await .map_err(|e| Status::internal(format!("fetch route failed: {e}")))?; @@ -961,12 +986,12 @@ async fn resolve_route_by_name( } let provider = store - .get_message_by_name::(&config.provider_name) + .get_message_by_name::(workspace, &config.provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| { Status::failed_precondition(format!( - "configured provider '{}' was not found", + "configured provider '{}' was not found in workspace '{workspace}'", config.provider_name )) })?; @@ -1031,9 +1056,9 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), - config: Some(ClusterInferenceConfig { + config: Some(InferenceRouteConfig { provider_name: provider_name.to_string(), model_id: model_id.to_string(), timeout_secs: 0, @@ -1050,7 +1075,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: std::iter::once((key_name.to_string(), key_value.to_string())).collect(), @@ -1096,8 +1121,9 @@ mod tests { .await .expect("provider should persist"); - let first = upsert_cluster_inference_route( + let first = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o", @@ -1108,8 +1134,9 @@ mod tests { .expect("first set should succeed"); assert_eq!(first.route.object_name(), CLUSTER_INFERENCE_ROUTE_NAME); - let second = upsert_cluster_inference_route( + let second = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4.1", @@ -1148,7 +1175,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: "aws-bedrock".to_string(), // Placeholder credential — the router ignores it because @@ -1171,8 +1198,9 @@ mod tests { .await .expect("provider should persist"); - let upserted = upsert_cluster_inference_route( + let upserted = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "bedrock-bridge", "anthropic.claude-3-5-sonnet-20241022-v2:0", @@ -1191,7 +1219,7 @@ mod tests { // auth (empty api_key + provider_type = "aws-bedrock"). Note // the api_key is empty even though the provider has a // credential — auth: None skips api-key lookup entirely. - let managed = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let managed = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("route should resolve") .expect("managed route should exist"); @@ -1225,7 +1253,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: "aws-bedrock".to_string(), credentials: std::iter::once(( @@ -1242,8 +1270,9 @@ mod tests { .await .expect("provider should persist"); - let err = upsert_cluster_inference_route( + let err = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "bedrock-misconfigured", "anthropic.claude-3-5-sonnet-20241022-v2:0", @@ -1274,7 +1303,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: "aws-bedrock".to_string(), credentials: std::collections::HashMap::new(), @@ -1302,8 +1331,9 @@ mod tests { "tab\there", "newline\nhere", ] { - let err = upsert_cluster_inference_route( + let err = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "bedrock-bridge", unsafe_model, @@ -1329,7 +1359,7 @@ mod tests { async fn resolve_managed_route_returns_none_when_missing() { let store = test_store().await; - let route = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let route = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("resolution should not fail"); assert!(route.is_none()); @@ -1348,7 +1378,7 @@ mod tests { let route = make_route(CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "mock/model-a"); store.put_message(&route).await.expect("persist route"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1386,7 +1416,7 @@ mod tests { ); store.put_message(&route).await.expect("persist route"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1428,7 +1458,7 @@ mod tests { ); store.put_message(&route).await.expect("persist route"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1453,7 +1483,7 @@ mod tests { async fn bundle_without_cluster_route_returns_empty_routes() { let store = test_store().await; - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); assert!(resp.routes.is_empty()); @@ -1476,10 +1506,10 @@ mod tests { ); store.put_message(&route).await.expect("persist route"); - let resp1 = resolve_inference_bundle(&store) + let resp1 = resolve_inference_bundle(&store, "default") .await .expect("first resolve"); - let resp2 = resolve_inference_bundle(&store) + let resp2 = resolve_inference_bundle(&store, "default") .await .expect("second resolve"); @@ -1500,7 +1530,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) @@ -1524,9 +1554,9 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), - config: Some(ClusterInferenceConfig { + config: Some(InferenceRouteConfig { provider_name: "openai-dev".to_string(), model_id: "test/model".to_string(), timeout_secs: 0, @@ -1538,7 +1568,7 @@ mod tests { .await .expect("route should persist"); - let managed = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let managed = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("route should resolve") .expect("managed route should exist"); @@ -1574,7 +1604,7 @@ mod tests { .await .expect("route should persist"); - let first = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let first = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("route should resolve") .expect("managed route should exist"); @@ -1593,7 +1623,7 @@ mod tests { .await .expect("provider rotation should persist"); - let second = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let second = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("route should resolve") .expect("managed route should exist"); @@ -1607,8 +1637,9 @@ mod tests { let provider = make_provider("anthropic-dev", "anthropic", "ANTHROPIC_API_KEY", "sk-ant"); store.put_message(&provider).await.expect("persist"); - let route = upsert_cluster_inference_route( + let route = upsert_inference_route( &store, + "default", SANDBOX_SYSTEM_ROUTE_NAME, "anthropic-dev", "claude-sonnet-4-20250514", @@ -1625,7 +1656,7 @@ mod tests { } #[tokio::test] - async fn upsert_cluster_inference_route_vertex_ai_anthropic_sets_model_in_path() { + async fn upsert_inference_route_vertex_ai_anthropic_sets_model_in_path() { let store = test_store().await; // Build a Vertex AI provider with the required config and a minted access token. @@ -1636,7 +1667,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -1660,8 +1691,9 @@ mod tests { .await .expect("persist provider"); - let result = upsert_cluster_inference_route( + let result = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "vertex-test", "claude-3-5-sonnet@20241022", @@ -1678,7 +1710,7 @@ mod tests { assert_eq!(config.model_id, "claude-3-5-sonnet@20241022"); // Resolve the persisted route and assert Vertex AI Anthropic path contract - let resolved = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let resolved = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("resolve should not fail") .expect("route should exist after upsert"); @@ -1732,7 +1764,7 @@ mod tests { .await .expect("persist system route"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1752,7 +1784,7 @@ mod tests { let system_route = make_route(SANDBOX_SYSTEM_ROUTE_NAME, "openai-dev", "gpt-4o-mini"); store.put_message(&system_route).await.expect("persist"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1768,8 +1800,9 @@ mod tests { let provider = make_provider("openai-dev", "openai", "OPENAI_API_KEY", "sk-test"); store.put_message(&provider).await.expect("persist"); - upsert_cluster_inference_route( + upsert_inference_route( &store, + "default", SANDBOX_SYSTEM_ROUTE_NAME, "openai-dev", "gpt-4o-mini", @@ -1780,7 +1813,7 @@ mod tests { .expect("upsert should succeed"); let route = store - .get_message_by_name::(SANDBOX_SYSTEM_ROUTE_NAME) + .get_message_by_name::("default", SANDBOX_SYSTEM_ROUTE_NAME) .await .expect("fetch should succeed") .expect("route should exist"); @@ -1825,8 +1858,9 @@ mod tests { .await .expect("persist provider"); - let route = upsert_cluster_inference_route( + let route = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o-mini", @@ -1865,8 +1899,9 @@ mod tests { .await .expect("persist provider"); - let err = upsert_cluster_inference_route( + let err = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o-mini", @@ -1885,7 +1920,7 @@ mod tests { assert!(err.message().contains("--no-verify")); let persisted = store - .get_message_by_name::(CLUSTER_INFERENCE_ROUTE_NAME) + .get_message_by_name::("default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("fetch route") .is_none(); @@ -1908,8 +1943,9 @@ mod tests { .await .expect("persist provider"); - let route = upsert_cluster_inference_route( + let route = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o-mini", @@ -1974,7 +2010,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 1, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -2879,8 +2915,9 @@ mod tests { // Spawn two concurrent upsert calls for the same route (create path) let store1 = store.clone(); let handle1 = tokio::spawn(async move { - upsert_cluster_inference_route( + upsert_inference_route( &store1, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o", @@ -2892,8 +2929,9 @@ mod tests { let store2 = store.clone(); let handle2 = tokio::spawn(async move { - upsert_cluster_inference_route( + upsert_inference_route( &store2, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4.1", @@ -2950,7 +2988,7 @@ mod tests { // Only one route should exist. let route = store - .get_message_by_name::(CLUSTER_INFERENCE_ROUTE_NAME) + .get_message_by_name::("default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("fetch") .expect("route should exist"); @@ -2966,8 +3004,9 @@ mod tests { store.put_message(&provider).await.expect("persist"); // Create initial route - upsert_cluster_inference_route( + upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-3.5", @@ -2980,8 +3019,9 @@ mod tests { // Spawn two concurrent updates let store1 = store.clone(); let handle1 = tokio::spawn(async move { - upsert_cluster_inference_route( + upsert_inference_route( &store1, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o", @@ -2993,8 +3033,9 @@ mod tests { let store2 = store.clone(); let handle2 = tokio::spawn(async move { - upsert_cluster_inference_route( + upsert_inference_route( &store2, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4.1", @@ -3017,7 +3058,7 @@ mod tests { // The route should have one of the new model values and version 2 let route = store - .get_message_by_name::(CLUSTER_INFERENCE_ROUTE_NAME) + .get_message_by_name::("default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("fetch") .expect("route should exist"); @@ -3037,4 +3078,130 @@ mod tests { route.version ); } + + // ------------------------------------------------------------------------- + // Workspace isolation tests + // ------------------------------------------------------------------------- + + #[tokio::test] + async fn inference_bundle_resolves_workspace_scoped_route() { + let store = test_store().await; + + let alpha_provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "provider-alpha".to_string(), + name: "openai-alpha".to_string(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + resource_version: 0, + workspace: "alpha".to_string(), + }), + r#type: "openai".to_string(), + credentials: std::iter::once(( + "OPENAI_API_KEY".to_string(), + "sk-alpha-key".to_string(), + )) + .collect(), + config: std::collections::HashMap::new(), + credential_expires_at_ms: std::collections::HashMap::new(), + }; + store + .put_message(&alpha_provider) + .await + .expect("persist alpha provider"); + + let beta_provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "provider-beta".to_string(), + name: "anthropic-beta".to_string(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + resource_version: 0, + workspace: "beta".to_string(), + }), + r#type: "anthropic".to_string(), + credentials: std::iter::once(( + "ANTHROPIC_API_KEY".to_string(), + "sk-beta-key".to_string(), + )) + .collect(), + config: std::collections::HashMap::new(), + credential_expires_at_ms: std::collections::HashMap::new(), + }; + store + .put_message(&beta_provider) + .await + .expect("persist beta provider"); + + upsert_inference_route( + &store, + "alpha", + CLUSTER_INFERENCE_ROUTE_NAME, + "openai-alpha", + "gpt-4", + 0, + false, + ) + .await + .expect("set alpha route"); + + upsert_inference_route( + &store, + "beta", + CLUSTER_INFERENCE_ROUTE_NAME, + "anthropic-beta", + "claude-sonnet-4-20250514", + 0, + false, + ) + .await + .expect("set beta route"); + + let alpha_bundle = resolve_inference_bundle(&store, "alpha") + .await + .expect("alpha bundle should resolve"); + assert_eq!(alpha_bundle.routes.len(), 1); + assert_eq!(alpha_bundle.routes[0].api_key, "sk-alpha-key"); + assert_eq!(alpha_bundle.routes[0].model_id, "gpt-4"); + assert_eq!(alpha_bundle.routes[0].provider_type, "openai"); + + let beta_bundle = resolve_inference_bundle(&store, "beta") + .await + .expect("beta bundle should resolve"); + assert_eq!(beta_bundle.routes.len(), 1); + assert_eq!(beta_bundle.routes[0].api_key, "sk-beta-key"); + assert_eq!(beta_bundle.routes[0].model_id, "claude-sonnet-4-20250514"); + assert_eq!(beta_bundle.routes[0].provider_type, "anthropic"); + } + + #[tokio::test] + async fn inference_bundle_empty_for_workspace_without_route() { + let store = test_store().await; + + let provider = make_provider("openai-dev", "openai", "OPENAI_API_KEY", "sk-test"); + store + .put_message(&provider) + .await + .expect("persist provider"); + + upsert_inference_route( + &store, + "default", + CLUSTER_INFERENCE_ROUTE_NAME, + "openai-dev", + "gpt-4", + 0, + false, + ) + .await + .expect("set default route"); + + let other_bundle = resolve_inference_bundle(&store, "other-workspace") + .await + .expect("bundle should resolve"); + assert!( + other_bundle.routes.is_empty(), + "workspace with no route should get empty bundle, not inherit from another workspace" + ); + } } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index d40364523c..acacf50afd 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -402,6 +402,8 @@ pub(crate) async fn run_server( // shutdown so the running compute state matches the persisted store. // Runs before watchers spawn so the watch loop sees the post-resume // snapshot on its first poll. + ensure_default_workspace(&store).await?; + if let Err(err) = state.compute.resume_persisted_sandboxes().await { warn!(error = %err, "Failed to resume persisted sandboxes during startup"); } @@ -928,6 +930,50 @@ fn warn_if_kubernetes_sandbox_jwt_expiry_disabled(config: &Config) { } } +pub(crate) async fn ensure_default_workspace(store: &Store) -> Result<()> { + use grpc::workspace::{DEFAULT_WORKSPACE_NAME, WORKSPACE_OBJECT_TYPE}; + use openshell_core::proto::Workspace; + use openshell_core::proto::datamodel::v1::ObjectMeta; + use prost::Message; + + let id = uuid::Uuid::new_v4().to_string(); + let workspace = Workspace { + metadata: Some(ObjectMeta { + id: id.clone(), + name: DEFAULT_WORKSPACE_NAME.to_string(), + created_at_ms: persistence::current_time_ms(), + labels: HashMap::new(), + resource_version: 0, + workspace: String::new(), + }), + }; + + match store + .put_if( + WORKSPACE_OBJECT_TYPE, + &id, + DEFAULT_WORKSPACE_NAME, + "", + &workspace.encode_to_vec(), + None, + persistence::WriteCondition::MustCreate, + ) + .await + { + Ok(_) => { + info!("Created default workspace"); + Ok(()) + } + Err(persistence::PersistenceError::UniqueViolation { .. }) => { + debug!("Default workspace already exists"); + Ok(()) + } + Err(e) => Err(Error::config(format!( + "failed to ensure default workspace: {e}" + ))), + } +} + #[cfg(test)] mod tests { use super::{ @@ -1062,7 +1108,7 @@ mod tests { fn service_request(addr: SocketAddr, extra_headers: &[(&str, &str)]) -> String { let mut request = format!( - "GET / HTTP/1.1\r\nHost: my-sandbox--web.dev.openshell.localhost:{}\r\nConnection: close\r\n", + "GET / HTTP/1.1\r\nHost: default--my-sandbox--web.dev.openshell.localhost:{}\r\nConnection: close\r\n", addr.port() ); for (name, value) in extra_headers { @@ -1193,7 +1239,7 @@ mod tests { let (addr, shutdown, handle, _tls_dir) = start_tls_gateway_listener("127.0.0.1:0", true).await; let origin = format!( - "http://my-sandbox--web.dev.openshell.localhost:{}", + "http://default--my-sandbox--web.dev.openshell.localhost:{}", addr.port() ); let response = send_plain_http( diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 5c1f9dad4f..83e27d27cb 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -2149,8 +2149,8 @@ mod tests { "/openshell.v1.OpenShell/DeleteSandbox", "/openshell.v1.OpenShell/CreateProvider", "/openshell.v1.OpenShell/ApproveDraftChunk", - "/openshell.inference.v1.Inference/GetClusterInference", - "/openshell.inference.v1.Inference/SetClusterInference", + "/openshell.inference.v1.Inference/GetInferenceRoute", + "/openshell.inference.v1.Inference/SetInferenceRoute", ] { let mock = Arc::new(MockAuthenticator::returning(Ok(Some(sandbox_principal())))); let chain = AuthenticatorChain::new(vec![mock]); diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index 8b6172c1c6..7f87280a11 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -81,6 +81,7 @@ pub struct ObjectRecord { pub object_type: String, pub id: String, pub name: String, + pub workspace: String, pub payload: Vec, pub created_at_ms: i64, pub updated_at_ms: i64, @@ -125,7 +126,7 @@ pub trait ObjectType { // Import object metadata accessor traits from openshell-core // (implementations for all proto types are in openshell-core::metadata) pub use openshell_core::{ - GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion, + GetResourceVersion, ObjectId, ObjectLabels, ObjectName, ObjectWorkspace, SetResourceVersion, }; /// Generate a random 6-character lowercase alphabetic name. @@ -221,6 +222,7 @@ impl Store { /// * `object_type` - Type discriminator for the object /// * `id` - Stable object identifier /// * `name` - Human-readable object name + /// * `workspace` - Workspace scope for multi-tenant isolation /// * `payload` - Serialized object data /// * `labels` - Optional JSON-serialized labels /// * `condition` - Write precondition (`MustCreate`, `MatchResourceVersion`, or `Unconditional`) @@ -229,16 +231,18 @@ impl Store { /// * `Ok(WriteResult)` - Write succeeded with new `resource_version` and timestamps /// * `Err(Conflict)` - Resource version mismatch (for `MatchResourceVersion`) /// * `Err(UniqueViolation)` - Object already exists (for `MustCreate`) or name conflict + #[allow(clippy::too_many_arguments)] pub async fn put_if( &self, object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, condition: WriteCondition, ) -> PersistenceResult { - store_dispatch!(self.put_if(object_type, id, name, payload, labels, condition)) + store_dispatch!(self.put_if(object_type, id, name, workspace, payload, labels, condition)) } /// Delete an object by id with compare-and-swap support. @@ -262,16 +266,18 @@ impl Store { } /// Insert or update a generic named object with an application-owned scope. + #[allow(clippy::too_many_arguments)] pub async fn put_scoped( &self, object_type: &str, id: &str, name: &str, + workspace: &str, scope: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { - store_dispatch!(self.put_scoped(object_type, id, name, scope, payload, labels)) + store_dispatch!(self.put_scoped(object_type, id, name, workspace, scope, payload, labels)) } /// Fetch an object by id. @@ -283,13 +289,14 @@ impl Store { store_dispatch!(self.get(object_type, id)) } - /// Fetch an object by name within an object type. + /// Fetch an object by name within an object type and workspace. pub async fn get_by_name( &self, object_type: &str, + workspace: &str, name: &str, ) -> PersistenceResult> { - store_dispatch!(self.get_by_name(object_type, name)) + store_dispatch!(self.get_by_name(object_type, workspace, name)) } /// Delete an object by id. @@ -297,19 +304,35 @@ impl Store { store_dispatch!(self.delete(object_type, id)) } - /// Delete an object by name within an object type. - pub async fn delete_by_name(&self, object_type: &str, name: &str) -> PersistenceResult { - store_dispatch!(self.delete_by_name(object_type, name)) + /// Delete an object by name within an object type and workspace. + pub async fn delete_by_name( + &self, + object_type: &str, + workspace: &str, + name: &str, + ) -> PersistenceResult { + store_dispatch!(self.delete_by_name(object_type, workspace, name)) } - /// List objects by type. + /// List objects by type and workspace. pub async fn list( &self, object_type: &str, + workspace: &str, limit: u32, offset: u32, ) -> PersistenceResult> { - store_dispatch!(self.list(object_type, limit, offset)) + store_dispatch!(self.list(object_type, workspace, limit, offset)) + } + + /// List objects by type across all workspaces. + pub async fn list_by_type( + &self, + object_type: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + store_dispatch!(self.list_by_type(object_type, limit, offset)) } /// List objects by type and application-owned scope. @@ -323,16 +346,23 @@ impl Store { store_dispatch!(self.list_by_scope(object_type, scope, limit, offset)) } - /// List objects by type with label selector filtering. + /// List objects by type and workspace with label selector filtering. /// Label selector format: "key1=value1,key2=value2" (comma-separated equality matches). pub async fn list_with_selector( &self, object_type: &str, + workspace: &str, label_selector: &str, limit: u32, offset: u32, ) -> PersistenceResult> { - store_dispatch!(self.list_with_selector(object_type, label_selector, limit, offset)) + store_dispatch!(self.list_with_selector( + object_type, + workspace, + label_selector, + limit, + offset + )) } // ----------------------------------------------------------------------- @@ -341,12 +371,17 @@ impl Store { /// Insert or update a protobuf message under an application-owned scope. pub async fn put_scoped_message< - T: Message + ObjectType + ObjectId + ObjectName + ObjectLabels, + T: Message + ObjectType + ObjectId + ObjectName + ObjectLabels + ObjectWorkspace, >( &self, message: &T, scope: &str, ) -> PersistenceResult<()> { + debug_assert!( + !T::requires_workspace() || !message.object_workspace().is_empty(), + "{} requires a non-empty workspace", + T::object_type(), + ); let labels_map = message.object_labels(); let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { None @@ -360,6 +395,7 @@ impl Store { T::object_type(), message.object_id(), message.object_name(), + message.object_workspace(), scope, &message.encode_to_vec(), labels_json.as_deref(), @@ -378,25 +414,41 @@ impl Store { .transpose() } - /// Fetch and decode a protobuf message by name. + /// Fetch and decode a protobuf message by workspace and name. pub async fn get_message_by_name( &self, + workspace: &str, name: &str, ) -> PersistenceResult> { - self.get_by_name(T::object_type(), name) + self.get_by_name(T::object_type(), workspace, name) .await? .map(decode_record) .transpose() } - /// List and decode protobuf messages, hydrating `resource_version` from - /// the authoritative DB row (mirrors `get_message`). + /// List and decode protobuf messages by workspace, hydrating + /// `resource_version` from the authoritative DB row (mirrors `get_message`). pub async fn list_messages( + &self, + workspace: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + self.list(T::object_type(), workspace, limit, offset) + .await? + .into_iter() + .map(decode_record) + .collect() + } + + /// List and decode protobuf messages across all workspaces, hydrating + /// `resource_version` from the authoritative DB row. + pub async fn list_all_messages( &self, limit: u32, offset: u32, ) -> PersistenceResult> { - self.list(T::object_type(), limit, offset) + self.list_by_type(T::object_type(), limit, offset) .await? .into_iter() .map(decode_record) @@ -409,11 +461,12 @@ impl Store { T: Message + Default + ObjectType + SetResourceVersion, >( &self, + workspace: &str, label_selector: &str, limit: u32, offset: u32, ) -> PersistenceResult> { - self.list_with_selector(T::object_type(), label_selector, limit, offset) + self.list_with_selector(T::object_type(), workspace, label_selector, limit, offset) .await? .into_iter() .map(decode_record) @@ -450,6 +503,7 @@ impl Store { + ObjectId + ObjectName + ObjectLabels + + ObjectWorkspace + SetResourceVersion + GetResourceVersion + Clone, @@ -491,12 +545,19 @@ impl Store { })?) }; + debug_assert!( + !T::requires_workspace() || !updated.object_workspace().is_empty(), + "{} requires a non-empty workspace", + T::object_type(), + ); + // Single-attempt CAS write - fails with Conflict on version mismatch let result = self .put_if( T::object_type(), updated.object_id(), updated.object_name(), + updated.object_workspace(), &updated.encode_to_vec(), labels_json.as_deref(), WriteCondition::MatchResourceVersion(cas_version), @@ -531,7 +592,7 @@ fn infer_sqlite_unique_constraint(message: &str) -> Option { Some("objects_version_uq".to_string()) } else if message.contains("objects.object_type, objects.scope, objects.dedup_key") { Some("objects_dedup_uq".to_string()) - } else if message.contains("objects.object_type, objects.name") { + } else if message.contains("objects.object_type, objects.workspace, objects.name") { Some("objects_name_uq".to_string()) } else if message.contains("objects.id") { Some("objects_pkey".to_string()) @@ -596,16 +657,24 @@ impl Store { object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { - store_dispatch!(self.put(object_type, id, name, payload, labels)) + store_dispatch!(self.put(object_type, id, name, workspace, payload, labels)) } - pub async fn put_message( + pub async fn put_message< + T: Message + ObjectType + ObjectId + ObjectName + ObjectLabels + ObjectWorkspace, + >( &self, message: &T, ) -> PersistenceResult<()> { + debug_assert!( + !T::requires_workspace() || !message.object_workspace().is_empty(), + "{} requires a non-empty workspace", + T::object_type(), + ); let labels_map = message.object_labels(); let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { None @@ -618,6 +687,7 @@ impl Store { T::object_type(), message.object_id(), message.object_name(), + message.object_workspace(), &message.encode_to_vec(), labels_json.as_deref(), ) diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index e3f9e5a7b3..6dcf774b69 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -63,6 +63,7 @@ impl PostgresStore { object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { @@ -74,9 +75,9 @@ impl PostgresStore { sqlx::query( r" -INSERT INTO objects (object_type, id, name, payload, created_at_ms, updated_at_ms, labels) -VALUES ($1, $2, $3, $4, $5, $5, COALESCE($6, '{}'::jsonb)) -ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET +INSERT INTO objects (object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels) +VALUES ($1, $2, $3, $4, $5, $6, $6, COALESCE($7, '{}'::jsonb)) +ON CONFLICT (object_type, workspace, name) WHERE name IS NOT NULL DO UPDATE SET payload = EXCLUDED.payload, updated_at_ms = EXCLUDED.updated_at_ms, labels = EXCLUDED.labels @@ -85,6 +86,7 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels_jsonb) @@ -94,11 +96,13 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET Ok(()) } + #[allow(clippy::too_many_arguments)] pub async fn put_if( &self, object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, condition: WriteCondition, @@ -114,14 +118,15 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET // Insert only - fail if object exists let row = sqlx::query( r" -INSERT INTO objects (object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version) -VALUES ($1, $2, $3, $4, $5, $5, COALESCE($6, '{}'::jsonb), 1) +INSERT INTO objects (object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version) +VALUES ($1, $2, $3, $4, $5, $6, $6, COALESCE($7, '{}'::jsonb), 1) RETURNING resource_version, created_at_ms, updated_at_ms ", ) .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels_jsonb) @@ -181,9 +186,9 @@ RETURNING resource_version, created_at_ms, updated_at_ms // Unconditional upsert by name let row = sqlx::query( r" -INSERT INTO objects (object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version) -VALUES ($1, $2, $3, $4, $5, $5, COALESCE($6, '{}'::jsonb), 1) -ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET +INSERT INTO objects (object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version) +VALUES ($1, $2, $3, $4, $5, $6, $6, COALESCE($7, '{}'::jsonb), 1) +ON CONFLICT (object_type, workspace, name) WHERE name IS NOT NULL DO UPDATE SET payload = EXCLUDED.payload, updated_at_ms = EXCLUDED.updated_at_ms, labels = EXCLUDED.labels, @@ -194,6 +199,7 @@ RETURNING resource_version, created_at_ms, updated_at_ms .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels_jsonb) @@ -245,11 +251,13 @@ WHERE object_type = $1 AND id = $2 AND resource_version = $3 } } + #[allow(clippy::too_many_arguments)] pub async fn put_scoped( &self, object_type: &str, id: &str, name: &str, + workspace: &str, scope: &str, payload: &[u8], labels: Option<&str>, @@ -262,9 +270,9 @@ WHERE object_type = $1 AND id = $2 AND resource_version = $3 sqlx::query( r" -INSERT INTO objects (object_type, id, name, scope, payload, created_at_ms, updated_at_ms, labels, resource_version) -VALUES ($1, $2, $3, $4, $5, $6, $6, COALESCE($7, '{}'::jsonb), 1) -ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET +INSERT INTO objects (object_type, id, name, workspace, scope, payload, created_at_ms, updated_at_ms, labels, resource_version) +VALUES ($1, $2, $3, $4, $5, $6, $7, $7, COALESCE($8, '{}'::jsonb), 1) +ON CONFLICT (object_type, workspace, name) WHERE name IS NOT NULL DO UPDATE SET scope = EXCLUDED.scope, payload = EXCLUDED.payload, updated_at_ms = EXCLUDED.updated_at_ms, @@ -275,6 +283,7 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(scope) .bind(payload) .bind(now_ms) @@ -292,7 +301,7 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET ) -> PersistenceResult> { let row = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND id = $2 ", @@ -309,16 +318,18 @@ WHERE object_type = $1 AND id = $2 pub async fn get_by_name( &self, object_type: &str, + workspace: &str, name: &str, ) -> PersistenceResult> { let row = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects -WHERE object_type = $1 AND name = $2 +WHERE object_type = $1 AND workspace = $2 AND name = $3 ", ) .bind(object_type) + .bind(workspace) .bind(name) .fetch_optional(&self.pool) .await @@ -337,25 +348,60 @@ WHERE object_type = $1 AND name = $2 Ok(result.rows_affected() > 0) } - pub async fn delete_by_name(&self, object_type: &str, name: &str) -> PersistenceResult { - let result = sqlx::query("DELETE FROM objects WHERE object_type = $1 AND name = $2") - .bind(object_type) - .bind(name) - .execute(&self.pool) - .await - .map_err(|e| map_db_error(&e))?; + pub async fn delete_by_name( + &self, + object_type: &str, + workspace: &str, + name: &str, + ) -> PersistenceResult { + let result = sqlx::query( + "DELETE FROM objects WHERE object_type = $1 AND workspace = $2 AND name = $3", + ) + .bind(object_type) + .bind(workspace) + .bind(name) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; Ok(result.rows_affected() > 0) } pub async fn list( &self, object_type: &str, + workspace: &str, limit: u32, offset: u32, ) -> PersistenceResult> { let rows = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version +FROM objects +WHERE object_type = $1 AND workspace = $2 +ORDER BY created_at_ms ASC, name ASC +LIMIT $3 OFFSET $4 +", + ) + .bind(object_type) + .bind(workspace) + .bind(i64::from(limit)) + .bind(i64::from(offset)) + .fetch_all(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + + Ok(rows.into_iter().map(row_to_object_record).collect()) + } + + pub async fn list_by_type( + &self, + object_type: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + let rows = sqlx::query( + r" +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 ORDER BY created_at_ms ASC, name ASC @@ -381,7 +427,7 @@ LIMIT $2 OFFSET $3 ) -> PersistenceResult> { let rows = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels FROM objects WHERE object_type = $1 AND scope = $2 ORDER BY created_at_ms ASC, name ASC @@ -402,6 +448,7 @@ LIMIT $3 OFFSET $4 pub async fn list_with_selector( &self, object_type: &str, + workspace: &str, label_selector: &str, limit: u32, offset: u32, @@ -414,14 +461,15 @@ LIMIT $3 OFFSET $4 let rows = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects -WHERE object_type = $1 AND labels @> $2 +WHERE object_type = $1 AND workspace = $2 AND labels @> $3 ORDER BY created_at_ms ASC, name ASC -LIMIT $3 OFFSET $4 +LIMIT $4 OFFSET $5 ", ) .bind(object_type) + .bind(workspace) .bind(&labels_jsonb) .bind(i64::from(limit)) .bind(i64::from(offset)) @@ -436,6 +484,7 @@ LIMIT $3 OFFSET $4 &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -458,9 +507,9 @@ LIMIT $3 OFFSET $4 sqlx::query( r" INSERT INTO objects ( - object_type, id, scope, version, status, payload, created_at_ms, updated_at_ms + object_type, id, scope, version, status, payload, created_at_ms, updated_at_ms, workspace ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $7) +VALUES ($1, $2, $3, $4, $5, $6, $7, $7, $8) ", ) .bind(POLICY_OBJECT_TYPE) @@ -470,6 +519,7 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $7) .bind("pending") .bind(wrapped_payload) .bind(now_ms) + .bind(workspace) .execute(&self.pool) .await .map_err(|e| map_db_error(&e))?; @@ -733,6 +783,7 @@ WHERE object_type = $1 &self, chunk: &DraftChunkRecord, dedup_key: Option<&str>, + workspace: &str, ) -> PersistenceResult { let payload = draft_chunk_payload_from_record(chunk)?; // RETURNING id gives the row's effective id whether INSERT inserted @@ -741,9 +792,9 @@ WHERE object_type = $1 let row = sqlx::query( r" INSERT INTO objects ( - object_type, id, scope, status, dedup_key, hit_count, payload, created_at_ms, updated_at_ms + object_type, id, scope, status, dedup_key, hit_count, payload, created_at_ms, updated_at_ms, workspace ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (object_type, scope, dedup_key) WHERE dedup_key IS NOT NULL DO UPDATE SET hit_count = objects.hit_count + EXCLUDED.hit_count, updated_at_ms = EXCLUDED.updated_at_ms @@ -759,6 +810,7 @@ RETURNING id .bind(payload) .bind(chunk.first_seen_ms) .bind(chunk.last_seen_ms) + .bind(workspace) .fetch_one(&self.pool) .await .map_err(|e| map_db_error(&e))?; @@ -988,6 +1040,7 @@ fn row_to_object_record(row: sqlx::postgres::PgRow) -> ObjectRecord { object_type: row.get("object_type"), id: row.get("id"), name: row.get("name"), + workspace: row.try_get("workspace").unwrap_or_default(), payload: row.get("payload"), created_at_ms: row.get("created_at_ms"), updated_at_ms: row.get("updated_at_ms"), diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index 0b1f50df6a..4fa18cee03 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -89,6 +89,7 @@ impl SqliteStore { object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { @@ -96,9 +97,9 @@ impl SqliteStore { sqlx::query( r#" -INSERT INTO "objects" ("object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels") -VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6) -ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7) +ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPDATE SET "payload" = excluded."payload", "updated_at_ms" = excluded."updated_at_ms", "labels" = excluded."labels" @@ -107,6 +108,7 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels.unwrap_or("{}")) @@ -116,11 +118,13 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET Ok(()) } + #[allow(clippy::too_many_arguments)] pub async fn put_if( &self, object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, condition: WriteCondition, @@ -132,13 +136,14 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET // Insert only - fail if object exists sqlx::query( r#" -INSERT INTO "objects" ("object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") -VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6, 1) +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 1) "#, ) .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels.unwrap_or("{}")) @@ -199,9 +204,9 @@ WHERE "object_type" = ?1 AND "id" = ?2 AND "resource_version" = ?3 // Unconditional upsert by name sqlx::query( r#" -INSERT INTO "objects" ("object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") -VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6, 1) -ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 1) +ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPDATE SET "payload" = excluded."payload", "updated_at_ms" = excluded."updated_at_ms", "labels" = excluded."labels", @@ -211,6 +216,7 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels.unwrap_or("{}")) @@ -219,9 +225,12 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET .map_err(|e| map_db_error(&e))?; // Fetch the result to get the resource_version - let record = self.get_by_name(object_type, name).await?.ok_or_else(|| { - PersistenceError::Database("object disappeared after upsert".to_string()) - })?; + let record = self + .get_by_name(object_type, workspace, name) + .await? + .ok_or_else(|| { + PersistenceError::Database("object disappeared after upsert".to_string()) + })?; Ok(WriteResult { resource_version: record.resource_version, @@ -265,11 +274,13 @@ WHERE "object_type" = ?1 AND "id" = ?2 AND "resource_version" = ?3 } } + #[allow(clippy::too_many_arguments)] pub async fn put_scoped( &self, object_type: &str, id: &str, name: &str, + workspace: &str, scope: &str, payload: &[u8], labels: Option<&str>, @@ -278,9 +289,9 @@ WHERE "object_type" = ?1 AND "id" = ?2 AND "resource_version" = ?3 sqlx::query( r#" -INSERT INTO "objects" ("object_type", "id", "name", "scope", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 1) -ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "scope", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8, 1) +ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPDATE SET "scope" = excluded."scope", "payload" = excluded."payload", "updated_at_ms" = excluded."updated_at_ms", @@ -291,6 +302,7 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(scope) .bind(payload) .bind(now_ms) @@ -308,7 +320,7 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET ) -> PersistenceResult> { let row = sqlx::query( r#" -SELECT "object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" FROM "objects" WHERE "object_type" = ?1 AND "id" = ?2 "#, @@ -325,16 +337,18 @@ WHERE "object_type" = ?1 AND "id" = ?2 pub async fn get_by_name( &self, object_type: &str, + workspace: &str, name: &str, ) -> PersistenceResult> { let row = sqlx::query( r#" -SELECT "object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" FROM "objects" -WHERE "object_type" = ?1 AND "name" = ?2 +WHERE "object_type" = ?1 AND "workspace" = ?2 AND "name" = ?3 "#, ) .bind(object_type) + .bind(workspace) .bind(name) .fetch_optional(&self.pool) .await @@ -358,14 +372,20 @@ WHERE "object_type" = ?1 AND "id" = ?2 Ok(result.rows_affected() > 0) } - pub async fn delete_by_name(&self, object_type: &str, name: &str) -> PersistenceResult { + pub async fn delete_by_name( + &self, + object_type: &str, + workspace: &str, + name: &str, + ) -> PersistenceResult { let result = sqlx::query( r#" DELETE FROM "objects" -WHERE "object_type" = ?1 AND "name" = ?2 +WHERE "object_type" = ?1 AND "workspace" = ?2 AND "name" = ?3 "#, ) .bind(object_type) + .bind(workspace) .bind(name) .execute(&self.pool) .await @@ -374,6 +394,33 @@ WHERE "object_type" = ?1 AND "name" = ?2 } pub async fn list( + &self, + object_type: &str, + workspace: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + let rows = sqlx::query( + r#" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 +ORDER BY "created_at_ms" ASC, "name" ASC +LIMIT ?3 OFFSET ?4 +"#, + ) + .bind(object_type) + .bind(workspace) + .bind(i64::from(limit)) + .bind(i64::from(offset)) + .fetch_all(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + + Ok(rows.into_iter().map(row_to_object_record).collect()) + } + + pub async fn list_by_type( &self, object_type: &str, limit: u32, @@ -381,7 +428,7 @@ WHERE "object_type" = ?1 AND "name" = ?2 ) -> PersistenceResult> { let rows = sqlx::query( r#" -SELECT "object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" FROM "objects" WHERE "object_type" = ?1 ORDER BY "created_at_ms" ASC, "name" ASC @@ -407,7 +454,7 @@ LIMIT ?2 OFFSET ?3 ) -> PersistenceResult> { let rows = sqlx::query( r#" -SELECT "object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels" FROM "objects" WHERE "object_type" = ?1 AND "scope" = ?2 ORDER BY "created_at_ms" ASC, "name" ASC @@ -427,6 +474,7 @@ LIMIT ?3 OFFSET ?4 pub async fn list_with_selector( &self, object_type: &str, + workspace: &str, label_selector: &str, limit: u32, offset: u32, @@ -434,7 +482,7 @@ LIMIT ?3 OFFSET ?4 use super::parse_label_selector; let required_labels = parse_label_selector(label_selector)?; - let all_records = self.list(object_type, u32::MAX, 0).await?; + let all_records = self.list(object_type, workspace, u32::MAX, 0).await?; let filtered: Vec = all_records .into_iter() @@ -457,6 +505,7 @@ LIMIT ?3 OFFSET ?4 &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -479,9 +528,9 @@ LIMIT ?3 OFFSET ?4 sqlx::query( r#" INSERT INTO "objects" ( - "object_type", "id", "scope", "version", "status", "payload", "created_at_ms", "updated_at_ms" + "object_type", "id", "scope", "version", "status", "payload", "created_at_ms", "updated_at_ms", "workspace" ) -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8) "#, ) .bind(POLICY_OBJECT_TYPE) @@ -491,6 +540,7 @@ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) .bind("pending") .bind(wrapped_payload) .bind(now_ms) + .bind(workspace) .execute(&self.pool) .await .map_err(|e| map_db_error(&e))?; @@ -757,6 +807,7 @@ WHERE "object_type" = ?1 &self, chunk: &DraftChunkRecord, dedup_key: Option<&str>, + workspace: &str, ) -> PersistenceResult { let payload = draft_chunk_payload_from_record(chunk)?; // RETURNING "id" gives us the row's effective id regardless of @@ -766,9 +817,9 @@ WHERE "object_type" = ?1 let row = sqlx::query( r#" INSERT INTO "objects" ( - "object_type", "id", "scope", "status", "dedup_key", "hit_count", "payload", "created_at_ms", "updated_at_ms" + "object_type", "id", "scope", "status", "dedup_key", "hit_count", "payload", "created_at_ms", "updated_at_ms", "workspace" ) -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) ON CONFLICT ("object_type", "scope", "dedup_key") WHERE "dedup_key" IS NOT NULL DO UPDATE SET "hit_count" = "objects"."hit_count" + excluded."hit_count", "updated_at_ms" = excluded."updated_at_ms" @@ -784,6 +835,7 @@ RETURNING "id" .bind(payload) .bind(chunk.first_seen_ms) .bind(chunk.last_seen_ms) + .bind(workspace) .fetch_one(&self.pool) .await .map_err(|e| map_db_error(&e))?; @@ -1044,6 +1096,7 @@ fn row_to_object_record(row: sqlx::sqlite::SqliteRow) -> ObjectRecord { object_type: row.get("object_type"), id: row.get("id"), name: row.get("name"), + workspace: row.try_get("workspace").unwrap_or_default(), payload: row.get("payload"), created_at_ms: row.get("created_at_ms"), updated_at_ms: row.get("updated_at_ms"), diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index c92f63827a..57fcf873c7 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -13,7 +13,7 @@ async fn sqlite_put_get_round_trip() { let store = test_store().await; store - .put("sandbox", "abc", "my-sandbox", b"payload", None) + .put("sandbox", "abc", "my-sandbox", "default", b"payload", None) .await .unwrap(); @@ -28,7 +28,7 @@ async fn sqlite_put_get_round_trip() { async fn sqlite_connect_runs_embedded_migrations() { let store = test_store().await; - let records = store.list("sandbox", 10, 0).await.unwrap(); + let records = store.list("sandbox", "default", 10, 0).await.unwrap(); assert!(records.is_empty()); } @@ -214,14 +214,14 @@ async fn sqlite_updates_timestamp() { let store = test_store().await; store - .put("sandbox", "abc", "my-sandbox", b"payload", None) + .put("sandbox", "abc", "my-sandbox", "default", b"payload", None) .await .unwrap(); let first = store.get("sandbox", "abc").await.unwrap().unwrap(); store - .put("sandbox", "abc", "my-sandbox", b"payload2", None) + .put("sandbox", "abc", "my-sandbox", "default", b"payload2", None) .await .unwrap(); @@ -239,12 +239,12 @@ async fn sqlite_list_paging() { let name = format!("name-{idx}"); let payload = format!("payload-{idx}"); store - .put("sandbox", &id, &name, payload.as_bytes(), None) + .put("sandbox", &id, &name, "default", payload.as_bytes(), None) .await .unwrap(); } - let records = store.list("sandbox", 2, 1).await.unwrap(); + let records = store.list("sandbox", "default", 2, 1).await.unwrap(); assert_eq!(records.len(), 2); assert_eq!(records[0].name, "name-1"); assert_eq!(records[1].name, "name-2"); @@ -255,7 +255,7 @@ async fn sqlite_delete_behavior() { let store = test_store().await; store - .put("sandbox", "abc", "my-sandbox", b"payload", None) + .put("sandbox", "abc", "my-sandbox", "default", b"payload", None) .await .unwrap(); @@ -294,12 +294,12 @@ async fn sqlite_get_by_name() { let store = test_store().await; store - .put("sandbox", "id-1", "my-sandbox", b"payload", None) + .put("sandbox", "id-1", "my-sandbox", "default", b"payload", None) .await .unwrap(); let record = store - .get_by_name("sandbox", "my-sandbox") + .get_by_name("sandbox", "default", "my-sandbox") .await .unwrap() .unwrap(); @@ -307,7 +307,10 @@ async fn sqlite_get_by_name() { assert_eq!(record.name, "my-sandbox"); assert_eq!(record.payload, b"payload"); - let missing = store.get_by_name("sandbox", "no-such-name").await.unwrap(); + let missing = store + .get_by_name("sandbox", "default", "no-such-name") + .await + .unwrap(); assert!(missing.is_none()); } @@ -324,7 +327,7 @@ async fn sqlite_get_message_by_name() { store.put_message(&object).await.unwrap(); let loaded = store - .get_message_by_name::("my-test") + .get_message_by_name::("", "my-test") .await .unwrap() .unwrap(); @@ -333,7 +336,7 @@ async fn sqlite_get_message_by_name() { assert_eq!(loaded.count, 7); let missing = store - .get_message_by_name::("no-such-name") + .get_message_by_name::("", "no-such-name") .await .unwrap(); assert!(missing.is_none()); @@ -344,14 +347,20 @@ async fn sqlite_delete_by_name() { let store = test_store().await; store - .put("sandbox", "id-1", "my-sandbox", b"payload", None) + .put("sandbox", "id-1", "my-sandbox", "default", b"payload", None) .await .unwrap(); - let deleted = store.delete_by_name("sandbox", "my-sandbox").await.unwrap(); + let deleted = store + .delete_by_name("sandbox", "default", "my-sandbox") + .await + .unwrap(); assert!(deleted); - let deleted_again = store.delete_by_name("sandbox", "my-sandbox").await.unwrap(); + let deleted_again = store + .delete_by_name("sandbox", "default", "my-sandbox") + .await + .unwrap(); assert!(!deleted_again); let gone = store.get("sandbox", "id-1").await.unwrap(); @@ -363,18 +372,32 @@ async fn sqlite_name_unique_per_object_type() { let store = test_store().await; store - .put("sandbox", "id-1", "shared-name", b"payload1", None) + .put( + "sandbox", + "id-1", + "shared-name", + "default", + b"payload1", + None, + ) .await .unwrap(); // Same name, same object_type, different id -> upsert on name. store - .put("sandbox", "id-2", "shared-name", b"payload2", None) + .put( + "sandbox", + "id-2", + "shared-name", + "default", + b"payload2", + None, + ) .await .unwrap(); let record = store - .get_by_name("sandbox", "shared-name") + .get_by_name("sandbox", "default", "shared-name") .await .unwrap() .unwrap(); @@ -383,7 +406,14 @@ async fn sqlite_name_unique_per_object_type() { // Same name, different object_type -> should succeed. store - .put("secret", "id-3", "shared-name", b"payload3", None) + .put( + "secret", + "id-3", + "shared-name", + "default", + b"payload3", + None, + ) .await .unwrap(); } @@ -393,14 +423,14 @@ async fn sqlite_id_globally_unique() { let store = test_store().await; store - .put("sandbox", "same-id", "name-a", b"payload1", None) + .put("sandbox", "same-id", "name-a", "default", b"payload1", None) .await .unwrap(); // Same id, different object_type -> should fail because ids remain global // primary keys even when writes upsert on name. let result = store - .put("secret", "same-id", "name-b", b"payload2", None) + .put("secret", "same-id", "name-b", "default", b"payload2", None) .await; assert!(result.is_err()); @@ -446,6 +476,7 @@ async fn labels_round_trip() { "sandbox", "id-1", "labeled-sandbox", + "default", b"payload", Some(labels), ) @@ -461,11 +492,25 @@ async fn label_selector_single_match() { let store = test_store().await; store - .put("sandbox", "id-1", "s1", b"p1", Some(r#"{"env":"prod"}"#)) + .put( + "sandbox", + "id-1", + "s1", + "default", + b"p1", + Some(r#"{"env":"prod"}"#), + ) .await .unwrap(); store - .put("sandbox", "id-2", "s2", b"p2", Some(r#"{"env":"dev"}"#)) + .put( + "sandbox", + "id-2", + "s2", + "default", + b"p2", + Some(r#"{"env":"dev"}"#), + ) .await .unwrap(); store @@ -473,6 +518,7 @@ async fn label_selector_single_match() { "sandbox", "id-3", "s3", + "default", b"p3", Some(r#"{"env":"prod","team":"platform"}"#), ) @@ -480,7 +526,7 @@ async fn label_selector_single_match() { .unwrap(); let results = store - .list_with_selector("sandbox", "env=prod", 10, 0) + .list_with_selector("sandbox", "default", "env=prod", 10, 0) .await .unwrap(); @@ -499,6 +545,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-1", "s1", + "default", b"p1", Some(r#"{"env":"prod","team":"platform"}"#), ) @@ -509,6 +556,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-2", "s2", + "default", b"p2", Some(r#"{"env":"prod","team":"data"}"#), ) @@ -519,6 +567,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-3", "s3", + "default", b"p3", Some(r#"{"env":"dev","team":"platform"}"#), ) @@ -526,7 +575,7 @@ async fn label_selector_multiple_labels() { .unwrap(); let results = store - .list_with_selector("sandbox", "env=prod,team=platform", 10, 0) + .list_with_selector("sandbox", "default", "env=prod,team=platform", 10, 0) .await .unwrap(); @@ -539,12 +588,19 @@ async fn label_selector_no_match() { let store = test_store().await; store - .put("sandbox", "id-1", "s1", b"p1", Some(r#"{"env":"prod"}"#)) + .put( + "sandbox", + "id-1", + "s1", + "default", + b"p1", + Some(r#"{"env":"prod"}"#), + ) .await .unwrap(); let results = store - .list_with_selector("sandbox", "env=staging", 10, 0) + .list_with_selector("sandbox", "default", "env=staging", 10, 0) .await .unwrap(); @@ -559,25 +615,32 @@ async fn label_selector_respects_paging() { let id = format!("id-{idx}"); let name = format!("name-{idx}"); store - .put("sandbox", &id, &name, b"payload", Some(r#"{"env":"prod"}"#)) + .put( + "sandbox", + &id, + &name, + "default", + b"payload", + Some(r#"{"env":"prod"}"#), + ) .await .unwrap(); } let page1 = store - .list_with_selector("sandbox", "env=prod", 2, 0) + .list_with_selector("sandbox", "default", "env=prod", 2, 0) .await .unwrap(); assert_eq!(page1.len(), 2); let page2 = store - .list_with_selector("sandbox", "env=prod", 2, 2) + .list_with_selector("sandbox", "default", "env=prod", 2, 2) .await .unwrap(); assert_eq!(page2.len(), 2); let page3 = store - .list_with_selector("sandbox", "env=prod", 2, 4) + .list_with_selector("sandbox", "default", "env=prod", 2, 4) .await .unwrap(); assert_eq!(page3.len(), 1); @@ -588,16 +651,23 @@ async fn empty_labels_not_matched_by_selector() { let store = test_store().await; store - .put("sandbox", "id-1", "s1", b"p1", None) + .put("sandbox", "id-1", "s1", "default", b"p1", None) .await .unwrap(); store - .put("sandbox", "id-2", "s2", b"p2", Some(r#"{"env":"prod"}"#)) + .put( + "sandbox", + "id-2", + "s2", + "default", + b"p2", + Some(r#"{"env":"prod"}"#), + ) .await .unwrap(); let results = store - .list_with_selector("sandbox", "env=prod", 10, 0) + .list_with_selector("sandbox", "default", "env=prod", 10, 0) .await .unwrap(); @@ -743,7 +813,7 @@ async fn policy_put_and_get_latest() { let policy_v1 = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &policy_v1, "hash1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &policy_v1, "hash1") .await .unwrap(); @@ -760,7 +830,7 @@ async fn policy_put_and_get_latest() { } .encode_to_vec(); store - .put_policy_revision("p2", "sandbox-1", 2, &policy_v2, "hash2") + .put_policy_revision("p2", "sandbox-1", "default", 2, &policy_v2, "hash2") .await .unwrap(); @@ -780,11 +850,11 @@ async fn policy_get_by_version() { } .encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &policy_v1, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &policy_v1, "h1") .await .unwrap(); store - .put_policy_revision("p2", "sandbox-1", 2, &policy_v2, "h2") + .put_policy_revision("p2", "sandbox-1", "default", 2, &policy_v2, "h2") .await .unwrap(); @@ -814,7 +884,7 @@ async fn policy_update_status_and_get_loaded() { let payload = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &payload, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &payload, "h1") .await .unwrap(); @@ -845,7 +915,7 @@ async fn policy_status_failed_with_error() { let payload = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &payload, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &payload, "h1") .await .unwrap(); @@ -869,15 +939,15 @@ async fn policy_supersede_older() { let payload = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &payload, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &payload, "h1") .await .unwrap(); store - .put_policy_revision("p2", "sandbox-1", 2, &payload, "h2") + .put_policy_revision("p2", "sandbox-1", "default", 2, &payload, "h2") .await .unwrap(); store - .put_policy_revision("p3", "sandbox-1", 3, &payload, "h3") + .put_policy_revision("p3", "sandbox-1", "default", 3, &payload, "h3") .await .unwrap(); @@ -922,15 +992,15 @@ async fn policy_list_ordered_by_version_desc() { let payload = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &payload, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &payload, "h1") .await .unwrap(); store - .put_policy_revision("p2", "sandbox-1", 2, &payload, "h2") + .put_policy_revision("p2", "sandbox-1", "default", 2, &payload, "h2") .await .unwrap(); store - .put_policy_revision("p3", "sandbox-1", 3, &payload, "h3") + .put_policy_revision("p3", "sandbox-1", "default", 3, &payload, "h3") .await .unwrap(); @@ -958,11 +1028,11 @@ async fn policy_isolation_between_sandboxes() { } .encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &policy_s1, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &policy_s1, "h1") .await .unwrap(); store - .put_policy_revision("p2", "sandbox-2", 1, &policy_s2, "h2") + .put_policy_revision("p2", "sandbox-2", "default", 1, &policy_s2, "h2") .await .unwrap(); @@ -1060,6 +1130,7 @@ async fn cas_put_if_must_create_succeeds() { "sandbox", "id-1", "new-sandbox", + "default", b"payload", None, WriteCondition::MustCreate, @@ -1086,6 +1157,7 @@ async fn cas_put_if_must_create_fails_on_duplicate() { "sandbox", "id-1", "sandbox-1", + "default", b"payload1", None, WriteCondition::MustCreate, @@ -1099,6 +1171,7 @@ async fn cas_put_if_must_create_fails_on_duplicate() { "sandbox", "id-1", "sandbox-2", + "default", b"payload2", None, WriteCondition::MustCreate, @@ -1123,6 +1196,7 @@ async fn cas_put_if_match_version_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1136,6 +1210,7 @@ async fn cas_put_if_match_version_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(1), @@ -1162,6 +1237,7 @@ async fn cas_put_if_match_version_fails_on_mismatch() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1175,6 +1251,7 @@ async fn cas_put_if_match_version_fails_on_mismatch() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(99), @@ -1205,6 +1282,7 @@ async fn cas_delete_if_succeeds_with_correct_version() { "sandbox", "id-1", "sandbox-1", + "default", b"payload", None, WriteCondition::MustCreate, @@ -1230,6 +1308,7 @@ async fn cas_delete_if_fails_with_wrong_version() { "sandbox", "id-1", "sandbox-1", + "default", b"payload", None, WriteCondition::MustCreate, @@ -1262,6 +1341,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1276,6 +1356,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(1), @@ -1290,6 +1371,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v3", None, WriteCondition::MatchResourceVersion(2), @@ -1315,6 +1397,7 @@ async fn cas_concurrent_updates_one_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"initial", None, WriteCondition::MustCreate, @@ -1332,6 +1415,7 @@ async fn cas_concurrent_updates_one_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", format!("update-{i}").as_bytes(), None, WriteCondition::MatchResourceVersion(1), @@ -1373,7 +1457,7 @@ async fn cas_update_message_cas_succeeds() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), spec: None, status: None, @@ -1413,7 +1497,7 @@ async fn cas_update_message_cas_conflicts_on_concurrent_updates() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), spec: None, status: None, diff --git a/crates/openshell-server/src/policy_store.rs b/crates/openshell-server/src/policy_store.rs index 7e3b7856a4..04f7466716 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -99,6 +99,7 @@ pub trait PolicyStoreExt { &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -165,6 +166,7 @@ pub trait PolicyStoreExt { &self, chunk: &DraftChunkRecord, dedup_key: Option<&str>, + workspace: &str, ) -> PersistenceResult; async fn get_draft_chunk(&self, id: &str) -> PersistenceResult>; @@ -210,6 +212,7 @@ impl PolicyStoreExt for Store { &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -217,12 +220,12 @@ impl PolicyStoreExt for Store { match self { Self::Postgres(store) => { store - .put_policy_revision(id, sandbox_id, version, payload, hash) + .put_policy_revision(id, sandbox_id, workspace, version, payload, hash) .await } Self::Sqlite(store) => { store - .put_policy_revision(id, sandbox_id, version, payload, hash) + .put_policy_revision(id, sandbox_id, workspace, version, payload, hash) .await } } @@ -323,10 +326,11 @@ impl PolicyStoreExt for Store { &self, chunk: &DraftChunkRecord, dedup_key: Option<&str>, + workspace: &str, ) -> PersistenceResult { match self { - Self::Postgres(store) => store.put_draft_chunk(chunk, dedup_key).await, - Self::Sqlite(store) => store.put_draft_chunk(chunk, dedup_key).await, + Self::Postgres(store) => store.put_draft_chunk(chunk, dedup_key, workspace).await, + Self::Sqlite(store) => store.put_draft_chunk(chunk, dedup_key, workspace).await, } } diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index b604a3ef33..da432bc440 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -6,6 +6,7 @@ #![allow(clippy::result_large_err)] use crate::persistence::{ObjectType, Store, current_time_ms}; +use openshell_core::ObjectWorkspace; use openshell_core::proto::{ Provider, ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, StoredProviderCredentialRefreshState, @@ -79,7 +80,7 @@ pub async fn list_all_refresh_states( let mut offset = 0; loop { let records = store - .list( + .list_by_type( StoredProviderCredentialRefreshState::object_type(), REFRESH_WORKER_PAGE_SIZE, offset, @@ -108,24 +109,30 @@ pub async fn list_all_refresh_states( pub async fn get_refresh_state( store: &Store, + workspace: &str, provider_id: &str, credential_key: &str, ) -> Result, Status> { let name = refresh_state_name(provider_id, credential_key); store - .get_message_by_name::(&name) + .get_message_by_name::(workspace, &name) .await .map_err(|e| Status::internal(format!("fetch provider refresh state failed: {e}"))) } pub async fn delete_refresh_state( store: &Store, + workspace: &str, provider_id: &str, credential_key: &str, ) -> Result { let name = refresh_state_name(provider_id, credential_key); store - .delete_by_name(StoredProviderCredentialRefreshState::object_type(), &name) + .delete_by_name( + StoredProviderCredentialRefreshState::object_type(), + workspace, + &name, + ) .await .map_err(|e| Status::internal(format!("delete provider refresh state failed: {e}"))) } @@ -136,10 +143,11 @@ pub async fn delete_refresh_states_for_provider( ) -> Result { let states = list_refresh_states_for_provider(store, provider_id).await?; let mut deleted = 0; - for state in states { + for state in &states { if store .delete_by_name( StoredProviderCredentialRefreshState::object_type(), + state.object_workspace(), state.object_name(), ) .await @@ -181,6 +189,7 @@ pub struct NewRefreshStateConfig { #[allow(clippy::unnecessary_wraps)] pub fn new_refresh_state( provider: &Provider, + workspace: &str, credential_key: &str, config: NewRefreshStateConfig, ) -> Result { @@ -200,7 +209,7 @@ pub fn new_refresh_state( created_at_ms: now_ms, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.to_string(), }), provider_id, provider_name, @@ -295,15 +304,17 @@ pub fn is_gateway_mintable_strategy(strategy: ProviderCredentialRefreshStrategy) pub async fn refresh_provider_credential( store: &Store, + workspace: &str, provider_name: &str, credential_key: &str, ) -> Result { let provider = store - .get_message_by_name::(provider_name) + .get_message_by_name::(workspace, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; - let Some(mut state) = get_refresh_state(store, provider.object_id(), credential_key).await? + let Some(mut state) = + get_refresh_state(store, workspace, provider.object_id(), credential_key).await? else { return Err(Status::not_found("provider refresh state not found")); }; @@ -322,7 +333,7 @@ pub async fn refresh_provider_credential( Ok(minted) => { let now_ms = current_time_ms(); if let Err(err) = - apply_minted_credential(store, &provider, credential_key, &minted).await + apply_minted_credential(store, workspace, &provider, credential_key, &minted).await { state.status = "error".to_string(); state.last_error = err.message().to_string(); @@ -400,6 +411,7 @@ pub async fn refresh_provider_credential( async fn apply_minted_credential( store: &Store, + workspace: &str, provider: &Provider, credential_key: &str, minted: &MintedCredential, @@ -415,8 +427,10 @@ async fn apply_minted_credential( } else { updated.credential_expires_at_ms.remove(credential_key); } - crate::grpc::provider::validate_provider_update_against_attached_sandboxes(store, &updated) - .await?; + crate::grpc::provider::validate_provider_update_against_attached_sandboxes( + store, workspace, &updated, + ) + .await?; store .update_message_cas::(provider.object_id(), 0, |current| { current @@ -753,8 +767,13 @@ async fn run_refresh_worker_tick(store: &Store) -> Result<(), Status> { status = %state.status, "refreshing provider credential" ); - if let Err(err) = - refresh_provider_credential(store, &state.provider_name, &state.credential_key).await + if let Err(err) = refresh_provider_credential( + store, + state.object_workspace(), + &state.provider_name, + &state.credential_key, + ) + .await { warn!( provider = %state.provider_name, @@ -853,6 +872,7 @@ mod tests { let before_refresh_ms = crate::persistence::current_time_ms(); let state = new_refresh_state( &provider, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials, @@ -871,9 +891,10 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let refreshed = refresh_provider_credential(&store, "my-graph", "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap(); + let refreshed = + refresh_provider_credential(&store, "default", "my-graph", "MS_GRAPH_ACCESS_TOKEN") + .await + .unwrap(); assert_eq!(refreshed.status, "refreshed"); assert!(refreshed.expires_at_ms > 0); assert!(refreshed.next_refresh_at_ms > 0); @@ -881,7 +902,7 @@ mod tests { assert!(refreshed.last_error.is_empty()); let stored = store - .get_message_by_name::("my-graph") + .get_message_by_name::("default", "my-graph") .await .unwrap() .unwrap(); @@ -925,7 +946,7 @@ mod tests { created_at_ms: 1, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -937,6 +958,7 @@ mod tests { .unwrap(); let state = new_refresh_state( &provider_b, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials, @@ -955,21 +977,30 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let err = refresh_provider_credential(&store, "refreshing-graph", "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap_err(); + let err = refresh_provider_credential( + &store, + "default", + "refreshing-graph", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap_err(); assert_eq!(err.code(), tonic::Code::FailedPrecondition); assert!(err.message().contains("MS_GRAPH_ACCESS_TOKEN")); - let stored_state = - get_refresh_state(&store, provider_b.object_id(), "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap() - .unwrap(); + let stored_state = get_refresh_state( + &store, + "default", + provider_b.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); assert_eq!(stored_state.status, "error"); assert!(stored_state.last_error.contains("MS_GRAPH_ACCESS_TOKEN")); let stored_provider = store - .get_message_by_name::("refreshing-graph") + .get_message_by_name::("default", "refreshing-graph") .await .unwrap() .unwrap(); @@ -1005,6 +1036,7 @@ mod tests { store.put_message(&provider).await.unwrap(); let state = new_refresh_state( &provider, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, @@ -1023,15 +1055,19 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let refreshed = - refresh_provider_credential(&store, "my-delegated-graph", "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap(); + let refreshed = refresh_provider_credential( + &store, + "default", + "my-delegated-graph", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap(); assert_eq!(refreshed.status, "refreshed"); assert!(refreshed.expires_at_ms > 0); let stored_provider = store - .get_message_by_name::("my-delegated-graph") + .get_message_by_name::("default", "my-delegated-graph") .await .unwrap() .unwrap(); @@ -1046,10 +1082,15 @@ mod tests { Some(&refreshed.expires_at_ms) ); - let stored_state = get_refresh_state(&store, provider.object_id(), "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap() - .unwrap(); + let stored_state = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); assert_eq!( stored_state.material.get("refresh_token"), Some(&"rotated-refresh-token".to_string()) @@ -1084,6 +1125,7 @@ mod tests { store.put_message(&provider).await.unwrap(); let state = new_refresh_state( &provider, + "default", "GOOGLE_DRIVE_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt, @@ -1106,14 +1148,14 @@ mod tests { put_refresh_state(&store, &state).await.unwrap(); let refreshed = - refresh_provider_credential(&store, "my-drive", "GOOGLE_DRIVE_ACCESS_TOKEN") + refresh_provider_credential(&store, "default", "my-drive", "GOOGLE_DRIVE_ACCESS_TOKEN") .await .unwrap(); assert_eq!(refreshed.status, "refreshed"); assert!(refreshed.expires_at_ms > 0); let stored = store - .get_message_by_name::("my-drive") + .get_message_by_name::("default", "my-drive") .await .unwrap() .unwrap(); @@ -1130,6 +1172,7 @@ mod tests { store.put_message(&provider).await.unwrap(); let state = new_refresh_state( &provider, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::External, @@ -1147,15 +1190,20 @@ mod tests { run_refresh_worker_tick(&store).await.unwrap(); - let stored_state = get_refresh_state(&store, provider.object_id(), "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap() - .unwrap(); + let stored_state = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); assert_ne!(stored_state.status, "error"); assert!(stored_state.last_error.is_empty()); let stored_provider = store - .get_message_by_name::("my-external") + .get_message_by_name::("default", "my-external") .await .unwrap() .unwrap(); @@ -1174,7 +1222,7 @@ mod tests { created_at_ms: 1, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 1dabb77442..efda30c427 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -48,10 +48,11 @@ pub fn endpoint_key(sandbox: &str, service: &str) -> String { pub fn endpoint_url( config: &openshell_core::Config, + workspace: &str, sandbox: &str, service: &str, ) -> Option { - let host = endpoint_host(&config.service_routing, sandbox, service)?; + let host = endpoint_host(&config.service_routing, workspace, sandbox, service)?; let scheme = endpoint_scheme(config); let port = config.bind_address.port(); let include_port = !matches!((scheme, port), ("https", 443) | ("http", 80)); @@ -73,34 +74,52 @@ fn endpoint_scheme(config: &openshell_core::Config) -> &'static str { } } -fn endpoint_host(config: &ServiceRoutingConfig, sandbox: &str, service: &str) -> Option { +fn endpoint_host( + config: &ServiceRoutingConfig, + workspace: &str, + sandbox: &str, + service: &str, +) -> Option { let base_domain = config.base_domains.first()?; + let ws = if workspace.is_empty() { + "default" + } else { + workspace + }; Some(if service.is_empty() { - format!("{sandbox}.{base_domain}") + format!("{ws}--{sandbox}.{base_domain}") } else { - format!("{sandbox}--{service}.{base_domain}") + format!("{ws}--{sandbox}--{service}.{base_domain}") }) } -pub fn parse_host(host: &str, config: &ServiceRoutingConfig) -> Option<(String, String)> { +pub fn parse_host(host: &str, config: &ServiceRoutingConfig) -> Option<(String, String, String)> { let host = host.split_once(':').map_or(host, |(name, _)| name); for base_domain in &config.base_domains { let expected_suffix = format!(".{base_domain}"); let Some(encoded) = host.strip_suffix(&expected_suffix) else { continue; }; - let (sandbox, service) = if let Some((sandbox, service)) = encoded.split_once("--") { - if service.is_empty() || service.contains("--") { - return None; + let parts: Vec<&str> = encoded.splitn(3, "--").collect(); + return match parts.len() { + 2 => { + if parts[0].is_empty() || parts[1].is_empty() { + return None; + } + Some((parts[0].to_string(), parts[1].to_string(), String::new())) } - (sandbox, service) - } else { - (encoded, "") + 3 => { + if parts[0].is_empty() || parts[1].is_empty() || parts[2].is_empty() { + return None; + } + Some(( + parts[0].to_string(), + parts[1].to_string(), + parts[2].to_string(), + )) + } + _ => None, }; - if sandbox.is_empty() || sandbox.contains("--") { - return None; - } - return Some((sandbox.to_string(), service.to_string())); } None } @@ -116,11 +135,13 @@ pub async fn proxy_sandbox_service_request( let Some(host) = request_host(&req) else { return StatusCode::NOT_FOUND.into_response(); }; - let Some((sandbox_name, service_name)) = parse_host(host, &state.config.service_routing) else { + let Some((workspace, sandbox_name, service_name)) = + parse_host(host, &state.config.service_routing) + else { return StatusCode::NOT_FOUND.into_response(); }; - match proxy_to_endpoint(state, req, sandbox_name, service_name).await { + match proxy_to_endpoint(state, req, &workspace, sandbox_name, service_name).await { Ok(response) => response.into_response(), Err(err) => err.into_response(), } @@ -209,10 +230,12 @@ pub fn service_error_response(status: StatusCode, message: &'static str) -> Axum async fn proxy_to_endpoint( state: Arc, mut req: Request, + workspace: &str, sandbox_name: String, service_name: String, ) -> Result, ServiceRouteError> { - let endpoint = match load_endpoint(&state.store, &sandbox_name, &service_name).await { + let endpoint = match load_endpoint(&state.store, workspace, &sandbox_name, &service_name).await + { Ok(endpoint) => endpoint, Err(err) => { emit_service_http_failure(&state, &req, &sandbox_name, &service_name, None, &err); @@ -409,12 +432,13 @@ async fn proxy_to_endpoint( async fn load_endpoint( store: &Store, + workspace: &str, sandbox_name: &str, service_name: &str, ) -> Result { let key = endpoint_key(sandbox_name, service_name); store - .get_message_by_name::(&key) + .get_message_by_name::(workspace, &key) .await .map_err(|err| { warn!(error = %err, endpoint = %key, "sandbox service routing: failed to load service endpoint"); @@ -572,7 +596,9 @@ pub fn emit_cross_origin_service_http_rejection(state: &ServerState, req: &Reque let Some(host) = request_host(req) else { return; }; - let Some((sandbox_name, service_name)) = parse_host(host, &state.config.service_routing) else { + let Some((_workspace, sandbox_name, service_name)) = + parse_host(host, &state.config.service_routing) + else { return; }; let err = ServiceRouteError::new( @@ -804,7 +830,7 @@ mod tests { created_at_ms: 1_700_000_000_000, labels: std::collections::HashMap::default(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), sandbox_id: "sandbox-id".to_string(), sandbox_name: "my-sandbox".to_string(), @@ -840,8 +866,8 @@ mod tests { .with_server_sans(["*.dev.openshell.localhost"]); assert_eq!( - endpoint_url(&cfg, "my-sandbox", "web").as_deref(), - Some("http://my-sandbox--web.dev.openshell.localhost:8080/") + endpoint_url(&cfg, "default", "my-sandbox", "web").as_deref(), + Some("http://default--my-sandbox--web.dev.openshell.localhost:8080/") ); } @@ -852,8 +878,8 @@ mod tests { .with_server_sans(["*.dev.openshell.localhost"]); assert_eq!( - endpoint_url(&cfg, "my-sandbox", "").as_deref(), - Some("http://my-sandbox.dev.openshell.localhost:8080/") + endpoint_url(&cfg, "default", "my-sandbox", "").as_deref(), + Some("http://default--my-sandbox.dev.openshell.localhost:8080/") ); } @@ -864,8 +890,8 @@ mod tests { .with_server_sans(["*.dev.openshell.localhost"]); assert_eq!( - endpoint_url(&cfg, "my-sandbox", "web").as_deref(), - Some("https://my-sandbox--web.dev.openshell.localhost:8080/") + endpoint_url(&cfg, "default", "my-sandbox", "web").as_deref(), + Some("https://default--my-sandbox--web.dev.openshell.localhost:8080/") ); } @@ -877,24 +903,47 @@ mod tests { .with_loopback_service_http(false); assert_eq!( - endpoint_url(&cfg, "my-sandbox", "web").as_deref(), - Some("https://my-sandbox--web.dev.openshell.localhost:8080/") + endpoint_url(&cfg, "default", "my-sandbox", "web").as_deref(), + Some("https://default--my-sandbox--web.dev.openshell.localhost:8080/") + ); + } + + #[test] + fn endpoint_url_includes_workspace_prefix_for_non_default() { + let cfg = openshell_core::Config::new(Some(tls_config())) + .with_bind_address("127.0.0.1:8080".parse().unwrap()) + .with_server_sans(["*.dev.openshell.localhost"]); + + assert_eq!( + endpoint_url(&cfg, "staging", "my-sandbox", "web").as_deref(), + Some("http://staging--my-sandbox--web.dev.openshell.localhost:8080/") ); } #[test] fn parses_sandbox_service_host() { assert_eq!( - parse_host("my-sandbox--web.dev.openshell.localhost", &config()), - Some(("my-sandbox".to_string(), "web".to_string())) + parse_host( + "default--my-sandbox--web.dev.openshell.localhost", + &config() + ), + Some(( + "default".to_string(), + "my-sandbox".to_string(), + "web".to_string() + )) ); } #[test] fn parses_sandbox_host_without_service_label() { assert_eq!( - parse_host("my-sandbox.dev.openshell.localhost", &config()), - Some(("my-sandbox".to_string(), String::new())) + parse_host("default--my-sandbox.dev.openshell.localhost", &config()), + Some(( + "default".to_string(), + "my-sandbox".to_string(), + String::new() + )) ); } @@ -909,16 +958,27 @@ mod tests { #[test] fn parses_sandbox_service_host_with_port() { assert_eq!( - parse_host("my-sandbox--web.dev.openshell.localhost:8080", &config()), - Some(("my-sandbox".to_string(), "web".to_string())) + parse_host( + "default--my-sandbox--web.dev.openshell.localhost:8080", + &config() + ), + Some(( + "default".to_string(), + "my-sandbox".to_string(), + "web".to_string() + )) ); } #[test] fn parses_alternate_service_routing_domain() { assert_eq!( - parse_host("my-sandbox--web.svc.gateway.localhost", &config()), - Some(("my-sandbox".to_string(), "web".to_string())) + parse_host("default--my-sandbox--web.svc.gateway.localhost", &config()), + Some(( + "default".to_string(), + "my-sandbox".to_string(), + "web".to_string() + )) ); } @@ -930,6 +990,21 @@ mod tests { ); } + #[test] + fn parses_workspace_prefixed_host() { + assert_eq!( + parse_host( + "staging--my-sandbox--web.dev.openshell.localhost", + &config() + ), + Some(( + "staging".to_string(), + "my-sandbox".to_string(), + "web".to_string() + )) + ); + } + #[test] fn identifies_sandbox_service_request_from_host_header() { let request = Request::builder() @@ -1101,4 +1176,35 @@ mod tests { assert_eq!(upstream.headers()["sec-websocket-key"], "abc"); assert_eq!(upstream.headers()[header::HOST], "127.0.0.1:8080"); } + + #[tokio::test] + async fn load_endpoint_uses_workspace_for_lookup() { + let store = crate::persistence::test_store().await; + + let ep = ServiceEndpoint { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "ep-1".to_string(), + name: "my-sandbox--web".to_string(), + created_at_ms: 1_700_000_000_000, + labels: std::collections::HashMap::default(), + resource_version: 0, + workspace: "default".to_string(), + }), + sandbox_id: "sandbox-1".to_string(), + sandbox_name: "my-sandbox".to_string(), + service_name: "web".to_string(), + target_port: 8080, + domain: true, + }; + store.put_message(&ep).await.unwrap(); + + let found = load_endpoint(&store, "default", "my-sandbox", "web").await; + assert!(found.is_ok(), "should find endpoint in correct workspace"); + + let not_found = load_endpoint(&store, "staging", "my-sandbox", "web").await; + assert!( + not_found.is_err(), + "should not find endpoint in wrong workspace" + ); + } } diff --git a/crates/openshell-server/src/ssh_sessions.rs b/crates/openshell-server/src/ssh_sessions.rs index 4c6589b9f7..8c168eccfe 100644 --- a/crates/openshell-server/src/ssh_sessions.rs +++ b/crates/openshell-server/src/ssh_sessions.rs @@ -37,7 +37,7 @@ async fn reap_expired_sessions(store: &Store) -> Result<(), String> { let now_ms = now_ms(); let records = store - .list(SshSession::object_type(), 1000, 0) + .list_by_type(SshSession::object_type(), 1000, 0) .await .map_err(|e| e.to_string())?; @@ -86,7 +86,7 @@ mod tests { created_at_ms: 1000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), sandbox_id: sandbox_id.to_string(), token: id.to_string(), diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index 16ddbc0d47..03fd032d3a 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -841,7 +841,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: "default".to_string(), }), ..Default::default() } diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index a5bfa1057e..93beeacf15 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -480,6 +480,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } // --------------------------------------------------------------------------- diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index 98c5d99f55..721baacd88 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -419,6 +419,51 @@ impl OpenShell for RelayGateway { ) -> Result, Status> { Err(Status::unimplemented("unused")) } + async fn create_workspace( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn get_workspace( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn list_workspaces( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn delete_workspace( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn add_workspace_member( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn remove_workspace_member( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn list_workspace_members( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } } // --------------------------------------------------------------------------- diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 0d59c58589..241fc9decd 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -529,12 +529,19 @@ pub struct App { pub gateway_selected: usize, pub pending_gateway_switch: Option, + // Workspace filter + pub current_workspace: String, + pub all_workspaces: bool, + pub workspace_names: Vec, + pub pending_workspace_refresh: bool, + // Provider list pub providers_v2_enabled: bool, pub provider_entries: Vec, pub provider_names: Vec, pub provider_types: Vec, pub provider_cred_keys: Vec, + pub provider_workspaces: Vec, pub provider_selected: usize, pub provider_count: usize, @@ -575,8 +582,7 @@ pub struct App { pub sandbox_notes: Vec, /// Formatted labels for each sandbox (e.g., "env=prod,team=platform" or empty string). pub sandbox_labels: Vec, - /// Formatted annotations for each sandbox (e.g., "policy-signature=abc" or empty string). - pub sandbox_annotations: Vec, + pub sandbox_workspaces: Vec, pub sandbox_policy_versions: Vec, pub sandbox_selected: usize, pub sandbox_count: usize, @@ -887,11 +893,16 @@ impl App { confirm_setting_delete: None, pending_setting_set: false, pending_setting_delete: false, + current_workspace: "default".to_string(), + all_workspaces: false, + workspace_names: Vec::new(), + pending_workspace_refresh: false, providers_v2_enabled: false, provider_entries: Vec::new(), provider_names: Vec::new(), provider_types: Vec::new(), provider_cred_keys: Vec::new(), + provider_workspaces: Vec::new(), provider_selected: 0, provider_count: 0, create_provider_form: None, @@ -910,7 +921,7 @@ impl App { sandbox_images: Vec::new(), sandbox_notes: Vec::new(), sandbox_labels: Vec::new(), - sandbox_annotations: Vec::new(), + sandbox_workspaces: Vec::new(), sandbox_policy_versions: Vec::new(), sandbox_selected: 0, sandbox_count: 0, @@ -1061,6 +1072,37 @@ impl App { } } + pub fn cycle_workspace(&mut self) { + if self.all_workspaces { + self.all_workspaces = false; + self.current_workspace = "default".to_string(); + } else if self.workspace_names.is_empty() { + self.all_workspaces = true; + } else { + let current_idx = self + .workspace_names + .iter() + .position(|n| n == &self.current_workspace); + match current_idx { + Some(idx) if idx + 1 < self.workspace_names.len() => { + self.current_workspace = self.workspace_names[idx + 1].clone(); + } + _ => { + self.all_workspaces = true; + } + } + } + self.pending_workspace_refresh = true; + } + + pub fn workspace_display(&self) -> &str { + if self.all_workspaces { + "all" + } else { + &self.current_workspace + } + } + pub fn handle_key(&mut self, key: KeyEvent) { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { self.running = false; @@ -1367,6 +1409,9 @@ impl App { self.input_mode = InputMode::Command; self.command_input.clear(); } + KeyCode::Char('w') => { + self.cycle_workspace(); + } KeyCode::Char('j') | KeyCode::Down if self.sandbox_count > 0 => { self.sandbox_selected = (self.sandbox_selected + 1).min(self.sandbox_count - 1); } @@ -2526,6 +2571,17 @@ impl App { .map(String::as_str) } + /// Get the workspace of the currently selected sandbox. + /// + /// In the all-workspaces view, returns the workspace from the selected row + /// rather than the globally active workspace. + pub fn selected_sandbox_workspace(&self) -> String { + self.sandbox_workspaces + .get(self.sandbox_selected) + .cloned() + .unwrap_or_else(|| self.current_workspace.clone()) + } + /// Get the name of the currently selected provider. pub fn selected_provider_name(&self) -> Option<&str> { self.provider_names @@ -2940,4 +2996,26 @@ mod tests { assert_eq!(gateway.source_label(), "unknown"); } + + // -- selected_sandbox_workspace ---------------------------------------- + + #[test] + fn selected_sandbox_workspace_returns_per_row_value() { + let workspaces = ["default", "beta", "staging"]; + let selected: usize = 1; + let current = "default"; + + let result = workspaces.get(selected).unwrap_or(¤t); + assert_eq!(*result, "beta"); + } + + #[test] + fn selected_sandbox_workspace_falls_back_to_current() { + let workspaces: &[&str] = &[]; + let selected: usize = 0; + let current = "default"; + + let result = workspaces.get(selected).unwrap_or(¤t); + assert_eq!(*result, "default"); + } } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index bdca8932b8..3c9ae958e2 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -20,7 +20,7 @@ use crossterm::terminal::{ 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::metadata::{ObjectId, ObjectLabels, ObjectName, ObjectWorkspace}; use openshell_core::proto::SandboxPhase; use openshell_core::proto::open_shell_client::OpenShellClient; use ratatui::Terminal; @@ -158,6 +158,11 @@ pub async fn run( let snapshot = std::mem::take(&mut app.approve_all_confirm_chunks); spawn_draft_approve_all(&app, snapshot, events.sender()); } + if app.pending_workspace_refresh { + app.pending_workspace_refresh = false; + refresh_providers(&mut app).await; + refresh_sandboxes(&mut app).await; + } } Some(Event::LogLines(lines)) => { app.sandbox_log_lines.extend(lines); @@ -625,6 +630,7 @@ fn spawn_log_stream(app: &mut App, tx: mpsc::UnboundedSender) { }; let mut client = app.client.clone(); + let workspace = app.selected_sandbox_workspace(); let handle = tokio::spawn(async move { // Phase 1: Fetch initial history via unary RPC. @@ -634,6 +640,7 @@ fn spawn_log_stream(app: &mut App, tx: mpsc::UnboundedSender) { since_ms: 0, sources: vec![], min_level: String::new(), + workspace, }; match tokio::time::timeout(Duration::from_secs(5), client.get_sandbox_logs(req)).await { @@ -734,7 +741,10 @@ async fn handle_sandbox_delete(app: &mut App) { ); } - let req = openshell_core::proto::DeleteSandboxRequest { name: sandbox_name }; + let req = openshell_core::proto::DeleteSandboxRequest { + name: sandbox_name, + workspace: app.selected_sandbox_workspace(), + }; match app.client.delete_sandbox(req).await { Ok(_) => { app.cancel_log_stream(); @@ -766,6 +776,7 @@ async fn fetch_sandbox_detail(app: &mut App) { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.clone(), + workspace: app.selected_sandbox_workspace(), }; // Step 1: Fetch sandbox metadata (providers, sandbox ID). @@ -851,6 +862,7 @@ async fn handle_shell_connect( let sandbox_id = { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.clone(), + workspace: app.selected_sandbox_workspace(), }; match tokio::time::timeout(Duration::from_secs(5), app.client.get_sandbox(req)).await { Ok(Ok(resp)) => { @@ -1006,6 +1018,7 @@ async fn handle_exec_command( let sandbox_id = { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.to_string(), + workspace: app.selected_sandbox_workspace(), }; match tokio::time::timeout(Duration::from_secs(5), app.client.get_sandbox(req)).await { Ok(Ok(resp)) => { @@ -1347,6 +1360,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { let endpoint = app.endpoint.clone(); let gateway_name = app.gateway_name.clone(); let need_ready = !ports.is_empty() || !app.pending_exec_command.is_empty(); + let workspace = app.current_workspace.clone(); tokio::spawn(async move { let has_custom_image = !image.is_empty(); @@ -1379,7 +1393,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { ..Default::default() }), labels: HashMap::new(), - annotations: HashMap::new(), + workspace: workspace.clone(), }; let sandbox_name = @@ -1420,6 +1434,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.clone(), + workspace: workspace.clone(), }; // Retry on transient errors. if let Ok(resp) = client.get_sandbox(req).await @@ -1626,6 +1641,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { form.name.clone() }; let credentials = form.discovered_credentials.clone().unwrap_or_default(); + let workspace = app.current_workspace.clone(); tokio::spawn(async move { // Try with the chosen name, retry with suffix on collision. @@ -1644,13 +1660,14 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.clone(), }), r#type: ptype.clone(), credentials: credentials.clone(), config: HashMap::default(), credential_expires_at_ms: HashMap::default(), }), + workspace: workspace.clone(), }; match client.create_provider(req).await { @@ -1688,9 +1705,10 @@ fn spawn_get_provider(app: &App, tx: mpsc::UnboundedSender) { Some(n) => n.to_string(), None => return, }; + let workspace = app.current_workspace.clone(); tokio::spawn(async move { - let req = openshell_core::proto::GetProviderRequest { name }; + let req = openshell_core::proto::GetProviderRequest { name, workspace }; match tokio::time::timeout(Duration::from_secs(5), client.get_provider(req)).await { Ok(Ok(resp)) => { if let Some(provider) = resp.into_inner().provider { @@ -1724,6 +1742,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { let ptype = form.provider_type.clone(); let cred_key = form.credential_key.clone(); let new_value = form.new_value.clone(); + let workspace = app.current_workspace.clone(); tokio::spawn(async move { let mut credentials = HashMap::new(); @@ -1737,7 +1756,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.clone(), }), r#type: ptype, credentials, @@ -1745,6 +1764,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { credential_expires_at_ms: HashMap::default(), }), credential_expires_at_ms: HashMap::default(), + workspace, }; match tokio::time::timeout(Duration::from_secs(5), client.update_provider(req)).await { @@ -1770,9 +1790,10 @@ fn spawn_delete_provider(app: &App, tx: mpsc::UnboundedSender) { Some(n) => n.to_string(), None => return, }; + let workspace = app.current_workspace.clone(); tokio::spawn(async move { - let req = openshell_core::proto::DeleteProviderRequest { name }; + let req = openshell_core::proto::DeleteProviderRequest { name, workspace }; match tokio::time::timeout(Duration::from_secs(5), client.delete_provider(req)).await { Ok(Ok(resp)) => { let _ = tx.send(Event::ProviderDeleteResult(Ok(resp.into_inner().deleted))); @@ -1809,9 +1830,14 @@ fn spawn_draft_approve(app: &App, tx: mpsc::UnboundedSender) { .draft_chunks .get(abs) .map_or_else(String::new, |c| c.rule_name.clone()); + let workspace = app.selected_sandbox_workspace(); tokio::spawn(async move { - let req = openshell_core::proto::ApproveDraftChunkRequest { name, chunk_id }; + let req = openshell_core::proto::ApproveDraftChunkRequest { + name, + chunk_id, + workspace, + }; match tokio::time::timeout(Duration::from_secs(5), client.approve_draft_chunk(req)).await { Ok(Ok(resp)) => { let inner = resp.into_inner(); @@ -1848,12 +1874,14 @@ fn spawn_draft_reject(app: &App, tx: mpsc::UnboundedSender) { .draft_chunks .get(abs) .map_or_else(String::new, |c| c.rule_name.clone()); + let workspace = app.selected_sandbox_workspace(); tokio::spawn(async move { let req = openshell_core::proto::RejectDraftChunkRequest { name, chunk_id, reason: String::new(), + workspace, }; match tokio::time::timeout(Duration::from_secs(5), client.reject_draft_chunk(req)).await { Ok(Ok(_)) => { @@ -1889,11 +1917,13 @@ fn spawn_draft_approve_all( Some(n) => n.to_string(), None => return, }; + let workspace = app.selected_sandbox_workspace(); tokio::spawn(async move { let req = openshell_core::proto::ApproveAllDraftChunksRequest { name, include_security_flagged: false, + workspace, }; match tokio::time::timeout( Duration::from_secs(30), @@ -1935,15 +1965,41 @@ fn spawn_draft_approve_all( async fn refresh_data(app: &mut App) { refresh_health(app).await; refresh_global_settings(app).await; + refresh_workspaces(app).await; refresh_providers(app).await; refresh_sandboxes(app).await; } +async fn refresh_workspaces(app: &mut App) { + let req = openshell_core::proto::ListWorkspacesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + }; + match tokio::time::timeout(Duration::from_secs(5), app.client.list_workspaces(req)).await { + Ok(Ok(resp)) => { + app.workspace_names = resp + .into_inner() + .workspaces + .into_iter() + .filter_map(|w| w.metadata.map(|m| m.name)) + .collect(); + } + Ok(Err(e)) => { + tracing::warn!("failed to list workspaces: {}", e.message()); + } + Err(_) => { + tracing::warn!("list workspaces timed out"); + } + } +} + async fn refresh_providers(app: &mut App) { let profiles = if app.providers_v2_enabled { let req = openshell_core::proto::ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: app.current_workspace.clone(), }; match tokio::time::timeout( Duration::from_secs(5), @@ -1973,6 +2029,12 @@ async fn refresh_providers(app: &mut App) { let req = openshell_core::proto::ListProvidersRequest { limit: 100, offset: 0, + workspace: if app.all_workspaces { + String::new() + } else { + app.current_workspace.clone() + }, + all_workspaces: app.all_workspaces, }; let result = tokio::time::timeout(Duration::from_secs(5), app.client.list_providers(req)).await; match result { @@ -2012,6 +2074,10 @@ async fn refresh_providers(app: &mut App) { .unwrap_or_else(|| "-".to_string()) }) .collect(); + app.provider_workspaces = providers + .iter() + .map(|p| p.object_workspace().to_string()) + .collect(); if app.provider_selected >= app.provider_count && app.provider_count > 0 { app.provider_selected = app.provider_count - 1; } @@ -2042,6 +2108,7 @@ async fn refresh_global_settings(app: &mut App) { limit: 1, offset: 0, global: true, + workspace: String::new(), }; if let Ok(Ok(resp)) = tokio::time::timeout( Duration::from_secs(5), @@ -2110,7 +2177,9 @@ fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { setting_key: key, setting_value: Some(SettingValue { value: Some(value) }), global: true, - ..Default::default() + merge_operations: vec![], + expected_resource_version: 0, + workspace: String::new(), }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2143,7 +2212,9 @@ fn spawn_delete_global_setting(app: &App, tx: mpsc::UnboundedSender) { setting_key: key, delete_setting: true, global: true, - ..Default::default() + merge_operations: vec![], + expected_resource_version: 0, + workspace: String::new(), }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2175,6 +2246,7 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { let raw = edit.input.trim().to_string(); let kind = entry.kind; let mut client = app.client.clone(); + let workspace = app.selected_sandbox_workspace(); tokio::spawn(async move { use openshell_core::proto::{SettingValue, UpdateConfigRequest, setting_value}; @@ -2209,7 +2281,11 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { name, setting_key: key, setting_value: Some(SettingValue { value: Some(value) }), - ..Default::default() + delete_setting: false, + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace, }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2237,6 +2313,7 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { let name = sandbox_name.to_string(); let key = entry.key.clone(); let mut client = app.client.clone(); + let workspace = app.selected_sandbox_workspace(); tokio::spawn(async move { use openshell_core::proto::UpdateConfigRequest; @@ -2245,7 +2322,10 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { name, setting_key: key, delete_setting: true, - ..Default::default() + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace, }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2288,6 +2368,12 @@ async fn refresh_sandboxes(app: &mut App) { limit: 100, offset: 0, label_selector: String::new(), + workspace: if app.all_workspaces { + String::new() + } else { + app.current_workspace.clone() + }, + all_workspaces: app.all_workspaces, }; let result = tokio::time::timeout(Duration::from_secs(5), app.client.list_sandboxes(req)).await; match result { @@ -2364,14 +2450,9 @@ async fn refresh_sandboxes(app: &mut App) { }) .collect(); - app.sandbox_annotations = sandboxes + app.sandbox_workspaces = sandboxes .iter() - .map(|s| { - s.metadata - .as_ref() - .map(|metadata| app::format_annotations(&metadata.annotations)) - .unwrap_or_default() - }) + .map(|s| s.object_workspace().to_string()) .collect(); if app.sandbox_selected >= app.sandbox_count && app.sandbox_count > 0 { @@ -2431,6 +2512,7 @@ async fn refresh_draft_chunks(app: &mut App) { let req = openshell_core::proto::GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: app.selected_sandbox_workspace(), }; if let Ok(Ok(resp)) = @@ -2451,11 +2533,17 @@ async fn refresh_draft_chunks(app: &mut App) { /// badges without entering the sandbox detail view. async fn refresh_sandbox_draft_counts(app: &mut App) { let names: Vec = app.sandbox_names.clone(); + let workspaces: Vec = app.sandbox_workspaces.clone(); let mut counts = vec![0usize; names.len()]; for (i, name) in names.iter().enumerate() { + let ws = workspaces + .get(i) + .cloned() + .unwrap_or_else(|| app.current_workspace.clone()); let req = openshell_core::proto::GetDraftPolicyRequest { name: name.clone(), status_filter: "pending".to_string(), + workspace: ws, }; if let Ok(Ok(resp)) = tokio::time::timeout(Duration::from_secs(2), app.client.get_draft_policy(req)).await diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 9200e8f761..8df6dd3470 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -152,6 +152,10 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled(" | ", t.muted), ]; + parts.push(Span::styled("Workspace: ", t.text)); + parts.push(Span::styled(app.workspace_display(), t.heading)); + parts.push(Span::styled(" | ", t.muted)); + match app.screen { Screen::Splash => unreachable!("splash handled before draw_title_bar"), Screen::Dashboard => { @@ -260,6 +264,9 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled(" ", t.text), Span::styled("[c]", t.key_hint), Span::styled(" Create Sandbox", t.text), + Span::styled(" ", t.text), + Span::styled("[w]", t.key_hint), + Span::styled(" Workspace", t.text), Span::styled(" | ", t.border), Span::styled("[:]", t.muted), Span::styled(" Command ", t.muted), diff --git a/crates/openshell-tui/src/ui/providers.rs b/crates/openshell-tui/src/ui/providers.rs index 06a092460c..e4f4d2d0ba 100644 --- a/crates/openshell-tui/src/ui/providers.rs +++ b/crates/openshell-tui/src/ui/providers.rs @@ -15,15 +15,22 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { return; } - let header = Row::new(vec![ + let show_ws = app.all_workspaces; + + let mut header_cells = Vec::new(); + if show_ws { + header_cells.push(Cell::from(Span::styled("WORKSPACE", t.muted))); + } + header_cells.extend([ Cell::from(Span::styled(" NAME", t.muted)), Cell::from(Span::styled("TYPE", t.muted)), Cell::from(Span::styled("CRED KEY", t.muted)), - ]) - .bottom_margin(1); + ]); + let header = Row::new(header_cells).bottom_margin(1); let rows: Vec> = (0..app.provider_count) .map(|i| { + let workspace = app.provider_workspaces.get(i).map_or("", String::as_str); let name = app.provider_names.get(i).map_or("", String::as_str); let ptype = app.provider_types.get(i).map_or("", String::as_str); let cred_key = app.provider_cred_keys.get(i).map_or("", String::as_str); @@ -41,19 +48,34 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { ])) }; - Row::new(vec![ + let mut cells: Vec> = Vec::new(); + if show_ws { + cells.push(Cell::from(Span::styled(workspace, t.muted))); + } + cells.extend([ name_cell, Cell::from(Span::styled(ptype, t.muted)), Cell::from(Span::styled(cred_key, t.muted)), - ]) + ]); + + Row::new(cells) }) .collect(); - let widths = [ - Constraint::Percentage(40), - Constraint::Percentage(25), - Constraint::Percentage(35), - ]; + let widths: Vec = if show_ws { + vec![ + Constraint::Percentage(20), + Constraint::Percentage(30), + Constraint::Percentage(20), + Constraint::Percentage(30), + ] + } else { + vec![ + Constraint::Percentage(40), + Constraint::Percentage(25), + Constraint::Percentage(35), + ] + }; let border_style = if focused { t.border_focused } else { t.border }; @@ -89,20 +111,27 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { fn draw_v2(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { let t = &app.theme; - let header = Row::new(vec![ + let show_ws = app.all_workspaces; + + let mut header_cells = Vec::new(); + if show_ws { + header_cells.push(Cell::from(Span::styled("WORKSPACE", t.muted))); + } + header_cells.extend([ Cell::from(Span::styled(" NAME", t.muted)), Cell::from(Span::styled("PROFILE", t.muted)), Cell::from(Span::styled("CATEGORY", t.muted)), Cell::from(Span::styled("CREDS", t.muted)), Cell::from(Span::styled("POLICY", t.muted)), - ]) - .bottom_margin(1); + ]); + let header = Row::new(header_cells).bottom_margin(1); let rows: Vec> = app .provider_entries .iter() .enumerate() .map(|(i, entry)| { + let workspace = app.provider_workspaces.get(i).map_or("", String::as_str); let selected = focused && i == app.provider_selected; let name_cell = if selected { Cell::from(Line::from(vec![ @@ -122,23 +151,40 @@ fn draw_v2(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { t.status_warn }; - Row::new(vec![ + let mut cells: Vec> = Vec::new(); + if show_ws { + cells.push(Cell::from(Span::styled(workspace, t.muted))); + } + cells.extend([ name_cell, Cell::from(Span::styled(entry.profile_label(), profile_style)), Cell::from(Span::styled(entry.category_label(), t.muted)), Cell::from(Span::styled(entry.credential_summary(), t.muted)), Cell::from(Span::styled(entry.policy_summary(), t.muted)), - ]) + ]); + + Row::new(cells) }) .collect(); - let widths = [ - Constraint::Percentage(22), - Constraint::Percentage(24), - Constraint::Percentage(16), - Constraint::Percentage(18), - Constraint::Percentage(20), - ]; + let widths: Vec = if show_ws { + vec![ + Constraint::Percentage(12), + Constraint::Percentage(18), + Constraint::Percentage(20), + Constraint::Percentage(14), + Constraint::Percentage(16), + Constraint::Percentage(20), + ] + } else { + vec![ + Constraint::Percentage(22), + Constraint::Percentage(24), + Constraint::Percentage(16), + Constraint::Percentage(18), + Constraint::Percentage(20), + ] + }; let border_style = if focused { t.border_focused } else { t.border }; let title = if focused && app.confirm_provider_delete { diff --git a/crates/openshell-tui/src/ui/sandboxes.rs b/crates/openshell-tui/src/ui/sandboxes.rs index 6ace580b8e..4d239241b9 100644 --- a/crates/openshell-tui/src/ui/sandboxes.rs +++ b/crates/openshell-tui/src/ui/sandboxes.rs @@ -10,7 +10,13 @@ use crate::app::App; pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { let t = &app.theme; - let header = Row::new(vec![ + let show_ws = app.all_workspaces; + + let mut header_cells = Vec::new(); + if show_ws { + header_cells.push(Cell::from(Span::styled("WORKSPACE", t.muted))); + } + header_cells.extend([ Cell::from(Span::styled(" NAME", t.muted)), Cell::from(Span::styled("STATUS", t.muted)), Cell::from(Span::styled("CREATED", t.muted)), @@ -18,11 +24,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { Cell::from(Span::styled("IMAGE", t.muted)), Cell::from(Span::styled("LABELS", t.muted)), Cell::from(Span::styled("NOTES", t.muted)), - ]) - .bottom_margin(1); + ]); + let header = Row::new(header_cells).bottom_margin(1); let rows: Vec> = (0..app.sandbox_count) .map(|i| { + let workspace = app.sandbox_workspaces.get(i).map_or("", String::as_str); let name = app.sandbox_names.get(i).map_or("", String::as_str); let phase = app.sandbox_phases.get(i).map_or("", String::as_str); let created = app.sandbox_created.get(i).map_or("", String::as_str); @@ -60,7 +67,11 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { let name_cell = Cell::from(Line::from(name_spans)); - Row::new(vec![ + let mut cells: Vec> = Vec::new(); + if show_ws { + cells.push(Cell::from(Span::styled(workspace, t.muted))); + } + cells.extend([ name_cell, Cell::from(Span::styled(phase, phase_style)), Cell::from(Span::styled(created, t.muted)), @@ -68,19 +79,34 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { Cell::from(Span::styled(image, t.muted)), Cell::from(Span::styled(labels, t.muted)), Cell::from(Span::styled(notes, t.muted)), - ]) + ]); + + Row::new(cells) }) .collect(); - let widths = [ - Constraint::Percentage(20), - Constraint::Percentage(10), - Constraint::Percentage(15), - Constraint::Percentage(8), - Constraint::Percentage(20), - Constraint::Percentage(15), - Constraint::Percentage(12), - ]; + let widths: Vec = if show_ws { + vec![ + Constraint::Percentage(10), + Constraint::Percentage(16), + Constraint::Percentage(9), + Constraint::Percentage(13), + Constraint::Percentage(7), + Constraint::Percentage(18), + Constraint::Percentage(15), + Constraint::Percentage(12), + ] + } else { + vec![ + Constraint::Percentage(20), + Constraint::Percentage(10), + Constraint::Percentage(15), + Constraint::Percentage(8), + Constraint::Percentage(20), + Constraint::Percentage(15), + Constraint::Percentage(12), + ] + }; let border_style = if focused { t.border_focused } else { t.border }; diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 09cb02a702..f638fa0c9c 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -102,6 +102,11 @@ name = "forward_proxy_jsonrpc_l7" path = "tests/forward_proxy_jsonrpc_l7.rs" required-features = ["e2e-host-gateway"] +[[test]] +name = "workspace_lifecycle" +path = "tests/workspace_lifecycle.rs" +required-features = ["e2e"] + [[test]] name = "gpu" path = "tests/gpu.rs" diff --git a/e2e/rust/tests/workspace_lifecycle.rs b/e2e/rust/tests/workspace_lifecycle.rs new file mode 100644 index 0000000000..bc9f2b0eb1 --- /dev/null +++ b/e2e/rust/tests/workspace_lifecycle.rs @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +//! E2E tests for workspace resource lifecycle. +//! +//! Covers: +//! - Workspace CRUD +//! - Provider creation scoped to a workspace +//! - Workspace isolation (resources in one workspace are invisible in another) +//! - `--all-workspaces` listing +//! - Deletion guard (workspace cannot be deleted while resources exist) +//! - Successful deletion after resource cleanup + +use std::process::Stdio; + +use openshell_e2e::harness::binary::{openshell_bin, openshell_cmd}; +use openshell_e2e::harness::output::strip_ansi; + +const WORKSPACE: &str = "lifecycle-test"; +const PROVIDER: &str = "lifecycle-prov"; + +struct CliResult { + output: String, + success: bool, +} + +async fn run_cli(args: &[&str]) -> CliResult { + let mut cmd = openshell_cmd(); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + + let output = cmd.output().await.expect("spawn openshell command"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = format!("{stdout}{stderr}"); + + CliResult { + output: strip_ansi(&combined), + success: output.status.success(), + } +} + +struct WorkspaceCleanup; + +impl Drop for WorkspaceCleanup { + fn drop(&mut self) { + let bin = openshell_bin(); + let _ = std::process::Command::new(&bin) + .args([ + "provider", + "delete", + PROVIDER, + "--workspace", + WORKSPACE, + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + let _ = std::process::Command::new(&bin) + .args(["workspace", "delete", WORKSPACE]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +#[tokio::test] +async fn workspace_full_crud_lifecycle() { + let _cleanup = WorkspaceCleanup; + + // 1. Create workspace. + let res = run_cli(&["workspace", "create", "--name", WORKSPACE]).await; + assert!(res.success, "workspace create failed: {}", res.output); + + // 2. Verify workspace exists. + let res = run_cli(&["workspace", "get", WORKSPACE]).await; + assert!(res.success, "workspace get failed: {}", res.output); + assert!( + res.output.contains(WORKSPACE), + "workspace get output should contain workspace name: {}", + res.output + ); + + // 3. Create provider in the workspace. + let res = run_cli(&[ + "provider", + "create", + "--name", + PROVIDER, + "--type", + "github", + "--runtime-credentials", + "--workspace", + WORKSPACE, + ]) + .await; + assert!( + res.success, + "provider create in workspace failed: {}", + res.output + ); + + // 4. List providers in workspace — should see our provider. + let res = run_cli(&["provider", "list", "--workspace", WORKSPACE]).await; + assert!(res.success, "provider list failed: {}", res.output); + assert!( + res.output.contains(PROVIDER), + "workspace-scoped list should show the provider: {}", + res.output + ); + + // 5. List providers in default workspace — should NOT see our provider. + let res = run_cli(&["provider", "list"]).await; + assert!(res.success, "provider list (default) failed: {}", res.output); + assert!( + !res.output.contains(PROVIDER), + "default workspace should not contain the workspace-scoped provider: {}", + res.output + ); + + // 6. List providers with --all-workspaces — should see our provider. + let res = run_cli(&["provider", "list", "--all-workspaces"]).await; + assert!( + res.success, + "provider list --all-workspaces failed: {}", + res.output + ); + assert!( + res.output.contains(PROVIDER), + "--all-workspaces should include the workspace-scoped provider: {}", + res.output + ); + + // 7. Attempt workspace deletion — should fail because provider exists. + let res = run_cli(&["workspace", "delete", WORKSPACE]).await; + assert!( + !res.success, + "workspace delete should fail while resources exist: {}", + res.output + ); + assert!( + res.output.contains("still contains resources"), + "error should mention blocking resources: {}", + res.output + ); + + // 8. Delete the provider. + let res = run_cli(&[ + "provider", + "delete", + PROVIDER, + "--workspace", + WORKSPACE, + ]) + .await; + assert!(res.success, "provider delete failed: {}", res.output); + + // 9. Workspace deletion should now succeed. + let res = run_cli(&["workspace", "delete", WORKSPACE]).await; + assert!( + res.success, + "workspace delete should succeed after resources removed: {}", + res.output + ); + + // 10. Verify workspace is gone. + let res = run_cli(&["workspace", "get", WORKSPACE]).await; + assert!( + !res.success, + "workspace get should fail after deletion: {}", + res.output + ); +} diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 3ef95afbbd..c99b7756b0 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -74,6 +74,9 @@ message DriverSandbox { DriverSandboxSpec spec = 4; // Raw platform-observed status. DriverSandboxStatus status = 5; + // Workspace the sandbox belongs to. Used by drivers to construct + // collision-safe resource names and labels in shared-namespace mode. + string workspace = 6; } // Driver-owned provisioning inputs required to create a sandbox. diff --git a/proto/datamodel.proto b/proto/datamodel.proto index ada4716b11..6b29405c29 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -30,9 +30,18 @@ message ObjectMeta { // Incremented by the gateway on each update. Clients can use this for compare-and-swap operations. uint64 resource_version = 5; - // Opaque key-value metadata that is not used for selectors. - // Annotation keys use the same qualified-key shape as labels, but values may be longer. - map annotations = 6; + // Workspace that owns this resource. Empty is normalized to "default" by the + // gateway. Immutable after creation. + string workspace = 6; +} + +// Workspace resource. A hard isolation boundary for sandboxes, providers, and +// other workspace-scoped resources. +message Workspace { + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + // The workspace field in this ObjectMeta is unused (a workspace does not + // belong to another workspace). + ObjectMeta metadata = 1; } // Provider model stored by OpenShell. diff --git a/proto/inference.proto b/proto/inference.proto index e982a2700e..0657995829 100644 --- a/proto/inference.proto +++ b/proto/inference.proto @@ -8,29 +8,30 @@ package openshell.inference.v1; import "datamodel.proto"; import "options.proto"; -// Inference service provides cluster inference configuration and bundle delivery. +// Inference service provides workspace-scoped inference route configuration and bundle delivery. service Inference { // Return the resolved inference route bundle for sandbox-local execution. rpc GetInferenceBundle(GetInferenceBundleRequest) returns (GetInferenceBundleResponse); - // Set cluster-level inference configuration. + // Set the inference route for a workspace. // - // This controls how requests sent to `inference.local` are routed. - rpc SetClusterInference(SetClusterInferenceRequest) - returns (SetClusterInferenceResponse); + // This controls how requests sent to `inference.local` are routed + // for sandboxes in the specified workspace. + rpc SetInferenceRoute(SetInferenceRouteRequest) + returns (SetInferenceRouteResponse); - // Get cluster-level inference configuration. - rpc GetClusterInference(GetClusterInferenceRequest) - returns (GetClusterInferenceResponse); + // Get the inference route for a workspace. + rpc GetInferenceRoute(GetInferenceRouteRequest) + returns (GetInferenceRouteResponse); } -// Persisted cluster inference configuration. +// Persisted inference route configuration. // // Only `provider_name` and `model_id` are stored; endpoint, protocols, // credentials, and auth style are resolved from the provider at bundle time. -message ClusterInferenceConfig { +message InferenceRouteConfig { // Provider record name backing this route. string provider_name = 1; // Model identifier to force on generation calls. @@ -39,15 +40,15 @@ message ClusterInferenceConfig { uint64 timeout_secs = 3; } -// Storage envelope for the managed cluster inference route. +// Storage envelope for a workspace-scoped inference route. message InferenceRoute { openshell.datamodel.v1.ObjectMeta metadata = 1; - ClusterInferenceConfig config = 2; + InferenceRouteConfig config = 2; // Monotonic version incremented on every update. uint64 version = 3; } -message SetClusterInferenceRequest { +message SetInferenceRouteRequest { // Provider record name to use for credentials + endpoint mapping. string provider_name = 1; // Model identifier to force on generation calls. @@ -61,6 +62,8 @@ message SetClusterInferenceRequest { bool no_verify = 5; // Per-route request timeout in seconds. 0 means use default (60s). uint64 timeout_secs = 6; + // Target workspace. Empty string defaults to "default". + string workspace = 7; } message ValidatedEndpoint { @@ -68,7 +71,7 @@ message ValidatedEndpoint { string protocol = 2; } -message SetClusterInferenceResponse { +message SetInferenceRouteResponse { string provider_name = 1; string model_id = 2; uint64 version = 3; @@ -80,15 +83,19 @@ message SetClusterInferenceResponse { repeated ValidatedEndpoint validated_endpoints = 6; // Per-route request timeout in seconds that was persisted. uint64 timeout_secs = 7; + // Workspace the route was configured in. + string workspace = 8; } -message GetClusterInferenceRequest { +message GetInferenceRouteRequest { // Route name to query. Empty string defaults to "inference.local" (user-facing). // Use "sandbox-system" for the sandbox system-level inference route. string route_name = 1; + // Target workspace. Empty string defaults to "default". + string workspace = 2; } -message GetClusterInferenceResponse { +message GetInferenceRouteResponse { string provider_name = 1; string model_id = 2; uint64 version = 3; @@ -96,6 +103,8 @@ message GetClusterInferenceResponse { string route_name = 4; // Per-route request timeout in seconds. 0 means default (60s). uint64 timeout_secs = 5; + // Workspace the route belongs to. + string workspace = 6; } message GetInferenceBundleRequest {} diff --git a/proto/openshell.proto b/proto/openshell.proto index b437613b04..130f5aabba 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -248,6 +248,31 @@ service OpenShell { // rewritten. rpc RefreshSandboxToken(RefreshSandboxTokenRequest) returns (RefreshSandboxTokenResponse); + + // --------------------------------------------------------------------------- + // Workspace management RPCs + // --------------------------------------------------------------------------- + + // Create a workspace. + rpc CreateWorkspace(CreateWorkspaceRequest) returns (CreateWorkspaceResponse); + + // Fetch a workspace by name. + rpc GetWorkspace(GetWorkspaceRequest) returns (GetWorkspaceResponse); + + // List workspaces. + rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse); + + // Delete a workspace by name. + rpc DeleteWorkspace(DeleteWorkspaceRequest) returns (DeleteWorkspaceResponse); + + // Add a member to a workspace. + rpc AddWorkspaceMember(AddWorkspaceMemberRequest) returns (AddWorkspaceMemberResponse); + + // Remove a member from a workspace. + rpc RemoveWorkspaceMember(RemoveWorkspaceMemberRequest) returns (RemoveWorkspaceMemberResponse); + + // List members of a workspace. + rpc ListWorkspaceMembers(ListWorkspaceMembersRequest) returns (ListWorkspaceMembersResponse); } // IssueSandboxToken request. Empty body; identity is established by the @@ -485,14 +510,16 @@ message CreateSandboxRequest { string name = 2; // Optional labels for the sandbox (key-value metadata). map labels = 3; - // Optional annotations for the sandbox (non-selector metadata). - map annotations = 4; + // Workspace for the sandbox. Empty defaults to "default". + string workspace = 4; } // Get sandbox request. message GetSandboxRequest { // Sandbox name (canonical lookup key). string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // List sandboxes request. @@ -501,12 +528,18 @@ message ListSandboxesRequest { uint32 offset = 2; // Optional label selector for filtering (format: "key1=value1,key2=value2"). string label_selector = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; + // List across all workspaces. Mutually exclusive with workspace. + bool all_workspaces = 5; } // List providers attached to a sandbox request. message ListSandboxProvidersRequest { // Sandbox name (canonical lookup key). string sandbox_name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // Attach provider to sandbox request. @@ -520,6 +553,8 @@ message AttachSandboxProviderRequest { // If non-zero, the server validates that the sandbox's current resource_version // matches this value before applying the mutation, returning ABORTED on mismatch. uint64 expected_resource_version = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; } // Detach provider from sandbox request. @@ -533,12 +568,16 @@ message DetachSandboxProviderRequest { // If non-zero, the server validates that the sandbox's current resource_version // matches this value before applying the mutation, returning ABORTED on mismatch. uint64 expected_resource_version = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; } // Delete sandbox request. message DeleteSandboxRequest { // Sandbox name (canonical lookup key). string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // Sandbox response. @@ -625,6 +664,8 @@ message ExposeServiceRequest { uint32 target_port = 3; // Whether to print/use the browser-facing service URL. bool domain = 4; + // Workspace scope. Empty defaults to "default". + string workspace = 5; } // Request to fetch an exposed sandbox service endpoint. @@ -633,6 +674,8 @@ message GetServiceRequest { string sandbox = 1; // Service name within the sandbox. Empty selects the unnamed endpoint. string service = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } // Request to list exposed sandbox service endpoints. @@ -643,6 +686,10 @@ message ListServicesRequest { uint32 limit = 2; // Page offset. uint32 offset = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; + // List across all workspaces. Mutually exclusive with workspace. + bool all_workspaces = 5; } // Response containing exposed sandbox service endpoints. @@ -656,6 +703,8 @@ message DeleteServiceRequest { string sandbox = 1; // Service name within the sandbox. Empty selects the unnamed endpoint. string service = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } // Response for deleting an exposed sandbox service endpoint. @@ -885,17 +934,25 @@ message SandboxStreamWarning { // Create provider request. message CreateProviderRequest { openshell.datamodel.v1.Provider provider = 1; + // Workspace for the provider. Empty defaults to "default". + string workspace = 2; } // Get provider request. message GetProviderRequest { string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // List providers request. message ListProvidersRequest { uint32 limit = 1; uint32 offset = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; + // List across all workspaces. Mutually exclusive with workspace. + bool all_workspaces = 4; } // Update provider request. @@ -904,11 +961,15 @@ message UpdateProviderRequest { // Optional per-credential expiry timestamps to merge into the provider. // A zero value removes the expiry for that credential. map credential_expires_at_ms = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } // Delete provider request. message DeleteProviderRequest { string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // Provider response. @@ -925,11 +986,19 @@ message ListProvidersResponse { message ListProviderProfilesRequest { uint32 limit = 1; uint32 offset = 2; + // Workspace scope for two-tier profile resolution. When set, returns merged + // view: workspace-scoped + platform-scoped + built-in. When empty, returns + // platform-scoped + built-in only. + string workspace = 3; } // Fetch provider type profile request. message GetProviderProfileRequest { string id = 1; + // Workspace scope for two-tier profile resolution. When set, checks + // workspace-scoped profiles first, then platform-scoped, then built-in. + // When empty, checks platform-scoped then built-in only. + string workspace = 2; } // Provider profile payload with optional source metadata for diagnostics. @@ -1072,6 +1141,8 @@ message StoredProviderCredentialRefreshState { message GetProviderRefreshStatusRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message GetProviderRefreshStatusResponse { @@ -1085,6 +1156,8 @@ message ConfigureProviderRefreshRequest { map material = 4 [(openshell.options.v1.secret) = true]; repeated string secret_material_keys = 5; optional int64 expires_at_ms = 6; + // Workspace scope. Empty defaults to "default". + string workspace = 7; } message ConfigureProviderRefreshResponse { @@ -1094,6 +1167,8 @@ message ConfigureProviderRefreshResponse { message RotateProviderCredentialRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message RotateProviderCredentialResponse { @@ -1103,6 +1178,8 @@ message RotateProviderCredentialResponse { message DeleteProviderRefreshRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message DeleteProviderRefreshResponse { @@ -1159,6 +1236,9 @@ message ListProviderProfilesResponse { // Import custom provider profiles request. message ImportProviderProfilesRequest { repeated ProviderProfileImportItem profiles = 1; + // Workspace scope. When set, profiles are workspace-scoped (Workspace Admin). + // When empty, profiles are platform-scoped (Platform Admin). + string workspace = 2; } // Import custom provider profiles response. @@ -1178,6 +1258,9 @@ message UpdateProviderProfilesRequest { uint64 expected_resource_version = 2; // Existing custom provider profile ID to update. The payload ID must match. string id = 3; + // Workspace scope. When set, targets workspace-scoped profile. When empty, + // targets platform-scoped profile. + string workspace = 4; } // Update one custom provider profile response. @@ -1190,6 +1273,9 @@ message UpdateProviderProfilesResponse { // Lint provider profiles request. message LintProviderProfilesRequest { repeated ProviderProfileImportItem profiles = 1; + // Workspace scope. Included for API consistency but ignored by the server + // (lint is stateless validation). + string workspace = 2; } // Lint provider profiles response. @@ -1206,6 +1292,9 @@ message DeleteProviderResponse { // Delete custom provider profile request. message DeleteProviderProfileRequest { string id = 1; + // Workspace scope. When set, targets workspace-scoped profile. When empty, + // targets platform-scoped profile. + string workspace = 2; } // Delete custom provider profile response. @@ -1268,13 +1357,8 @@ message UpdateConfigRequest { // matches this value before applying the mutation, returning ABORTED on mismatch. // Ignored for global-scoped updates. uint64 expected_resource_version = 8; - // Caller-provided annotations associated with a sandbox-scoped update. Values - // must not contain secrets; the gateway treats them as opaque metadata and does - // not interpret or verify their semantics. For policy updates, the gateway - // stores the annotations immutably with the revision and merges them into - // sandbox metadata as a convenience projection. For setting-only updates, it - // only merges them into sandbox metadata. - map annotations = 9; + // Workspace scope. Empty defaults to "default". Ignored for global-scoped updates. + string workspace = 9; } message PolicyMergeOperation { @@ -1342,6 +1426,8 @@ message GetSandboxPolicyStatusRequest { uint32 version = 2; // Query global policy revisions instead of a sandbox-scoped one. bool global = 3; + // Workspace scope. Empty defaults to "default". Ignored when global is true. + string workspace = 4; } // Get sandbox policy status response. @@ -1360,6 +1446,8 @@ message ListSandboxPoliciesRequest { uint32 offset = 3; // List global policy revisions instead of sandbox-scoped ones. bool global = 4; + // Workspace scope. Empty defaults to "default". Ignored when global is true. + string workspace = 5; } // List sandbox policies response. @@ -1431,6 +1519,8 @@ message GetSandboxLogsRequest { repeated string sources = 4; // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. string min_level = 5; + // Workspace scope. Empty defaults to "default". + string workspace = 6; } // Batch of log lines pushed from sandbox to server. @@ -1730,6 +1820,8 @@ message SubmitPolicyAnalysisRequest { string name = 4; // Anonymous network activity counters. repeated NetworkActivitySummary network_activity_summaries = 5; + // Workspace scope. Empty defaults to "default". + string workspace = 6; } message SubmitPolicyAnalysisResponse { @@ -1751,6 +1843,8 @@ message GetDraftPolicyRequest { string name = 1; // Optional status filter: "pending", "approved", "rejected", or "" for all. string status_filter = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message GetDraftPolicyResponse { @@ -1770,6 +1864,8 @@ message ApproveDraftChunkRequest { string name = 1; // Chunk ID to approve. string chunk_id = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message ApproveDraftChunkResponse { @@ -1787,6 +1883,8 @@ message RejectDraftChunkRequest { string chunk_id = 2; // Optional reason for rejection (fed to LLM context in future analysis). string reason = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; } message RejectDraftChunkResponse {} @@ -1797,6 +1895,8 @@ message ApproveAllDraftChunksRequest { string name = 1; // Include chunks with security_notes (default false: skips them). bool include_security_flagged = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message ApproveAllDraftChunksResponse { @@ -1818,6 +1918,8 @@ message EditDraftChunkRequest { string chunk_id = 2; // The modified rule (replaces existing proposed_rule). openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; } message EditDraftChunkResponse {} @@ -1828,6 +1930,8 @@ message UndoDraftChunkRequest { string name = 1; // Chunk ID to undo. string chunk_id = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message UndoDraftChunkResponse { @@ -1841,6 +1945,8 @@ message UndoDraftChunkResponse { message ClearDraftChunksRequest { // Sandbox name. string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } message ClearDraftChunksResponse { @@ -1852,6 +1958,8 @@ message ClearDraftChunksResponse { message GetDraftHistoryRequest { // Sandbox name. string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } message DraftHistoryEntry { @@ -1953,3 +2061,116 @@ message StoredDraftChunk { // Operator-supplied free-form rejection text. See PolicyChunk. string rejection_reason = 19; } + +// --------------------------------------------------------------------------- +// Workspace messages +// --------------------------------------------------------------------------- + +// Create workspace request. +message CreateWorkspaceRequest { + // Workspace name. Must be a valid DNS-1123 label. + string name = 1; + // Optional labels for the workspace (key-value metadata). + map labels = 2; +} + +// Create workspace response. +message CreateWorkspaceResponse { + openshell.datamodel.v1.Workspace workspace = 1; +} + +// Get workspace request. +message GetWorkspaceRequest { + // Workspace name (canonical lookup key). + string name = 1; +} + +// Get workspace response. +message GetWorkspaceResponse { + openshell.datamodel.v1.Workspace workspace = 1; +} + +// List workspaces request. +message ListWorkspacesRequest { + uint32 limit = 1; + uint32 offset = 2; + // Optional label selector for filtering (format: "key1=value1,key2=value2"). + string label_selector = 3; +} + +// List workspaces response. +message ListWorkspacesResponse { + repeated openshell.datamodel.v1.Workspace workspaces = 1; +} + +// Delete workspace request. +message DeleteWorkspaceRequest { + // Workspace name (canonical lookup key). + string name = 1; +} + +// Delete workspace response. +message DeleteWorkspaceResponse { + bool deleted = 1; +} + +// --------------------------------------------------------------------------- +// Workspace membership messages +// --------------------------------------------------------------------------- + +// Workspace-scoped role for members. +enum WorkspaceRole { + WORKSPACE_ROLE_UNSPECIFIED = 0; + WORKSPACE_ROLE_USER = 1; + WORKSPACE_ROLE_ADMIN = 2; +} + +// Workspace membership record. +message WorkspaceMember { + openshell.datamodel.v1.ObjectMeta metadata = 1; + // OIDC subject claim identifying the principal. + string principal_subject = 2; + // Role assigned to the principal within the workspace. + WorkspaceRole role = 3; +} + +// Add workspace member request. +message AddWorkspaceMemberRequest { + // Workspace name. + string workspace = 1; + // OIDC subject claim identifying the principal. + string principal_subject = 2; + // Role to assign. + WorkspaceRole role = 3; +} + +// Add workspace member response. +message AddWorkspaceMemberResponse { + WorkspaceMember member = 1; +} + +// Remove workspace member request. +message RemoveWorkspaceMemberRequest { + // Workspace name. + string workspace = 1; + // OIDC subject claim identifying the principal to remove. + string principal_subject = 2; +} + +// Remove workspace member response. +message RemoveWorkspaceMemberResponse { + bool removed = 1; +} + +// List workspace members request. +message ListWorkspaceMembersRequest { + // Workspace name. + string workspace = 1; + uint32 limit = 2; + uint32 offset = 3; +} + +// List workspace members response. +message ListWorkspaceMembersResponse { + repeated WorkspaceMember members = 1; +} diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 8a5a593334..048e1c3c37 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -329,4 +329,7 @@ message GetSandboxConfigResponse { // Fingerprint for provider credential inputs attached to this sandbox. // Changes when attached provider names or attached provider records change. uint64 provider_env_revision = 8; + // Workspace the sandbox belongs to. Allows the supervisor to learn its + // workspace context for subsequent workspace-scoped RPCs. + string workspace = 9; } diff --git a/rfc/0011-multi-player-design/README.md b/rfc/0011-multi-player-design/README.md new file mode 100644 index 0000000000..92fe2de0bf --- /dev/null +++ b/rfc/0011-multi-player-design/README.md @@ -0,0 +1,1334 @@ +--- +authors: + - "@derekwaynecarr" +state: draft +links: + - https://github.com/NVIDIA/OpenShell/issues/1977 +--- + +# RFC 0011 - Multi-Player Support + +## Summary + +This RFC proposes adding multi-user support to OpenShell. Today, sandboxes and +providers are gateway-global with no ownership tracking or isolation between +users. This proposal introduces workspaces as hard isolation boundaries, an +expanded role model (Platform Admin, Workspace Admin, User), workspace-scoped +access, per-workspace quota enforcement, and audit trail enhancements. The Sandbox Supervisor remains a separate principal type +with sandbox-scoped authentication distinct from the user role model. A +`default` workspace preserves backwards compatibility for single-player +deployments. + +## Motivation + +OpenShell is currently a single-player experience. Every authenticated user sees +every sandbox and every provider. There is no concept of resource ownership, +tenant isolation, or delegated administration. This blocks several adoption +scenarios: + +- **Enterprise teams** cannot share a gateway without seeing each other's + sandboxes, credentials, and activity. There is no way to scope visibility + or enforce per-team resource limits. + +- **CI/CD and agent orchestration** workflows need machine identities with + workspace-scoped access. Today the only option is full-privilege OIDC tokens + or mTLS certs with no role granularity beyond admin/user. Workspaces provide + the scoping boundary for these identities. + +- **Compliance and incident response** teams need audit trails that attribute + every sandbox and control-plane action to a specific principal. The existing + OCSF infrastructure logs sandbox-level events but does not consistently tag + them with the creating principal. + +- **Cost attribution** is impossible without ownership metadata. Operators cannot + answer "which team is consuming how many active sandboxes." + +The existing codebase provides a foundation: OIDC authentication, a principal +model (User/Sandbox/Anon), two-tier RBAC, OCSF event infrastructure, and labels +on `ObjectMeta`. The gap is the isolation, ownership, and governance layer on +top. + +Leaving the current design unchanged limits OpenShell to single-operator, +single-team deployments, which constrains adoption and forces organizations +to run one gateway per team. + +## Non-goals + +- **Cross-gateway federation.** This RFC scopes multi-player to a single gateway. + Multi-gateway federation (e.g., routing users to regional gateways) is a + separate concern. +- **Fine-grained ABAC or policy language.** The role model uses coarse-grained + roles with workspace scoping, not attribute-based access control or a policy + DSL like OPA/Rego for authorization decisions. +- **UI/dashboard for user management.** This RFC covers the API and data model. + Administrative UIs are a follow-on. +- **Billing integration.** Principal attribution on resources enables cost + attribution; integration with billing systems is out of scope. +- **Sandbox-to-sandbox networking isolation.** Network isolation between + workspaces at the container/pod level is out of scope; this RFC addresses + control-plane isolation only. +- **Multi-provider OIDC.** This RFC assumes a single configured OIDC provider. + Supporting multiple OIDC providers (e.g., corporate SSO for humans and + GitHub Actions for CI/CD simultaneously) requires issuer-based token routing, + provider-qualified subject formats in membership records, and authenticator + chain changes. These are valuable extensions but are not required for the + core workspace and role model. A follow-on RFC can add multi-provider support + without changing the workspace or membership abstractions. + +## Proposal + +### System Roles + +The role model expands from the current two-tier (admin/user) to three user +roles: + +| Role | Description | +|------|-------------| +| **Platform Admin** | Runtime role with full visibility across all workspaces. Creates workspaces, assigns Workspace Admins, and sets gateway-wide default policies. | +| **Workspace Admin** | Manages users, providers, policies, and quotas within a single workspace. Cannot change gateway infra or access other workspaces. | +| **User** | Creates sandboxes and accesses all sandboxes within assigned workspaces. Uses credentials available in those workspaces. Default role for OIDC-authenticated principals, both human and machine. | + +### Sandbox Supervisor + +The Sandbox Supervisor is not a user role — it is a separate principal type +with its own authentication and authorization path. User roles are properties +of a `Principal::User` and are resolved via OIDC claims or workspace membership +records. The Sandbox Supervisor authenticates as a `Principal::Sandbox` via a +gateway-minted JWT whose subject is bound to a single sandbox UUID. + +The supervisor is scoped to a single sandbox, analogous to a Kubernetes kubelet +identity. It authenticates via a gateway-minted JWT or bootstrap certificate +and is restricted to RPCs that operate on its own sandbox. Authorization is +enforced by a static method allowlist (`is_sandbox_callable`) at the router +layer and per-handler scope guards (`ensure_sandbox_principal_scope`) that +verify the JWT's sandbox UUID matches the request target. The supervisor never +goes through the RBAC role check or workspace membership lookup. + +The supervisor learns its workspace from the `GetSandboxConfigResponse.workspace` +field returned by its first settings poll. It caches this value and passes it +in subsequent workspace-scoped RPCs (policy sync, policy analysis, draft +policy queries). This avoids server-side special-casing for sandbox principals +while keeping the supervisor's JWT scoped to a single sandbox UUID. + +### Role-to-RPC Access Matrix + +Access is grouped by domain. Within each domain, the access level (none, read, +read-write) applies to all RPCs in that group unless noted otherwise. All user +roles are scoped to their workspace except Platform Admin, which operates +cross-workspace. The Sandbox Supervisor column is included for completeness — +it uses a separate authentication and authorization path (see Sandbox +Supervisor section above). + +| Domain | Platform Admin | Workspace Admin | User | Sandbox Supervisor | +|--------|---------------|-----------------|------|--------------------| +| Workspace lifecycle (`Create`, `Get`, `List`, `Delete`) | read-write | read (own) | read (own) | none | +| Workspace membership (`Add`, `Remove`, `List`) | read-write | read-write (own ws, no admin assign) | none | none | +| Sandbox lifecycle (`Create`, `Get`, `List`, `Delete`) | read-write | read-write (own ws) | read-write (own ws) | read (own sandbox) | +| Sandbox data-plane (`Exec`, `ForwardTcp`, `CreateSshSession`, `RelayStream`) | full | full (own ws) | full (own ws) | none | +| Sandbox observability (`GetSandboxLogs`, `ListSandboxPolicies`, `GetSandboxPolicyStatus`) | read | read (own ws) | read (own ws) | own sandbox | +| Provider management (`Create`, `Get`, `List`, `Update`, `Delete`) | read-write | read-write (own ws) | read (no creds) | none | +| Provider attachment (`Attach`, `Detach`, `ListSandboxProviders`) | read-write | read-write (own ws) | read (own ws) | none | +| Services (`Expose`, `Get`, `List`, `Delete`) | read-write | read-write (own ws) | read-write (own ws) | none | +| Gateway config (`GetGatewayConfig`, `UpdateConfig`) | read-write | none | none | none | +| Policy drafts (`SubmitPolicyAnalysis`, `Approve`, etc.) | read-write | read-write (own ws) | none | none | +| Supervisor path (`ConnectSupervisor`, `IssueSandboxToken`, `RefreshSandboxToken`, `GetSandboxProviderEnvironment`, `PushSandboxLogs`, `ReportPolicyStatus`) | none | none | none | own sandbox | + +**Control-plane audit log.** Every mutating gRPC call emits an OCSF +`ApiActivity` event recording the principal, action, target resource, and +timestamp. These events are emitted through the existing OCSF infrastructure +and exported as structured JSONL for consumption by external systems (see +Audit Trail section below). + +### Workspaces + +A workspace is a first-class resource and a hard isolation boundary. Sandboxes, +providers, and policies within a workspace are invisible to other workspaces. +Every resource belongs to exactly one workspace. A `default` workspace exists +for single-player backwards compatibility. Workspace creation is admin-only: +Platform Admins create workspaces and assign Workspace Admins. Self-service +workspace creation can be added later as a gateway configuration option. + +The `Workspace` resource uses standard `ObjectMeta` (the `workspace` field in +its own `ObjectMeta` is unused, following the same convention as Kubernetes +Namespace objects). Workspace-level configuration — quota limits, policy +overrides, and Workspace Admin role bindings — are properties on the Workspace +resource. The gateway exposes `CreateWorkspace`, `GetWorkspace`, +`ListWorkspaces`, and `DeleteWorkspace` RPCs, gated to Platform Admins. +Sandbox and provider create operations validate that the referenced workspace +exists, rejecting unknown workspace values. + +`DeleteWorkspace` is rejected if the workspace contains any workspace-scoped +resources — sandboxes, providers, services, or inference routes. All resources +must be removed before the workspace can be deleted. Membership records are +cleaned up as part of deletion; if any member record fails to delete, the +workspace deletion is aborted and the error is returned to the caller. The +`default` workspace cannot be deleted. Gateway startup fails if the `default` +workspace cannot be created or verified — this is a fatal error, not a +best-effort operation. + +Workspace membership is capped at 1000 members per workspace. +`AddWorkspaceMember` rejects additions that would exceed this limit with a +resource-exhausted error. This cap bounds the cleanup cost during workspace +deletion and ensures the member listing used for cleanup is exhaustive. + +`ObjectMeta` gains a `workspace` field referencing a Workspace by name. Within +a workspace, organizational grouping (teams, projects, cost centers) uses the +existing label system with well-known key conventions (e.g., +`openshell.dev/team=infra`, `openshell.dev/project=alpha`) rather than +additional dedicated fields. This: + +- Gives a clear security boundary (workspace) without over-modeling + organizational hierarchy. +- Allows multiple overlapping groupings within a workspace via labels. +- Keeps the proto surface minimal: `workspace` is the only new field on + `ObjectMeta`. + +#### Workspace use cases + +- **Credential segmentation within a team.** Each user gets their own workspace + on a shared gateway, keeping their API keys (e.g., per-user Claude or Codex + keys) isolated from other users. This eliminates the need for a separate + gateway per user while preserving credential isolation. + +- **Shared coding sessions.** All workspace members can access any sandbox in + the workspace, enabling pair programming and collaborative debugging without + additional access grants. + +- **CI/CD and automation.** A workspace scopes sandbox lifecycle to a specific + pipeline or project. Machine workloads authenticate via the configured OIDC + provider and are added as workspace members with the appropriate role. + +- **Agent harness integration.** A single OpenShell gateway can be partitioned + into discrete workspaces so that multiple agent harness instances (e.g., + OpenClaw) can procure sandboxes from the same gateway with proper isolation. + This removes the requirement for a one-to-one association between an agent + harness instance and an OpenShell gateway instance. + +### Ownership and Access Control + +Access control is based on workspace membership. Principal attribution (who +created or modified a resource) is handled by the control-plane audit log, not +by fields on the resource itself. + +#### Role assignment + +Roles fall into two categories: + +- **Global roles** (Platform Admin) are assigned externally via OIDC claims in + the identity provider (e.g., an `openshell-platform-admin` role in the JWT). + This role is a property of the principal, not of any workspace, and grants + cross-workspace access. The gateway evaluates it from the authenticated + token at request time. + +- **Workspace-scoped roles** (Workspace Admin, User) are assigned internally + via workspace membership records stored in the gateway's durable object + store. These roles are properties of a principal's relationship to a + specific workspace. + +The authorization layer combines both: the gateway first evaluates global roles +from the JWT, then resolves workspace-scoped roles from membership records for +the target workspace. A request to a workspace-scoped RPC is authorized if the +principal has the Platform Admin global role or a +workspace membership with a sufficient role. + +Workspace membership is managed through three RPCs: + +- `AddWorkspaceMember(workspace, principal_subject, role)` — Platform Admins + can assign any workspace-scoped role. Workspace Admins can add Users to + their own workspace but cannot assign the Workspace Admin role. +- `RemoveWorkspaceMember(workspace, principal_subject)` — same access pattern. +- `ListWorkspaceMembers(workspace)` — Platform Admins can list any workspace; + Workspace Admins can list their own. + +Principal subjects are the OIDC `sub` claim from the configured identity +provider. The gateway does not maintain a user directory — membership +references OIDC subjects that are resolved at authentication time. Membership +records are persisted in the durable object store, indexed by both workspace +and principal subject for efficient lookup. + +A principal's request to any workspace-scoped RPC is rejected if they are not a +member of the target workspace. Platform Admins bypass membership checks — +their role grants cross-workspace access by definition. + +#### Resource-level access + +Within a workspace, access varies by resource type: + +- **Sandboxes.** All workspace members can list, get, exec into, and access any + sandbox in their workspace. Credential isolation happens at the workspace + boundary — within a workspace, all members share the same trust domain and + the same provider credentials, so there is no security benefit to restricting + sandbox access by owner. Platform Admins can list across workspaces using + `all_workspaces = true` on list RPCs (see Cross-Workspace List Operations + below). + +- **Providers.** Users can list and reference providers by name within their + workspace but cannot create, update, or delete them, and cannot see raw + credential material. Workspace Admins manage provider lifecycle within their + workspace. `ListProviders` is scoped to the caller's workspace. + +- **Services.** Services are child resources of sandboxes, keyed by sandbox + name. They carry the parent sandbox's workspace in their `ObjectMeta` for + consistent filtering, but the workspace is always inherited from the sandbox, + never set independently. All workspace members can expose, list, and delete + services on any sandbox in their workspace. + +- **Provider profiles.** Provider profiles are type definitions that describe + what a provider type needs (credentials, endpoints, filesystem paths). + Profiles have two-tier scoping: platform-scoped profiles are managed by + Platform Admins; workspace-scoped profiles are managed by Workspace Admins + and visible only within their workspace. Built-in profiles (claude-code, + github, nvidia, etc.) are included in both scopes for convenience. Each + scope is listed independently — `ListProviderProfiles` returns profiles + from the requested scope (workspace or platform) plus built-ins, not a + merged view across scopes. This keeps the listing unambiguous: users see + exactly which custom profiles exist in their workspace without conflating + them with platform-level profiles. Import, update, and delete operations + target either platform scope or workspace scope explicitly. + +- **Policies.** Users cannot modify policies directly. Sandbox policy is + derived from attached provider profiles (see Policy Scoping below). + Workspace Admins control policy indirectly by managing which providers are + available in the workspace. `ListSandboxPolicies` is scoped to the caller's + workspace. + +#### Sandbox access within a workspace + +All workspace members have full access to all sandboxes in the workspace. There +is no per-sandbox sharing mechanism within a workspace — the workspace boundary +is the access control surface. Cross-workspace sandbox sharing is deferred to +future work (see Future Work section). + +### Policy Scoping + +Sandbox policy is derived from the providers attached to the sandbox. There is +no separate workspace-level policy to author or maintain — the providers a +Workspace Admin makes available in the workspace define what sandboxes in that +workspace can do. + +| Layer | Scope | Set by | Purpose | +|-------|-------|--------|---------| +| Gateway default | All sandboxes | Platform Admin | Enforcement modes (Landlock) and gateway-wide network deny rules | +| Provider profiles | Per sandbox | Workspace Admin (provider lifecycle) / User (provider attachment) | Network endpoints, filesystem paths, environment variables | + +Each provider carries a profile describing the endpoints and filesystem paths +it requires. When a user attaches providers to a sandbox, the gateway computes +the effective policy as the union of the attached provider profiles, layered on +top of the gateway default. The gateway returns a single resolved +`SandboxPolicy` at `GetSandboxConfig` time — the sandbox sees a flat policy, +not the composition. + +Provider types fall into two categories: + +- **Credential providers** (Anthropic, OpenAI, GitHub) — inject secrets and add + the provider's network endpoints to the sandbox policy. +- **Endpoint providers** — add network endpoints only, no secrets. Used for + internal services (Git servers, artifact registries, custom APIs) that + sandboxes need to reach but that don't require credential injection. + +Both types use the same provider abstraction. The Workspace Admin's policy +decision reduces to: "which providers does this workspace have?" The User's +sandbox-level policy decision is: "which of those providers does my sandbox +use?" + +The gateway default (enforcement modes, deny rules) remains the floor and +cannot be overridden by provider profiles. Deny rules at the gateway level +override provider profile allows. + +**Migration.** Existing global policies map to the gateway default. Existing +per-sandbox policies continue to work through the policy advisor flow, which +generates policy from attached providers. The `restrictive_default_policy()` +fallback applies when no providers are attached — identical to current behavior. + +### Authorization Enforcement + +The gateway's existing authorization pattern — compile-time per-method +metadata, middleware authentication, and per-handler guards — extends to +workspace-scoped enforcement without architectural changes. + +**Proto-driven method metadata.** Authorization rules are declared as custom +options on each proto RPC method, making the proto definition the single +source of truth for the API contract and its access control: + +```proto +import "google/protobuf/descriptor.proto"; + +message AuthorizationRule { + string auth_mode = 1; // "bearer", "sandbox", "dual", "unauthenticated" + string workspace_role = 2; // "user", "admin" + string global_role = 3; // "platform_admin" +} + +extend google.protobuf.MethodOptions { + AuthorizationRule authorization = 50000; +} +``` + +Each RPC carries its authorization requirement: + +```proto +service OpenShell { + rpc CreateSandbox(CreateSandboxRequest) returns (CreateSandboxResponse) { + option (authorization) = { auth_mode: "bearer", workspace_role: "user" }; + } + rpc CreateProvider(CreateProviderRequest) returns (CreateProviderResponse) { + option (authorization) = { auth_mode: "bearer", workspace_role: "admin" }; + } + rpc CreateWorkspace(CreateWorkspaceRequest) returns (CreateWorkspaceResponse) { + option (authorization) = { auth_mode: "bearer", global_role: "platform_admin" }; + } + rpc ConnectSupervisor(stream SupervisorMessage) returns (stream GatewayMessage) { + option (authorization) = { auth_mode: "sandbox" }; + } +} +``` + +The gateway already compiles a `FileDescriptorSet` at build time and embeds +it in the binary (`openshell_core::FILE_DESCRIPTOR_SET`). Adding +`prost_reflect::DescriptorPool` allows the runtime to resolve custom +extensions natively — no build.rs code generation, no external tooling: + +```rust +static DESCRIPTOR_POOL: LazyLock = LazyLock::new(|| { + DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET) + .expect("decode descriptor pool") +}); +``` + +At startup the middleware walks the pool's methods, reads the +`(authorization)` extension from each `MethodDescriptor::options()`, and +builds the lookup table keyed by gRPC method path. This replaces the current +`#[rpc_authz]` proc macro and per-service `AUTH_METADATA` tables — the proto +definition becomes the single source of truth for both the API contract and +its access control. The middleware calls the same `method_authz::lookup()` +function at request dispatch time; only the source of the table changes. + +The existing exhaustiveness tests switch from `prost_types::FileDescriptorSet` +to `DescriptorPool` and assert that every method in the pool carries a valid +`(authorization)` option, catching missing annotations at `cargo test` time. + +This follows the pattern established by `google.api.http` annotations for +REST gateway generation: the proto carries the metadata, the descriptor pool +resolves it, and the runtime consumes it directly. + +**Workspace on every scoped request.** Since resource names are +unique-within-workspace, every workspace-scoped RPC includes the workspace in +its request message. A `WorkspaceScoped` trait implemented on each request type +provides uniform access: + + trait WorkspaceScoped { + fn workspace(&self) -> &str; + } + +**Single authorization path.** A shared `authorize_workspace` function replaces +per-handler authorization boilerplate. It extracts the principal from request +extensions, checks for Platform Admin global role bypass, resolves +workspace membership from the durable store, and verifies the membership role +meets the method's declared minimum: + + let principal = authorize_workspace( + &request, WorkspaceRole::User, &self.membership, + )?; + +Every workspace-scoped handler uses this one-line call. The middleware layer +is unchanged: it authenticates the caller, inserts the principal into request +extensions, and the handler resolves workspace authorization. + +### Authorization Boundaries (Kubernetes Deployments) + +In Kubernetes deployments, authorization operates at two layers. Kubernetes +RBAC governs control-plane operations: who can create, delete, or manage +sandbox custom resources and other objects in a Kubernetes namespace. The +gateway's role model governs data-plane operations: who can exec into a +running sandbox, stream relay output, or view audit logs. +These are runtime authorization decisions through the gateway's gRPC endpoints +where Kubernetes RBAC has no reach. + +Both layers are needed in Kubernetes deployments with clear boundaries between +them. For non-Kubernetes drivers (Docker, Podman, VM), the gateway's role model +is the sole authorization mechanism. + +### Provider Credential Scoping + +Providers belong to a workspace. A User can only attach providers available in +their workspace when creating a sandbox. No role sees raw credential material +through the API; all roles reference providers by +name. The sandbox supervisor resolves credentials at runtime through an internal +trusted path, not through role-level permissions. + +### Authentication + +The gateway authenticates principals via its existing OIDC provider. The +workspace and role model does not change the authentication mechanism — it +layers authorization on top of the authenticated identity. + +When OIDC is configured, the gateway validates the Bearer token, extracts the +`sub` claim as the principal subject, and evaluates global roles from JWT +claims (e.g., Platform Admin). Workspace-scoped roles are resolved from +membership records keyed by the principal's `sub` claim. + +When OIDC is not configured, every request is treated as a Platform Admin +principal. The full workspace model remains available — workspaces can be +created, members added, resources scoped — but no authentication or +authorization checks are enforced. This preserves the current single-player +experience and allows operators to adopt workspaces for organizational +structure before enabling authentication. + +### Audit Trail + +Multi-player introduces multiple principals acting on shared infrastructure. +The audit trail must attribute every action to a specific principal so that +security teams, compliance reviewers, and operators can answer "who did what, +when, and in which workspace" without needing a gateway role to do so. + +OpenShell already has an OCSF event infrastructure with two output layers: +a shorthand formatter for human-readable logs and a JSONL formatter for +structured machine consumption. Multi-player extends this infrastructure +with principal and workspace attribution on every event. + +**Control-plane events.** Every mutating gRPC call (`CreateSandbox`, +`DeleteSandbox`, `CreateProvider`, `UpdatePolicy`, `AddWorkspaceMember`) +emits an OCSF event with the authenticated principal's subject, the target +workspace, the action, the target resource, and a timestamp. `ApiActivity` +is a new OCSF event class that must be added to the `openshell-ocsf` crate. +`ConfigStateChange` covers policy and configuration mutations. These events +are emitted through the existing OCSF infrastructure — no separate audit +storage or query API is required. + +**Sandbox-level events.** Sandbox activity (network decisions, process +lifecycle, SSH sessions) is already emitted as OCSF events by the sandbox +supervisor. Multi-player adds the creating principal's subject to these +events so security teams can trace sandbox behavior back to the human or +machine principal that created it. + +**Log forwarding.** The gateway writes OCSF events as structured JSONL to +a configurable output (file, stdout). Operators forward this JSONL to their +SIEM or log aggregation system (Splunk, Elastic, Datadog, CloudWatch) using +standard log shipping tools (Fluentd, Vector, Filebeat). The JSONL format +follows OCSF v1.7.0 schema conventions, making it directly ingestible by +SIEM platforms that support OCSF. + +This model follows the Kubernetes pattern: the API server writes audit events +to a log backend, and external tooling handles aggregation, retention, and +querying. Audit consumers do not need a gateway role — they access audit data +through their organization's log infrastructure. Queries like "who created +sandbox X" or "what did user Y do between T1 and T2" are answered in the SIEM, +not through a gateway API. + +### Resource Governance + +- **Per-workspace quotas.** Max concurrent sandboxes, max GPU allocations, max + sandbox lifetime per workspace. Enforced at the gateway before sandbox + creation. Quota limits are hard — sandbox creation is rejected when a quota + is exceeded. Quotas are framed as DoS and abuse protection for the control + and data plane, not as a chargeback mechanism. Quota limits are properties on the + workspace data model; detailed schema design is deferred to implementation. + +### Kubernetes Compute Driver: Workspace Mapping + +OpenShell workspaces are a gateway-level concept. The gateway populates the +workspace on each `DriverSandbox` passed to the compute driver. Drivers consume +the workspace to map it to the appropriate infrastructure-level isolation (K8s +namespace, Docker label, etc.) but do not define or manage workspaces +themselves. When the Kubernetes compute driver renders sandboxes onto a cluster, +it must map each OpenShell workspace to a Kubernetes namespace. The driver +supports two modes, configured per deployment: + +**Managed mode** (default) — the driver creates and deletes Kubernetes +namespaces on demand. The Kubernetes namespace name is derived from the gateway +identifier and the OpenShell workspace: +`openshell-{gateway-id}-{workspace-name}`. For example, if the gateway +identifier is `prod` and the OpenShell workspace is `team-ml`, the Kubernetes +namespace is `openshell-prod-team-ml`. + +The gateway identifier prefix ensures that multiple gateways can operate on a +common Kubernetes cluster without namespace collisions. Each gateway owns its +own set of Kubernetes namespaces and can independently create, watch, and delete +them. The gateway identifier is already part of the gateway's bootstrap +configuration. + +When an OpenShell workspace is deleted and all sandboxes have been removed, the +driver deletes the corresponding Kubernetes namespace. + +Managed mode requires a `ClusterRole` with namespace create/delete permissions. +The Helm chart includes conditional `ClusterRole` and `ClusterRoleBinding` +templates that are enabled by default. Workspace names must be validated at +creation time to ensure the resulting Kubernetes namespace name is DNS-1123 +compliant (lowercase alphanumeric and hyphens, max 63 characters total +including the `openshell-{gateway-id}-` prefix). The gateway rejects workspace +names that would produce invalid or colliding Kubernetes namespace names. + +**Operator mode** — an alternative for environments where the gateway should not +create Kubernetes namespaces. The OpenShell workspace name maps one-to-one to a +Kubernetes namespace of the same name. If a sandbox belongs to OpenShell +workspace `team-ml`, the driver renders it into the Kubernetes namespace +`team-ml`. No mapping configuration is required. The Kubernetes namespaces must +be pre-provisioned — the driver has no permission to create or delete them. +If the target Kubernetes namespace does not exist, the driver lets the +Kubernetes API reject the request and surfaces the error — no pre-validation, +which avoids TOCTOU races. + +This direct identity mapping enables the OpenShell gateway to operate as a +natural Kubernetes-style operator: it receives a desired state (sandbox in +workspace X) and renders it into the corresponding cluster namespace. Platform +teams manage Kubernetes namespaces through their existing tooling (kubectl, +GitOps, Terraform) and OpenShell follows. + +```toml +[openshell.drivers.kubernetes] +workspace_mode = "operator" # opt-in; default is "managed" +``` + +**Watcher strategy.** Today the Kubernetes driver watches a single Kubernetes +namespace via `Api::namespaced_with()`. With multiple workspaces, the driver +shifts to a cluster-wide list/watch filtered by OpenShell labels (e.g., +`openshell.dev/managed-by=gateway`). This follows the standard Kubernetes +operator pattern for multi-namespace controllers. A per-namespace watcher +approach does not scale — it requires O(n) API connections and complicates +dynamic workspace addition/removal. The cluster-wide watch requires a +`ClusterRole` granting list/watch across Kubernetes namespaces (applicable to +both operator and managed modes). + +#### Shared-namespace resource naming + +Before namespace-per-workspace support lands, the Kubernetes driver operates +in shared-namespace mode: all workspaces render sandboxes into the same +Kubernetes namespace. The gateway populates `DriverSandbox.workspace` on +every sandbox dispatched to the compute driver. The driver uses this field to +construct collision-safe Kubernetes resource names and to label and annotate +the resulting objects. + +**Resource names.** Kubernetes resource names follow the format +`{workspace}--{name}`. Two sandboxes named `work` in workspaces `alpha` and +`beta` produce distinct resources `alpha--work` and `beta--work`. The +workspace prefix is always present — there is no bare-name fallback. + +**Labels.** Each sandbox object carries labels for selector-based lookup: + +| Label | Value | Purpose | +|-------|-------|---------| +| `openshell.ai/sandbox-id` | sandbox UUID | Get/delete by ID | +| `openshell.ai/sandbox-name` | bare sandbox name | Lookup by (workspace, name) tuple | +| `openshell.ai/sandbox-workspace` | workspace name | Lookup by (workspace, name) tuple | +| `openshell.ai/managed-by` | `openshell-gateway` | Scope cluster-wide watches | + +**Annotations.** Annotations store authoritative sandbox metadata for +reconstructing `DriverSandbox` from a Kubernetes object. The annotation keys +mirror the label keys (`openshell.ai/sandbox-id`, `openshell.ai/sandbox-name`, +`openshell.ai/sandbox-workspace`). On read, the driver checks annotations +first, then falls back to labels for backwards compatibility with objects +created before annotations were added. If neither source provides a workspace, +the driver returns an error — it never falls back to an empty workspace. + +**Label-based lookup.** Get and delete operations use label selectors instead +of Kubernetes resource name lookup. `get_sandbox(sandbox_id)` lists by +`openshell.ai/sandbox-id={uuid}`. `delete_sandbox(sandbox_id)` lists by the +same label to discover the Kubernetes resource name, then issues the delete. +This decouples the API contract (sandbox UUID) from the Kubernetes resource +name (which encodes the workspace). + +When namespace-per-workspace mode is implemented (Phase 3–4), the driver will +use the workspace to select the target Kubernetes namespace instead of encoding +it in the resource name. The label-based lookup and annotation patterns +established here carry over unchanged. + +**Docker and Podman drivers.** The Docker driver's `sandbox_namespace` label +provides a foundation for workspace mapping, but the driver currently uses a +single configured namespace rather than per-sandbox values. The driver contract +must be updated so that workspace flows through `DriverSandbox` and the driver +applies it as the container label filter. The same applies to Podman and other +local drivers — workspace isolation is enforced at the gateway level and does +not require Kubernetes. + +### Compute Driver Trust Model + +The `ComputeDriver` gRPC service is a gateway-internal contract between the +gateway and its compute backend. Drivers can be in-process (Docker, Podman) or +out-of-process (VM driver subprocess, remote Kubernetes driver). The trust model +for this channel is: + +**The driver is a trusted backend.** The gateway is the sole caller of the +driver service. The driver does not enforce workspace isolation — it operates on +`DriverSandbox` messages keyed by `(id, name, namespace)` and has no concept of +workspaces. Workspace is a gateway-level tenancy boundary that the gateway +resolves before dispatching to the driver. The driver trusts the gateway to have +already authenticated the user, authorized the operation, and resolved the +workspace-to-namespace mapping. + +**Gateway-to-driver authentication.** For in-process drivers, no authentication +is needed — the driver runs in the gateway process. For out-of-process drivers, +the gateway authenticates to the driver via mTLS or a shared credential +configured at deployment time. This is a service-to-service trust boundary, not +a user-facing one. The `RemoteComputeDriver` connects over a gRPC channel; the +channel's transport security governs the trust. + +**Multi-gateway deployments.** When multiple gateways share a compute backend +(e.g., a shared Kubernetes cluster), each gateway independently manages its own +workspace set and namespace mappings. The driver has no mechanism to enforce +cross-gateway workspace isolation — it sees containers/pods from all gateways +indiscriminately. Isolation between gateways relies on the deterministic +namespace naming convention (`openshell-{gateway-id}-{workspace-name}`) ensuring +non-overlapping Kubernetes namespaces, and on OpenShell labels that scope +list/watch results to a specific gateway's resources. + +**The driver does not need workspace-scoped RPCs.** The `ComputeDriver` service +contract remains workspace-unaware. `CreateSandbox` receives a `DriverSandbox` +with the resolved Kubernetes namespace (or Docker label). `ListSandboxes` returns +all platform-observed sandboxes — the gateway correlates them back to workspaces. +Adding workspace awareness to the driver contract would violate the separation +between the gateway's tenancy model and the driver's infrastructure model. + +### Cross-Workspace Infrastructure Operations + +Several gateway-internal operations must query workspace-scoped resources across +all workspaces. These operations run as the gateway process itself — they are +not user-initiated gRPC calls and do not go through the authentication or +workspace authorization path. The gateway is the actor. + +**Affected operations:** + +- **Sandbox reconciliation** (`reconcile_store_with_backend`). The reconciler + periodically compares all stored sandbox records against the compute driver's + inventory to detect orphans (store records with no driver-side resource) and + ghost resources (driver resources with no store record). The driver's + `ListSandboxes` returns all platform-observed sandboxes regardless of + workspace — the driver has no workspace concept. The gateway must query all + stored sandboxes across all workspaces to produce the full set for comparison. + +- **Startup resume** (`resume_persisted_sandboxes`). On gateway startup, the + resume path iterates all stored sandboxes whose phase indicates they should + be running and asks the driver to resume each one. This must cover all + workspaces. + +- **Provider credential refresh** (`refresh_provider_credential`). A background + worker iterates `StoredProviderCredentialRefreshState` records to refresh + expiring credentials. These refresh state records are globally scoped (not + workspace-scoped), but the worker must look up the corresponding `Provider` + resource, which is workspace-scoped. With multiple workspaces, the same + provider name can exist in different workspaces, so the refresh state must + carry the provider's workspace to resolve the correct one unambiguously. + +**Store-level cross-workspace query.** The persistence layer gains a +`list_by_type(object_type, limit, offset)` method that omits the workspace +filter. This is distinct from the workspace-scoped `list(object_type, workspace, +limit, offset)` used by gRPC handlers. The cross-workspace query is used only +by internal infrastructure operations — it is not exposed through any gRPC RPC +directly. + +**Workspace resolution on driver watch events.** When the driver reports a +sandbox that does not exist in the store (a watch event for a sandbox the +gateway has no record of), the gateway creates a new store record. The workspace +for this record is resolved by reverse-mapping the `DriverSandbox.namespace` +field through the workspace-to-namespace configuration: + +- **Managed mode**: the gateway parses the Kubernetes namespace name + (`openshell-{gateway-id}-{workspace-name}`) to extract the workspace. +- **Operator mode**: the gateway looks up which workspace maps to the reported + Kubernetes namespace via the identity mapping. +- **Docker/Podman**: the gateway reads the workspace from the container label. + +If no mapping matches, the sandbox is assigned to the `default` workspace. This +is a best-effort fallback for cases like manually created containers or +resources from a prior gateway configuration. + +### Cross-Workspace List Operations + +Platform Admins need the ability to list resources across all workspaces, +analogous to `kubectl get pods --all-namespaces`. This is an explicit opt-in +on list RPCs, not the default behavior. + +**RPC mechanism.** Workspace-scoped list RPCs (`ListSandboxes`, +`ListProviders`, `ListServices`) gain an `all_workspaces` boolean field. When +`all_workspaces = true`, the handler bypasses workspace scoping and returns +results from all workspaces. The caller must have the Platform Admin global role; +workspace-scoped roles cannot set `all_workspaces`. Results include the +`workspace` field in each resource's `ObjectMeta` so the caller can distinguish +provenance. + +This is distinct from passing an empty workspace string. Empty workspace is +resolved to `"default"` by the gateway's `resolve_workspace()` logic for +backwards compatibility — it does not mean "all workspaces." + +**Store query.** The `all_workspaces` handler path uses the same +`list_by_type(object_type, limit, offset)` store method as the internal +infrastructure operations. The authorization gate (Platform Admin check) is +enforced at the gRPC handler level, not at the store level — the store method +itself is access-control-unaware. + +**CLI surface.** The `--all-workspaces` flag is available on list commands for +Platform Admins: + +```shell +openshell sandbox list --all-workspaces +openshell provider list --all-workspaces +openshell service list --all-workspaces +``` + +### Enterprise Deployment: Multi-Consumer Gateway + +A common enterprise deployment pattern — particularly in regulated industries +like financial services and defense — involves one or two data centers, each +running a handful of Kubernetes clusters. In this environment, organizations +want to minimize the number of OpenShell gateways they need to reason about. +Workspaces enable a single gateway per compute region to serve multiple +independent sandbox consumers with proper isolation between them. + +**Multiple consumers, one gateway.** In a single enterprise OpenShell +deployment, many independent consumers procure sandboxes on demand — agent +harnesses, CI/CD pipelines, internal tooling, and interactive users. Using +OpenClaw as an example agent-harness consumer that needs to procure sandboxes +on demand, an OpenClaw instance adds a single `workspace` field to its +OpenShell plugin config: + +```json5 +// OpenClaw plugin config — workspace scopes all sandbox operations +{ + plugins: { + entries: { + openshell: { + enabled: true, + config: { + from: "openclaw", + mode: "remote", + gateway: "prod", + gatewayEndpoint: "https://openshell.internal:8443", + workspace: "team-capital-markets", + }, + }, + }, + }, +} +``` + +The plugin passes `--workspace` to every `openshell` CLI invocation (`sandbox +get`, `sandbox create`, `sandbox list`). The rest of the OpenClaw integration +— sandbox lifecycle, SSH transport, workspace sync — is unchanged. + +OpenClaw sandboxes for tool execution and agent sessions are provisioned within +the assigned workspace. Other consumers — a different OpenClaw instance for +another department, a separate agent harness, or a CI pipeline — each operate +in their own workspace on the same gateway. Sandbox list operations are +workspace-scoped: a consumer sees only its own sandboxes, never sandboxes +belonging to other consumers. + +This is not OpenShell absorbing multiplayer concerns from its consumers. +OpenClaw and other agent harnesses own their own multi-user models. The +requirement on the OpenShell side is narrower: a single gateway must support +1:N partitioning so that each consumer's sandboxes are properly isolated from +every other consumer's sandboxes, without requiring a dedicated gateway per +consumer. + +**Kubernetes-level isolation chain.** When the gateway renders sandboxes onto +the cluster, each workspace maps to a discrete Kubernetes namespace. This +enables the Kubernetes isolation stack when the cluster is configured for it: + +- **Network policy** can partition traffic between Kubernetes namespaces, + preventing cross-workspace network access between sandboxes (requires a CNI + that enforces NetworkPolicy). +- **UID/GID range allocation** (as enforced on platforms like OpenShift) assigns + each Kubernetes namespace a unique UID/GID range. Every sandbox process runs + under a UID that is unique to its workspace's namespace. +- **SELinux labeling** (on OpenShift and similarly configured platforms) assigns + each Kubernetes namespace a unique SELinux label (MCS category). Kernel-level + mandatory access control constrains processes to their namespace's domain. + +These are platform prerequisites, not features provisioned by OpenShell. The +workspace-to-namespace mapping provides the structure; the cluster must be +configured to enforce isolation at each layer. + +**Sandbox escape threat model.** Container breakout is the dominant concern in +regulated environments. The workspace-to-Kubernetes-namespace mapping means +that even in the event of a sandbox escape, the attacker's process carries the +UID/GID and SELinux label of the originating workspace's Kubernetes namespace. +On platforms that enforce these boundaries at the node level, a compromised +process is constrained by: + +- Kubernetes RBAC and secrets scoped to the originating namespace. +- UID/GID range enforcement preventing access to other namespaces' resources. +- SELinux MCS labels preventing cross-namespace process and file access. + +The combination of gateway-level workspace isolation (control plane) and +Kubernetes namespace isolation (data plane) produces defense in depth: even if +one layer is compromised, the other constrains the blast radius to a single +workspace. + +### CLI Surface + +All sandbox and provider commands accept an optional `--workspace` flag that +scopes operations to a specific workspace. When omitted, the CLI defaults to +the `default` workspace, preserving the single-player experience. + +The workspace can also be set via the `OPENSHELL_WORKSPACE` environment +variable. The explicit `--workspace` flag takes precedence over the environment +variable. + +```shell +openshell sandbox create --workspace team-ml --name my-sandbox +openshell sandbox list --workspace team-ml +openshell provider list --workspace team-ml +``` + +#### Cross-workspace listing + +Platform Admins can list resources across all workspaces using the +`--all-workspaces` flag. This is mutually exclusive with `--workspace`. Output +includes the workspace in each row so the admin can distinguish provenance. + +```shell +openshell sandbox list --all-workspaces +openshell provider list --all-workspaces +openshell service list --all-workspaces +``` + +#### Provider profile scope flag + +Provider profiles use two-tier scoping (see Resource-level access above). +Because `--workspace` defaults to the `default` workspace, a separate +`--global` flag distinguishes platform-scoped operations from workspace-scoped +ones. The two flags are mutually exclusive. + +| Flag | Scope | Who | Behavior | +|------|-------|-----|----------| +| *(neither)* | Workspace (`default`) | Workspace Admin | Operates on workspace-scoped profiles; list returns workspace custom + built-in | +| `--workspace team-ml` | Workspace (`team-ml`) | Workspace Admin | Same, targeting a specific workspace | +| `--global` | Platform | Platform Admin | Operates on platform-scoped profiles; list returns platform custom + built-in | + +```shell +# Platform Admin imports an org-wide custom profile (platform-scoped) +openshell provider profile import --global -f internal-gitlab.yaml + +# Workspace Admin imports a team-specific profile (workspace-scoped) +openshell provider profile import --workspace team-ml -f team-registry.yaml + +# Workspace custom + built-in (does not include platform-scoped) +openshell provider profile list --workspace team-ml + +# Platform custom + built-in (does not include workspace-scoped) +openshell provider profile list --global +``` + +## Implementation plan + +The implementation builds on the existing authentication, RBAC, and OCSF +foundations. The work can be phased to deliver value incrementally: + +- **Phase 1: Workspace and membership model.** Add the `Workspace` resource + with standard `ObjectMeta` and `CreateWorkspace`, `GetWorkspace`, + `ListWorkspaces`, `DeleteWorkspace` RPCs gated to Platform Admins. Add + `workspace` field to `ObjectMeta` for Sandbox and Provider resources, + validated against existing workspaces on create. All workspace-scoped + resources inherit workspace from their parent sandbox or workspace context: + services, SSH sessions, policy revisions, policy drafts, settings, provider + refresh state, inference routes, audit records, and log/watch streams. + Provider profiles use two-tier scoping: platform-scoped profiles are managed + by Platform Admins; workspace-scoped profiles are managed by Workspace Admins + and visible only within their workspace. Each scope is listed independently + — `ListProviderProfiles` returns profiles from the requested scope plus + built-ins, not a merged view across scopes. Built-in profiles are included + in both scopes for convenience. Implement workspace-scoped + storage and filtering in gRPC handlers. Add the membership store with `(workspace, principal_subject) → + role` records and `AddWorkspaceMember`, `RemoveWorkspaceMember`, + `ListWorkspaceMembers` RPCs. Create the `default` workspace on gateway + startup for backwards compatibility. Sandbox name uniqueness shifts from + globally unique to unique-within-workspace. The current global uniqueness + constraint `(object_type, name)` shifts to `(object_type, workspace, name)`. + Existing resources are backfilled to the `default` workspace during + migration. Service endpoint hostnames always include workspace + (`{workspace}--{sandbox}--{service}.{base-domain}`), including the `default` + workspace. Always including the workspace eliminates hostname parsing + ambiguity — with a variable number of `--`-delimited segments, the parser + cannot distinguish `{sandbox}--{service}` from `{workspace}--{sandbox}` + without knowing whether the default workspace was omitted. The consistent + three-segment format makes parsing unambiguous: two segments is + `{workspace}--{sandbox}`, three is `{workspace}--{sandbox}--{service}`. + Backward compatibility is desirable but not a hard requirement + at this stage — existing users may need to recreate resources when upgrading. + Add a cross-workspace `list_by_type(object_type, limit, offset)` store method + for internal infrastructure operations (reconciler, resume, provider refresh) + that need to query workspace-scoped resources across all workspaces. Thread + workspace through `StoredProviderCredentialRefreshState` so the provider + refresh worker can unambiguously resolve workspace-scoped providers — with + multiple workspaces, `provider_name` alone is insufficient because different + workspaces can have same-named providers. Add `all_workspaces` field to + workspace-scoped list RPCs for Platform Admin cross-workspace visibility. + Add `ObjectWorkspace::requires_workspace()` trait method and `debug_assert!` + validation in store write helpers to catch workspace-scoped resources + persisted with an empty workspace in debug builds. + +- **Phase 2: Expanded role model and authorization enforcement.** Extend the + RBAC system from two-tier (admin/user) to three user roles (Platform Admin, + Workspace Admin, User). Add proto-driven authorization metadata via custom + method options and `prost_reflect::DescriptorPool`. Implement + `authorize_workspace()` and `WorkspaceScoped` trait for workspace-scoped + access guards in gRPC handlers. Replace the `#[rpc_authz]` proc macro with + descriptor pool-based lookup. Add Workspace Admin role with per-workspace + management capabilities. + +- **Phase 3: Kubernetes driver — managed mode (default).** The driver creates + Kubernetes namespaces on demand using the naming convention + `openshell-{gateway-id}-{workspace-name}`. The watcher shifts from + single-namespace `Api::namespaced_with()` to cluster-wide list/watch with + OpenShell label filtering. Once all sandboxes in a workspace are deleted, + the driver deletes the corresponding Kubernetes namespace. Helm chart adds + `ClusterRole` and `ClusterRoleBinding` for namespace create/delete and + multi-namespace list/watch permissions (enabled by default). Includes + idempotent create with retry to handle races. The reconciler and watch + event handler use the cross-workspace `list_by_type` store query (from + Phase 1) to compare driver inventory against all stored sandboxes. When + the driver reports a sandbox not in the store, the gateway resolves its + workspace by reverse-mapping `DriverSandbox.namespace` through the + workspace-to-namespace configuration (parsing the managed-mode naming + convention or looking up the operator-mode identity mapping). + +- **Phase 4: Kubernetes driver — operator mode.** Alternative mode where the + OpenShell workspace name maps one-to-one to a pre-existing Kubernetes + namespace. The driver accepts per-sandbox workspaces from the gateway + (populated via `driver_sandbox_from_public()`) and renders sandboxes into the + corresponding Kubernetes namespace. No namespace create/delete permissions + required. Opt-in via `workspace_mode = "operator"` in the driver config. + Shares the cluster-wide watcher infrastructure from Phase 3. + +- **Phase 5: Audit trail enhancements.** Add `ApiActivity` OCSF event type for + control-plane mutations. Tag all sandbox activity events with the + authenticated principal's subject and workspace. Extend OCSF JSONL export + with principal and workspace attribution fields. + +- **Phase 6: Quota enforcement.** Implement per-workspace quota checks at the + gateway. Add quota configuration surface for Platform Admins and Workspace + Admins. Quota limits are stored as workspace properties and usage counters + are tracked in the existing durable object store. + +Phases 1 and 2 are sequential prerequisites — Phase 2 depends on Phase 1. +Phase 4 depends on Phase 3 (shared watcher infrastructure). Phases 3, 5, and 6 +can be reordered relative to each other based on priority. Phases 5 and 6 +depend only on Phase 1. + +## Risks + +- **Migration complexity.** Existing deployments have no workspace concept. The + `default` workspace provides backwards compatibility, but platform teams with + established workflows may need to re-organize resources when adopting + workspaces. Migration tooling and documentation will be needed. + +- **Proto surface growth.** Adding `workspace` and role-related fields to the + proto increases the API surface that must be maintained across versions. The + design intentionally keeps new proto fields minimal (`workspace` on + `ObjectMeta`) and uses labels for soft grouping to limit this. + +- **RBAC complexity.** Three roles with workspace scoping is significantly more + complex than the current two-tier model. Misconfiguration could lead to + privilege escalation or overly restrictive access. Clear defaults, validation, + and documentation are essential. + +- **Performance at scale.** Workspace-scoped filtering and quota enforcement add + per-request overhead. For deployments with many workspaces and users, the + filtering and quota checks must be efficient. Indexing strategies need + consideration during implementation. + +- **Quota enforcement races.** Concurrent sandbox creation within a workspace + could race against quota limits. The quota check and sandbox creation must be + atomic or use optimistic concurrency control with retry. + +- **Kubernetes ClusterRole requirements.** Both operator and managed modes require + a `ClusterRole` for cluster-wide list/watch. Managed mode additionally + requires namespace create/delete permissions. Some clusters restrict these + grants. The Helm chart must make these conditional and clearly documented. + +- **Managed mode race conditions.** Kubernetes namespace creation is async. + Sandbox creation may race against it. The naming convention + (`openshell-{gateway-id}-{workspace-name}`) is deterministic, so concurrent + creates from the same gateway are idempotent. + +- **In-flight sandboxes during workspace deletion.** Workspace deletion is + rejected if active sandboxes exist. Once all sandboxes are removed, the + driver deletes the corresponding Kubernetes namespace. + +- **Multi-gateway coordination.** The `openshell-{gateway-id}-{workspace-name}` + naming convention partitions Kubernetes namespaces by gateway, so multiple + gateways can share a cluster without collisions. However, this means each + gateway manages its own workspace set independently — cross-gateway workspace + visibility requires external coordination. + +- **Cross-workspace store query authorization.** The `list_by_type` store method + has no access-control gate — it is a persistence-layer primitive. Authorization + for cross-workspace queries is enforced at the gRPC handler level (Platform + Admin check for `all_workspaces` on list RPCs) and by code-level access + control for internal operations (only the reconciler, resume, and refresh + worker call it). This relies on internal code discipline rather than an + enforced store-level boundary. A future extension could add a store-level + caller identity parameter if defense-in-depth is desired. + +- **Remote compute driver channel security.** The `RemoteComputeDriver` gRPC + channel does not currently enforce authentication. For deployments where the + driver runs as a separate service (rather than a co-located subprocess), the + channel should use mTLS or a shared credential. Without transport security, + any network-adjacent process could impersonate the gateway to the driver. + +## Future Work + +### Cross-workspace sandbox sharing + +This design treats the workspace as the access control boundary — all workspace +members have full access to all sandboxes in the workspace, and there is no +per-sandbox sharing mechanism within a workspace. + +A future extension could allow sharing a sandbox with a principal who is not a +member of the workspace. The motivating use case: a platform team runs a +"shared-tools" workspace containing sandboxes with internal services (a test +database, a mock API, a reference environment). Engineers in other workspaces +need exec access to specific sandboxes in shared-tools without becoming full +members of that workspace — full membership would grant them access to all +sandboxes and providers in shared-tools, which is broader than needed. + +This would require a scoped access grant that gives the target principal access +to a specific sandbox without conferring workspace membership. The grant would +need to be auditable, revocable, and limited to the specific sandbox — not a +general workspace bypass. Design considerations include whether the sharee can +list other resources in the workspace, how provider credential exposure is +handled (the sandbox environment may already have credentials injected), and +whether the grant survives sandbox recreation. + +### Built-in profile visibility scoping + +Built-in profiles (claude-code, github, nvidia, etc.) are currently included +in both workspace-scoped and platform-scoped profile listings. This is +convenient but may be misleading — a Workspace Admin seeing built-in profiles +in their workspace listing might assume they can modify or override them at +the workspace level. + +A future change could restrict built-in profiles to the platform-scoped +listing only (`--global`). Workspace-scoped listings would show only custom +profiles that have been explicitly imported into that workspace. This would +make the listing semantics cleaner: workspace scope shows what the Workspace +Admin has configured, platform scope shows what the Platform Admin and the +system provide. The trade-off is discoverability — new users listing profiles +in their workspace would see nothing until a Workspace Admin imports profiles, +which could be confusing for single-player deployments. + +### Workspace-scoped settings + +Runtime settings (`providers_v2_enabled`, `ocsf_json_enabled`, +`agent_policy_proposals_enabled`, `proposal_approval_mode`) currently use +two-tier resolution: a gateway-global value overrides per-sandbox values. +A Workspace Admin who wants to enable agent policy proposals or set auto- +approval mode for their workspace must ask a Platform Admin to set it +globally, or configure it on each sandbox individually. + +A workspace-scoped settings tier would sit between gateway-global and +per-sandbox: gateway-global > workspace > sandbox. This would let Workspace +Admins control operational knobs for their workspace without Platform Admin +involvement and without per-sandbox configuration burden. The resolution +chain would check gateway-global first (Platform Admin override), then +workspace (Workspace Admin default for the workspace), then sandbox +(per-sandbox override), then the built-in default. + +Design considerations: + +- Some settings are inherently gateway-wide feature flags + (`providers_v2_enabled`) and may not make sense at workspace scope. The + settings registry could gain a `scopes` field indicating which tiers a + setting participates in. +- The `UpdateConfig` RPC currently distinguishes `global: true` from + sandbox-targeted updates. A workspace tier would need a third targeting + mode in the request, or workspace settings could use a separate RPC. +- Settings resolution already loads both global and sandbox settings on every + `GetSandboxConfig` call. Adding a workspace tier adds one more store read + per config fetch. Caching or batching may be needed at scale. + +### Machine identity via OIDC workload identity + +CI/CD pipelines, agent harnesses, and other machine workloads could +authenticate to the gateway using OIDC workload identity tokens issued by +their platform (GitHub Actions, GitLab CI, GCP, AWS). The gateway would +validate these tokens and resolve the token's `sub` claim to a workspace +membership, following the same `authorize_workspace` path as human principals. +No stored API keys or service account secrets would be required. + +The most common deployment — human users authenticating via corporate SSO +and machine workloads authenticating via CI/CD platform OIDC — requires +multi-provider OIDC support (see Non-goals). The workspace and membership +model introduced in this RFC is designed to support workload identity without +changes: a machine workload's OIDC subject is added as a workspace member +with the User role, and the standard authorization path applies. + +### SandboxStatus portability + +`SandboxStatus` exposes platform-specific coordinates (`sandbox_name`, +`agent_pod`, `agent_fd`, `sandbox_fd`) that are informational and +display-oriented today. These fields are always accessed in the context of a +parent `Sandbox` record that carries the workspace, so they do not need +workspace context themselves. + +As workspace-to-namespace mapping modes evolve (shared namespace, dedicated +namespace per workspace, operator mode with pre-provisioned namespaces), +revisit whether these fields remain portable or need to become abstract and +self-describing regardless of the active mapping method. In particular: + +- `sandbox_name` currently holds the bare user-facing name. In shared-namespace + mode the Kubernetes resource name differs (`{workspace}--{name}`), but the + status field intentionally carries the bare name for display. If + namespace-per-workspace mode is added, this distinction may become moot. +- `agent_pod` is operationally critical — it correlates supervisor connections + to specific pods. Its value is Kubernetes-specific and may need an + abstraction layer for non-Kubernetes drivers. +- `agent_fd` and `sandbox_fd` are currently unused by consumers. Evaluate + whether to remove them rather than carry them forward. + +## Alternatives + +### Flat label-based tenancy (no workspaces) + +Use labels alone for all isolation, without a first-class workspace concept. +Users would filter by label, and access control would use label selectors. + +This was rejected because labels are a soft grouping mechanism with no +enforcement guarantee. A mislabeled resource would be visible across tenant +boundaries. Hard isolation requires a first-class field that the system enforces +at every access point, not a convention that depends on correct labeling. + +### One gateway per team + +Instead of multi-tenancy, deploy separate gateways per team. This provides +complete isolation by default. + +This was rejected because it creates operational overhead (N gateways to +manage), prevents resource sharing across teams, and makes cross-team +collaboration impossible. It also pushes the multi-tenancy problem to the +infrastructure layer without solving it. In practice, even within a single +team, individual members typically have private per-user API keys for services +like Claude or Codex that they cannot share with teammates. This pushes +team-level deployments toward per-user gateways, compounding the operational +cost. The multi-player proposal mitigates this by giving each user their own +workspace on a shared gateway for credential isolation, while allowing teams +to share workspaces for collaboration where appropriate. + +### OPA/Rego for authorization + +Use a policy language like OPA/Rego for fine-grained authorization decisions +instead of role-based access control. + +This was considered but deferred. The current need is coarse-grained role-based +isolation, not attribute-based policy evaluation. OPA/Rego authorization could +be layered on top of the workspace and role model in a future RFC if +fine-grained policies are needed. + +## Prior art + +- **Kubernetes namespaces and RBAC.** The workspace model draws from Kubernetes + conventions: hard isolation boundaries, labels for soft grouping, and RBAC + with role bindings scoped to boundaries. The term "workspace" is intentionally + distinct from "Kubernetes namespace" to avoid conflation — an OpenShell + workspace may map to a Kubernetes namespace, but the concepts are independent. + +- **GitHub organizations and teams.** GitHub's model of organizations + (workspaces) with teams (label-based grouping) and per-repo role assignments + informed the separation between hard boundaries and soft grouping. + +- **AWS IAM.** AWS's account-level isolation with IAM roles and policies within + accounts informed the quota and credential scoping model. The lesson is that + hard account boundaries with delegated administration scales better than + flat permission models. + +## Appendix: End-to-End Workspace Lifecycle + +This walkthrough traces a workspace from creation through sandbox teardown, +showing the authorization check at each step. + +**1. Platform Admin creates a workspace.** + + CreateWorkspace { name: "team-ml" } + → global_role = "platform_admin" → pass + → persist Workspace { name: "team-ml" } + +**2. Platform Admin adds a Workspace Admin.** + + AddWorkspaceMember { workspace: "team-ml", subject: "bob@corp.example.com", role: WORKSPACE_ADMIN } + → global_role = "platform_admin" → bypass membership check → pass + → persist membership ("team-ml", "bob@corp.example.com", Admin) + +**3. Workspace Admin adds a User.** + + AddWorkspaceMember { workspace: "team-ml", subject: "alice@corp.example.com", role: USER } + → authorize_workspace("team-ml", WorkspaceRole::Admin) + → lookup ("team-ml", "bob@corp.example.com") → Admin → pass + → validate: Workspace Admins cannot assign the Admin role → USER is allowed + → persist membership ("team-ml", "alice@corp.example.com", User) + +**4. Workspace Admin adds a provider.** + + CreateProvider { workspace: "team-ml", name: "claude-key", type: "claude", credentials: {...} } + → authorize_workspace("team-ml", WorkspaceRole::Admin) → pass + → persist Provider { workspace: "team-ml", name: "claude-key" } + +**5. User creates a sandbox.** + + CreateSandbox { workspace: "team-ml", name: "my-sandbox", providers: ["claude-key"] } + → authorize_workspace("team-ml", WorkspaceRole::User) + → lookup ("team-ml", "alice@corp.example.com") → User → pass + → validate provider "claude-key" exists in "team-ml" → yes + → resolve effective policy: gateway default + provider profiles for ["claude-key"] + → persist Sandbox { workspace: "team-ml", name: "my-sandbox" } + → dispatch to driver (K8s managed → namespace "openshell-prod-team-ml") + +**6. User deletes the sandbox.** + + DeleteSandbox { workspace: "team-ml", name: "my-sandbox" } + → authorize_workspace("team-ml", WorkspaceRole::User) → pass + → drain and delete sandbox + → dispatch delete to driver + +Membership records are stored in the durable object store as a +`(workspace, principal_subject) → role` mapping, separate from the Workspace +resource itself. This follows the Kubernetes pattern where RoleBindings are +independent resources, not properties of the Namespace object. + +### Sandbox Supervisor Lifecycle + +The sandbox supervisor uses a separate authentication path from user roles. +This walkthrough continues from step 5 above, showing how the supervisor +bootstraps and operates scoped to a single sandbox. + +**7. Gateway mints a sandbox JWT at creation time.** + + CreateSandbox → persist sandbox (uuid-a) → mint JWT: + JWT { sub: "spiffe://openshell/sandbox/uuid-a", sandbox_id: "uuid-a" } + → token injected into container/pod via compute driver + +**8. Supervisor connects to the gateway.** + + ConnectSupervisor (bidirectional stream) + Auth: Bearer + → SandboxJwtAuthenticator → Principal::Sandbox { sandbox_id: "uuid-a" } + → router: is_sandbox_callable("ConnectSupervisor") → yes + → supervisor sends SupervisorHello { sandbox_id: "uuid-a" } + → ensure_sandbox_principal_scope: JWT sandbox_id == hello sandbox_id → pass + → register session, send SessionAccepted, notify driver: sandbox ready + +**9. Supervisor fetches provider credentials.** + + GetSandboxProviderEnvironment { sandbox_id: "uuid-a" } + → enforce_sandbox_scope: JWT sandbox_id == request sandbox_id → pass + → gateway resolves providers for sandbox uuid-a (workspace-internal lookup) + → return { ANTHROPIC_API_KEY: "sk-...", ... } + +**10. Cross-sandbox and cross-principal access is rejected.** + + Supervisor-A → GetSandboxProviderEnvironment { sandbox_id: "uuid-b" } + → enforce_sandbox_scope: "uuid-a" != "uuid-b" → PERMISSION_DENIED + + Supervisor-A → ListSandboxes { workspace: "team-ml" } + → router: is_sandbox_callable("ListSandboxes") → false → PERMISSION_DENIED + +**11. Supervisor learns its workspace from the config response.** + + GetSandboxConfig { sandbox_id: "uuid-a" } + → response includes workspace: "team-ml" + → supervisor caches workspace for subsequent RPCs + +The supervisor discovers its workspace from the `workspace` field in +`GetSandboxConfigResponse`, returned by its first settings poll. It caches +this value and uses it for workspace-scoped RPCs such as `UpdateConfig` +(policy sync), `SubmitPolicyAnalysis`, and `GetDraftPolicy`. The supervisor's +authorization surface remains a single sandbox UUID — the workspace is used +only to scope resource lookups, not for access control. + From f0e32dd6d767996e2b5c22ddb6dbb433b6399c9d Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Tue, 14 Jul 2026 14:18:11 -0400 Subject: [PATCH 02/14] feat(sdk): add workspace support to Python SDK Add workspace as a required parameter for resource-scoped operations (create, get, delete, wait_ready, wait_deleted) and optional for list operations that support all_workspaces global lookup. Rename ClusterInferenceConfig to InferenceRouteConfig and set_cluster/get_cluster to set_route/get_route to match the renamed proto RPCs. Signed-off-by: Derek Carr --- python/openshell/__init__.py | 4 +- python/openshell/sandbox.py | 99 ++++++++++----- python/openshell/sandbox_test.py | 204 ++++++++++++++++++++++++++++--- 3 files changed, 255 insertions(+), 52 deletions(-) diff --git a/python/openshell/__init__.py b/python/openshell/__init__.py index 94a391d4ef..39200158d8 100644 --- a/python/openshell/__init__.py +++ b/python/openshell/__init__.py @@ -6,10 +6,10 @@ from __future__ import annotations from .sandbox import ( - ClusterInferenceConfig, ExecChunk, ExecResult, InferenceRouteClient, + InferenceRouteConfig, Sandbox, SandboxClient, SandboxError, @@ -27,10 +27,10 @@ __version__ = "0.0.0" __all__ = [ - "ClusterInferenceConfig", "ExecChunk", "ExecResult", "InferenceRouteClient", + "InferenceRouteConfig", "Sandbox", "SandboxClient", "SandboxError", diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 9b00ebfb70..68eaa2b484 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -159,6 +159,7 @@ def __reduce_ex__(self, protocol: SupportsIndex, /) -> str | tuple[Any, ...]: class SandboxRef: id: str name: str + workspace: str status: SandboxStatusRef # Excluded from equality/hash to preserve the original identity while the # immutable mapping remains safe for deepcopy, pickle, and asdict. @@ -197,6 +198,7 @@ class SandboxSession: def __init__(self, client: SandboxClient, sandbox: SandboxRef) -> None: self._client = client self.sandbox = sandbox + self._workspace = sandbox.workspace @property def id(self) -> str: @@ -245,7 +247,7 @@ def exec_python( ) def delete(self) -> bool: - return self._client.delete(self.sandbox.name) + return self._client.delete(self.sandbox.name, workspace=self._workspace) class SandboxClient: @@ -427,6 +429,7 @@ def health(self) -> openshell_pb2.HealthResponse: def create( self, *, + workspace: str, spec: openshell_pb2.SandboxSpec | None = None, name: str | None = None, labels: Mapping[str, str] | None = None, @@ -437,6 +440,7 @@ def create( spec=request_spec, name=name or "", labels=dict(labels) if labels else {}, + workspace=workspace, ), timeout=self._timeout, ) @@ -448,42 +452,54 @@ def create( def create_session( self, *, + workspace: str, spec: openshell_pb2.SandboxSpec | None = None, name: str | None = None, labels: Mapping[str, str] | None = None, ) -> SandboxSession: - return SandboxSession(self, self.create(spec=spec, name=name, labels=labels)) + return SandboxSession( + self, self.create(workspace=workspace, spec=spec, name=name, labels=labels) + ) - def get(self, sandbox_name: str) -> SandboxRef: + def get(self, sandbox_name: str, *, workspace: str) -> SandboxRef: response = self._stub.GetSandbox( - openshell_pb2.GetSandboxRequest(name=sandbox_name), + openshell_pb2.GetSandboxRequest(name=sandbox_name, workspace=workspace), timeout=self._timeout, ) return _sandbox_ref(response.sandbox) - def get_session(self, sandbox_name: str) -> SandboxSession: - return SandboxSession(self, self.get(sandbox_name)) + def get_session(self, sandbox_name: str, *, workspace: str) -> SandboxSession: + return SandboxSession(self, self.get(sandbox_name, workspace=workspace)) def list( self, *, + workspace: str | None = None, limit: int = 100, offset: int = 0, label_selector: str | None = None, ) -> builtins.list[SandboxRef]: - response = self._stub.ListSandboxes( - openshell_pb2.ListSandboxesRequest( + if workspace is not None: + request = openshell_pb2.ListSandboxesRequest( + workspace=workspace, limit=limit, offset=offset, label_selector=label_selector or "", - ), - timeout=self._timeout, - ) + ) + else: + request = openshell_pb2.ListSandboxesRequest( + all_workspaces=True, + limit=limit, + offset=offset, + label_selector=label_selector or "", + ) + response = self._stub.ListSandboxes(request, timeout=self._timeout) return [_sandbox_ref(item) for item in response.sandboxes] def list_ids( self, *, + workspace: str | None = None, limit: int = 100, offset: int = 0, label_selector: str | None = None, @@ -491,22 +507,27 @@ def list_ids( return [ item.id for item in self.list( - limit=limit, offset=offset, label_selector=label_selector + workspace=workspace, + limit=limit, + offset=offset, + label_selector=label_selector, ) ] - def delete(self, sandbox_name: str) -> bool: + def delete(self, sandbox_name: str, *, workspace: str) -> bool: response = self._stub.DeleteSandbox( - openshell_pb2.DeleteSandboxRequest(name=sandbox_name), + openshell_pb2.DeleteSandboxRequest(name=sandbox_name, workspace=workspace), timeout=self._timeout, ) return bool(response.deleted) - def wait_deleted(self, sandbox_name: str, *, timeout_seconds: float = 60.0) -> None: + def wait_deleted( + self, sandbox_name: str, *, workspace: str, timeout_seconds: float = 60.0 + ) -> None: deadline = time.time() + timeout_seconds while time.time() < deadline: try: - self.get(sandbox_name) + self.get(sandbox_name, workspace=workspace) except grpc.RpcError as exc: if ( isinstance(exc, grpc.Call) @@ -518,11 +539,11 @@ def wait_deleted(self, sandbox_name: str, *, timeout_seconds: float = 60.0) -> N raise SandboxError(f"sandbox {sandbox_name} was not deleted within timeout") def wait_ready( - self, sandbox_name: str, *, timeout_seconds: float = 300.0 + self, sandbox_name: str, *, workspace: str, timeout_seconds: float = 300.0 ) -> SandboxRef: deadline = time.time() + timeout_seconds while time.time() < deadline: - sandbox = self.get(sandbox_name) + sandbox = self.get(sandbox_name, workspace=workspace) if sandbox.status.phase == openshell_pb2.SANDBOX_PHASE_READY: return sandbox if sandbox.status.phase == openshell_pb2.SANDBOX_PHASE_ERROR: @@ -646,14 +667,14 @@ def exec_python( @dataclass(frozen=True) -class ClusterInferenceConfig: +class InferenceRouteConfig: provider_name: str model_id: str version: int class InferenceRouteClient: - """gRPC client for cluster-level inference configuration.""" + """gRPC client for workspace-scoped inference route configuration.""" def __init__(self, channel: grpc.Channel, *, timeout: float = 30.0) -> None: self._stub = inference_pb2_grpc.InferenceStub(channel) @@ -663,33 +684,35 @@ def __init__(self, channel: grpc.Channel, *, timeout: float = 30.0) -> None: def from_sandbox_client(cls, client: SandboxClient) -> InferenceRouteClient: return cls(client._channel, timeout=client._timeout) - def set_cluster( + def set_route( self, *, + workspace: str, provider_name: str, model_id: str, no_verify: bool = False, - ) -> ClusterInferenceConfig: - response = self._stub.SetClusterInference( - inference_pb2.SetClusterInferenceRequest( + ) -> InferenceRouteConfig: + response = self._stub.SetInferenceRoute( + inference_pb2.SetInferenceRouteRequest( + workspace=workspace, provider_name=provider_name, model_id=model_id, no_verify=no_verify, ), timeout=self._timeout, ) - return ClusterInferenceConfig( + return InferenceRouteConfig( provider_name=response.provider_name, model_id=response.model_id, version=response.version, ) - def get_cluster(self) -> ClusterInferenceConfig: - response = self._stub.GetClusterInference( - inference_pb2.GetClusterInferenceRequest(), + def get_route(self, *, workspace: str) -> InferenceRouteConfig: + response = self._stub.GetInferenceRoute( + inference_pb2.GetInferenceRouteRequest(workspace=workspace), timeout=self._timeout, ) - return ClusterInferenceConfig( + return InferenceRouteConfig( provider_name=response.provider_name, model_id=response.model_id, version=response.version, @@ -702,6 +725,7 @@ class Sandbox: def __init__( self, *, + workspace: str = "default", cluster: str | None = None, sandbox: str | SandboxRef | None = None, delete_on_exit: bool = True, @@ -723,6 +747,7 @@ def __init__( OIDC-protected gateways (e.g. passing `insecure=True` for a self-signed dev IdP). Non-OIDC gateways ignore them. """ + self._workspace = workspace self._cluster = cluster self._sandbox_input = sandbox self._delete_on_exit = delete_on_exit @@ -771,15 +796,21 @@ def __enter__(self) -> Sandbox: if self._sandbox_input is None: self._session = client.create_session( - spec=self._spec, name=self._name, labels=self._labels + workspace=self._workspace, + spec=self._spec, + name=self._name, + labels=self._labels, ) elif isinstance(self._sandbox_input, SandboxRef): self._session = SandboxSession(client, self._sandbox_input) else: - self._session = client.get_session(self._sandbox_input) + self._session = client.get_session( + self._sandbox_input, workspace=self._workspace + ) ready = client.wait_ready( self._session.sandbox.name, + workspace=self._workspace, timeout_seconds=self._ready_timeout_seconds, ) self._session = SandboxSession(client, ready) @@ -796,7 +827,10 @@ def __exit__(self, *args: object) -> None: try: deleted = self._session.delete() if deleted: - self._client.wait_deleted(self._session.sandbox.name) + self._client.wait_deleted( + self._session.sandbox.name, + workspace=self._workspace, + ) except grpc.RpcError as exc: if ( not isinstance(exc, grpc.Call) @@ -885,6 +919,7 @@ def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef: return SandboxRef( id=sandbox.metadata.id if sandbox.metadata else "", name=sandbox.metadata.name if sandbox.metadata else "", + workspace=sandbox.metadata.workspace if sandbox.metadata else "", status=SandboxStatusRef( phase=status.phase if status else 0, current_policy_version=status.current_policy_version if status else 0, diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index 700a9d1ed6..75dd35281e 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -55,10 +55,11 @@ def ExecSandbox( class _FakeInferenceStub: def __init__(self) -> None: - self.request = None + self.set_request = None + self.get_request = None - def SetClusterInference(self, request: Any, timeout: float | None = None) -> Any: - self.request = request + def SetInferenceRoute(self, request: Any, timeout: float | None = None) -> Any: + self.set_request = request _ = timeout class _Response: @@ -68,6 +69,17 @@ class _Response: return _Response() + def GetInferenceRoute(self, request: Any, timeout: float | None = None) -> Any: + self.get_request = request + _ = timeout + + class _Response: + provider_name = "openai-dev" + model_id = "gpt-4.1" + version = 2 + + return _Response() + def _client_with_fake_stub(stub: object) -> SandboxClient: client = cast("SandboxClient", object.__new__(SandboxClient)) @@ -1341,20 +1353,37 @@ def fake_from_active_cluster(**kwargs: Any) -> Any: assert captured["insecure"] is False -def test_inference_set_cluster_forwards_no_verify_flag() -> None: +def test_inference_set_route_forwards_workspace_and_no_verify() -> None: stub = _FakeInferenceStub() client = cast("InferenceRouteClient", object.__new__(InferenceRouteClient)) client._timeout = 30.0 client._stub = cast("Any", stub) - client.set_cluster( + client.set_route( + workspace="production", provider_name="openai-dev", model_id="gpt-4.1", no_verify=True, ) - assert stub.request is not None - assert stub.request.no_verify is True + assert stub.set_request is not None + assert stub.set_request.no_verify is True + assert stub.set_request.workspace == "production" + + +def test_inference_get_route_forwards_workspace() -> None: + stub = _FakeInferenceStub() + client = cast("InferenceRouteClient", object.__new__(InferenceRouteClient)) + client._timeout = 30.0 + client._stub = cast("Any", stub) + + config = client.get_route(workspace="staging") + + assert stub.get_request is not None + assert stub.get_request.workspace == "staging" + assert config.provider_name == "openai-dev" + assert config.model_id == "gpt-4.1" + assert config.version == 2 # --------------------------------------------------------------------------- @@ -1433,10 +1462,12 @@ def _make_sandbox_proto( labels: dict[str, str] | None = None, phase: openshell_pb2.SandboxPhase = openshell_pb2.SANDBOX_PHASE_READY, version: int = 0, + workspace: str = "default", ) -> openshell_pb2.Sandbox: sandbox = openshell_pb2.Sandbox() sandbox.metadata.id = id_ sandbox.metadata.name = name + sandbox.metadata.workspace = workspace for key, value in (labels or {}).items(): sandbox.metadata.labels[key] = value sandbox.status.phase = phase @@ -1448,8 +1479,32 @@ class _FakeSandboxStub: def __init__(self, listed: list[openshell_pb2.Sandbox] | None = None) -> None: self.create_request: openshell_pb2.CreateSandboxRequest | None = None self.list_request: openshell_pb2.ListSandboxesRequest | None = None + self.get_request: openshell_pb2.GetSandboxRequest | None = None + self.delete_request: openshell_pb2.DeleteSandboxRequest | None = None self._listed = listed or [] + def GetSandbox( + self, + request: openshell_pb2.GetSandboxRequest, + timeout: float | None = None, + ) -> Any: + self.get_request = request + _ = timeout + return SimpleNamespace( + sandbox=_make_sandbox_proto( + "sandbox-1", request.name, workspace=request.workspace or "default" + ) + ) + + def DeleteSandbox( + self, + request: openshell_pb2.DeleteSandboxRequest, + timeout: float | None = None, + ) -> Any: + self.delete_request = request + _ = timeout + return SimpleNamespace(deleted=True) + def CreateSandbox( self, request: openshell_pb2.CreateSandboxRequest, @@ -1459,7 +1514,10 @@ def CreateSandbox( _ = timeout return SimpleNamespace( sandbox=_make_sandbox_proto( - "sandbox-1", request.name or "generated", dict(request.labels) + "sandbox-1", + request.name or "generated", + dict(request.labels), + workspace=request.workspace or "default", ) ) @@ -1482,18 +1540,27 @@ def __init__(self) -> None: def create_session( self, *, + workspace: str, spec: Any = None, name: str | None = None, labels: Any = None, ) -> Any: - self.create_kwargs = {"spec": spec, "name": name, "labels": labels} + self.create_kwargs = { + "workspace": workspace, + "spec": spec, + "name": name, + "labels": labels, + } return SimpleNamespace(sandbox=SimpleNamespace(name=name or "generated")) - def wait_ready(self, name: str, *, timeout_seconds: float = 300.0) -> SandboxRef: + def wait_ready( + self, name: str, *, workspace: str, timeout_seconds: float = 300.0 + ) -> SandboxRef: _ = timeout_seconds return SandboxRef( id="sandbox-1", name=name, + workspace=workspace, status=SandboxStatusRef(phase=2, current_policy_version=0), ) @@ -1502,7 +1569,9 @@ def test_create_forwards_name_and_labels() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - ref = client.create(name="job-1", labels={"aiq": "deep-research"}) + ref = client.create( + workspace="default", name="job-1", labels={"aiq": "deep-research"} + ) assert stub.create_request is not None assert stub.create_request.name == "job-1" @@ -1514,11 +1583,12 @@ def test_create_without_args_sends_empty_metadata() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - client.create() + client.create(workspace="default") assert stub.create_request is not None assert stub.create_request.name == "" assert dict(stub.create_request.labels) == {} + assert stub.create_request.workspace == "default" def test_create_copies_caller_labels() -> None: @@ -1526,7 +1596,7 @@ def test_create_copies_caller_labels() -> None: client = _client_with_fake_stub(stub) caller_labels = {"aiq": "deep-research"} - client.create(labels=caller_labels) + client.create(workspace="default", labels=caller_labels) caller_labels["aiq"] = "mutated" assert stub.create_request is not None @@ -1537,7 +1607,9 @@ def test_create_session_forwards_name_and_labels() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - session = client.create_session(name="job-2", labels={"team": "aiq"}) + session = client.create_session( + workspace="default", name="job-2", labels={"team": "aiq"} + ) assert stub.create_request is not None assert stub.create_request.name == "job-2" @@ -1549,17 +1621,18 @@ def test_list_forwards_label_selector() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - client.list(label_selector="aiq=deep-research") + client.list(workspace="default", label_selector="aiq=deep-research") assert stub.list_request is not None assert stub.list_request.label_selector == "aiq=deep-research" + assert stub.list_request.workspace == "default" def test_list_without_selector_sends_empty_string() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - client.list() + client.list(workspace="default") assert stub.list_request is not None assert stub.list_request.label_selector == "" @@ -1569,7 +1642,7 @@ def test_list_ids_forwards_label_selector() -> None: stub = _FakeSandboxStub(listed=[_make_sandbox_proto("sandbox-1", "job-1")]) client = _client_with_fake_stub(stub) - ids = client.list_ids(label_selector="aiq=deep-research") + ids = client.list_ids(workspace="default", label_selector="aiq=deep-research") assert stub.list_request is not None assert stub.list_request.label_selector == "aiq=deep-research" @@ -1598,6 +1671,7 @@ def test_direct_sandbox_ref_construction_defaults_labels() -> None: ref = SandboxRef( id="sandbox-1", name="job-1", + workspace="default", status=SandboxStatusRef(phase=2, current_policy_version=0), ) @@ -1629,6 +1703,7 @@ def test_default_sandbox_ref_labels_support_standard_serialization() -> None: ref = SandboxRef( id="sandbox-1", name="job-1", + workspace="default", status=SandboxStatusRef(phase=2, current_policy_version=0), ) @@ -1642,6 +1717,7 @@ def test_direct_sandbox_ref_copies_and_freezes_labels() -> None: ref = SandboxRef( id="sandbox-1", name="job-1", + workspace="default", status=SandboxStatusRef(phase=2, current_policy_version=0), labels=labels, ) @@ -1663,11 +1739,15 @@ def test_high_level_creation_forwards_name_and_labels( ) sandbox = Sandbox( - name="job-1", labels={"aiq": "deep-research"}, delete_on_exit=False + workspace="staging", + name="job-1", + labels={"aiq": "deep-research"}, + delete_on_exit=False, ) sandbox.__enter__() assert recording.create_kwargs == { + "workspace": "staging", "spec": None, "name": "job-1", "labels": {"aiq": "deep-research"}, @@ -1685,9 +1765,97 @@ def test_high_level_attach_rejects_labels() -> None: ref = SandboxRef( id="sandbox-1", name="existing", + workspace="default", status=SandboxStatusRef(phase=2, current_policy_version=0), ) sandbox = Sandbox(sandbox=ref, labels={"aiq": "deep-research"}) with pytest.raises(SandboxError): sandbox.__enter__() + + +# --------------------------------------------------------------------------- +# Workspace support +# --------------------------------------------------------------------------- + + +def test_create_passes_workspace_to_proto() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + + ref = client.create(workspace="staging", name="job-1") + + assert stub.create_request is not None + assert stub.create_request.workspace == "staging" + assert ref.workspace == "staging" + + +def test_get_passes_workspace_to_proto() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + + ref = client.get("job-1", workspace="production") + + assert stub.get_request is not None + assert stub.get_request.workspace == "production" + assert ref.workspace == "production" + + +def test_delete_passes_workspace_to_proto() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + + result = client.delete("job-1", workspace="staging") + + assert result is True + assert stub.delete_request is not None + assert stub.delete_request.workspace == "staging" + + +def test_list_without_workspace_sets_all_workspaces() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + + client.list() + + assert stub.list_request is not None + assert stub.list_request.all_workspaces is True + assert stub.list_request.workspace == "" + + +def test_list_with_workspace_passes_workspace() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + + client.list(workspace="staging") + + assert stub.list_request is not None + assert stub.list_request.workspace == "staging" + assert stub.list_request.all_workspaces is False + + +def test_sandbox_ref_includes_workspace_from_proto() -> None: + proto = _make_sandbox_proto("sandbox-1", "job-1", workspace="production") + + ref = _sandbox_ref(proto) + + assert ref.workspace == "production" + + +def test_sandbox_session_delete_passes_workspace() -> None: + from openshell.sandbox import SandboxSession + + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + ref = SandboxRef( + id="sandbox-1", + name="job-1", + workspace="staging", + status=SandboxStatusRef(phase=2, current_policy_version=0), + ) + session = SandboxSession(client, ref) + + session.delete() + + assert stub.delete_request is not None + assert stub.delete_request.workspace == "staging" From 78dabe21aad042611a1b3070f5afe09120d62a72 Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Tue, 14 Jul 2026 19:38:03 -0400 Subject: [PATCH 03/14] fix(workspace): address review findings from workspace model - Backfill provider_credential_refresh_state in workspace migration - Set requires_workspace() to true for refresh state - Reject consecutive hyphens in workspace and sandbox names - Restore parse_host rejection of consecutive hyphens in all segments - Pass workspace from SSH session metadata in put_if call - Require explicit workspace in Python SDK Sandbox() constructor - Add sandbox name character validation matching workspace rules Signed-off-by: Derek Carr --- crates/openshell-core/src/metadata.rs | 2 +- .../postgres/006_add_workspace_column.sql | 2 +- .../sqlite/006_add_workspace_column.sql | 2 +- crates/openshell-server/src/grpc/sandbox.rs | 2 +- .../openshell-server/src/grpc/validation.rs | 43 ++++++++++++++ crates/openshell-server/src/grpc/workspace.rs | 11 ++++ .../openshell-server/src/service_routing.rs | 58 +++++++++++++------ python/openshell/sandbox.py | 2 +- python/openshell/sandbox_test.py | 7 ++- 9 files changed, 103 insertions(+), 26 deletions(-) diff --git a/crates/openshell-core/src/metadata.rs b/crates/openshell-core/src/metadata.rs index 5a48b8b37c..8794c11d5d 100644 --- a/crates/openshell-core/src/metadata.rs +++ b/crates/openshell-core/src/metadata.rs @@ -269,7 +269,7 @@ impl ObjectWorkspace for StoredProviderCredentialRefreshState { self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) } fn requires_workspace() -> bool { - false + true } } diff --git a/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql index b67eee2e98..01d3e7078a 100644 --- a/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql +++ b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql @@ -4,7 +4,7 @@ ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; -- Backfill workspace-scoped object types to 'default'. UPDATE objects SET workspace = 'default' WHERE workspace = '' AND name IS NOT NULL - AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session'); + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state'); -- Replace global name uniqueness with workspace-scoped uniqueness. DROP INDEX IF EXISTS objects_name_uq; diff --git a/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql index b67eee2e98..01d3e7078a 100644 --- a/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql +++ b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql @@ -4,7 +4,7 @@ ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; -- Backfill workspace-scoped object types to 'default'. UPDATE objects SET workspace = 'default' WHERE workspace = '' AND name IS NOT NULL - AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session'); + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state'); -- Replace global name uniqueness with workspace-scoped uniqueness. DROP INDEX IF EXISTS objects_name_uq; diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index b03f9d431e..c9d33095e5 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -1433,7 +1433,7 @@ pub(super) async fn handle_create_ssh_session( SshSession::object_type(), &token, session.object_name(), - "", + session.object_workspace(), &session.encode_to_vec(), None, WriteCondition::MustCreate, diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 49bd48754a..8d86800922 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -115,6 +115,26 @@ pub(super) fn validate_sandbox_spec( name.len() ))); } + if !name.is_empty() { + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(Status::invalid_argument( + "name must contain only lowercase alphanumeric characters or hyphens", + )); + } + if name.starts_with('-') || name.ends_with('-') { + return Err(Status::invalid_argument( + "name must not start or end with a hyphen", + )); + } + if name.contains("--") { + return Err(Status::invalid_argument( + "name must not contain consecutive hyphens", + )); + } + } // --- spec.providers --- if spec.providers.len() > MAX_PROVIDERS { @@ -871,6 +891,29 @@ mod tests { assert!(err.message().contains("name")); } + #[test] + fn validate_sandbox_spec_rejects_uppercase_name() { + let err = validate_sandbox_spec("MySandbox", &default_spec()).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_sandbox_spec_rejects_leading_hyphen_name() { + let err = validate_sandbox_spec("-sandbox", &default_spec()).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_sandbox_spec_rejects_consecutive_hyphens_name() { + let err = validate_sandbox_spec("my--sandbox", &default_spec()).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_sandbox_spec_accepts_single_hyphens_name() { + assert!(validate_sandbox_spec("my-sandbox", &default_spec()).is_ok()); + } + #[test] fn validate_sandbox_spec_accepts_at_limit_providers() { let spec = SandboxSpec { diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index d70ff92979..43d28e03f0 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -63,6 +63,11 @@ fn validate_workspace_name(name: &str) -> Result<(), Status> { "workspace name must not start or end with a hyphen", )); } + if name.contains("--") { + return Err(Status::invalid_argument( + "workspace name must not contain consecutive hyphens", + )); + } Ok(()) } @@ -741,4 +746,10 @@ mod tests { let err = validate_workspace_name("-workspace").unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument); } + + #[test] + fn validate_workspace_name_rejects_consecutive_hyphens() { + let err = validate_workspace_name("team--ml").unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } } diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index efda30c427..e90f15cdbd 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -100,26 +100,26 @@ pub fn parse_host(host: &str, config: &ServiceRoutingConfig) -> Option<(String, let Some(encoded) = host.strip_suffix(&expected_suffix) else { continue; }; - let parts: Vec<&str> = encoded.splitn(3, "--").collect(); - return match parts.len() { - 2 => { - if parts[0].is_empty() || parts[1].is_empty() { - return None; - } - Some((parts[0].to_string(), parts[1].to_string(), String::new())) - } - 3 => { - if parts[0].is_empty() || parts[1].is_empty() || parts[2].is_empty() { - return None; - } - Some(( - parts[0].to_string(), - parts[1].to_string(), - parts[2].to_string(), - )) + let (workspace, rest) = encoded.split_once("--")?; + if workspace.is_empty() || workspace.contains("--") { + return None; + } + let (sandbox, service) = if let Some((sandbox, service)) = rest.split_once("--") { + if service.is_empty() || service.contains("--") { + return None; } - _ => None, + (sandbox, service) + } else { + (rest, "") }; + if sandbox.is_empty() || sandbox.contains("--") { + return None; + } + return Some(( + workspace.to_string(), + sandbox.to_string(), + service.to_string(), + )); } None } @@ -1005,6 +1005,28 @@ mod tests { ); } + #[test] + fn rejects_consecutive_hyphens_in_workspace() { + assert_eq!( + parse_host( + "team--ml--my-sandbox--web.dev.openshell.localhost", + &config() + ), + None + ); + } + + #[test] + fn rejects_consecutive_hyphens_in_service() { + assert_eq!( + parse_host( + "default--my-sandbox--web--app.dev.openshell.localhost", + &config() + ), + None + ); + } + #[test] fn identifies_sandbox_service_request_from_host_header() { let request = Request::builder() diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 68eaa2b484..774383332b 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -725,7 +725,7 @@ class Sandbox: def __init__( self, *, - workspace: str = "default", + workspace: str, cluster: str | None = None, sandbox: str | SandboxRef | None = None, delete_on_exit: bool = True, diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index 75dd35281e..d0579af50c 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -1305,6 +1305,7 @@ def fake_from_active_cluster(**kwargs: Any) -> Any: ) sandbox = Sandbox( + workspace="default", cluster="my-gw", timeout=42.0, auto_refresh=False, @@ -1346,7 +1347,7 @@ def fake_from_active_cluster(**kwargs: Any) -> Any: import pytest as _pytest with _pytest.raises(_Sentinel): - Sandbox().__enter__() + Sandbox(workspace="default").__enter__() assert captured["auto_refresh"] is True assert captured["write_back"] is True @@ -1755,7 +1756,7 @@ def test_high_level_creation_forwards_name_and_labels( def test_high_level_attach_rejects_name() -> None: - sandbox = Sandbox(sandbox="existing-sandbox", name="job-1") + sandbox = Sandbox(workspace="default", sandbox="existing-sandbox", name="job-1") with pytest.raises(SandboxError): sandbox.__enter__() @@ -1768,7 +1769,7 @@ def test_high_level_attach_rejects_labels() -> None: workspace="default", status=SandboxStatusRef(phase=2, current_policy_version=0), ) - sandbox = Sandbox(sandbox=ref, labels={"aiq": "deep-research"}) + sandbox = Sandbox(workspace="default", sandbox=ref, labels={"aiq": "deep-research"}) with pytest.raises(SandboxError): sandbox.__enter__() From 34fa4d2ddbc444a258028f2d036b0ab43371a5ef Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Tue, 14 Jul 2026 19:54:41 -0400 Subject: [PATCH 04/14] fix(workspace): thread workspace to flush tasks and TUI provider actions - Add watch channel to broadcast workspace from policy poll loop to denial and activity flush tasks, fixing proposals targeting the wrong workspace for non-default sandboxes - Add set_workspace() to CachedOpenShellClient for pre-seeding workspace on fresh connections - Use selected_provider_workspace() in TUI provider get/delete actions instead of current_workspace, fixing cross-workspace misrouting Signed-off-by: Derek Carr --- crates/openshell-core/src/grpc_client.rs | 6 + crates/openshell-sandbox/src/lib.rs | 148 +++++++---------------- crates/openshell-tui/src/app.rs | 30 +++++ crates/openshell-tui/src/lib.rs | 4 +- 4 files changed, 83 insertions(+), 105 deletions(-) diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 8923dbe012..99117131ba 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -842,6 +842,12 @@ impl CachedOpenShellClient { self.workspace.get().cloned().unwrap_or_default() } + /// Pre-seed the workspace without polling. The value is ignored if the + /// workspace was already learned from `poll_settings`. + pub fn set_workspace(&self, workspace: String) { + let _ = self.workspace.set(workspace); + } + /// Submit denial summaries and/or agent-authored proposals for policy analysis. /// /// Returns the gateway response so callers can surface accepted/rejected diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index d74c46ee49..2b76d266dc 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -383,79 +383,10 @@ pub async fn run_sandbox( None }; - #[cfg(target_os = "linux")] - let sidecar_control_server = if network_enabled && sidecar_network_enforcement { - if !matches!(policy.network.mode, NetworkMode::Proxy) { - return Err(miette::miette!( - "sidecar network enforcement requires proxy network mode" - )); - } - let socket = sidecar_control_socket().ok_or_else(|| { - miette::miette!( - "{} is required for sidecar topology", - openshell_core::sandbox_env::SIDECAR_CONTROL_SOCKET - ) - })?; - let proto = retained_proto.as_ref().ok_or_else(|| { - miette::miette!( - "sidecar topology requires gateway policy data for the process supervisor" - ) - })?; - let ca_paths = networking.as_ref().and_then(|n| n.ca_file_paths.clone()); - Some(sidecar_control::spawn_server( - &socket, - sidecar_control::BootstrapData { - policy_proto: proto.clone(), - provider_env_revision: provider_credentials.snapshot().revision, - provider_child_env: provider_env.clone(), - proxy_ca_cert_path: ca_paths.as_ref().map(|paths| paths.0.clone()), - proxy_ca_bundle_path: ca_paths.as_ref().map(|paths| paths.1.clone()), - }, - sidecar_expected_peer()?, - )?) - } else { - None - }; - #[cfg(not(target_os = "linux"))] - let sidecar_control_server: Option = None; - - let sidecar_control_publisher = sidecar_control_server - .as_ref() - .map(sidecar_control::ServerHandle::publisher); - - #[cfg(target_os = "linux")] - let mut sidecar_control_task = None; - - #[cfg(target_os = "linux")] - if network_enabled - && sidecar_network_enforcement - && let Some(server) = sidecar_control_server - { - let trusted_ssh_socket_path = ssh_socket_path.clone().ok_or_else(|| { - miette::miette!( - "{} is required for sidecar network topology", - openshell_core::sandbox_env::SSH_SOCKET_PATH - ) - })?; - let (entrypoint_rx, connection_task) = server.into_runtime_parts(); - sidecar_control_task = Some(connection_task); - spawn_sidecar_entrypoint_handler( - entrypoint_rx, - entrypoint_pid.clone(), - opa_engine.clone(), - retained_proto.clone(), - openshell_endpoint.clone(), - sandbox_id.clone(), - std::path::PathBuf::from(trusted_ssh_socket_path), - ); - } - - #[cfg(not(target_os = "linux"))] - if network_enabled && sidecar_network_enforcement { - return Err(miette::miette!( - "sidecar network enforcement is only supported on Linux" - )); - } + // Workspace watch: the policy poll loop learns the workspace from + // GetSandboxConfig and broadcasts it. Flush tasks read the current + // value so proposals target the correct workspace. + let (workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new()); // Spawn the denial-aggregator flush task. The aggregator drains denial // events from the proxy + bypass monitor, batches them, and ships @@ -474,15 +405,17 @@ pub async fn run_sandbox( .unwrap_or(10); let aggregator = denial_aggregator::DenialAggregator::new(rx, flush_interval_secs); + let denial_workspace_rx = workspace_rx.clone(); tokio::spawn(async move { aggregator .run(|summaries| { let endpoint = agg_endpoint.clone(); let sandbox_name = agg_name.clone(); + let workspace = denial_workspace_rx.borrow().clone(); async move { if let Err(e) = - flush_proposals_to_gateway(&endpoint, &sandbox_name, summaries).await + flush_proposals_to_gateway(&endpoint, &sandbox_name, &workspace, summaries).await { warn!(error = %e, "Failed to flush denial summaries to gateway"); } @@ -508,15 +441,17 @@ pub async fn run_sandbox( ); let aggregator = activity_aggregator::ActivityAggregator::new(rx, flush_interval_secs); + let activity_workspace_rx = workspace_rx.clone(); tokio::spawn(async move { aggregator .run(move |summary| { let endpoint = agg_endpoint.clone(); let sandbox_name = agg_name.clone(); + let workspace = activity_workspace_rx.borrow().clone(); async move { if let Err(e) = - flush_activity_to_gateway(&endpoint, &sandbox_name, summary).await + flush_activity_to_gateway(&endpoint, &sandbox_name, &workspace, summary).await { warn!(error = %e, "Failed to flush activity summary to gateway"); } @@ -555,7 +490,7 @@ pub async fn run_sandbox( ocsf_enabled: poll_ocsf_enabled, provider_credentials: poll_provider_credentials, policy_local_ctx: poll_policy_local, - sidecar_control_publisher: sidecar_control_publisher.clone(), + workspace_tx, }; tokio::spawn(async move { @@ -972,12 +907,14 @@ fn process_policy_for_topology( async fn flush_proposals_to_gateway( endpoint: &str, sandbox_name: &str, + workspace: &str, summaries: Vec, ) -> Result<()> { use openshell_core::grpc_client::CachedOpenShellClient; use openshell_core::proto::{DenialSummary, L7RequestSample}; let client = CachedOpenShellClient::connect(endpoint).await?; + client.set_workspace(workspace.to_string()); let proto_summaries: Vec = summaries .into_iter() @@ -1040,12 +977,14 @@ async fn flush_proposals_to_gateway( async fn flush_activity_to_gateway( endpoint: &str, sandbox_name: &str, + workspace: &str, summary: activity_aggregator::FlushableActivitySummary, ) -> Result<()> { use openshell_core::grpc_client::CachedOpenShellClient; use openshell_core::proto::{DenialGroupCount, NetworkActivitySummary}; let client = CachedOpenShellClient::connect(endpoint).await?; + client.set_workspace(workspace.to_string()); let proto_summary = NetworkActivitySummary { network_activity_count: summary.network_activity_count, @@ -2366,7 +2305,7 @@ struct PolicyPollLoopContext { ocsf_enabled: Arc, provider_credentials: ProviderCredentialState, policy_local_ctx: Option>, - sidecar_control_publisher: Option, + workspace_tx: tokio::sync::watch::Sender, } async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { @@ -2399,33 +2338,36 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { // policy revision the supervisor actually loaded. A mismatched result is // reconciled below instead of being recorded as already applied. match client.poll_settings(&ctx.sandbox_id).await { - Ok(result) => match initial_poll_disposition(&ctx.loaded_policy_origin, &result) { - InitialPollDisposition::Acknowledge(candidate) => { - apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); - current_config_revision = candidate.config_revision; - current_policy_hash.clone_from(&candidate.policy_hash); - current_settings = result.settings; - enqueue_policy_status( - &status_sender, - PolicyStatusUpdate::initial_loaded(&candidate), - ); - debug!( - config_revision = current_config_revision, - "Settings poll: initial policy matches loaded revision" - ); - } - InitialPollDisposition::Reconcile => pending_result = Some(result), - InitialPollDisposition::TrackOnly => { - apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); - current_config_revision = result.config_revision; - current_policy_hash = result.policy_hash.clone(); - current_settings = result.settings; - debug!( - config_revision = current_config_revision, - "Settings poll: tracking gateway config while preserving local policy override" - ); + Ok(result) => { + let _ = ctx.workspace_tx.send(client.workspace()); + match initial_poll_disposition(&ctx.loaded_policy_origin, &result) { + InitialPollDisposition::Acknowledge(candidate) => { + apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); + current_config_revision = candidate.config_revision; + current_policy_hash.clone_from(&candidate.policy_hash); + current_settings = result.settings; + enqueue_policy_status( + &status_sender, + PolicyStatusUpdate::initial_loaded(&candidate), + ); + debug!( + config_revision = current_config_revision, + "Settings poll: initial policy matches loaded revision" + ); + } + InitialPollDisposition::Reconcile => pending_result = Some(result), + InitialPollDisposition::TrackOnly => { + apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); + current_config_revision = result.config_revision; + current_policy_hash = result.policy_hash.clone(); + current_settings = result.settings; + debug!( + config_revision = current_config_revision, + "Settings poll: tracking gateway config while preserving local policy override" + ); + } } - }, + } Err(e) => { warn!(error = %e, "Settings poll: failed to fetch initial version, will retry"); } diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 241fc9decd..9dce564dd1 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -2582,6 +2582,14 @@ impl App { .unwrap_or_else(|| self.current_workspace.clone()) } + /// Get the workspace of the currently selected provider row. + pub fn selected_provider_workspace(&self) -> String { + self.provider_workspaces + .get(self.provider_selected) + .cloned() + .unwrap_or_else(|| self.current_workspace.clone()) + } + /// Get the name of the currently selected provider. pub fn selected_provider_name(&self) -> Option<&str> { self.provider_names @@ -3018,4 +3026,26 @@ mod tests { let result = workspaces.get(selected).unwrap_or(¤t); assert_eq!(*result, "default"); } + + // -- selected_provider_workspace ---------------------------------------- + + #[test] + fn selected_provider_workspace_returns_per_row_value() { + let workspaces = ["default", "beta", "staging"]; + let selected: usize = 1; + let current = "default"; + + let result = workspaces.get(selected).unwrap_or(¤t); + assert_eq!(*result, "beta"); + } + + #[test] + fn selected_provider_workspace_falls_back_to_current() { + let workspaces: &[&str] = &[]; + let selected: usize = 0; + let current = "default"; + + let result = workspaces.get(selected).unwrap_or(¤t); + assert_eq!(*result, "default"); + } } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 3c9ae958e2..eeeeccb5ae 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1705,7 +1705,7 @@ fn spawn_get_provider(app: &App, tx: mpsc::UnboundedSender) { Some(n) => n.to_string(), None => return, }; - let workspace = app.current_workspace.clone(); + let workspace = app.selected_provider_workspace(); tokio::spawn(async move { let req = openshell_core::proto::GetProviderRequest { name, workspace }; @@ -1790,7 +1790,7 @@ fn spawn_delete_provider(app: &App, tx: mpsc::UnboundedSender) { Some(n) => n.to_string(), None => return, }; - let workspace = app.current_workspace.clone(); + let workspace = app.selected_provider_workspace(); tokio::spawn(async move { let req = openshell_core::proto::DeleteProviderRequest { name, workspace }; From 9b761aa9a92499487a3e9ceae66940dffc8dbf75 Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Tue, 14 Jul 2026 19:58:53 -0400 Subject: [PATCH 05/14] fix(workspace): include SSH sessions in workspace deletion blockers Signed-off-by: Derek Carr --- crates/openshell-server/src/grpc/workspace.rs | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index 43d28e03f0..ddaa9aabac 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -15,7 +15,7 @@ use openshell_core::proto::{ GetWorkspaceResponse, InferenceRoute, ListWorkspaceMembersRequest, ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, Provider, RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, Sandbox, ServiceEndpoint, - StoredProviderProfile, Workspace, WorkspaceMember, WorkspaceRole, + SshSession, StoredProviderProfile, Workspace, WorkspaceMember, WorkspaceRole, }; use prost::Message; use tonic::{Request, Response, Status}; @@ -220,6 +220,7 @@ pub(super) async fn handle_delete_workspace( (StoredProviderProfile::object_type(), "provider profile"), (ServiceEndpoint::object_type(), "service"), (InferenceRoute::object_type(), "inference route"), + (SshSession::object_type(), "ssh session"), ] { let records = state .store @@ -461,6 +462,52 @@ mod tests { assert!(resp.deleted); } + #[tokio::test] + async fn delete_workspace_blocked_by_ssh_session() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "sessioned".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let session = SshSession { + metadata: Some(ObjectMeta { + id: "ssh-1".to_string(), + name: "session-ssh-1".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + workspace: "sessioned".to_string(), + }), + sandbox_id: "sbx-1".to_string(), + token: "ssh-1".to_string(), + revoked: false, + expires_at_ms: 0, + }; + state.store.put_message(&session).await.unwrap(); + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "sessioned".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message().contains("ssh session"), + "error should name ssh session as blocker: {}", + err.message() + ); + } + #[tokio::test] async fn delete_workspace_blocked_by_provider_profiles() { let state = test_server_state().await; From 68901b17f2fcbee9558477633175c9789b80f518 Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Tue, 14 Jul 2026 21:13:12 -0400 Subject: [PATCH 06/14] feat(workspace): add profile_workspace to Provider for explicit profile scope binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider profiles can be global (platform-scoped) or workspace-scoped. Previously, profile lookups used the ambient request workspace, which caused pre-existing global profiles (workspace="") to become invisible after the workspace migration. Add a `profile_workspace` field to the Provider proto that explicitly declares where the provider's type profile is stored. The field must be empty (global) or match the provider's own workspace — cross-workspace references are rejected. All profile lookup call sites now use `provider.profile_workspace` instead of the ambient workspace. Existing serialized providers get `profile_workspace=""` via protobuf defaults, correctly pointing to their global profiles with no migration. CLI gains `--global-profile` flag on `provider create`. Signed-off-by: Derek Carr --- crates/openshell-cli/src/main.rs | 8 + crates/openshell-cli/src/run.rs | 15 + .../tests/ensure_providers_integration.rs | 2 + .../tests/provider_commands_integration.rs | 3 + crates/openshell-server/src/grpc/policy.rs | 30 +- crates/openshell-server/src/grpc/provider.rs | 289 +++++++++++++++++- crates/openshell-server/src/grpc/sandbox.rs | 1 + .../openshell-server/src/grpc/validation.rs | 1 + crates/openshell-server/src/inference.rs | 10 + .../openshell-server/src/provider_refresh.rs | 1 + crates/openshell-tui/src/lib.rs | 2 + proto/datamodel.proto | 4 + 12 files changed, 352 insertions(+), 14 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 554f57bf9b..3bb6a231b4 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -810,6 +810,11 @@ enum ProviderCommands { /// Provider config key/value pair. #[arg(long = "config", value_name = "KEY=VALUE")] config: Vec, + + /// Use a platform-scoped (global) provider profile instead of + /// a workspace-scoped one. + #[arg(long)] + global_profile: bool, }, /// Manage provider credential refresh. @@ -3269,7 +3274,9 @@ async fn main() -> Result<()> { from_gcloud_adc, runtime_credentials, config, + global_profile, } => { + let profile_ws = if global_profile { "" } else { &cli.workspace }; run::provider_create_with_options( endpoint, &name, @@ -3280,6 +3287,7 @@ async fn main() -> Result<()> { runtime_credentials, &config, &cli.workspace, + profile_ws, &tls, ) .await?; diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 48b7695084..ea8523d8de 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4162,6 +4162,7 @@ async fn auto_create_provider( credentials: discovered.credentials.clone(), config: discovered.config.clone(), credential_expires_at_ms: HashMap::new(), + profile_workspace: workspace.to_string(), }), workspace: workspace.to_string(), }; @@ -4206,6 +4207,7 @@ async fn auto_create_provider( credentials: discovered.credentials.clone(), config: discovered.config.clone(), credential_expires_at_ms: HashMap::new(), + profile_workspace: workspace.to_string(), }), workspace: workspace.to_string(), }; @@ -4985,6 +4987,7 @@ pub async fn provider_create( false, config, workspace, + workspace, tls, ) .await @@ -5001,6 +5004,7 @@ pub async fn provider_create_with_options( runtime_credentials: bool, config: &[String], workspace: &str, + profile_workspace: &str, tls: &TlsOptions, ) -> Result<()> { if from_gcloud_adc && (from_existing || !credentials.is_empty() || runtime_credentials) { @@ -5153,6 +5157,7 @@ pub async fn provider_create_with_options( credentials: credential_map, config: config_map, credential_expires_at_ms: HashMap::new(), + profile_workspace: profile_workspace.to_string(), }), workspace: workspace.to_string(), }) @@ -6196,6 +6201,7 @@ pub async fn provider_update( credentials: credential_map, config: config_map, credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), }), credential_expires_at_ms, workspace: workspace.to_string(), @@ -9052,6 +9058,7 @@ mod tests { )) .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }], false, ); @@ -10570,6 +10577,7 @@ mod tests { credentials: std::collections::HashMap::new(), config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -10591,6 +10599,7 @@ mod tests { credentials, config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -10628,6 +10637,7 @@ mod tests { credentials: std::collections::HashMap::new(), config, credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -10658,6 +10668,7 @@ mod tests { credentials: std::collections::HashMap::new(), config: std::collections::HashMap::new(), // Empty config credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -10688,6 +10699,7 @@ mod tests { credentials: std::collections::HashMap::new(), config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -10713,6 +10725,7 @@ mod tests { credentials: std::collections::HashMap::new(), config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -10742,6 +10755,7 @@ mod tests { credentials: std::collections::HashMap::new(), config: std::collections::HashMap::new(), credential_expires_at_ms, + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -10767,6 +10781,7 @@ mod tests { credentials: std::collections::HashMap::new(), config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 75035d04b1..9ef0301b5f 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -69,6 +69,7 @@ impl TestOpenShell { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ); } @@ -366,6 +367,7 @@ impl OpenShell for TestOpenShell { existing.credential_expires_at_ms, provider.credential_expires_at_ms, ), + profile_workspace: existing.profile_workspace, }; let updated_name = updated.object_name().to_string(); providers.insert(updated_name, updated.clone()); diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index b28df6f012..70bd86e5ad 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -626,6 +626,7 @@ impl OpenShell for TestOpenShell { existing.credential_expires_at_ms, provider.credential_expires_at_ms, ), + profile_workspace: existing.profile_workspace, }; let updated_name = updated.object_name().to_string(); providers.insert(updated_name, updated.clone()); @@ -1516,6 +1517,7 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() true, &[], "default", + "default", &ts.tls, ) .await @@ -2055,6 +2057,7 @@ async fn provider_update_from_existing_uses_profile_discovery_when_v2_enabled() credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ); let _env = EnvVarGuard::set(&[("CUSTOM_UPDATE_DISCOVERY_API_KEY", "updated-profile-secret")]); diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index d2782a581b..e61a4f2492 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -548,8 +548,12 @@ async fn build_credential_set_for_sandbox_with_catalog( }; profile.clone() } else { - let Some(profile) = - super::provider::get_provider_type_profile(store, workspace, provider_type).await? + let Some(profile) = super::provider::get_provider_type_profile( + store, + &provider.profile_workspace, + provider_type, + ) + .await? else { warn!( provider_name = %name, @@ -1410,8 +1414,13 @@ pub(super) async fn compute_provider_env_revision_with_catalog( Status::internal(format!("decode provider '{provider_name}' failed: {e}")) })?; hasher.update(provider.r#type.as_bytes()); - hash_provider_profile_revision(store, workspace, &provider.r#type, &mut hasher) - .await?; + hash_provider_profile_revision( + store, + &provider.profile_workspace, + &provider.r#type, + &mut hasher, + ) + .await?; let mut credential_keys: Vec<_> = provider.credentials.keys().collect(); credential_keys.sort(); @@ -1439,7 +1448,7 @@ pub(super) async fn compute_provider_env_revision_with_catalog( async fn hash_provider_profile_revision( store: &Store, - workspace: &str, + profile_workspace: &str, provider_type: &str, hasher: &mut Sha256, ) -> Result<(), Status> { @@ -1453,7 +1462,7 @@ async fn hash_provider_profile_revision( match store .get_by_name( openshell_core::proto::StoredProviderProfile::object_type(), - workspace, + profile_workspace, provider_type, ) .await @@ -1512,8 +1521,12 @@ async fn profile_provider_policy_layers_with_catalog( }; profile.clone() } else { - let Some(profile) = - super::provider::get_provider_type_profile(store, workspace, provider_type).await? + let Some(profile) = super::provider::get_provider_type_profile( + store, + &provider.profile_workspace, + provider_type, + ) + .await? else { warn!( provider_name = %name, @@ -4760,6 +4773,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 8aaa2ff69f..5b8ffdf646 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -104,8 +104,18 @@ pub(super) async fn create_provider_record( if provider.r#type.trim().is_empty() { return Err(Status::invalid_argument("provider.type is required")); } + if !provider.profile_workspace.is_empty() && provider.profile_workspace != workspace { + return Err(Status::invalid_argument( + "profile_workspace must be empty (global) or match the provider workspace", + )); + } + let profile_ws = if provider.profile_workspace.is_empty() { + "" + } else { + workspace + }; if provider.credentials.is_empty() - && !provider_type_allows_empty_credentials(store, workspace, &provider.r#type).await? + && !provider_type_allows_empty_credentials(store, profile_ws, &provider.r#type).await? { return Err(Status::invalid_argument( "provider.credentials must not be empty", @@ -230,6 +240,13 @@ pub(super) async fn update_provider_record_with_catalog( "provider type cannot be changed; delete and recreate the provider", )); } + if !provider.profile_workspace.is_empty() + && provider.profile_workspace != existing.profile_workspace + { + return Err(Status::invalid_argument( + "profile_workspace cannot be changed; delete and recreate the provider", + )); + } let current_version = existing.metadata.as_ref().map_or(0, |m| m.resource_version); @@ -575,7 +592,9 @@ pub(super) async fn resolve_dynamic_credentials_with_catalog( let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = get_provider_type_profile(store, workspace, profile_id).await? else { + let Some(profile) = + get_provider_type_profile(store, &provider.profile_workspace, profile_id).await? + else { continue; }; @@ -995,12 +1014,14 @@ struct DynamicTokenGrantBinding { async fn dynamic_token_grant_bindings_for_provider( store: &Store, - workspace: &str, + _workspace: &str, provider: &Provider, ) -> Vec { let provider_name = provider.object_name().to_string(); let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = get_provider_type_profile(store, workspace, profile_id).await? else { + let Some(profile) = + get_provider_type_profile(store, &provider.profile_workspace, profile_id).await? + else { return Ok(Vec::new()); }; dynamic_token_grant_bindings_for_profile(&provider_name, &profile.to_proto()) @@ -1619,11 +1640,13 @@ pub(super) async fn get_provider_type_profile( async fn provider_refresh_defaults( store: &Store, - workspace: &str, + _workspace: &str, provider: &Provider, credential_key: &str, ) -> Result, Status> { - let Some(profile) = get_provider_type_profile(store, workspace, &provider.r#type).await? else { + let Some(profile) = + get_provider_type_profile(store, &provider.profile_workspace, &provider.r#type).await? + else { return Ok(None); }; Ok(profile @@ -2328,6 +2351,7 @@ pub(super) async fn handle_configure_provider_refresh( credential_key.to_string(), expires_at_ms, )]), + profile_workspace: String::new(), }; update_provider_record(state.store.as_ref(), &workspace, updated).await?; } @@ -2435,6 +2459,7 @@ pub(super) async fn handle_delete_provider_refresh( credential_key.to_string(), 0, )]), + profile_workspace: String::new(), }; update_provider_record(state.store.as_ref(), &workspace, updated).await?; } @@ -2728,6 +2753,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -3207,6 +3233,7 @@ mod tests { .into_iter() .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } @@ -3878,6 +3905,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4129,6 +4157,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4195,6 +4224,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4240,6 +4270,7 @@ mod tests { "MS_GRAPH_ACCESS_TOKEN".to_string(), manual_expires_at_ms, )]), + profile_workspace: "default".to_string(), }, ) .await @@ -4296,6 +4327,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4317,6 +4349,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4390,6 +4423,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4484,6 +4518,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4555,6 +4590,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4720,6 +4756,7 @@ mod tests { config: std::iter::once(("endpoint".to_string(), "https://gitlab.com".to_string())) .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4896,6 +4933,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4927,6 +4965,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4959,6 +4998,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4981,6 +5021,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5059,6 +5100,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5098,6 +5140,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5137,6 +5180,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5159,6 +5203,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5189,6 +5234,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5221,6 +5267,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5272,6 +5319,7 @@ mod tests { credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(), config: std::iter::once(("region".to_string(), String::new())).collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5327,6 +5375,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5360,6 +5409,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5395,6 +5445,7 @@ mod tests { credentials: std::iter::once((oversized_key, "value".to_string())).collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5424,6 +5475,7 @@ mod tests { credentials: std::iter::once(("API_TOKEN".to_string(), "old".to_string())).collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; store.put_message(&legacy).await.unwrap(); @@ -5444,6 +5496,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5486,6 +5539,7 @@ mod tests { )) .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; create_provider_record(&store, "default", provider) .await @@ -5546,6 +5600,7 @@ mod tests { ] .into_iter() .collect(), + profile_workspace: "default".to_string(), }; create_provider_record(&store, "default", provider) .await @@ -5595,6 +5650,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; create_provider_record(&store, "default", provider) .await @@ -5632,6 +5688,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5653,6 +5710,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5689,6 +5747,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5713,6 +5772,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5762,6 +5822,7 @@ mod tests { .into_iter() .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5837,6 +5898,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5877,6 +5939,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5938,6 +6001,7 @@ mod tests { .into_iter() .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5975,6 +6039,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6018,6 +6083,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6042,6 +6108,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6083,6 +6150,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6119,6 +6187,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6222,6 +6291,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; // Attempt to update with an oversized credential key (exceeds MAX_MAP_KEY_LEN) @@ -6582,6 +6652,7 @@ mod tests { credentials: HashMap::new(), config, credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } @@ -6685,6 +6756,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::from([("project_id".to_string(), "should-be-ignored".to_string())]), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; let mut env = HashMap::new(); openshell_providers::ProviderRegistry::new().inject_env(&provider, &mut env); @@ -6721,6 +6793,7 @@ mod tests { credentials: HashMap::from([("TOKEN".to_string(), "secret".to_string())]), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), }; let created_default = handle_create_provider( @@ -6927,4 +7000,208 @@ mod tests { .unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument); } + + #[tokio::test] + async fn create_provider_rejects_cross_workspace_profile_workspace() { + let store = test_store().await; + let provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "cross-ws".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + }), + r#type: "claude".to_string(), + credentials: [("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())] + .into_iter() + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "other-workspace".to_string(), + }; + let err = create_provider_record(&store, "default", provider) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("profile_workspace")); + } + + #[tokio::test] + async fn create_provider_accepts_global_profile_workspace() { + let store = test_store().await; + let provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "global-profile".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + }), + r#type: "claude".to_string(), + credentials: [("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())] + .into_iter() + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + }; + let created = create_provider_record(&store, "default", provider) + .await + .unwrap(); + assert!(created.profile_workspace.is_empty()); + } + + #[tokio::test] + async fn create_provider_accepts_same_workspace_profile_workspace() { + let store = test_store().await; + let provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "same-ws-profile".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + }), + r#type: "claude".to_string(), + credentials: [("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())] + .into_iter() + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + }; + let created = create_provider_record(&store, "default", provider) + .await + .unwrap(); + assert_eq!(created.profile_workspace, "default"); + } + + #[tokio::test] + async fn update_provider_rejects_profile_workspace_change() { + let store = test_store().await; + let provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "immutable-pw".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + }), + r#type: "claude".to_string(), + credentials: [("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())] + .into_iter() + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + }; + create_provider_record(&store, "default", provider) + .await + .unwrap(); + + let update = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "immutable-pw".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + }), + r#type: String::new(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "other".to_string(), + }; + let err = update_provider_record(&store, "default", update) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("profile_workspace")); + } + + #[tokio::test] + async fn provider_with_global_profile_resolves_platform_scoped_profile() { + use crate::persistence::{ObjectName, WriteCondition}; + use prost::Message; + + let store = test_store().await; + + let stored = stored_provider_profile_for_workspace(custom_profile("global-custom"), ""); + store + .put_if( + StoredProviderProfile::object_type(), + stored.object_id(), + stored.object_name(), + "", + &stored.encode_to_vec(), + None, + WriteCondition::MustCreate, + ) + .await + .unwrap(); + + let profile = get_provider_type_profile(&store, "", "global-custom") + .await + .unwrap(); + assert!(profile.is_some(), "global profile should be found at workspace ''"); + + let miss = get_provider_type_profile(&store, "default", "global-custom") + .await + .unwrap(); + assert!(miss.is_none(), "global profile should NOT be found at workspace 'default'"); + } + + #[tokio::test] + async fn provider_with_workspace_profile_resolves_workspace_scoped_profile() { + let state = test_server_state().await; + + handle_import_provider_profiles( + &state, + Request::new(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile("ws-custom")), + source: "ws-custom.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + create_provider_record( + state.store.as_ref(), + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "uses-ws".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + }), + r#type: "ws-custom".to_string(), + credentials: [("TOKEN".to_string(), "val".to_string())] + .into_iter() + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + }, + ) + .await + .unwrap(); + + let profile = get_provider_type_profile(state.store.as_ref(), "default", "ws-custom") + .await + .unwrap(); + assert!(profile.is_some()); + } } diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index c9d33095e5..6e7609553e 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -2370,6 +2370,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 8d86800922..4be93b6a80 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -1229,6 +1229,7 @@ mod tests { credentials, config, credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index 761d589e8f..18e3b71e2c 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -1081,6 +1081,7 @@ mod tests { credentials: std::iter::once((key_name.to_string(), key_value.to_string())).collect(), config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), } } @@ -1192,6 +1193,7 @@ mod tests { )) .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) @@ -1264,6 +1266,7 @@ mod tests { // Intentionally no BEDROCK_BASE_URL. config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) @@ -1313,6 +1316,7 @@ mod tests { )) .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) @@ -1541,6 +1545,7 @@ mod tests { )) .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) @@ -1617,6 +1622,7 @@ mod tests { .collect(), config: provider.config.clone(), credential_expires_at_ms: provider.credential_expires_at_ms.clone(), + profile_workspace: provider.profile_workspace.clone(), }; store .put_message(&rotated_provider) @@ -1685,6 +1691,7 @@ mod tests { .into_iter() .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) @@ -2020,6 +2027,7 @@ mod tests { .collect(), config, credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), } } @@ -3104,6 +3112,7 @@ mod tests { .collect(), config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&alpha_provider) @@ -3127,6 +3136,7 @@ mod tests { .collect(), config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&beta_provider) diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index da432bc440..f543795db8 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -1228,6 +1228,7 @@ mod tests { credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index eeeeccb5ae..94dc628a81 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1666,6 +1666,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { credentials: credentials.clone(), config: HashMap::default(), credential_expires_at_ms: HashMap::default(), + profile_workspace: workspace.clone(), }), workspace: workspace.clone(), }; @@ -1762,6 +1763,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { credentials, config: HashMap::default(), credential_expires_at_ms: HashMap::default(), + profile_workspace: String::new(), }), credential_expires_at_ms: HashMap::default(), workspace, diff --git a/proto/datamodel.proto b/proto/datamodel.proto index 6b29405c29..7708183907 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -57,4 +57,8 @@ message Provider { // Expiration timestamps for credential values, keyed by credential/env var // name. A zero or missing value means the credential does not expire. map credential_expires_at_ms = 5; + // Workspace where this provider's type profile is stored. + // Empty string = platform/global scope. Must be empty or match + // metadata.workspace; cross-workspace references are rejected. + string profile_workspace = 6; } From ffd5c13f43856ef05d6f8c9d1e39d6c9ecf70721 Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Tue, 14 Jul 2026 21:15:02 -0400 Subject: [PATCH 07/14] fix(workspace): use session workspace in SSH session revocation write-back handle_revoke_ssh_session passed empty string for workspace in put_if instead of session.object_workspace(), dropping the workspace on the revoked session record. Signed-off-by: Derek Carr --- crates/openshell-server/src/grpc/sandbox.rs | 40 ++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 6e7609553e..8fcc34f621 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -1492,7 +1492,7 @@ pub(super) async fn handle_revoke_ssh_session( SshSession::object_type(), session.object_id(), session.object_name(), - "", + session.object_workspace(), &session.encode_to_vec(), None, WriteCondition::MatchResourceVersion(resource_version), @@ -3749,4 +3749,42 @@ mod tests { .unwrap_err(); assert_eq!(err.code(), tonic::Code::InvalidArgument); } + + #[tokio::test] + async fn revoke_ssh_session_preserves_workspace() { + let state = test_server_state().await; + state + .store + .put_message(&test_sandbox("ws-test", Vec::new())) + .await + .unwrap(); + + let response = handle_create_ssh_session( + &state, + Request::new(CreateSshSessionRequest { + sandbox_id: "sandbox-ws-test".to_string(), + }), + ) + .await + .unwrap(); + let token = response.into_inner().token; + + handle_revoke_ssh_session( + &state, + Request::new(RevokeSshSessionRequest { + token: token.clone(), + }), + ) + .await + .unwrap(); + + let session: SshSession = state + .store + .get_message::(&token) + .await + .unwrap() + .expect("session should still exist after revocation"); + assert!(session.revoked); + assert_eq!(session.object_workspace(), "default"); + } } From 72d681c0c964a7f232e0954f7daeccd910d53a7f Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Tue, 14 Jul 2026 21:37:01 -0400 Subject: [PATCH 08/14] fix(workspace): resolve workspace in lint provider profiles handler The lint handler was passing an empty string to profile_conflict_diagnostics instead of using the request workspace, so it only checked for conflicts in global scope rather than the workspace the user specified. Signed-off-by: Derek Carr --- crates/openshell-sandbox/src/lib.rs | 12 ++- crates/openshell-server/src/grpc/provider.rs | 90 ++++++++++++++++++-- 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 2b76d266dc..78a2c62dd4 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -414,8 +414,13 @@ pub async fn run_sandbox( let sandbox_name = agg_name.clone(); let workspace = denial_workspace_rx.borrow().clone(); async move { - if let Err(e) = - flush_proposals_to_gateway(&endpoint, &sandbox_name, &workspace, summaries).await + if let Err(e) = flush_proposals_to_gateway( + &endpoint, + &sandbox_name, + &workspace, + summaries, + ) + .await { warn!(error = %e, "Failed to flush denial summaries to gateway"); } @@ -451,7 +456,8 @@ pub async fn run_sandbox( let workspace = activity_workspace_rx.borrow().clone(); async move { if let Err(e) = - flush_activity_to_gateway(&endpoint, &sandbox_name, &workspace, summary).await + flush_activity_to_gateway(&endpoint, &sandbox_name, &workspace, summary) + .await { warn!(error = %e, "Failed to flush activity summary to gateway"); } diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 5b8ffdf646..e125ad8519 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1553,9 +1553,12 @@ pub(super) async fn handle_lint_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); - diagnostics.extend(profile_conflict_diagnostics(state.store.as_ref(), "", &profiles).await?); + diagnostics + .extend(profile_conflict_diagnostics(state.store.as_ref(), &workspace, &profiles).await?); diagnostics.extend(validate_profile_set(&profiles)); let valid = !has_errors(&diagnostics); @@ -2558,13 +2561,13 @@ mod tests { use crate::grpc::{MAX_MAP_KEY_LEN, MAX_PROVIDER_TYPE_LEN}; use crate::persistence::test_store; use openshell_core::proto::{ - DeleteProviderProfileRequest, GetProviderProfileRequest, ImportProviderProfilesRequest, - L7Allow, L7Rule, LintProviderProfilesRequest, ListProviderProfilesRequest, NetworkBinary, - NetworkEndpoint, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, - ProviderCredentialTokenGrant, ProviderCredentialTokenGrantAudienceOverride, - ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, - ProviderProfileImportItem, Sandbox, SandboxSpec, StoredProviderProfile, - UpdateProviderProfilesRequest, + CreateWorkspaceRequest, DeleteProviderProfileRequest, GetProviderProfileRequest, + ImportProviderProfilesRequest, L7Allow, L7Rule, LintProviderProfilesRequest, + ListProviderProfilesRequest, NetworkBinary, NetworkEndpoint, ProviderCredentialRefresh, + ProviderCredentialRefreshMaterial, ProviderCredentialTokenGrant, + ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, ProviderProfileCategory, + ProviderProfileCredential, ProviderProfileImportItem, Sandbox, SandboxSpec, + StoredProviderProfile, UpdateProviderProfilesRequest, }; use openshell_core::{ObjectId, ObjectName}; use std::collections::HashMap; @@ -3814,6 +3817,77 @@ mod tests { } } + #[tokio::test] + async fn lint_provider_profiles_checks_conflicts_in_request_workspace() { + let state = test_server_state().await; + + handle_import_provider_profiles( + &state, + Request::new(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile("scoped-lint")), + source: "scoped-lint.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let conflict = handle_lint_provider_profiles( + &state, + Request::new(LintProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile("scoped-lint")), + source: "scoped-lint.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!( + !conflict.valid, + "lint should detect conflict with existing profile in the same workspace" + ); + assert!(conflict.diagnostics.iter().any(|d| { + d.profile_id == "scoped-lint" && d.message.contains("already exists") + })); + + crate::grpc::workspace::handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "alpha".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let no_conflict = handle_lint_provider_profiles( + &state, + Request::new(LintProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile("scoped-lint")), + source: "scoped-lint.yaml".to_string(), + }], + workspace: "alpha".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!( + no_conflict + .diagnostics + .iter() + .all(|d| d.profile_id != "scoped-lint" + || !d.message.contains("already exists")), + "lint against different workspace should not see profile from default" + ); + } + #[tokio::test] async fn delete_provider_profile_rejects_builtin_and_in_use_custom_profiles() { let state = test_server_state().await; From f1a40906b2c333f273e816529663d4b145526a67 Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Wed, 15 Jul 2026 07:44:59 -0400 Subject: [PATCH 09/14] fix(workspace): address review findings from workspace model - Fix proto comments: ListProviderProfilesRequest.workspace no longer claims two-tier merged resolution, LintProviderProfilesRequest.workspace now documents that it is used for conflict detection - Converge Podman driver labels to openshell.ai/ prefix by importing from driver_utils, matching Docker and Kubernetes drivers - Replace debug_assert with runtime PersistenceError for workspace enforcement in put_message, put_scoped_message, and cas_update_message - Add comment in parse_host explaining -- delimiter safety invariant - Read OPENSHELL_WORKSPACE env var in tab completers instead of defaulting to empty string - Remove unused _workspace params from provider_refresh_defaults and dynamic_token_grant_bindings_for_provider - Remove scattered workspace "default" fallbacks in service_routing, inference, and compute; normalization is handled by resolve_workspace at the handler layer Signed-off-by: Derek Carr --- crates/openshell-cli/src/completers.rs | 6 ++-- .../openshell-driver-podman/src/container.rs | 24 ++++++-------- crates/openshell-server/src/compute/mod.rs | 12 +++---- crates/openshell-server/src/grpc/provider.rs | 8 ++--- crates/openshell-server/src/inference.rs | 5 --- .../openshell-server/src/persistence/mod.rs | 33 ++++++++++--------- .../openshell-server/src/service_routing.rs | 12 +++---- proto/openshell.proto | 9 +++-- 8 files changed, 48 insertions(+), 61 deletions(-) diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index a419f4bef2..90a44defec 100644 --- a/crates/openshell-cli/src/completers.rs +++ b/crates/openshell-cli/src/completers.rs @@ -39,7 +39,8 @@ pub fn complete_sandbox_names(_prefix: &OsStr) -> Vec { limit: 200, offset: 0, label_selector: String::new(), - workspace: String::new(), + workspace: std::env::var("OPENSHELL_WORKSPACE") + .unwrap_or_else(|_| "default".to_string()), all_workspaces: false, }) .await @@ -64,7 +65,8 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec { .list_providers(ListProvidersRequest { limit: 200, offset: 0, - workspace: String::new(), + workspace: std::env::var("OPENSHELL_WORKSPACE") + .unwrap_or_else(|_| "default".to_string()), all_workspaces: false, }) .await diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 0ee803af3d..e926f549d9 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -36,14 +36,10 @@ fn is_selinux_enabled() -> bool { false } -/// Label key for the sandbox ID. -pub const LABEL_SANDBOX_ID: &str = "openshell.sandbox-id"; -/// Label key for the sandbox name. -pub const LABEL_SANDBOX_NAME: &str = "openshell.sandbox-name"; -/// Label key for the sandbox namespace. -pub const LABEL_SANDBOX_NAMESPACE: &str = "openshell.sandbox-namespace"; -/// Label key for the sandbox workspace. -pub const LABEL_SANDBOX_WORKSPACE: &str = "openshell.sandbox-workspace"; +pub use openshell_core::driver_utils::{ + LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_NAMESPACE, LABEL_SANDBOX_WORKSPACE, +}; + /// Label applied to all managed containers. pub const LABEL_MANAGED: &str = "openshell.managed"; /// Label filter string for list/event queries. @@ -1690,13 +1686,13 @@ mod tests { let mut sandbox = test_sandbox("real-id", "real-name"); sandbox.namespace = "real-namespace".to_string(); let mut label_overrides = std::collections::HashMap::new(); - label_overrides.insert("openshell.sandbox-id".to_string(), "spoofed-id".to_string()); + label_overrides.insert("openshell.ai/sandbox-id".to_string(), "spoofed-id".to_string()); label_overrides.insert( - "openshell.sandbox-name".to_string(), + "openshell.ai/sandbox-name".to_string(), "spoofed-name".to_string(), ); label_overrides.insert( - "openshell.sandbox-namespace".to_string(), + "openshell.ai/sandbox-namespace".to_string(), "spoofed-namespace".to_string(), ); sandbox.spec = Some(DriverSandboxSpec { @@ -1714,20 +1710,20 @@ mod tests { .as_object() .expect("labels should be an object"); assert_eq!( - labels.get("openshell.sandbox-id").and_then(|v| v.as_str()), + labels.get("openshell.ai/sandbox-id").and_then(|v| v.as_str()), Some("real-id"), "openshell.sandbox-id must not be overridden by template labels" ); assert_eq!( labels - .get("openshell.sandbox-name") + .get("openshell.ai/sandbox-name") .and_then(|v| v.as_str()), Some("real-name"), "openshell.sandbox-name must not be overridden by template labels" ); assert_eq!( labels - .get("openshell.sandbox-namespace") + .get("openshell.ai/sandbox-namespace") .and_then(|v| v.as_str()), Some("real-namespace"), "openshell.sandbox-namespace must not be overridden by template labels" diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index fb06a02024..b75fe88521 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -1175,11 +1175,7 @@ impl ComputeRuntime { if supervisor_promoted { ensure_supervisor_ready_status(&mut status, &sandbox_name); } - let workspace = if incoming.workspace.is_empty() { - "default".to_string() - } else { - incoming.workspace.clone() - }; + let workspace = incoming.workspace.clone(); let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: incoming.id.clone(), @@ -3740,7 +3736,7 @@ mod tests { } #[tokio::test] - async fn apply_sandbox_update_defaults_workspace_when_empty() { + async fn apply_sandbox_update_preserves_workspace() { let runtime = test_runtime(Arc::new(TestDriver::default())).await; runtime .apply_sandbox_update(DriverSandbox { @@ -3752,7 +3748,7 @@ mod tests { "DependenciesNotReady", "Provisioning", ))), - workspace: String::new(), + workspace: "alpha".to_string(), }) .await .unwrap(); @@ -3763,6 +3759,6 @@ mod tests { .await .unwrap() .unwrap(); - assert_eq!(stored.object_workspace(), "default"); + assert_eq!(stored.object_workspace(), "alpha"); } } diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index e125ad8519..ddbc06cf40 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -996,7 +996,7 @@ async fn validate_provider_environment_keys_unique_at( } } dynamic_bindings - .extend(dynamic_token_grant_bindings_for_provider(store, workspace, &provider).await?); + .extend(dynamic_token_grant_bindings_for_provider(store, &provider).await?); } validate_dynamic_token_grant_bindings_unambiguous(&dynamic_bindings)?; Ok(()) @@ -1014,7 +1014,6 @@ struct DynamicTokenGrantBinding { async fn dynamic_token_grant_bindings_for_provider( store: &Store, - _workspace: &str, provider: &Provider, ) -> Vec { let provider_name = provider.object_name().to_string(); @@ -1643,7 +1642,6 @@ pub(super) async fn get_provider_type_profile( async fn provider_refresh_defaults( store: &Store, - _workspace: &str, provider: &Provider, credential_key: &str, ) -> Result, Status> { @@ -1945,7 +1943,7 @@ async fn profile_attached_sandbox_diagnostics( } } else { bindings.extend( - dynamic_token_grant_bindings_for_provider(store, workspace, &provider).await?, + dynamic_token_grant_bindings_for_provider(store, &provider).await?, ); } } @@ -2261,7 +2259,7 @@ pub(super) async fn handle_configure_provider_refresh( ) .await?; let refresh_defaults = - provider_refresh_defaults(state.store.as_ref(), &workspace, &provider, credential_key) + provider_refresh_defaults(state.store.as_ref(), &provider, credential_key) .await?; validate_refresh_material(&request.material, refresh_defaults.as_ref())?; let material_scopes = crate::provider_refresh::material_scopes(&request.material); diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index 18e3b71e2c..0bf1c0a891 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -81,11 +81,6 @@ impl Inference for InferenceService { .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found(format!("sandbox '{sandbox_id}' not found")))?; let workspace = sandbox.object_workspace(); - let workspace = if workspace.is_empty() { - "default" - } else { - workspace - }; resolve_inference_bundle(self.state.store.as_ref(), workspace) .await .map(Response::new) diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index 7f87280a11..0540013939 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -377,11 +377,12 @@ impl Store { message: &T, scope: &str, ) -> PersistenceResult<()> { - debug_assert!( - !T::requires_workspace() || !message.object_workspace().is_empty(), - "{} requires a non-empty workspace", - T::object_type(), - ); + if T::requires_workspace() && message.object_workspace().is_empty() { + return Err(PersistenceError::Encode(format!( + "{} requires a non-empty workspace", + T::object_type(), + ))); + } let labels_map = message.object_labels(); let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { None @@ -545,11 +546,12 @@ impl Store { })?) }; - debug_assert!( - !T::requires_workspace() || !updated.object_workspace().is_empty(), - "{} requires a non-empty workspace", - T::object_type(), - ); + if T::requires_workspace() && updated.object_workspace().is_empty() { + return Err(PersistenceError::Encode(format!( + "{} requires a non-empty workspace", + T::object_type(), + ))); + } // Single-attempt CAS write - fails with Conflict on version mismatch let result = self @@ -670,11 +672,12 @@ impl Store { &self, message: &T, ) -> PersistenceResult<()> { - debug_assert!( - !T::requires_workspace() || !message.object_workspace().is_empty(), - "{} requires a non-empty workspace", - T::object_type(), - ); + if T::requires_workspace() && message.object_workspace().is_empty() { + return Err(PersistenceError::Encode(format!( + "{} requires a non-empty workspace", + T::object_type(), + ))); + } let labels_map = message.object_labels(); let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { None diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index e90f15cdbd..305e2777bc 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -81,18 +81,16 @@ fn endpoint_host( service: &str, ) -> Option { let base_domain = config.base_domains.first()?; - let ws = if workspace.is_empty() { - "default" - } else { - workspace - }; Some(if service.is_empty() { - format!("{ws}--{sandbox}.{base_domain}") + format!("{workspace}--{sandbox}.{base_domain}") } else { - format!("{ws}--{sandbox}--{service}.{base_domain}") + format!("{workspace}--{sandbox}--{service}.{base_domain}") }) } +// The `--` delimiter is unambiguous because both workspace and sandbox name +// validation reject consecutive hyphens (see `validate_workspace_name` and +// `validate_sandbox_spec`). pub fn parse_host(host: &str, config: &ServiceRoutingConfig) -> Option<(String, String, String)> { let host = host.split_once(':').map_or(host, |(name, _)| name); for base_domain in &config.base_domains { diff --git a/proto/openshell.proto b/proto/openshell.proto index 130f5aabba..10b4e86dc4 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -986,9 +986,8 @@ message ListProvidersResponse { message ListProviderProfilesRequest { uint32 limit = 1; uint32 offset = 2; - // Workspace scope for two-tier profile resolution. When set, returns merged - // view: workspace-scoped + platform-scoped + built-in. When empty, returns - // platform-scoped + built-in only. + // Workspace scope. When set, returns workspace-scoped + built-in profiles. + // When empty, returns platform-scoped + built-in only. string workspace = 3; } @@ -1273,8 +1272,8 @@ message UpdateProviderProfilesResponse { // Lint provider profiles request. message LintProviderProfilesRequest { repeated ProviderProfileImportItem profiles = 1; - // Workspace scope. Included for API consistency but ignored by the server - // (lint is stateless validation). + // Workspace scope. Used to check for conflicts against existing profiles + // in the target workspace. string workspace = 2; } From 0b2a9d6d62a9a5367f1f00c82b9721217ab1e963 Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Wed, 15 Jul 2026 08:09:12 -0400 Subject: [PATCH 10/14] fix(workspace): address review findings from workspace model - Backfill policy and draft_chunk object types in migration - Add label selector support to all_workspaces sandbox listing - Replace per-member deletion loop with batch delete_all_in_workspace - Extract shared validate_dns1123_label helper from duplicated validation - Add count_in_workspace for efficient member cap enforcement - Document intentional omission of workspace filter in list_by_scope Signed-off-by: Derek Carr --- .../postgres/006_add_workspace_column.sql | 2 +- .../sqlite/006_add_workspace_column.sql | 2 +- crates/openshell-server/src/grpc/sandbox.rs | 26 ++++--- .../openshell-server/src/grpc/validation.rs | 67 +++++++++++------- crates/openshell-server/src/grpc/workspace.rs | 53 +++------------ .../openshell-server/src/persistence/mod.rs | 55 +++++++++++++++ .../src/persistence/postgres.rs | 65 ++++++++++++++++++ .../src/persistence/sqlite.rs | 68 +++++++++++++++++++ 8 files changed, 256 insertions(+), 82 deletions(-) diff --git a/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql index 01d3e7078a..93df36c439 100644 --- a/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql +++ b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql @@ -4,7 +4,7 @@ ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; -- Backfill workspace-scoped object types to 'default'. UPDATE objects SET workspace = 'default' WHERE workspace = '' AND name IS NOT NULL - AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state'); + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'policy', 'draft_chunk'); -- Replace global name uniqueness with workspace-scoped uniqueness. DROP INDEX IF EXISTS objects_name_uq; diff --git a/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql index 01d3e7078a..93df36c439 100644 --- a/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql +++ b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql @@ -4,7 +4,7 @@ ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; -- Backfill workspace-scoped object types to 'default'. UPDATE objects SET workspace = 'default' WHERE workspace = '' AND name IS NOT NULL - AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state'); + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'policy', 'draft_chunk'); -- Replace global name uniqueness with workspace-scoped uniqueness. DROP INDEX IF EXISTS objects_name_uq; diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 8fcc34f621..47936ace5d 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -274,16 +274,24 @@ pub(super) async fn handle_list_sandboxes( let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); let sandboxes: Vec = if request.all_workspaces { - if !request.label_selector.is_empty() { - return Err(Status::invalid_argument( - "label_selector is not supported with all_workspaces", - )); + if request.label_selector.is_empty() { + state + .store + .list_all_messages(limit, request.offset) + .await + .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))? + } else { + crate::grpc::validation::validate_label_selector(&request.label_selector)?; + state + .store + .list_all_messages_with_selector( + &request.label_selector, + limit, + request.offset, + ) + .await + .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))? } - state - .store - .list_all_messages(limit, request.offset) - .await - .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))? } else { let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 4be93b6a80..16dc09d11c 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -97,6 +97,46 @@ pub(super) fn reject_newline_chars(value: &str, field_name: &str) -> Result<(), Ok(()) } +// --------------------------------------------------------------------------- +// DNS-1123 label validation +// --------------------------------------------------------------------------- + +/// Validate that a string conforms to DNS-1123 label rules: lowercase +/// alphanumeric and hyphens, no leading/trailing hyphens, no consecutive +/// hyphens, max 63 characters. `field` is used in error messages. +/// +/// Empty names are allowed (the caller decides whether empty is valid). +pub(super) fn validate_dns1123_label(name: &str, field: &str) -> Result<(), Status> { + if name.is_empty() { + return Ok(()); + } + if name.len() > MAX_NAME_LEN { + return Err(Status::invalid_argument(format!( + "{field} exceeds maximum length ({} > {MAX_NAME_LEN})", + name.len() + ))); + } + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(Status::invalid_argument(format!( + "{field} must contain only lowercase alphanumeric characters or hyphens", + ))); + } + if name.starts_with('-') || name.ends_with('-') { + return Err(Status::invalid_argument(format!( + "{field} must not start or end with a hyphen", + ))); + } + if name.contains("--") { + return Err(Status::invalid_argument(format!( + "{field} must not contain consecutive hyphens", + ))); + } + Ok(()) +} + // --------------------------------------------------------------------------- // Sandbox spec validation // --------------------------------------------------------------------------- @@ -109,32 +149,7 @@ pub(super) fn validate_sandbox_spec( spec: &openshell_core::proto::SandboxSpec, ) -> Result<(), Status> { // --- request.name --- - if name.len() > MAX_NAME_LEN { - return Err(Status::invalid_argument(format!( - "name exceeds maximum length ({} > {MAX_NAME_LEN})", - name.len() - ))); - } - if !name.is_empty() { - if !name - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - { - return Err(Status::invalid_argument( - "name must contain only lowercase alphanumeric characters or hyphens", - )); - } - if name.starts_with('-') || name.ends_with('-') { - return Err(Status::invalid_argument( - "name must not start or end with a hyphen", - )); - } - if name.contains("--") { - return Err(Status::invalid_argument( - "name must not contain consecutive hyphens", - )); - } - } + validate_dns1123_label(name, "name")?; // --- spec.providers --- if spec.providers.len() > MAX_PROVIDERS { diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index ddaa9aabac..ab1685c074 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -45,30 +45,7 @@ fn validate_workspace_name(name: &str) -> Result<(), Status> { if name.is_empty() { return Err(Status::invalid_argument("workspace name is required")); } - if name.len() > 63 { - return Err(Status::invalid_argument( - "workspace name must be 63 characters or fewer", - )); - } - if !name - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - { - return Err(Status::invalid_argument( - "workspace name must contain only lowercase alphanumeric characters or hyphens", - )); - } - if name.starts_with('-') || name.ends_with('-') { - return Err(Status::invalid_argument( - "workspace name must not start or end with a hyphen", - )); - } - if name.contains("--") { - return Err(Status::invalid_argument( - "workspace name must not contain consecutive hyphens", - )); - } - Ok(()) + super::validation::validate_dns1123_label(name, "workspace name") } /// Resolve and validate a workspace name from a request field. @@ -240,25 +217,11 @@ pub(super) async fn handle_delete_workspace( } // Clean up membership records before deleting the workspace itself. - let members: Vec = state + state .store - .list_messages(&name, MAX_WORKSPACE_MEMBERS, 0) + .delete_all_in_workspace(WorkspaceMember::object_type(), &name) .await - .map_err(|e| Status::internal(format!("list workspace members failed: {e}")))?; - for member in &members { - if let Some(meta) = &member.metadata { - state - .store - .delete(WorkspaceMember::object_type(), &meta.id) - .await - .map_err(|e| { - Status::internal(format!( - "delete workspace member '{}' failed: {e}", - meta.name - )) - })?; - } - } + .map_err(|e| Status::internal(format!("delete workspace members failed: {e}")))?; let deleted = state .store @@ -288,12 +251,12 @@ pub(super) async fn handle_add_workspace_member( )); } - let existing: Vec = state + let count = state .store - .list_messages(&workspace, MAX_WORKSPACE_MEMBERS, 0) + .count_in_workspace(WorkspaceMember::object_type(), &workspace) .await - .map_err(|e| Status::internal(format!("list workspace members failed: {e}")))?; - if existing.len() >= MAX_WORKSPACE_MEMBERS as usize { + .map_err(|e| Status::internal(format!("count workspace members failed: {e}")))?; + if count >= u64::from(MAX_WORKSPACE_MEMBERS) { return Err(Status::resource_exhausted(format!( "workspace has reached the maximum of {MAX_WORKSPACE_MEMBERS} members" ))); diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index 0540013939..98ab247bdc 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -304,6 +304,24 @@ impl Store { store_dispatch!(self.delete(object_type, id)) } + /// Count objects of a given type within a workspace. + pub async fn count_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + store_dispatch!(self.count_in_workspace(object_type, workspace)) + } + + /// Delete all objects of a given type within a workspace. + pub async fn delete_all_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + store_dispatch!(self.delete_all_in_workspace(object_type, workspace)) + } + /// Delete an object by name within an object type and workspace. pub async fn delete_by_name( &self, @@ -336,6 +354,9 @@ impl Store { } /// List objects by type and application-owned scope. + /// + /// Workspace filtering is intentionally omitted: scope values are sandbox + /// UUIDs which are globally unique. Revisit if non-UUID scopes are introduced. pub async fn list_by_scope( &self, object_type: &str, @@ -365,6 +386,22 @@ impl Store { )) } + /// List objects by type across all workspaces with label selector filtering. + pub async fn list_all_with_selector( + &self, + object_type: &str, + label_selector: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + store_dispatch!(self.list_all_with_selector( + object_type, + label_selector, + limit, + offset + )) + } + // ----------------------------------------------------------------------- // Generic protobuf message helpers // ----------------------------------------------------------------------- @@ -456,6 +493,24 @@ impl Store { .collect() } + /// List and decode protobuf messages across all workspaces with label + /// selector filtering, hydrating `resource_version` from the authoritative + /// DB row. + pub async fn list_all_messages_with_selector< + T: Message + Default + ObjectType + SetResourceVersion, + >( + &self, + label_selector: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + self.list_all_with_selector(T::object_type(), label_selector, limit, offset) + .await? + .into_iter() + .map(decode_record) + .collect() + } + /// List and decode protobuf messages with label selector filtering, /// hydrating `resource_version` from the authoritative DB row. pub async fn list_messages_with_selector< diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index 6dcf774b69..0de003bb02 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -348,6 +348,38 @@ WHERE object_type = $1 AND workspace = $2 AND name = $3 Ok(result.rows_affected() > 0) } + pub async fn count_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + let row: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM objects WHERE object_type = $1 AND workspace = $2", + ) + .bind(object_type) + .bind(workspace) + .fetch_one(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(row.0 as u64) + } + + pub async fn delete_all_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + let result = sqlx::query( + "DELETE FROM objects WHERE object_type = $1 AND workspace = $2", + ) + .bind(object_type) + .bind(workspace) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(result.rows_affected()) + } + pub async fn delete_by_name( &self, object_type: &str, @@ -480,6 +512,39 @@ LIMIT $4 OFFSET $5 Ok(rows.into_iter().map(row_to_object_record).collect()) } + pub async fn list_all_with_selector( + &self, + object_type: &str, + label_selector: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + use super::parse_label_selector; + + let required_labels = parse_label_selector(label_selector)?; + let labels_jsonb = serde_json::to_value(&required_labels) + .map_err(|e| PersistenceError::Encode(format!("failed to serialize labels: {e}")))?; + + let rows = sqlx::query( + r" +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version +FROM objects +WHERE object_type = $1 AND labels @> $2 +ORDER BY created_at_ms ASC, name ASC +LIMIT $3 OFFSET $4 +", + ) + .bind(object_type) + .bind(&labels_jsonb) + .bind(i64::from(limit)) + .bind(i64::from(offset)) + .fetch_all(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + + Ok(rows.into_iter().map(row_to_object_record).collect()) + } + pub async fn put_policy_revision( &self, id: &str, diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index 4fa18cee03..b196356bc8 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -372,6 +372,44 @@ WHERE "object_type" = ?1 AND "id" = ?2 Ok(result.rows_affected() > 0) } + pub async fn count_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + let row: (i64,) = sqlx::query_as( + r#" +SELECT COUNT(*) FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 +"#, + ) + .bind(object_type) + .bind(workspace) + .fetch_one(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(row.0 as u64) + } + + pub async fn delete_all_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + let result = sqlx::query( + r#" +DELETE FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 +"#, + ) + .bind(object_type) + .bind(workspace) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(result.rows_affected()) + } + pub async fn delete_by_name( &self, object_type: &str, @@ -501,6 +539,36 @@ LIMIT ?3 OFFSET ?4 Ok(filtered) } + + pub async fn list_all_with_selector( + &self, + object_type: &str, + label_selector: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + use super::parse_label_selector; + + let required_labels = parse_label_selector(label_selector)?; + let all_records = self.list_by_type(object_type, u32::MAX, 0).await?; + + let filtered: Vec = all_records + .into_iter() + .filter(|record| { + let labels_json = record.labels.as_deref().unwrap_or("{}"); + let labels: std::collections::HashMap = + serde_json::from_str(labels_json).unwrap_or_default(); + + required_labels + .iter() + .all(|(key, value)| labels.get(key).is_some_and(|v| v == value)) + }) + .skip(offset as usize) + .take(limit as usize) + .collect(); + + Ok(filtered) + } pub async fn put_policy_revision( &self, id: &str, From 6ed495303ea9a2db0533383b29e3e470814d0c65 Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Wed, 15 Jul 2026 09:11:53 -0400 Subject: [PATCH 11/14] fix(workspace): fix review findings and sync RFC with implementation Fix workspace-scoped cleanup bugs: settings deletion used empty workspace instead of sandbox workspace, migration backfill missed sandbox_settings, SSH session cleanup scanned globally instead of by workspace, and workspace deletion did not check for blocking sandbox_settings records. Add K8s resource name length validation to reject names exceeding DNS-1123 63-char limit. Update RFC 0011 to match Phase 1 implementation: document inference RPC rename, UpdateProviderProfiles with optimistic concurrency, Provider profile_workspace field, Python SDK workspace requirements, service hostname breaking change, and persistence validation behavior. Signed-off-by: Derek Carr --- .../openshell-driver-kubernetes/src/driver.rs | 28 +++++++++++++++ .../postgres/006_add_workspace_column.sql | 2 +- .../sqlite/006_add_workspace_column.sql | 2 +- crates/openshell-server/src/compute/mod.rs | 9 ++--- crates/openshell-server/src/grpc/workspace.rs | 1 + rfc/0011-multi-player-design/README.md | 36 ++++++++++++++++--- 6 files changed, 67 insertions(+), 11 deletions(-) diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 6ed8df179b..5288da9e67 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -668,6 +668,7 @@ impl KubernetesComputeDriver { let _ = self .validate_driver_config_for_sandbox(sandbox) .map_err(tonic::Status::invalid_argument)?; + validate_kube_resource_name_length(&sandbox.workspace, &sandbox.name)?; let gpu_requirements = sandbox .spec .as_ref() @@ -1155,6 +1156,19 @@ fn kube_resource_name(workspace: &str, name: &str) -> String { format!("{workspace}--{name}") } +const MAX_KUBE_NAME_LEN: usize = 63; + +fn validate_kube_resource_name_length(workspace: &str, name: &str) -> Result<(), tonic::Status> { + let combined = workspace.len() + 2 + name.len(); // "--" separator + if combined > MAX_KUBE_NAME_LEN { + return Err(tonic::Status::invalid_argument(format!( + "combined Kubernetes resource name '{workspace}--{name}' is {combined} characters, \ + exceeding the DNS-1123 limit of {MAX_KUBE_NAME_LEN}" + ))); + } + Ok(()) +} + fn sandbox_labels(sandbox: &Sandbox) -> BTreeMap { let mut labels = BTreeMap::new(); labels.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone()); @@ -5721,6 +5735,20 @@ mod tests { assert_ne!(alpha, beta); } + #[test] + fn kube_resource_name_length_validation_accepts_short_names() { + validate_kube_resource_name_length("default", "my-sandbox").unwrap(); + } + + #[test] + fn kube_resource_name_length_validation_rejects_oversized_names() { + let long_ws = "a".repeat(40); + let long_name = "b".repeat(25); + let err = validate_kube_resource_name_length(&long_ws, &long_name).unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("67")); + } + #[test] fn sandbox_from_object_reads_annotations() { let obj = DynamicObject { diff --git a/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql index 93df36c439..1b5f9d3884 100644 --- a/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql +++ b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql @@ -4,7 +4,7 @@ ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; -- Backfill workspace-scoped object types to 'default'. UPDATE objects SET workspace = 'default' WHERE workspace = '' AND name IS NOT NULL - AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'policy', 'draft_chunk'); + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'policy', 'draft_chunk', 'sandbox_settings'); -- Replace global name uniqueness with workspace-scoped uniqueness. DROP INDEX IF EXISTS objects_name_uq; diff --git a/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql index 93df36c439..1b5f9d3884 100644 --- a/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql +++ b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql @@ -4,7 +4,7 @@ ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; -- Backfill workspace-scoped object types to 'default'. UPDATE objects SET workspace = 'default' WHERE workspace = '' AND name IS NOT NULL - AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'policy', 'draft_chunk'); + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'policy', 'draft_chunk', 'sandbox_settings'); -- Replace global name uniqueness with workspace-scoped uniqueness. DROP INDEX IF EXISTS objects_name_uq; diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index b75fe88521..17bf422ec7 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -1397,11 +1397,12 @@ impl ComputeRuntime { } async fn cleanup_sandbox_owned_records(&self, sandbox: &Sandbox) { - self.cleanup_sandbox_ssh_sessions(sandbox.object_id()).await; + self.cleanup_sandbox_ssh_sessions(sandbox.object_id(), sandbox.object_workspace()) + .await; if let Err(e) = self .store - .delete_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, "", sandbox.object_name()) + .delete_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, sandbox.object_workspace(), sandbox.object_name()) .await { warn!( @@ -1413,10 +1414,10 @@ impl ComputeRuntime { } } - async fn cleanup_sandbox_ssh_sessions(&self, sandbox_id: &str) { + async fn cleanup_sandbox_ssh_sessions(&self, sandbox_id: &str, workspace: &str) { if let Ok(records) = self .store - .list_by_type(SshSession::object_type(), 1000, 0) + .list(SshSession::object_type(), workspace, 1000, 0) .await { for record in records { diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index ab1685c074..4189eb2c6d 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -198,6 +198,7 @@ pub(super) async fn handle_delete_workspace( (ServiceEndpoint::object_type(), "service"), (InferenceRoute::object_type(), "inference route"), (SshSession::object_type(), "ssh session"), + (super::policy::SANDBOX_SETTINGS_OBJECT_TYPE, "sandbox settings"), ] { let records = state .store diff --git a/rfc/0011-multi-player-design/README.md b/rfc/0011-multi-player-design/README.md index 92fe2de0bf..952921aeba 100644 --- a/rfc/0011-multi-player-design/README.md +++ b/rfc/0011-multi-player-design/README.md @@ -907,6 +907,17 @@ openshell provider profile list --workspace team-ml openshell provider profile list --global ``` +### Python SDK Surface + +The Python SDK requires `workspace` as an explicit keyword-only argument on +all workspace-scoped operations (`create()`, `get()`, `delete()`, `list()`, +etc.). Unlike the CLI, the SDK does not default to `"default"` — programmatic +callers must always specify the target workspace. This is a deliberate design +choice: agents and automation scripts should be explicit about which workspace +they operate on, and a silent default could mask workspace-routing bugs. +Passing `workspace=None` to `list()` uses `all_workspaces=True` for +cross-workspace queries. + ## Implementation plan The implementation builds on the existing authentication, RBAC, and OCSF @@ -925,7 +936,16 @@ foundations. The work can be phased to deliver value incrementally: and visible only within their workspace. Each scope is listed independently — `ListProviderProfiles` returns profiles from the requested scope plus built-ins, not a merged view across scopes. Built-in profiles are included - in both scopes for convenience. Implement workspace-scoped + in both scopes for convenience. Add `UpdateProviderProfiles` RPC for + in-place updates to existing custom profiles, with optimistic concurrency + control via a `resource_version` field on `ProviderProfile` — updates must + supply the current version to prevent stale overwrites. + The `Provider` datamodel message gains a + `profile_workspace` field that binds a provider to a specific workspace's + profile scope — when set, the provider resolves profiles from that workspace + rather than the platform scope, enabling workspace-scoped provider + configuration without duplicating the provider record itself. + Implement workspace-scoped storage and filtering in gRPC handlers. Add the membership store with `(workspace, principal_subject) → role` records and `AddWorkspaceMember`, `RemoveWorkspaceMember`, `ListWorkspaceMembers` RPCs. Create the `default` workspace on gateway @@ -941,8 +961,10 @@ foundations. The work can be phased to deliver value incrementally: without knowing whether the default workspace was omitted. The consistent three-segment format makes parsing unambiguous: two segments is `{workspace}--{sandbox}`, three is `{workspace}--{sandbox}--{service}`. + This is a breaking change: existing two-segment hostnames + (`{sandbox}--{service}.{domain}`) will fail to parse after the upgrade. Backward compatibility is desirable but not a hard requirement - at this stage — existing users may need to recreate resources when upgrading. + at this stage — existing users must recreate service endpoints when upgrading. Add a cross-workspace `list_by_type(object_type, limit, offset)` store method for internal infrastructure operations (reconciler, resume, provider refresh) that need to query workspace-scoped resources across all workspaces. Thread @@ -951,9 +973,13 @@ foundations. The work can be phased to deliver value incrementally: multiple workspaces, `provider_name` alone is insufficient because different workspaces can have same-named providers. Add `all_workspaces` field to workspace-scoped list RPCs for Platform Admin cross-workspace visibility. - Add `ObjectWorkspace::requires_workspace()` trait method and `debug_assert!` - validation in store write helpers to catch workspace-scoped resources - persisted with an empty workspace in debug builds. + Add `ObjectWorkspace::requires_workspace()` trait method and validation in + store write helpers (`put_message`, `put_scoped_message`) that returns an + error when a workspace-scoped resource is persisted with an empty workspace. + Rename inference RPCs from `SetClusterInference`/`GetClusterInference` to + `SetInferenceRoute`/`GetInferenceRoute` (and `ClusterInferenceConfig` to + `InferenceRouteConfig`) to reflect that inference routes are now + workspace-scoped rather than cluster-global. - **Phase 2: Expanded role model and authorization enforcement.** Extend the RBAC system from two-tier (admin/user) to three user roles (Platform Admin, From d4f6c75bfaaa5bb7ff028ac6b769583419b47065 Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Wed, 15 Jul 2026 17:45:06 -0400 Subject: [PATCH 12/14] fix(workspace): address PR review findings and fix clippy errors Fix bugs found during PR #2243 review: correct migration type strings, resolve platform-scoped profile workspace handling, fix sandbox workspace_tx single-fire, add missing workspace deletion blockers, reset TUI row indices on workspace switch, fix TUI provider update workspace, and update e2e Python tests for workspace and inference route API changes. Add Phase 2 TODO comments for authorization gaps. Fix pre-existing clippy errors (too_many_arguments, cast_sign_loss, iter_on_single_items). Signed-off-by: Derek Carr --- crates/openshell-cli/src/ssh.rs | 1 + crates/openshell-sandbox/src/lib.rs | 5 ++- .../postgres/006_add_workspace_column.sql | 4 +- .../sqlite/006_add_workspace_column.sql | 4 +- crates/openshell-server/src/grpc/mod.rs | 12 ++++++ crates/openshell-server/src/grpc/policy.rs | 2 + crates/openshell-server/src/grpc/provider.rs | 36 ++++++++---------- crates/openshell-server/src/grpc/workspace.rs | 38 ++++++++++++++++++- .../openshell-server/src/persistence/mod.rs | 5 +++ .../src/persistence/postgres.rs | 2 +- .../src/persistence/sqlite.rs | 2 +- .../openshell-server/src/service_routing.rs | 5 +++ crates/openshell-tui/src/app.rs | 4 ++ crates/openshell-tui/src/lib.rs | 2 +- e2e/python/conftest.py | 1 + e2e/python/test_inference_routing.py | 30 +++++++++------ python/openshell/sandbox.py | 2 + rfc/0011-multi-player-design/README.md | 6 +++ 18 files changed, 120 insertions(+), 41 deletions(-) diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index cfae779df8..50bc2bbee9 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -956,6 +956,7 @@ fn resolve_file_download_target( /// Files are streamed as a tar archive to `ssh ... tar xf - -C ` on /// the sandbox side. When `dest` is `None`, files are uploaded to the /// sandbox user's home directory. +#[allow(clippy::too_many_arguments)] pub async fn sandbox_sync_up_files( server: &str, name: &str, diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 78a2c62dd4..e9adc0c0c2 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -2386,7 +2386,10 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { } else { tokio::time::sleep(interval).await; match client.poll_settings(&ctx.sandbox_id).await { - Ok(result) => result, + Ok(result) => { + let _ = ctx.workspace_tx.send(client.workspace()); + result + } Err(e) => { debug!(error = %e, "Settings poll: server unreachable, will retry"); continue; diff --git a/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql index 1b5f9d3884..a4b4dea97d 100644 --- a/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql +++ b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql @@ -2,9 +2,11 @@ ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; -- Backfill workspace-scoped object types to 'default'. +-- stored_provider_profile is intentionally omitted: profiles use workspace="" +-- for platform scope and are created with an explicit workspace when scoped. UPDATE objects SET workspace = 'default' WHERE workspace = '' AND name IS NOT NULL - AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'policy', 'draft_chunk', 'sandbox_settings'); + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'sandbox_policy', 'draft_policy_chunk', 'sandbox_settings'); -- Replace global name uniqueness with workspace-scoped uniqueness. DROP INDEX IF EXISTS objects_name_uq; diff --git a/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql index 1b5f9d3884..a4b4dea97d 100644 --- a/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql +++ b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql @@ -2,9 +2,11 @@ ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; -- Backfill workspace-scoped object types to 'default'. +-- stored_provider_profile is intentionally omitted: profiles use workspace="" +-- for platform scope and are created with an explicit workspace when scoped. UPDATE objects SET workspace = 'default' WHERE workspace = '' AND name IS NOT NULL - AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'policy', 'draft_chunk', 'sandbox_settings'); + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'sandbox_policy', 'draft_policy_chunk', 'sandbox_settings'); -- Replace global name uniqueness with workspace-scoped uniqueness. DROP INDEX IF EXISTS objects_name_uq; diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index efd7fe602d..51dd33b316 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -249,6 +249,9 @@ impl OpenShell for OpenShellService { type WatchSandboxStream = ReceiverStream>; + // TODO(phase2): data-plane RPCs do not carry a workspace field. Add + // workspace verification to confirm the sandbox belongs to the caller's + // workspace before proxying. #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn watch_sandbox( &self, @@ -265,6 +268,8 @@ impl OpenShell for OpenShellService { sandbox::handle_get_sandbox(&self.state, request).await } + // TODO(phase2): all_workspaces flag is currently accessible to any + // authenticated user. Restrict to Platform Admin role in Phase 2. #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn list_sandboxes( &self, @@ -309,6 +314,7 @@ impl OpenShell for OpenShellService { type ExecSandboxStream = ReceiverStream>; + // TODO(phase2): no workspace field — see watch_sandbox comment. #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn exec_sandbox( &self, @@ -320,6 +326,7 @@ impl OpenShell for OpenShellService { type ForwardTcpStream = Pin> + Send + 'static>>; + // TODO(phase2): no workspace field — see watch_sandbox comment. #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn forward_tcp( &self, @@ -340,6 +347,7 @@ impl OpenShell for OpenShellService { // --- SSH sessions --- + // TODO(phase2): no workspace field — see watch_sandbox comment. #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn create_ssh_session( &self, @@ -364,6 +372,8 @@ impl OpenShell for OpenShellService { service::handle_get_service(&self.state, request).await } + // TODO(phase2): all_workspaces flag is currently accessible to any + // authenticated user. Restrict to Platform Admin role in Phase 2. #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn list_services( &self, @@ -406,6 +416,8 @@ impl OpenShell for OpenShellService { provider::handle_get_provider(&self.state, request).await } + // TODO(phase2): all_workspaces flag is currently accessible to any + // authenticated user. Restrict to Platform Admin role in Phase 2. #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn list_providers( &self, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index e61a4f2492..2127fa6294 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -2423,6 +2423,8 @@ pub(super) async fn handle_get_sandbox_logs( request: Request, ) -> Result, Status> { let req = request.into_inner(); + // TODO(phase2): workspace is resolved but not used for authorization. + // Verify the sandbox belongs to this workspace before returning logs. let _workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.sandbox_id.is_empty() { diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index ddbc06cf40..0359d83ab9 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1333,7 +1333,8 @@ pub(super) async fn handle_list_provider_profiles( ) -> Result, Status> { let request = request.into_inner(); let workspace = - super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; + super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) + .await?; let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE) as usize; let offset = request.offset as usize; let mut profiles = merged_provider_profiles(state.store.as_ref(), &workspace).await?; @@ -1353,7 +1354,7 @@ pub(super) async fn handle_get_provider_profile( ) -> Result, Status> { let req = request.into_inner(); let workspace = - super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + super::workspace::resolve_profile_workspace(state.store.as_ref(), &req.workspace).await?; let id = req.id; let id = normalize_profile_id_request(&id)?; let profile = get_provider_type_profile(state.store.as_ref(), &workspace, &id) @@ -1372,7 +1373,8 @@ pub(super) async fn handle_import_provider_profiles( ) -> Result, Status> { let request = request.into_inner(); let workspace = - super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; + super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) + .await?; let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; @@ -1438,7 +1440,8 @@ pub(super) async fn handle_update_provider_profiles( ) -> Result, Status> { let request = request.into_inner(); let workspace = - super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; + super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) + .await?; let items = request.profile.into_iter().collect::>(); let (profiles, mut diagnostics) = profiles_from_import_items(&items); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); @@ -1553,7 +1556,8 @@ pub(super) async fn handle_lint_provider_profiles( ) -> Result, Status> { let request = request.into_inner(); let workspace = - super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; + super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) + .await?; let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); diagnostics @@ -1573,7 +1577,7 @@ pub(super) async fn handle_delete_provider_profile( ) -> Result, Status> { let req = request.into_inner(); let workspace = - super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + super::workspace::resolve_profile_workspace(state.store.as_ref(), &req.workspace).await?; let id = req.id; let id = normalize_profile_id_request(&id)?; let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; @@ -7086,9 +7090,7 @@ mod tests { workspace: "default".to_string(), }), r#type: "claude".to_string(), - credentials: [("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())] - .into_iter() - .collect(), + credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "other-workspace".to_string(), @@ -7113,9 +7115,7 @@ mod tests { workspace: "default".to_string(), }), r#type: "claude".to_string(), - credentials: [("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())] - .into_iter() - .collect(), + credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), @@ -7139,9 +7139,7 @@ mod tests { workspace: "default".to_string(), }), r#type: "claude".to_string(), - credentials: [("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())] - .into_iter() - .collect(), + credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), @@ -7165,9 +7163,7 @@ mod tests { workspace: "default".to_string(), }), r#type: "claude".to_string(), - credentials: [("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())] - .into_iter() - .collect(), + credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), @@ -7260,9 +7256,7 @@ mod tests { workspace: "default".to_string(), }), r#type: "ws-custom".to_string(), - credentials: [("TOKEN".to_string(), "val".to_string())] - .into_iter() - .collect(), + credentials: HashMap::from([("TOKEN".to_string(), "val".to_string())]), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index 4189eb2c6d..b77cb42534 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -15,13 +15,16 @@ use openshell_core::proto::{ GetWorkspaceResponse, InferenceRoute, ListWorkspaceMembersRequest, ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, Provider, RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, Sandbox, ServiceEndpoint, - SshSession, StoredProviderProfile, Workspace, WorkspaceMember, WorkspaceRole, + SshSession, StoredProviderCredentialRefreshState, StoredProviderProfile, Workspace, + WorkspaceMember, WorkspaceRole, }; use prost::Message; use tonic::{Request, Response, Status}; use crate::ServerState; -use crate::persistence::{ObjectType, WriteCondition, current_time_ms}; +use crate::persistence::{ + DRAFT_CHUNK_OBJECT_TYPE, ObjectType, POLICY_OBJECT_TYPE, WriteCondition, current_time_ms, +}; use super::{MAX_PAGE_SIZE, clamp_limit}; @@ -48,10 +51,30 @@ fn validate_workspace_name(name: &str) -> Result<(), Status> { super::validation::validate_dns1123_label(name, "workspace name") } +/// Resolve a workspace for provider profile operations. +/// +/// Provider profiles support a platform scope where `""` is a distinct, +/// meaningful value (not an alias for `"default"`). This function preserves +/// `""` as-is for platform-scoped operations. Non-empty workspace values are +/// validated for existence via [`resolve_workspace`]. +pub async fn resolve_profile_workspace( + store: &crate::persistence::Store, + workspace: &str, +) -> Result { + if workspace.is_empty() { + return Ok(String::new()); + } + resolve_workspace(store, workspace).await +} + /// Resolve and validate a workspace name from a request field. /// /// Empty strings are normalized to `"default"`. The workspace must exist in the /// store; returns `NOT_FOUND` if it doesn't. +/// +/// TODO(phase2): this only validates existence. Workspace membership enforcement +/// (checking the caller is a member of the resolved workspace) is deferred to +/// Phase 2. pub async fn resolve_workspace( store: &crate::persistence::Store, workspace: &str, @@ -190,7 +213,15 @@ pub(super) async fn handle_delete_workspace( )); } + // TOCTOU: the blocker scan and the subsequent delete are not transactional, + // so a resource could be created between the check and the delete. The + // persistence layer does not currently offer a transaction primitive that + // spans both reads and deletes. Acceptable for now — workspace deletion is + // an admin-only, low-frequency operation. let mut blocking = Vec::new(); + // Policy revisions and draft chunks are normally deleted when their parent + // sandbox is removed, and refresh state is transient; these checks are a + // safety net against regressions. for (object_type, label) in [ (Sandbox::object_type(), "sandbox"), (Provider::object_type(), "provider"), @@ -199,6 +230,9 @@ pub(super) async fn handle_delete_workspace( (InferenceRoute::object_type(), "inference route"), (SshSession::object_type(), "ssh session"), (super::policy::SANDBOX_SETTINGS_OBJECT_TYPE, "sandbox settings"), + (POLICY_OBJECT_TYPE, "sandbox policy"), + (DRAFT_CHUNK_OBJECT_TYPE, "draft policy chunk"), + (StoredProviderCredentialRefreshState::object_type(), "credential refresh state"), ] { let records = state .store diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index 98ab247bdc..d5e4216f70 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -140,6 +140,11 @@ pub fn generate_name() -> String { /// Decode a single [`ObjectRecord`] into a protobuf message, hydrating /// `resource_version` from the authoritative DB row. /// +/// Only `resource_version` is hydrated here; `workspace` is NOT backfilled from +/// the DB column because the workspace field is authoritative in the protobuf +/// payload at creation time. This is a breaking upgrade — pre-workspace records +/// will carry an empty workspace until they are re-created. +/// /// Extracted to avoid repeating the identical decode-and-hydrate block across /// `get_message`, `get_message_by_name`, `list_messages`, and /// `list_messages_with_selector`. diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index 0de003bb02..b33ed669ad 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -361,7 +361,7 @@ WHERE object_type = $1 AND workspace = $2 AND name = $3 .fetch_one(&self.pool) .await .map_err(|e| map_db_error(&e))?; - Ok(row.0 as u64) + Ok(u64::try_from(row.0).unwrap_or(0)) } pub async fn delete_all_in_workspace( diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index b196356bc8..b594d07c8a 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -388,7 +388,7 @@ WHERE "object_type" = ?1 AND "workspace" = ?2 .fetch_one(&self.pool) .await .map_err(|e| map_db_error(&e))?; - Ok(row.0 as u64) + Ok(u64::try_from(row.0).unwrap_or(0)) } pub async fn delete_all_in_workspace( diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 305e2777bc..2aeb272738 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -74,6 +74,11 @@ fn endpoint_scheme(config: &openshell_core::Config) -> &'static str { } } +// The DNS label before the base domain can exceed the 63-character limit +// (RFC 1035) when workspace + sandbox + service names are long. This is a +// pre-existing gap — sandbox names alone could already exceed the limit +// before workspace scoping was added. A future validation pass should +// enforce the combined label length at creation time. fn endpoint_host( config: &ServiceRoutingConfig, workspace: &str, diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 9dce564dd1..567a8ad4a3 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -1092,6 +1092,10 @@ impl App { } } } + // Reset selection indices so the cursor doesn't point past the end + // of the new workspace's (potentially shorter) sandbox/provider lists. + self.sandbox_selected = 0; + self.provider_selected = 0; self.pending_workspace_refresh = true; } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 94dc628a81..8f112452da 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1743,7 +1743,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { let ptype = form.provider_type.clone(); let cred_key = form.credential_key.clone(); let new_value = form.new_value.clone(); - let workspace = app.current_workspace.clone(); + let workspace = app.selected_provider_workspace(); tokio::spawn(async move { let mut credentials = HashMap::new(); diff --git a/e2e/python/conftest.py b/e2e/python/conftest.py index 07b7feb34b..a5fa03cd99 100644 --- a/e2e/python/conftest.py +++ b/e2e/python/conftest.py @@ -56,6 +56,7 @@ def ensure_sandbox_persistence_ready(sandbox_client: SandboxClient) -> None: def sandbox(cluster_name: str | None) -> Callable[..., Sandbox]: def _create(*, spec: object | None = None, delete_on_exit: bool = True) -> Sandbox: return Sandbox( + workspace="default", cluster=cluster_name, spec=spec, delete_on_exit=delete_on_exit, diff --git a/e2e/python/test_inference_routing.py b/e2e/python/test_inference_routing.py index e4a1dd5497..0e971f5b89 100644 --- a/e2e/python/test_inference_routing.py +++ b/e2e/python/test_inference_routing.py @@ -23,8 +23,8 @@ from collections.abc import Callable, Iterator from openshell import ( - ClusterInferenceConfig, InferenceRouteClient, + InferenceRouteConfig, Sandbox, SandboxClient, ) @@ -78,7 +78,9 @@ def _upsert_managed_inference( for _ in range(5): try: sandbox_client._stub.UpdateProvider( - openshell_pb2.UpdateProviderRequest(provider=provider), + openshell_pb2.UpdateProviderRequest( + provider=provider, workspace="default" + ), timeout=timeout, ) break @@ -88,7 +90,9 @@ def _upsert_managed_inference( try: sandbox_client._stub.CreateProvider( - openshell_pb2.CreateProviderRequest(provider=provider), + openshell_pb2.CreateProviderRequest( + provider=provider, workspace="default" + ), timeout=timeout, ) break @@ -99,31 +103,33 @@ def _upsert_managed_inference( else: raise RuntimeError("failed to upsert managed e2e provider after retries") - inference_client.set_cluster( + inference_client.set_route( + workspace="default", provider_name=provider_name, model_id=model_id, ) -def _current_cluster_inference( +def _current_route( inference_client: InferenceRouteClient, -) -> ClusterInferenceConfig | None: +) -> InferenceRouteConfig | None: try: - return inference_client.get_cluster() + return inference_client.get_route(workspace="default") except grpc.RpcError as exc: if exc.code() == grpc.StatusCode.NOT_FOUND: return None raise -def _restore_cluster_inference( +def _restore_route( inference_client: InferenceRouteClient, - previous: ClusterInferenceConfig | None, + previous: InferenceRouteConfig | None, ) -> None: if previous is None: return - inference_client.set_cluster( + inference_client.set_route( + workspace="default", provider_name=previous.provider_name, model_id=previous.model_id, # Teardown restores prior shared state as-is, even if the previous @@ -148,7 +154,7 @@ def managed_openai_route( sandbox_client: SandboxClient, ) -> Iterator[str]: with _cluster_config_lock(): - previous = _current_cluster_inference(inference_client) + previous = _current_route(inference_client) _upsert_managed_inference( inference_client, sandbox_client, @@ -162,7 +168,7 @@ def managed_openai_route( try: yield _MANAGED_OPENAI_MODEL_ID finally: - _restore_cluster_inference(inference_client, previous) + _restore_route(inference_client, previous) def test_model_discovery_call_routed_to_backend( diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 774383332b..0c8cf1d232 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -479,6 +479,8 @@ def list( offset: int = 0, label_selector: str | None = None, ) -> builtins.list[SandboxRef]: + # workspace=None lists across all workspaces (all_workspaces=True). + # Pass an explicit workspace string to scope to a single workspace. if workspace is not None: request = openshell_pb2.ListSandboxesRequest( workspace=workspace, diff --git a/rfc/0011-multi-player-design/README.md b/rfc/0011-multi-player-design/README.md index 952921aeba..376804fb85 100644 --- a/rfc/0011-multi-player-design/README.md +++ b/rfc/0011-multi-player-design/README.md @@ -980,6 +980,12 @@ foundations. The work can be phased to deliver value incrementally: `SetInferenceRoute`/`GetInferenceRoute` (and `ClusterInferenceConfig` to `InferenceRouteConfig`) to reflect that inference routes are now workspace-scoped rather than cluster-global. + **Additional breaking changes:** The Docker/Podman container label key + changes from `openshell.sandbox_namespace` to `openshell.workspace` — + existing containers will not be discovered after the upgrade; delete all + sandboxes before upgrading. SSH config host aliases change from + `{sandbox}.openshell` to `{workspace}/{sandbox}.openshell` — existing + SSH configs must be regenerated after the upgrade. - **Phase 2: Expanded role model and authorization enforcement.** Extend the RBAC system from two-tier (admin/user) to three user roles (Platform Admin, From 214a360625cb8ea89373a6c76c511cae7c9a9d2b Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Wed, 15 Jul 2026 18:13:29 -0400 Subject: [PATCH 13/14] test(workspace): add profile scope isolation tests and fix e2e workspace args Add Rust handler test and Python e2e test verifying that platform-scoped and workspace-scoped provider profiles are isolated from each other. Fix missing workspace keyword arguments in test_sandbox_api.py and test_sandbox_providers.py that caused TypeErrors. Signed-off-by: Derek Carr --- crates/openshell-server/src/grpc/provider.rs | 100 +++++++++++++++++++ e2e/python/test_sandbox_api.py | 24 +++-- e2e/python/test_sandbox_providers.py | 77 +++++++++++++- 3 files changed, 190 insertions(+), 11 deletions(-) diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 0359d83ab9..51b95811c9 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -7270,4 +7270,104 @@ mod tests { .unwrap(); assert!(profile.is_some()); } + + #[tokio::test] + async fn import_provider_profile_platform_and_workspace_scopes_are_isolated() { + let state = test_server_state().await; + + let import = |id: &str, workspace: &str| { + let state = state.clone(); + let id = id.to_string(); + let workspace = workspace.to_string(); + async move { + handle_import_provider_profiles( + &state, + Request::new(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile(&id)), + source: format!("{id}.yaml"), + }], + workspace, + }), + ) + .await + .unwrap() + .into_inner() + } + }; + + let resp = import("e2e-platform", "").await; + assert!(resp.imported); + + let resp = import("e2e-workspace", "default").await; + assert!(resp.imported); + + let list = |workspace: &str| { + let state = state.clone(); + let workspace = workspace.to_string(); + async move { + handle_list_provider_profiles( + &state, + Request::new(ListProviderProfilesRequest { + limit: 200, + offset: 0, + workspace, + }), + ) + .await + .unwrap() + .into_inner() + } + }; + + let platform_profiles = list("").await; + assert!( + platform_profiles + .profiles + .iter() + .any(|p| p.id == "e2e-platform"), + "platform-scoped profile should appear in platform list" + ); + assert!( + !platform_profiles + .profiles + .iter() + .any(|p| p.id == "e2e-workspace"), + "workspace-scoped profile should NOT appear in platform list" + ); + + let workspace_profiles = list("default").await; + assert!( + workspace_profiles + .profiles + .iter() + .any(|p| p.id == "e2e-workspace"), + "workspace-scoped profile should appear in workspace list" + ); + assert!( + !workspace_profiles + .profiles + .iter() + .any(|p| p.id == "e2e-platform"), + "platform-scoped profile should NOT appear in workspace list" + ); + + let delete = |id: &str, workspace: &str| { + let state = state.clone(); + let id = id.to_string(); + let workspace = workspace.to_string(); + async move { + handle_delete_provider_profile( + &state, + Request::new(DeleteProviderProfileRequest { id, workspace }), + ) + .await + .unwrap() + .into_inner() + } + }; + + assert!(delete("e2e-platform", "").await.deleted); + assert!(delete("e2e-workspace", "default").await.deleted); + } } diff --git a/e2e/python/test_sandbox_api.py b/e2e/python/test_sandbox_api.py index 929728d7c3..46c1106ad8 100644 --- a/e2e/python/test_sandbox_api.py +++ b/e2e/python/test_sandbox_api.py @@ -36,7 +36,7 @@ def read(self, path: str) -> str: ) assert all(p.isalpha() and p.islower() for p in parts) - fetched = sandbox_client.get(sb.sandbox.name) + fetched = sandbox_client.get(sb.sandbox.name, workspace="default") assert fetched.id == sb.id ids = set(sandbox_client.list_ids(limit=100)) @@ -73,18 +73,22 @@ def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: created: list[str] = [] try: ref_a = sandbox_client.create( - name=job_a, labels={"aiq-test": suffix, "role": "primary"} + workspace="default", + name=job_a, + labels={"aiq-test": suffix, "role": "primary"}, ) created.append(ref_a.name) ref_b = sandbox_client.create( - name=job_b, labels={"aiq-test": suffix, "role": "secondary"} + workspace="default", + name=job_b, + labels={"aiq-test": suffix, "role": "secondary"}, ) created.append(ref_b.name) # Labels round-trip through create and get. assert ref_a.labels["role"] == "primary" - assert dict(sandbox_client.get(job_a).labels)["role"] == "primary" - assert dict(sandbox_client.get(job_b).labels)["role"] == "secondary" + assert dict(sandbox_client.get(job_a, workspace="default").labels)["role"] == "primary" + assert dict(sandbox_client.get(job_b, workspace="default").labels)["role"] == "secondary" # A specific selector filters to exactly the primary sandbox. assert { @@ -97,19 +101,19 @@ def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: } # Deleting one removes only it from selector results. - assert sandbox_client.delete(job_a) - sandbox_client.wait_deleted(job_a) + assert sandbox_client.delete(job_a, workspace="default") + sandbox_client.wait_deleted(job_a, workspace="default") created.remove(job_a) assert {s.name for s in sandbox_client.list(label_selector=group_selector)} == { job_b } # Final deletion leaves no matching sandboxes. - assert sandbox_client.delete(job_b) - sandbox_client.wait_deleted(job_b) + assert sandbox_client.delete(job_b, workspace="default") + sandbox_client.wait_deleted(job_b, workspace="default") created.remove(job_b) assert not sandbox_client.list(label_selector=group_selector) finally: for name in created: with contextlib.suppress(Exception): - sandbox_client.delete(name) + sandbox_client.delete(name, workspace="default") diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index 083a424921..eac4606060 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -285,7 +285,7 @@ def test_create_sandbox_rejects_unknown_provider( providers=["nonexistent-provider-xyz"], ) with pytest.raises(grpc.RpcError) as exc_info: - sandbox_client.create(spec=spec) + sandbox_client.create(workspace="default", spec=spec) assert exc_info.value.code() == grpc.StatusCode.FAILED_PRECONDITION assert "nonexistent-provider-xyz" in (exc_info.value.details() or "") @@ -484,3 +484,78 @@ def test_update_provider_rejects_type_change( assert "type cannot be changed" in exc_info.value.details() finally: _delete_provider(stub, name) + + +# =========================================================================== +# Tests: provider profile platform vs workspace scope isolation +# =========================================================================== + + +def test_provider_profile_platform_vs_workspace_isolation( + sandbox_client: "SandboxClient", +) -> None: + """Platform-scoped (workspace='') and workspace-scoped profiles are isolated.""" + stub = sandbox_client._stub + platform_id = "e2e-platform-profile" + workspace_id = "e2e-workspace-profile" + + def _make_profile(profile_id: str) -> openshell_pb2.ProviderProfileImportItem: + return openshell_pb2.ProviderProfileImportItem( + profile=openshell_pb2.ProviderProfile( + id=profile_id, + display_name=f"{profile_id} display", + category=openshell_pb2.PROVIDER_PROFILE_CATEGORY_OTHER, + ), + source=f"{profile_id}.yaml", + ) + + def _cleanup() -> None: + for pid, ws in [(platform_id, ""), (workspace_id, "default")]: + try: + stub.DeleteProviderProfile( + openshell_pb2.DeleteProviderProfileRequest(id=pid, workspace=ws) + ) + except grpc.RpcError: + pass + + _cleanup() + try: + resp = stub.ImportProviderProfiles( + openshell_pb2.ImportProviderProfilesRequest( + profiles=[_make_profile(platform_id)], + workspace="", + ) + ) + assert resp.imported, "platform-scoped import should succeed" + + resp = stub.ImportProviderProfiles( + openshell_pb2.ImportProviderProfilesRequest( + profiles=[_make_profile(workspace_id)], + workspace="default", + ) + ) + assert resp.imported, "workspace-scoped import should succeed" + + platform_list = stub.ListProviderProfiles( + openshell_pb2.ListProviderProfilesRequest(limit=200, workspace="") + ) + platform_ids = [p.id for p in platform_list.profiles] + assert platform_id in platform_ids, ( + "platform profile should appear in platform list" + ) + assert workspace_id not in platform_ids, ( + "workspace profile should NOT appear in platform list" + ) + + workspace_list = stub.ListProviderProfiles( + openshell_pb2.ListProviderProfilesRequest(limit=200, workspace="default") + ) + workspace_ids = [p.id for p in workspace_list.profiles] + assert workspace_id in workspace_ids, ( + "workspace profile should appear in workspace list" + ) + assert platform_id not in workspace_ids, ( + "platform profile should NOT appear in workspace list" + ) + finally: + _cleanup() From 6d05bdf2daf2170d9322a1a0090bc70c9e59dcd7 Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Wed, 15 Jul 2026 18:21:46 -0400 Subject: [PATCH 14/14] fix(workspace): fix workspace mismatches in reconciler and e2e tests Fix reconciler test that stored sandbox settings with workspace "" while the sandbox itself used "default", causing the cleanup delete to miss the record. Fix e2e tests missing the required workspace keyword argument on SandboxClient.create, .get, .delete, and .wait_deleted calls. Signed-off-by: Derek Carr --- crates/openshell-server/src/compute/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 17bf422ec7..bb8c6262b1 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -3233,7 +3233,7 @@ mod tests { SANDBOX_SETTINGS_OBJECT_TYPE, "settings-sb-1", sandbox.object_name(), - "", + sandbox.object_workspace(), br#"{"revision":1,"settings":{}}"#, None, ) @@ -3266,7 +3266,7 @@ mod tests { assert!( runtime .store - .get_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, "", sandbox.object_name()) + .get_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, sandbox.object_workspace(), sandbox.object_name()) .await .unwrap() .is_none()