diff --git a/crates/openshell-bootstrap/src/metadata.rs b/crates/openshell-bootstrap/src/metadata.rs index 851d39de54..fe4c277bd2 100644 --- a/crates/openshell-bootstrap/src/metadata.rs +++ b/crates/openshell-bootstrap/src/metadata.rs @@ -275,10 +275,14 @@ pub fn load_active_gateway() -> Option { } /// Save the last-used sandbox name for a gateway to persistent storage. -pub fn save_last_sandbox(gateway: &str, sandbox: &str) -> Result<()> { +/// +/// The workspace is stored alongside the name so that `load_last_sandbox` +/// only returns a sandbox that belongs to the requested workspace. +pub fn save_last_sandbox(gateway: &str, workspace: &str, sandbox: &str) -> Result<()> { let path = last_sandbox_path(gateway)?; ensure_parent_dir_restricted(&path)?; - std::fs::write(&path, sandbox) + let content = format!("{workspace}\n{sandbox}"); + std::fs::write(&path, content) .into_diagnostic() .wrap_err_with(|| format!("failed to write last sandbox to {}", path.display()))?; Ok(()) @@ -286,20 +290,31 @@ pub fn save_last_sandbox(gateway: &str, sandbox: &str) -> Result<()> { /// Load the last-used sandbox name for a gateway from persistent storage. /// -/// Returns `None` if no last sandbox has been set. -pub fn load_last_sandbox(gateway: &str) -> Option { - last_sandbox_path(gateway) +/// Returns the sandbox name only if the stored workspace matches the +/// requested workspace. Returns `None` if no last sandbox has been set +/// or the workspace does not match. +pub fn load_last_sandbox(gateway: &str, workspace: &str) -> Option { + let content = last_sandbox_path(gateway) .ok() .as_deref() - .and_then(read_nonempty_trimmed) + .and_then(read_nonempty_trimmed)?; + let mut lines = content.lines(); + let stored_workspace = lines.next()?.trim(); + let sandbox = lines.next().map(str::trim).filter(|s| !s.is_empty())?; + if stored_workspace == workspace { + Some(sandbox.to_string()) + } else { + None + } } -/// Clear the last-used sandbox record for a gateway if it matches the given name. +/// Clear the last-used sandbox record for a gateway if it matches the given +/// workspace and name. /// /// This should be called after a sandbox is deleted so that subsequent commands /// don't try to connect to a sandbox that no longer exists. -pub fn clear_last_sandbox_if_matches(gateway: &str, sandbox: &str) { - if let Some(current) = load_last_sandbox(gateway) +pub fn clear_last_sandbox_if_matches(gateway: &str, workspace: &str, sandbox: &str) { + if let Some(current) = load_last_sandbox(gateway, workspace) && current == sandbox && let Ok(path) = last_sandbox_path(gateway) { @@ -507,8 +522,11 @@ mod tests { fn save_and_load_last_sandbox_roundtrip() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - save_last_sandbox("mygateway", "dev-box").unwrap(); - assert_eq!(load_last_sandbox("mygateway"), Some("dev-box".to_string())); + save_last_sandbox("mygateway", "default", "dev-box").unwrap(); + assert_eq!( + load_last_sandbox("mygateway", "default"), + Some("dev-box".to_string()) + ); }); } @@ -516,7 +534,7 @@ mod tests { fn load_last_sandbox_returns_none_when_not_set() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - assert_eq!(load_last_sandbox("no-such-gateway"), None); + assert_eq!(load_last_sandbox("no-such-gateway", "default"), None); }); } @@ -524,9 +542,12 @@ mod tests { fn save_last_sandbox_overwrites_previous() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - save_last_sandbox("g1", "first").unwrap(); - save_last_sandbox("g1", "second").unwrap(); - assert_eq!(load_last_sandbox("g1"), Some("second".to_string())); + save_last_sandbox("g1", "default", "first").unwrap(); + save_last_sandbox("g1", "default", "second").unwrap(); + assert_eq!( + load_last_sandbox("g1", "default"), + Some("second".to_string()) + ); }); } @@ -534,24 +555,22 @@ mod tests { fn save_last_sandbox_creates_parent_dirs() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - // The gateway subdir doesn't exist yet — save should create it. - save_last_sandbox("brand-new-gateway", "sb1").unwrap(); + save_last_sandbox("brand-new-gateway", "default", "sb1").unwrap(); assert_eq!( - load_last_sandbox("brand-new-gateway"), + load_last_sandbox("brand-new-gateway", "default"), Some("sb1".to_string()) ); }); } #[test] - fn load_last_sandbox_ignores_whitespace() { + fn load_last_sandbox_returns_none_for_legacy_single_line_file() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - // Write the file manually with surrounding whitespace. let path = last_sandbox_path("ws-gateway").unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, " my-sb \n").unwrap(); - assert_eq!(load_last_sandbox("ws-gateway"), Some("my-sb".to_string())); + assert_eq!(load_last_sandbox("ws-gateway", "default"), None); }); } @@ -562,7 +581,7 @@ mod tests { let path = last_sandbox_path("empty-gateway").unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, " \n").unwrap(); - assert_eq!(load_last_sandbox("empty-gateway"), None); + assert_eq!(load_last_sandbox("empty-gateway", "default"), None); }); } @@ -570,19 +589,32 @@ mod tests { fn last_sandbox_is_per_gateway() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - save_last_sandbox("gateway-a", "sandbox-a").unwrap(); - save_last_sandbox("gateway-b", "sandbox-b").unwrap(); + save_last_sandbox("gateway-a", "default", "sandbox-a").unwrap(); + save_last_sandbox("gateway-b", "default", "sandbox-b").unwrap(); assert_eq!( - load_last_sandbox("gateway-a"), + load_last_sandbox("gateway-a", "default"), Some("sandbox-a".to_string()) ); assert_eq!( - load_last_sandbox("gateway-b"), + load_last_sandbox("gateway-b", "default"), Some("sandbox-b".to_string()) ); }); } + #[test] + fn last_sandbox_is_workspace_scoped() { + let tmp = tempfile::tempdir().unwrap(); + with_tmp_xdg(tmp.path(), || { + save_last_sandbox("gw", "alpha", "sb-alpha").unwrap(); + assert_eq!( + load_last_sandbox("gw", "alpha"), + Some("sb-alpha".to_string()) + ); + assert_eq!(load_last_sandbox("gw", "beta"), None); + }); + } + // ── system gateway dir fallback ─────────────────────────────────── /// Helper: hold the shared XDG test lock, point `XDG_CONFIG_HOME` at @@ -635,9 +667,12 @@ mod tests { with_tmp_xdg_and_system(user.path(), system.path(), || { write_system_metadata(&system.path().join("gateways"), "shared", "https://system"); - save_last_sandbox("shared", "sb-123").unwrap(); + save_last_sandbox("shared", "default", "sb-123").unwrap(); - assert_eq!(load_last_sandbox("shared"), Some("sb-123".to_string())); + assert_eq!( + load_last_sandbox("shared", "default"), + Some("sb-123".to_string()) + ); assert_eq!( gateway_metadata_source("shared").unwrap(), Some(GatewayMetadataSource::System) @@ -671,12 +706,12 @@ mod tests { .to_path_buf(); assert!(!user_gateway_dir.exists()); - save_last_sandbox("shared", "sb-123").unwrap(); + save_last_sandbox("shared", "default", "sb-123").unwrap(); assert!(user_gateway_dir.is_dir()); assert_eq!( std::fs::read_to_string(user_gateway_dir.join("last_sandbox")).unwrap(), - "sb-123" + "default\nsb-123" ); assert!(!user_gateway_dir.join("metadata.json").exists()); assert_eq!( @@ -692,11 +727,11 @@ mod tests { let system = tempfile::tempdir().unwrap(); with_tmp_xdg_and_system(user.path(), system.path(), || { write_system_metadata(&system.path().join("gateways"), "shared", "https://system"); - save_last_sandbox("shared", "sb-123").unwrap(); + save_last_sandbox("shared", "default", "sb-123").unwrap(); - clear_last_sandbox_if_matches("shared", "sb-123"); + clear_last_sandbox_if_matches("shared", "default", "sb-123"); - assert_eq!(load_last_sandbox("shared"), None); + assert_eq!(load_last_sandbox("shared", "default"), None); let gateways = list_gateways_with_source().unwrap(); assert_eq!(gateways.len(), 1); assert_eq!(gateways[0].metadata.name, "shared"); @@ -924,7 +959,7 @@ mod tests { }; assert!(store_gateway_metadata("../escape", &meta).is_err()); assert!(load_gateway_metadata("../escape").is_err()); - assert!(save_last_sandbox("../escape", "sb-123").is_err()); + assert!(save_last_sandbox("../escape", "default", "sb-123").is_err()); assert!(save_active_gateway("../escape").is_err()); }); } diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index a421b418ae..572d601eaa 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: workspace_from_args(), + 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: workspace_from_args(), + all_workspaces: false, }) .await .ok()?; @@ -76,6 +80,44 @@ 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 workspace_from_args() -> String { + let args: Vec = std::env::args().collect(); + for (i, arg) in args.iter().enumerate() { + if arg == "--workspace" { + if let Some(val) = args.get(i + 1) { + return val.clone(); + } + } else if let Some(val) = arg.strip_prefix("--workspace=") { + return val.to_string(); + } + } + std::env::var("OPENSHELL_WORKSPACE").unwrap_or_else(|_| "default".to_string()) +} + 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 38a4ace162..df4e983e5b 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -203,16 +203,17 @@ fn apply_auth(tls: &mut TlsOptions, gateway_name: &str) { } } -/// Resolve a sandbox name, falling back to the last-used sandbox for the gateway. +/// Resolve a sandbox name, falling back to the last-used sandbox for the +/// gateway and workspace. /// /// When `name` is `None`, looks up the last sandbox recorded for the active -/// gateway. Prints a hint when falling back so the user knows which sandbox -/// was chosen. -fn resolve_sandbox_name(name: Option, gateway: &str) -> Result { +/// gateway and workspace. Prints a hint when falling back so the user knows +/// which sandbox was chosen. +fn resolve_sandbox_name(name: Option, gateway: &str, workspace: &str) -> Result { if let Some(n) = name { return Ok(n); } - let last = load_last_sandbox(gateway).ok_or_else(|| { + let last = load_last_sandbox(gateway, workspace).ok_or_else(|| { miette::miette!( "No sandbox name provided and no last-used sandbox.\n\ Specify a sandbox name or connect to one first: openshell sandbox connect " @@ -360,6 +361,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 +433,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 +543,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 // =================================================================== @@ -784,6 +813,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. @@ -816,6 +850,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 (overrides --workspace). + #[arg(long)] + all_workspaces: bool, }, /// List available provider profiles. @@ -824,6 +862,10 @@ enum ProviderCommands { /// Output format. #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] output: OutputFormat, + + /// List platform-scoped profiles (ignores --workspace). + #[arg(long)] + global: bool, }, /// Manage provider profiles. @@ -954,6 +996,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. @@ -966,6 +1012,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. @@ -977,6 +1027,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. @@ -989,6 +1043,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. @@ -996,6 +1054,10 @@ enum ProviderProfileCommands { Delete { /// Provider profile id. id: String, + + /// Target platform-scoped profile (ignores --workspace). + #[arg(long)] + global: bool, }, } @@ -1134,7 +1196,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. @@ -1160,7 +1222,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). @@ -1184,7 +1246,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. @@ -1192,6 +1254,14 @@ enum InferenceCommands { #[arg(long)] system: bool, }, + + /// Delete a workspace-level inference route. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Delete { + /// Delete the system inference route instead of the user-facing route. + #[arg(long)] + system: bool, + }, } // ----------------------------------------------------------------------- @@ -1387,6 +1457,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 (overrides --workspace). + #[arg(long)] + all_workspaces: bool, }, /// Delete a sandbox by name. @@ -1894,6 +1968,10 @@ enum ServiceCommands { /// Number of endpoints to skip. #[arg(long, default_value_t = 0)] offset: u32, + + /// List services across all workspaces (overrides --workspace). + #[arg(long)] + all_workspaces: bool, }, /// Show one exposed sandbox service endpoint. @@ -1919,6 +1997,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, max 19 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<()> { @@ -2172,7 +2352,7 @@ async fn main() -> Result<()> { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); apply_auth(&mut tls, &ctx.name); - let name = resolve_sandbox_name(name, &ctx.name)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; let local = local.unwrap_or_else(|| target_port.to_string()); run::service_forward_tcp( &ctx.endpoint, @@ -2181,6 +2361,7 @@ async fn main() -> Result<()> { &target_host, target_port, &tls, + &cli.workspace, ) .await?; } @@ -2193,8 +2374,16 @@ async fn main() -> Result<()> { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; 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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_forward( + &ctx.endpoint, + &name, + &spec, + background, + &tls, + &cli.workspace, + ) + .await?; if background { eprintln!( "{} Forwarding port {} to sandbox {name} in the background", @@ -2223,24 +2412,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?; } } } @@ -2258,7 +2465,7 @@ async fn main() -> Result<()> { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); apply_auth(&mut tls, &ctx.name); - let name = resolve_sandbox_name(name, &ctx.name)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; run::sandbox_logs( &ctx.endpoint, &name, @@ -2267,6 +2474,7 @@ async fn main() -> Result<()> { since.as_deref(), &source, &level, + &cli.workspace, &tls, ) .await?; @@ -2303,13 +2511,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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_policy_set( + &ctx.endpoint, + &name, + &policy, + wait, + timeout, + &cli.workspace, + &tls, + ) + .await?; } } PolicyCommands::Update { @@ -2325,7 +2542,7 @@ async fn main() -> Result<()> { wait, timeout, } => { - let name = resolve_sandbox_name(name, &ctx.name)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; run::sandbox_policy_update( &ctx.endpoint, &name, @@ -2339,6 +2556,7 @@ async fn main() -> Result<()> { dry_run, wait, timeout, + &cli.workspace, &tls, ) .await?; @@ -2358,17 +2576,19 @@ async fn main() -> Result<()> { rev, view, output.as_str(), + &cli.workspace, &tls, ) .await?; } else { - let name = resolve_sandbox_name(name, &ctx.name)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; run::sandbox_policy_get( &ctx.endpoint, &name, rev, view, output.as_str(), + &cli.workspace, &tls, ) .await?; @@ -2380,10 +2600,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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_policy_list(&ctx.endpoint, &name, limit, &cli.workspace, &tls) + .await?; } } PolicyCommands::Delete { global, yes } => { @@ -2392,7 +2614,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?; } } } @@ -2417,8 +2640,9 @@ 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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_settings_get(&ctx.endpoint, &name, json, &cli.workspace, &tls) + .await?; } } SettingsCommands::Set { @@ -2429,10 +2653,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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_setting_set( + &ctx.endpoint, + &name, + &key, + &value, + &cli.workspace, + &tls, + ) + .await?; } } SettingsCommands::Delete { @@ -2442,10 +2682,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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_setting_delete( + &ctx.endpoint, + &name, + &key, + &cli.workspace, + &tls, + ) + .await?; } } } @@ -2462,43 +2710,65 @@ async fn main() -> Result<()> { apply_auth(&mut tls, &ctx.name); 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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + 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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_draft_approve( + &ctx.endpoint, + &name, + &chunk_id, + &cli.workspace, + &tls, + ) + .await?; } DraftCommands::Reject { name, chunk_id, reason, } => { - let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_reject(&ctx.endpoint, &name, &chunk_id, &reason, &tls) - .await?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_draft_reject( + &ctx.endpoint, + &name, + &chunk_id, + &reason, + &cli.workspace, + &tls, + ) + .await?; } DraftCommands::ApproveAll { name, include_security_flagged, } => { - let name = resolve_sandbox_name(name, &ctx.name)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; run::sandbox_draft_approve_all( &ctx.endpoint, &name, include_security_flagged, + &cli.workspace, &tls, ) .await?; } DraftCommands::Clear { name } => { - let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_clear(&ctx.endpoint, &name, &tls).await?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + 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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_draft_history(&ctx.endpoint, &name, &cli.workspace, &tls).await?; } } } @@ -2523,7 +2793,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?; } @@ -2542,13 +2819,19 @@ 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?; + } + InferenceCommands::Delete { system } => { + let route_name = if system { "sandbox-system" } else { "" }; + run::gateway_inference_delete(endpoint, route_name, &cli.workspace, &tls) + .await?; } } } @@ -2669,6 +2952,7 @@ async fn main() -> Result<()> { environment: env_map, approval_mode: &approval_mode, }, + &cli.workspace, &tls, )) .await?; @@ -2682,15 +2966,15 @@ async fn main() -> Result<()> { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); apply_auth(&mut tls, &ctx.name); - let sandbox_dest = dest.as_deref(); let local = std::path::Path::new(&local_path); run::sandbox_upload( &ctx.endpoint, &name, local, - sandbox_dest, + dest.as_deref(), !no_git_ignore, &tls, + &cli.workspace, ) .await?; } @@ -2704,8 +2988,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 => { @@ -2720,8 +3011,9 @@ async fn main() -> Result<()> { unreachable!() } SandboxCommands::Get { name, policy_only } => { - let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_get(endpoint, &name, policy_only, &tls).await?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_get(endpoint, &name, policy_only, &cli.workspace, &tls) + .await?; } SandboxCommands::List { limit, @@ -2730,6 +3022,7 @@ async fn main() -> Result<()> { names, selector, output, + all_workspaces, } => { run::sandbox_list( endpoint, @@ -2739,24 +3032,39 @@ 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)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; 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); + let _ = save_last_sandbox(&ctx.name, &cli.workspace, &name); } SandboxCommands::Exec { name, @@ -2767,7 +3075,7 @@ async fn main() -> Result<()> { envs, command, } => { - let name = resolve_sandbox_name(name, &ctx.name)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; // Resolve --tty / --no-tty into an Option override. let tty_override = if no_tty { Some(false) @@ -2786,35 +3094,111 @@ async fn main() -> Result<()> { tty_override, &env_map, &tls, + &cli.workspace, ) .await?; - let _ = save_last_sandbox(&ctx.name, &name); + let _ = save_last_sandbox(&ctx.name, &cli.workspace, &name); if exit_code != 0 { std::process::exit(exit_code); } } SandboxCommands::SshConfig { name } => { - let name = resolve_sandbox_name(name, &ctx.name)?; - run::print_ssh_config(&ctx.name, &name); + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + 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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + 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), }) => { @@ -2832,7 +3216,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, @@ -2842,6 +3228,8 @@ async fn main() -> Result<()> { from_gcloud_adc, runtime_credentials, &config, + &cli.workspace, + profile_ws, &tls, ) .await?; @@ -2855,6 +3243,7 @@ async fn main() -> Result<()> { endpoint, &name, credential_key.as_deref(), + &cli.workspace, &tls, ) .await?; @@ -2879,6 +3268,7 @@ async fn main() -> Result<()> { secret_material_keys: &secret_material_keys, credential_expires_at_ms: credential_expires_at, }, + &cli.workspace, &tls, ) .await?; @@ -2887,60 +3277,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::ListProfiles { output, global } => { + let ws = if global { "" } else { &cli.workspace }; + run::provider_list_profiles(endpoint, output.as_str(), ws, &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, - ) - .await?; - } - ProviderProfileCommands::Delete { id } => { - run::provider_profile_delete(endpoint, &id, &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?; + } } - }, + } ProviderCommands::Update { name, from_existing, @@ -2955,12 +3395,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?; } } } @@ -2973,7 +3414,15 @@ async fn main() -> Result<()> { tls.oidc_token.as_deref(), tls.edge_token.as_deref(), )?; - openshell_tui::run(channel, interceptor, &ctx.name, &ctx.endpoint, theme).await?; + openshell_tui::run( + channel, + interceptor, + &ctx.name, + &ctx.endpoint, + &cli.workspace, + theme, + ) + .await?; } Some(Commands::Completions { shell }) => { let exe = std::env::current_exe() @@ -3023,11 +3472,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!( @@ -3073,6 +3522,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") @@ -3591,7 +4047,7 @@ mod tests { fn resolve_sandbox_name_returns_explicit_name() { // When a name is provided, it should be returned regardless of any // stored last-sandbox state. - let result = resolve_sandbox_name(Some("explicit".to_string()), "any-gateway"); + let result = resolve_sandbox_name(Some("explicit".to_string()), "any-gateway", "default"); assert_eq!(result.unwrap(), "explicit"); } @@ -3599,8 +4055,8 @@ mod tests { fn resolve_sandbox_name_falls_back_to_last_used() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - save_last_sandbox("test-gateway", "remembered-sb").unwrap(); - let result = resolve_sandbox_name(None, "test-gateway"); + save_last_sandbox("test-gateway", "default", "remembered-sb").unwrap(); + let result = resolve_sandbox_name(None, "test-gateway", "default"); assert_eq!(result.unwrap(), "remembered-sb"); }); } @@ -3609,7 +4065,7 @@ mod tests { fn resolve_sandbox_name_errors_without_fallback() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - let err = resolve_sandbox_name(None, "unknown-gateway").unwrap_err(); + let err = resolve_sandbox_name(None, "unknown-gateway", "default").unwrap_err(); let msg = err.to_string(); assert!( msg.contains("openshell sandbox connect"), @@ -3745,7 +4201,8 @@ mod tests { cli.command, Some(Commands::Provider { command: Some(ProviderCommands::ListProfiles { - output: OutputFormat::Table + output: OutputFormat::Table, + global: false, }) }) )); @@ -3760,7 +4217,8 @@ mod tests { cli.command, Some(Commands::Provider { command: Some(ProviderCommands::ListProfiles { - output: OutputFormat::Json + output: OutputFormat::Json, + global: false, }) }) )); @@ -3783,7 +4241,8 @@ mod tests { Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Export { id, - output: OutputFormat::Yaml + output: OutputFormat::Yaml, + .. })) }) if id == "custom-api" )); @@ -3822,7 +4281,8 @@ mod tests { Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Update { id, - file: _ + file: _, + .. })) }) if id == "custom-api" )); @@ -3834,7 +4294,8 @@ mod tests { delete.command, Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Delete { - id + id, + .. })) }) if id == "custom-api" )); @@ -4786,6 +5247,7 @@ mod tests { sandbox, limit, offset, + .. }), }) => { assert_eq!(sandbox.as_deref(), Some("my-sandbox")); @@ -4805,6 +5267,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 d9b2df10b5..c3092d1396 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -36,11 +36,11 @@ use openshell_core::proto::ProviderProfileCategory; use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, AttachSandboxProviderRequest, ClearDraftChunksRequest, ConfigureProviderRefreshRequest, CreateProviderRequest, - CreateSandboxRequest, CreateSshSessionRequest, DeleteProviderProfileRequest, - DeleteProviderRefreshRequest, DeleteProviderRequest, DeleteSandboxRequest, - DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, ExposeServiceRequest, - GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, - GetGatewayConfigRequest, GetGatewayInfoRequest, GetProviderProfileRequest, + CreateSandboxRequest, CreateSshSessionRequest, DeleteInferenceRouteRequest, + DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, + DeleteSandboxRequest, DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, + ExposeServiceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, + GetGatewayInfoRequest, GetInferenceRouteRequest, GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, HealthRequest, ImportProviderProfilesRequest, @@ -51,13 +51,13 @@ use openshell_core::proto::{ ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, ServiceEndpointResponse, - ServiceStatus, SetClusterInferenceRequest, SettingScope, SettingValue, TcpForwardFrame, + ServiceStatus, 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, @@ -1886,6 +1886,7 @@ async fn finalize_sandbox_create_session( sandbox_name: &str, persist: bool, session_result: Result<()>, + workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { @@ -1894,7 +1895,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); } @@ -1961,6 +1962,7 @@ pub async fn sandbox_create( server: &str, gateway_name: &str, config: SandboxCreateConfig<'_>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let SandboxCreateConfig { @@ -2035,6 +2037,7 @@ pub async fn sandbox_create( providers, &inferred_types, auto_providers_override, + workspace, ) .await?; @@ -2069,6 +2072,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 { @@ -2097,7 +2101,7 @@ pub async fn sandbox_create( // Record this sandbox as the last-used for the active gateway only when it // is expected to persist beyond the initial session. if persist && let Some(gateway) = effective_tls.gateway_name() { - let _ = save_last_sandbox(gateway, &sandbox_name); + let _ = save_last_sandbox(gateway, workspace, &sandbox_name); } // Persist `--approval-mode` as a sandbox-scoped setting now that the @@ -2113,6 +2117,7 @@ pub async fn sandbox_create( name: sandbox_name.clone(), setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), setting_value: Some(setting), + workspace: workspace.to_string(), ..Default::default() }) .await @@ -2362,6 +2367,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2377,6 +2383,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2387,6 +2394,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2404,6 +2412,7 @@ pub async fn sandbox_create( spec, true, // background &effective_tls, + workspace, ) .await?; eprintln!( @@ -2426,6 +2435,7 @@ pub async fn sandbox_create( &sandbox_name, editor, &effective_tls, + workspace, ) .await?; return Ok(()); @@ -2433,12 +2443,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 }; @@ -2448,6 +2460,7 @@ pub async fn sandbox_create( &sandbox_name, persist, connect_result, + workspace, &effective_tls, gateway_name, ) @@ -2466,6 +2479,7 @@ pub async fn sandbox_create( command, tty, &effective_tls, + workspace, ) .await } else { @@ -2475,6 +2489,7 @@ pub async fn sandbox_create( command, tty, &effective_tls, + workspace, ) .await }; @@ -2484,6 +2499,7 @@ pub async fn sandbox_create( &sandbox_name, persist, exec_result, + workspace, &effective_tls, gateway_name, ) @@ -2709,6 +2725,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) => { @@ -2721,13 +2738,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()); } _ => { @@ -2748,6 +2765,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?; @@ -2755,6 +2773,7 @@ pub async fn sandbox_get( let response = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -2883,6 +2902,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?; @@ -2890,6 +2910,7 @@ pub async fn sandbox_exec_grpc( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -2998,11 +3019,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 @@ -3032,7 +3054,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() => { @@ -3096,10 +3118,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 { @@ -3493,6 +3517,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?; @@ -3502,6 +3528,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()?; @@ -3527,8 +3559,12 @@ pub async fn sandbox_list( } if names_only { - for sandbox in sandboxes { - println!("{}", sandbox.object_name()); + for sandbox in &sandboxes { + if all_workspaces { + println!("{}/{}", sandbox.object_workspace(), sandbox.object_name()); + } else { + println!("{}", sandbox.object_name()); + } } return Ok(()); } @@ -3541,14 +3577,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 { serde_json::json!({ "id": sandbox.object_id(), "name": sandbox.object_name(), + "workspace": sandbox.object_workspace(), "labels": labels, "annotations": annotations, "resource_version": meta.map_or(0, |m| m.resource_version), @@ -3591,11 +3658,17 @@ fn sandbox_to_json(sandbox: &Sandbox) -> 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()?; @@ -3614,6 +3687,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?; @@ -3622,6 +3696,7 @@ pub async fn sandbox_provider_attach( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -3636,6 +3711,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 { @@ -3667,6 +3743,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?; @@ -3675,6 +3752,7 @@ pub async fn sandbox_provider_detach( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -3689,6 +3767,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 { @@ -3781,6 +3860,7 @@ pub async fn sandbox_delete( server: &str, names: &[String], all: bool, + workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { @@ -3793,6 +3873,8 @@ pub async fn sandbox_delete( limit: 1000, offset: 0, label_selector: String::new(), + workspace: workspace.to_string(), + all_workspaces: false, }) .await .into_diagnostic()?; @@ -3821,13 +3903,16 @@ 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()?; let deleted = response.into_inner().deleted; if deleted { - clear_last_sandbox_if_matches(gateway, name); + clear_last_sandbox_if_matches(gateway, workspace, name); println!("{} Deleted sandbox {name}", "✓".green().bold()); } else { println!("{} Sandbox {name} not found", "!".yellow()); @@ -3858,6 +3943,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()); @@ -3876,7 +3962,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; @@ -3913,6 +4004,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 @@ -3953,6 +4045,7 @@ pub async fn ensure_required_providers( auto_providers_override, &mut seen_names, &mut configured_names, + workspace, ) .await?; } @@ -3974,6 +4067,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}"); @@ -4013,7 +4107,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 { @@ -4037,12 +4131,16 @@ async fn auto_create_provider( labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), credential_expires_at_ms: HashMap::new(), + profile_workspace: workspace.to_string(), }), + workspace: workspace.to_string(), }; let response = client.create_provider(request).await.map_err(|status| { @@ -4080,12 +4178,16 @@ async fn auto_create_provider( labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), credential_expires_at_ms: HashMap::new(), + profile_workspace: workspace.to_string(), }), + workspace: workspace.to_string(), }; match client.create_provider(request).await { @@ -4321,6 +4423,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?; @@ -4330,6 +4433,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)? @@ -4367,6 +4471,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?; @@ -4375,6 +4481,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))? @@ -4389,7 +4501,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(()) } @@ -4397,6 +4509,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?; @@ -4404,12 +4517,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(()) } @@ -4417,6 +4531,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?; @@ -4424,6 +4539,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))? @@ -4470,11 +4586,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() { @@ -4482,7 +4606,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::>(); @@ -4490,38 +4620,64 @@ 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!( - "{: &str { @@ -4623,10 +4779,12 @@ async fn rollback_provider_create_after_gcloud_adc_failure( provider_name: &str, stage: &str, source: &Status, + workspace: &str, ) -> Result<()> { match client .delete_provider(DeleteProviderRequest { name: provider_name.to_string(), + workspace: workspace.to_string(), }) .await { @@ -4693,10 +4851,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| { @@ -4718,9 +4878,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| { @@ -4791,6 +4952,7 @@ pub async fn provider_create( credentials: &[String], from_gcloud_adc: bool, config: &[String], + workspace: &str, tls: &TlsOptions, ) -> Result<()> { provider_create_with_options( @@ -4802,6 +4964,8 @@ pub async fn provider_create( from_gcloud_adc, false, config, + workspace, + workspace, tls, ) .await @@ -4817,6 +4981,8 @@ pub async fn provider_create_with_options( from_gcloud_adc: bool, runtime_credentials: bool, config: &[String], + workspace: &str, + profile_workspace: &str, tls: &TlsOptions, ) -> Result<()> { if from_gcloud_adc && (from_existing || !credentials.is_empty() || runtime_credentials) { @@ -4847,6 +5013,7 @@ pub async fn provider_create_with_options( let response = client .get_provider_profile(GetProviderProfileRequest { id: profile_id.to_string(), + workspace: profile_workspace.to_string(), }) .await; match response { @@ -4866,7 +5033,7 @@ pub async fn provider_create_with_options( }; let adc_credential_key = if from_gcloud_adc { - let profile = fetch_provider_profile(&mut client, &provider_type) + let profile = fetch_provider_profile(&mut client, &provider_type, profile_workspace) .await .map_err(|err| { miette::miette!( @@ -4899,7 +5066,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, profile_workspace).await?; let Some(discovered) = discovered else { return Err(miette::miette!( "no existing local credentials/config found for provider type '{provider_type}'" @@ -4923,10 +5091,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, profile_workspace).await?, ) } else { - fetch_provider_profile(&mut client, &provider_type) + fetch_provider_profile(&mut client, &provider_type, profile_workspace) .await .ok() .is_some_and(|profile| provider_profile_allows_empty_credentials(&profile)) @@ -4962,12 +5130,16 @@ pub async fn provider_create_with_options( labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, }), r#type: provider_type.clone(), credentials: credential_map, config: config_map, credential_expires_at_ms: HashMap::new(), + profile_workspace: profile_workspace.to_string(), }), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -4997,6 +5169,7 @@ pub async fn provider_create_with_options( "refresh_token".to_string(), ], expires_at_ms: None, + workspace: workspace.to_string(), }) .await { @@ -5005,6 +5178,7 @@ pub async fn provider_create_with_options( &provider_name, "configure", &configure_err, + workspace, ) .await; } @@ -5013,6 +5187,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 { @@ -5021,6 +5196,7 @@ pub async fn provider_create_with_options( &provider_name, "mint the initial access token for", &rotate_err, + workspace, ) .await; } @@ -5038,11 +5214,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()?; @@ -5096,6 +5278,10 @@ fn provider_to_json(provider: &Provider) -> serde_json::Value { "name".to_string(), serde_json::json!(provider.object_name()), ); + obj.insert( + "workspace".to_string(), + serde_json::json!(provider.object_workspace()), + ); obj.insert("type".to_string(), serde_json::json!(provider.r#type)); // Credential keys (NEVER values - security) @@ -5141,17 +5327,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; @@ -5169,12 +5367,26 @@ pub async fn provider_list( } if names_only { - for provider in providers { - println!("{}", provider.object_name()); + for provider in &providers { + if all_workspaces { + println!("{}/{}", provider.object_workspace(), provider.object_name()); + } else { + println!("{}", provider.object_name()); + } } 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()) @@ -5188,33 +5400,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()?; @@ -5262,9 +5502,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 { @@ -5277,11 +5518,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 @@ -5304,6 +5549,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)?; @@ -5318,7 +5564,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(); @@ -5345,6 +5594,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)?; @@ -5367,6 +5617,7 @@ pub async fn provider_profile_update( profile: Some(item), expected_resource_version, id: id.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5386,6 +5637,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)?; @@ -5396,7 +5648,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(); @@ -5412,10 +5667,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(); @@ -5431,6 +5694,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?; @@ -5438,6 +5702,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()? @@ -5488,6 +5753,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)?; @@ -5515,6 +5781,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()? @@ -5535,6 +5802,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?; @@ -5542,6 +5810,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()? @@ -5570,6 +5839,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?; @@ -5577,6 +5847,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()? @@ -5852,6 +6123,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, @@ -5859,6 +6131,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() { @@ -5878,6 +6151,7 @@ pub async fn provider_update( let existing = client .get_provider(GetProviderRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5886,7 +6160,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}'" @@ -5911,13 +6186,17 @@ pub async fn provider_update( labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, }), r#type: String::new(), credentials: credential_map, config: config_map, credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), }), credential_expires_at_ms, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5935,11 +6214,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 { @@ -5951,6 +6238,363 @@ 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!("{: &'static str { + use openshell_core::proto::datamodel::v1::WorkspacePhase; + let phase = workspace + .status + .as_ref() + .and_then(|s| WorkspacePhase::try_from(s.phase).ok()) + .unwrap_or(WorkspacePhase::Active); + match phase { + WorkspacePhase::Terminating => "Terminating", + _ => "Active", + } +} + +fn workspace_phase_display(workspace: &openshell_core::proto::Workspace) -> String { + let s = workspace_phase_str(workspace); + if s == "Terminating" { + s.yellow().to_string() + } else { + s.to_string() + } +} + +fn workspace_to_json(workspace: &openshell_core::proto::Workspace) -> 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)); + } + } + obj.insert( + "status".to_string(), + serde_json::json!(workspace_phase_str(workspace)), + ); + serde_json::Value::Object(obj) +} + +#[allow(clippy::too_many_arguments)] pub async fn gateway_inference_set( server: &str, provider_name: &str, @@ -5958,6 +6602,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() { @@ -5975,13 +6620,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; @@ -5995,10 +6641,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); @@ -6013,6 +6660,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>, @@ -6020,6 +6668,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() { @@ -6032,8 +6681,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()? @@ -6057,13 +6707,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; @@ -6077,10 +6728,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); @@ -6098,6 +6750,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?; @@ -6105,8 +6758,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()?; @@ -6115,19 +6769,50 @@ 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(()) +} + +pub async fn gateway_inference_delete( + server: &str, + route_name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_inference_client(server, tls).await?; + + let response = client + .delete_inference_route(DeleteInferenceRouteRequest { + route_name: route_name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + + let label = if route_name == "sandbox-system" { + "System inference route" + } else { + "Inference route" + }; + + if response.into_inner().deleted { + println!("{label} deleted."); + } else { + println!("{label} not found (already deleted)."); } Ok(()) } @@ -6136,10 +6821,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 { @@ -6147,6 +6834,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); @@ -6341,6 +7029,7 @@ pub async fn sandbox_upload( sandbox_path: Option<&str>, git_ignore: bool, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { let upload_plan = sandbox_upload_plan(local_path, git_ignore)?; let dest_display = sandbox_path.unwrap_or("~"); @@ -6360,6 +7049,7 @@ pub async fn sandbox_upload( local_path, sandbox_path, tls, + workspace, ) .await?; } @@ -6369,10 +7059,10 @@ pub async fn sandbox_upload( "⚠".yellow().bold(), local_path.display(), ); - sandbox_sync_up(server, name, local_path, sandbox_path, tls).await?; + sandbox_sync_up(server, name, local_path, sandbox_path, tls, workspace).await?; } SandboxUploadPlan::Regular => { - sandbox_sync_up(server, name, local_path, sandbox_path, tls).await?; + sandbox_sync_up(server, name, local_path, sandbox_path, tls, workspace).await?; } } @@ -6554,6 +7244,7 @@ pub async fn sandbox_policy_set_global( yes: bool, wait: bool, _timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if wait { @@ -6573,6 +7264,7 @@ pub async fn sandbox_policy_set_global( name: String::new(), policy: Some(policy), global: true, + workspace: workspace.to_string(), ..Default::default() }) .await @@ -6596,12 +7288,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()? @@ -6618,7 +7312,7 @@ pub async fn sandbox_settings_get( .into_inner(); if json { - let obj = settings_to_json_sandbox(name, &response); + let obj = settings_to_json_sandbox(name, workspace, &response); println!("{}", serde_json::to_string_pretty(&obj).into_diagnostic()?); return Ok(()); } @@ -6696,6 +7390,7 @@ pub async fn gateway_settings_get(server: &str, json: bool, tls: &TlsOptions) -> fn settings_to_json_sandbox( name: &str, + workspace: &str, response: &openshell_core::proto::GetSandboxConfigResponse, ) -> serde_json::Value { let policy_source = if response.policy_source == PolicySource::Global as i32 { @@ -6726,6 +7421,7 @@ fn settings_to_json_sandbox( serde_json::json!({ "sandbox": name, + "workspace": workspace, "config_revision": response.config_revision, "policy_source": policy_source, "policy_hash": response.policy_hash, @@ -6757,6 +7453,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)?; @@ -6769,6 +7466,7 @@ pub async fn gateway_setting_set( setting_key: key.to_string(), setting_value: Some(setting_value), global: true, + workspace: workspace.to_string(), ..Default::default() }) .await @@ -6790,6 +7488,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)?; @@ -6800,6 +7499,7 @@ pub async fn sandbox_setting_set( name: name.to_string(), setting_key: key.to_string(), setting_value: Some(setting_value), + workspace: workspace.to_string(), ..Default::default() }) .await @@ -6821,6 +7521,7 @@ pub async fn gateway_setting_delete( server: &str, key: &str, yes: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { confirm_global_setting_delete(key, yes)?; @@ -6832,6 +7533,7 @@ pub async fn gateway_setting_delete( setting_key: key.to_string(), delete_setting: true, global: true, + workspace: workspace.to_string(), ..Default::default() }) .await @@ -6855,6 +7557,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?; @@ -6863,6 +7566,7 @@ pub async fn sandbox_setting_delete( name: name.to_string(), setting_key: key.to_string(), delete_setting: true, + workspace: workspace.to_string(), ..Default::default() }) .await @@ -6894,6 +7598,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))? @@ -6907,6 +7612,7 @@ pub async fn sandbox_policy_set( name: name.to_string(), version: 0, global: false, + workspace: workspace.to_string(), }) .await .ok() @@ -6917,6 +7623,7 @@ pub async fn sandbox_policy_set( .update_config(UpdateConfigRequest { name: name.to_string(), policy: Some(policy), + workspace: workspace.to_string(), ..Default::default() }) .await @@ -6964,6 +7671,7 @@ pub async fn sandbox_policy_set( name: name.to_string(), version: resp.version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7019,6 +7727,7 @@ pub async fn sandbox_policy_update( dry_run: bool, wait: bool, timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if dry_run && wait { @@ -7039,6 +7748,7 @@ pub async fn sandbox_policy_update( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7087,6 +7797,7 @@ pub async fn sandbox_policy_update( .update_config(UpdateConfigRequest { name: name.to_string(), merge_operations: plan.merge_operations, + workspace: workspace.to_string(), ..Default::default() }) .await @@ -7134,6 +7845,7 @@ pub async fn sandbox_policy_update( name: name.to_string(), version: response.version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7181,6 +7893,7 @@ pub async fn sandbox_policy_get( version: u32, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut stdout = Vec::new(); @@ -7191,6 +7904,7 @@ pub async fn sandbox_policy_get( version, view, output, + workspace, tls, (&mut stdout, &mut stderr), ) @@ -7209,12 +7923,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<()> @@ -7223,8 +7939,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; @@ -7235,6 +7953,7 @@ where name: name.to_string(), version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7302,6 +8021,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<()> @@ -7315,6 +8035,7 @@ where let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7416,6 +8137,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?; @@ -7425,6 +8147,7 @@ pub async fn sandbox_policy_get_global( name: String::new(), version, global: true, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7553,6 +8276,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?; @@ -7563,6 +8287,7 @@ pub async fn sandbox_policy_list( limit, offset: 0, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7577,7 +8302,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 @@ -7586,6 +8316,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()?; @@ -7641,6 +8372,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?; @@ -7649,6 +8381,7 @@ pub async fn sandbox_logs( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7713,6 +8446,7 @@ pub async fn sandbox_logs( since_ms, sources: source_filter, min_level: level.to_uppercase(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7775,6 +8509,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?; @@ -7783,6 +8518,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()?; @@ -7867,6 +8603,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?; @@ -7875,6 +8612,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()?; @@ -7896,6 +8634,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?; @@ -7905,6 +8644,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()?; @@ -7919,6 +8659,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?; @@ -7927,6 +8668,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()?; @@ -7944,12 +8686,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()?; @@ -7965,12 +8713,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()?; @@ -8396,6 +9150,7 @@ mod tests { )) .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }], false, ); @@ -9927,12 +10682,14 @@ 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); assert_eq!(json["id"], "prov-123"); assert_eq!(json["name"], "test-provider"); + assert_eq!(json["workspace"], ""); assert_eq!(json["type"], "anthropic"); } @@ -9948,6 +10705,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); @@ -9985,6 +10743,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); @@ -10015,6 +10774,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); @@ -10037,6 +10797,8 @@ mod tests { created_at_ms: 1_234_567_890_000, labels, annotations: std::collections::HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, }; let provider = Provider { @@ -10045,6 +10807,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); @@ -10070,6 +10833,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); @@ -10099,6 +10863,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); @@ -10124,6 +10889,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/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index a5f7b8bcd8..50bc2bbee9 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. @@ -940,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, @@ -948,6 +965,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 +980,7 @@ pub async fn sandbox_sync_up_files( archive_prefix: file_list_archive_prefix(local_path), }, tls, + workspace, ) .await } @@ -979,6 +998,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 +1025,7 @@ pub async fn sandbox_sync_up( tar_name: target_name.into(), }, tls, + workspace, ) .await; } @@ -1032,6 +1053,7 @@ pub async fn sandbox_sync_up( tar_name, }, tls, + workspace, ) .await } @@ -1139,9 +1161,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 +1440,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 +1456,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 +1596,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 +1605,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 +1653,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 +1703,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 +1715,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 +1735,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..883c8c4446 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -64,11 +64,14 @@ impl TestOpenShell { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, }), r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ); } @@ -358,6 +361,8 @@ impl OpenShell for TestOpenShell { labels: existing_metadata.labels, resource_version: 0, annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), @@ -366,6 +371,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()); @@ -592,6 +598,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 +727,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 +756,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 +790,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 +822,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 +852,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 +888,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 +920,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..53304b57c5 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -137,6 +137,8 @@ impl OpenShell for TestOpenShell { labels: HashMap::new(), resource_version: 1, annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, }), spec: None, status: None, @@ -618,6 +620,8 @@ impl OpenShell for TestOpenShell { labels: existing_metadata.labels, resource_version: 0, annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), @@ -626,6 +630,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()); @@ -996,6 +1001,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 +1131,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 +1160,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 +1175,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 +1193,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 +1236,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 +1271,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 +1297,7 @@ async fn provider_refresh_cli_run_functions_wire_requests() { &["MS_GRAPH_ACCESS_TOKEN=token".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -1209,6 +1314,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 +1323,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 +1386,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 +1405,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 +1447,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 +1477,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 +1520,8 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() false, true, &[], + "default", + "default", &ts.tls, ) .await @@ -1437,6 +1562,7 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_ &[], false, &[], + "default", &ts.tls, ) .await @@ -1464,26 +1590,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 +1670,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 +1719,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 +1737,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 +1756,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 +1770,7 @@ binaries: [/usr/bin/custom] &["CUSTOM_API_KEY=abc".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -1623,10 +1786,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 +1830,7 @@ async fn provider_create_from_existing_uses_profile_discovery_when_v2_enabled() &[], false, &[], + "default", &ts.tls, ) .await @@ -1695,6 +1864,7 @@ async fn provider_create_from_existing_uses_registry_discovery_when_v2_disabled( &[], false, &[], + "default", &ts.tls, ) .await @@ -1738,6 +1908,7 @@ async fn provider_create_from_existing_vertex_discovers_credentials_and_config_w &[], false, &[], + "default", &ts.tls, ) .await @@ -1793,6 +1964,7 @@ async fn provider_create_from_existing_requires_profile_when_v2_enabled() { &[], false, &[], + "default", &ts.tls, ) .await @@ -1836,6 +2008,7 @@ async fn provider_create_from_existing_fails_when_profile_discovery_finds_nothin &[], false, &[], + "default", &ts.tls, ) .await @@ -1888,13 +2061,23 @@ 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")]); - 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 +2126,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 +2173,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 +2183,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 +2218,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 +2250,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 +2258,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 +2275,7 @@ async fn provider_create_rejects_key_only_credentials_without_local_env_value() &["INVALID_PAIR".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2115,6 +2301,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 +2313,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 +2338,7 @@ async fn provider_create_rejects_combined_from_existing_and_credentials() { &["API_KEY=abc".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2174,6 +2363,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_from_existing() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2199,6 +2389,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 +2416,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 +2442,7 @@ async fn provider_create_supports_nvidia_type_with_nvidia_api_key() { &["NVIDIA_API_KEY".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2261,6 +2454,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 +2496,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 +2582,7 @@ async fn provider_create_from_gcloud_adc_rejects_service_account() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2424,6 +2620,7 @@ async fn provider_create_from_gcloud_adc_missing_file() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2457,6 +2654,7 @@ async fn provider_create_from_gcloud_adc_rejects_wrong_provider_type_before_cred &[], true, &[], + "default", &ts.tls, ) .await @@ -2494,6 +2692,7 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_refresh_config &[], true, &[], + "default", &ts.tls, ) .await @@ -2544,6 +2743,7 @@ async fn provider_create_from_gcloud_adc_warn_path_keeps_provider_when_rollback_ &[], true, &[], + "default", &ts.tls, ) .await @@ -2592,6 +2792,7 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_initial_rotate &[], true, &[], + "default", &ts.tls, ) .await @@ -2632,6 +2833,7 @@ async fn provider_create_from_existing_vertex_config_only_reports_missing_vertex &[], false, &[], + "default", &ts.tls, ) .await @@ -2678,6 +2880,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 +2955,7 @@ async fn provider_create_from_gcloud_adc_missing_refresh_token() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2794,6 +2998,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..fda3d529f3 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -95,6 +95,8 @@ impl OpenShell for TestOpenShell { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, }), ..Sandbox::default() }; @@ -117,6 +119,8 @@ impl OpenShell for TestOpenShell { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, }), ..Sandbox::default() }; @@ -378,6 +382,8 @@ impl OpenShell for TestOpenShell { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, }), ..Sandbox::default() }; @@ -665,6 +671,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 +1194,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1146,7 +1202,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { assert!(deleted_names(&server).await.is_empty()); assert_eq!( - load_last_sandbox("openshell").as_deref(), + load_last_sandbox("openshell", "default").as_deref(), Some("default-command"), "default sandboxes should be persisted as last-used" ); @@ -1171,6 +1227,7 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1238,6 +1295,7 @@ async fn sandbox_create_sends_driver_config_json() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1299,6 +1357,7 @@ async fn sandbox_create_sends_gpu_default_request() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1333,6 +1392,7 @@ async fn sandbox_create_sends_gpu_count_request() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1368,6 +1428,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { tty_override: Some(true), ..test_config() }, + "default", &tls, ) .await @@ -1413,6 +1474,7 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1454,6 +1516,7 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1487,6 +1550,7 @@ async fn sandbox_create_times_out_when_only_logs_arrive() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1517,6 +1581,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1527,7 +1592,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { vec![vec!["ephemeral-command".to_string()]] ); assert_eq!( - load_last_sandbox("openshell"), + load_last_sandbox("openshell", "default"), None, "no-keep sandboxes should not be persisted as last-used" ); @@ -1551,6 +1616,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { tty_override: Some(true), ..test_config() }, + "default", &tls, ) .await @@ -1561,7 +1627,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { vec![vec!["ephemeral-shell".to_string()]] ); assert_eq!( - load_last_sandbox("openshell"), + load_last_sandbox("openshell", "default"), None, "no-keep shell sessions should not be persisted as last-used" ); @@ -1584,6 +1650,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1591,7 +1658,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { assert!(deleted_names(&server).await.is_empty()); assert_eq!( - load_last_sandbox("openshell").as_deref(), + load_last_sandbox("openshell", "default").as_deref(), Some("persistent-keep"), "persistent sandboxes should remain selectable as last-used" ); @@ -1619,6 +1686,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1646,9 +1714,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 +1751,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 +1781,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 +1847,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..699749f0d1 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -87,6 +87,8 @@ impl OpenShell for TestOpenShell { labels: std::collections::HashMap::new(), resource_version: 0, annotations: std::collections::HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, }), ..Default::default() }), @@ -569,6 +571,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 +687,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 +704,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"); @@ -670,16 +721,16 @@ async fn sandbox_get_with_persisted_last_sandbox() { let _guard = EnvVarGuard::set(&[("XDG_CONFIG_HOME", xdg_dir.path().to_str().unwrap())]); // Persist a last-used sandbox for "integration-cluster". - save_last_sandbox("integration-cluster", "persisted-sb") + save_last_sandbox("integration-cluster", "default", "persisted-sb") .expect("save_last_sandbox should succeed"); // Resolve the name (simulates what the CLI does in main.rs). - let resolved = load_last_sandbox("integration-cluster") + let resolved = load_last_sandbox("integration-cluster", "default") .expect("load_last_sandbox should return the saved name"); 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 +754,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 +803,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 +845,7 @@ async fn policy_get_explicit_revision_uses_stored_policy_status() { 3, run::PolicyGetView::Full, "json", + "default", &ts.tls, (&mut stdout, &mut stderr), ) @@ -827,9 +881,9 @@ async fn explicit_name_takes_precedence_over_persisted() { let _guard = EnvVarGuard::set(&[("XDG_CONFIG_HOME", xdg_dir.path().to_str().unwrap())]); // Persist one name, but supply a different one explicitly. - save_last_sandbox("my-cluster", "old-sandbox").expect("save should succeed"); + save_last_sandbox("my-cluster", "default", "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 b18cbb1293..070704fb0a 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?; @@ -620,11 +650,13 @@ 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()), + workspace: workspace.to_string(), ..Default::default() }) .await @@ -643,6 +675,7 @@ pub async fn discover_and_sync_policy( sandbox_id: &str, sandbox: &str, discovered_policy: &ProtoSandboxPolicy, + workspace: &str, ) -> Result { debug!( endpoint = %endpoint, @@ -654,7 +687,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) @@ -668,10 +701,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. @@ -680,9 +718,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 } @@ -722,6 +761,7 @@ pub async fn fetch_provider_environment( #[derive(Clone)] pub struct CachedOpenShellClient { client: OpenShellClient, + workspace: Arc>, } /// Settings poll result returned by [`CachedOpenShellClient::poll_settings`]. @@ -738,6 +778,8 @@ pub struct SettingsPollResult { pub global_policy_version: u32, pub provider_env_revision: u64, pub supervisor_middleware_services: Vec, + /// Workspace the sandbox belongs to. + pub workspace: String, } fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> SettingsPollResult { @@ -752,6 +794,7 @@ fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> Settin global_policy_version: inner.global_policy_version, provider_env_revision: inner.provider_env_revision, supervisor_middleware_services: inner.supervisor_middleware_services, + workspace: inner.workspace, } } @@ -766,7 +809,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. @@ -785,7 +831,20 @@ 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() + } + + /// 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. @@ -811,6 +870,7 @@ impl CachedOpenShellClient { proposed_chunks, network_activity_summaries, analysis_mode: analysis_mode.to_string(), + workspace: self.workspace(), }) .await .into_diagnostic()?; @@ -833,6 +893,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 a57acb0065..20c4dccb7d 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -47,7 +47,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..8794c11d5d 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 { + true + } +} + // 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 97a0f60839..2f89c2229e 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, @@ -1555,6 +1556,7 @@ fn pending_sandbox_snapshot( conditions: vec![condition], deleting, }), + workspace: sandbox.workspace.clone(), } } @@ -2312,6 +2314,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 @@ -2743,6 +2749,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 { @@ -2755,6 +2765,7 @@ fn sandbox_from_container_summary( &name, supervisor_connected, )), + workspace, }) } @@ -2928,24 +2939,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); } @@ -2956,7 +2969,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 7ca3405251..67036b2192 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(), } } @@ -1951,6 +1952,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(), @@ -1976,15 +1978,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 55b34c3f40..5dba71adcc 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -13,14 +13,15 @@ use futures::{Stream, StreamExt, TryStreamExt}; use k8s_openapi::api::core::v1::{ Event as KubeEventObj, Namespace, Node, PersistentVolumeClaimVolumeSource, Volume, VolumeMount, }; -use kube::api::{Api, ApiResource, DeleteParams, ListParams, PostParams}; +use kube::api::{Api, ApiResource, DeleteParams, ListParams, PostParams, Preconditions}; use kube::core::gvk::GroupVersionKind; use kube::core::{DynamicObject, ObjectMeta}; 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::{ @@ -667,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() @@ -684,9 +686,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 +696,24 @@ 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_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{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| { + Ok(sandbox_from_object(&self.config.namespace, obj) + .ok() + .map(|(_, s)| s)) + }, + ), Ok(Err(err)) => { warn!( - sandbox_name = %name, + sandbox_id = %sandbox_id, error = %err, "Failed to fetch sandbox from Kubernetes" ); @@ -710,7 +721,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" ); @@ -733,16 +744,20 @@ impl KubernetesComputeDriver { .await?; match tokio::time::timeout( KUBE_API_TIMEOUT, - agent_sandbox_api.api.list(&ListParams::default()), + agent_sandbox_api.api.list( + &ListParams::default() + .labels(&format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE}")), + ), ) .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)) - .collect::, _>>()?; + .filter_map(|obj| sandbox_from_object(&self.config.namespace, obj).ok()) + .map(|(_, s)| s) + .collect(); sandboxes.sort_by(|left, right| { left.name .cmp(&right.name) @@ -839,30 +854,25 @@ impl KubernetesComputeDriver { 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() }; @@ -905,9 +915,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" ); @@ -915,23 +925,71 @@ impl KubernetesComputeDriver { let agent_sandbox_api = self .supported_agent_sandbox_api(self.client.clone()) .await?; + let selector = + format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{LABEL_SANDBOX_ID}={sandbox_id}"); + let lp = ListParams::default().labels(&selector); + let (kube_name, preconditions) = 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) => { + let pc = Preconditions { + uid: obj.metadata.uid, + resource_version: obj.metadata.resource_version, + }; + (name, pc) + } + 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() + )); + } + }; + + let dp = DeleteParams::default().preconditions(preconditions); match tokio::time::timeout( KUBE_API_TIMEOUT, - agent_sandbox_api.api.delete(name, &DeleteParams::default()), + agent_sandbox_api.api.delete(&kube_name, &dp), ) .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)"); + Ok(Err(KubeError::Api(err))) if err.code == 404 || err.code == 409 => { + debug!(sandbox_id = %sandbox_id, "Sandbox not found in Kubernetes (already deleted or replaced)"); Ok(false) } Ok(Err(err)) => { warn!( - sandbox_name = %name, + sandbox_id = %sandbox_id, error = %err, "Failed to delete sandbox from Kubernetes" ); @@ -939,7 +997,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" ); @@ -951,13 +1009,15 @@ 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_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{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", @@ -974,8 +1034,9 @@ impl KubernetesComputeDriver { .supported_agent_sandbox_api(self.watch_client.clone()) .await?; let event_api: Api = Api::namespaced(self.watch_client.clone(), &namespace); - let mut sandbox_stream = - watcher::watcher(agent_sandbox_api.api, watcher::Config::default()).boxed(); + let watcher_config = watcher::Config::default() + .labels(&format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE}")); + let mut sandbox_stream = watcher::watcher(agent_sandbox_api.api, watcher_config).boxed(); let mut event_stream = watcher::watcher(event_api, watcher::Config::default()).boxed(); let (tx, rx) = mpsc::channel(256); @@ -987,63 +1048,44 @@ impl KubernetesComputeDriver { tokio::select! { 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); - let event = WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Sandbox( - WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } - )), - }; - if tx.send(Ok(event)).await.is_err() { - break; - } - } - Err(err) => { - if tx.send(Err(KubernetesDriverError::Message(err))).await.is_err() { - break; - } + if let Ok((kube_name, sandbox)) = sandbox_from_object(&namespace, obj) { + 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) } + )), + }; + if tx.send(Ok(event)).await.is_err() { + break; } } } Ok(Some(Event::Deleted(obj))) => { - match sandbox_id_from_object(&obj) { - Ok(sandbox_id) => { - remove_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &sandbox_id); - let event = WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Deleted( - WatchSandboxesDeletedEvent { sandbox_id } - )), - }; - if tx.send(Ok(event)).await.is_err() { - break; - } - } - Err(err) => { - if tx.send(Err(KubernetesDriverError::Message(err))).await.is_err() { - break; - } + if is_openshell_managed(&obj) + && let Ok(sandbox_id) = sandbox_id_from_object(&obj) + { + remove_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &sandbox_id); + let event = WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { sandbox_id } + )), + }; + if tx.send(Ok(event)).await.is_err() { + break; } } } 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); - let event = WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Sandbox( - WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } - )), - }; - if tx.send(Ok(event)).await.is_err() { - return; - } - } - Err(err) => { - if tx.send(Err(KubernetesDriverError::Message(err))).await.is_err() { - return; - } + if let Ok((kube_name, sandbox)) = sandbox_from_object(&namespace, obj) { + 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) } + )), + }; + if tx.send(Ok(event)).await.is_err() { + return; } } } @@ -1115,9 +1157,31 @@ fn validate_gpu_request( Ok(()) } +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()); + 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(), @@ -1125,24 +1189,70 @@ 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()); } + Err("sandbox id not found on object".to_string()) +} - let name = obj.metadata.name.clone().unwrap_or_default(); - if let Some(id) = name.strip_prefix("sandbox-") { - return Ok(id.to_string()); - } +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() +} - Err("sandbox id not found on object".to_string()) +fn is_openshell_managed(obj: &DynamicObject) -> bool { + annotation_or_label(obj, LABEL_MANAGED_BY).as_deref() == Some(LABEL_MANAGED_BY_VALUE) } -fn sandbox_from_object(namespace: &str, obj: DynamicObject) -> Result { - let id = sandbox_id_from_object(&obj)?; - let name = obj.metadata.name.clone().unwrap_or_default(); +/// Returns `(kube_resource_name, DriverSandbox)`. +/// +/// Returns `Err` in two cases (callers should skip, not fail): +/// - The object is not managed by `OpenShell` (missing/wrong `managed-by` label). +/// - The object is managed by `OpenShell` but missing required fields (orphan). +fn sandbox_from_object(namespace: &str, obj: DynamicObject) -> Result<(String, Sandbox), String> { + let kube_name = obj.metadata.name.clone().unwrap_or_default(); + + if !is_openshell_managed(&obj) { + debug!(object = %kube_name, "skipping sandbox CR not managed by openshell"); + return Err(format!("object {kube_name} not managed by openshell")); + } + + let Ok(id) = sandbox_id_from_object(&obj) else { + warn!(object = %kube_name, "openshell-managed sandbox CR missing id"); + return Err(format!("object {kube_name} missing sandbox id")); + }; + let Some(name) = annotation_or_label(&obj, LABEL_SANDBOX_NAME) else { + warn!(object = %kube_name, "openshell-managed sandbox CR missing name"); + return Err(format!("object {kube_name} missing sandbox name")); + }; + let Some(workspace) = annotation_or_label(&obj, LABEL_SANDBOX_WORKSPACE) else { + warn!(object = %kube_name, "openshell-managed sandbox CR missing workspace"); + return Err(format!("object {kube_name} missing sandbox workspace")); + }; + let namespace = obj .metadata .namespace @@ -1150,22 +1260,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() @@ -5617,4 +5732,186 @@ mod tests { assert!(validate_kubernetes_dns1123_label("MySandbox", "sandbox name").is_err()); assert!(validate_kubernetes_dns1123_label("dotted.name", "sandbox name").is_err()); } + + #[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 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 { + 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()), + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.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_skips_unmanaged_cr() { + let obj = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("foreign-sandbox".to_string()), + namespace: Some("default".to_string()), + labels: Some(BTreeMap::from([( + "some-other-label".to_string(), + "value".to_string(), + )])), + ..Default::default() + }, + data: serde_json::json!({}), + }; + + let result = sandbox_from_object("default", obj); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not managed by openshell")); + } + + #[test] + fn sandbox_from_object_warns_on_managed_cr_missing_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()), + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + ])), + ..Default::default() + }, + data: serde_json::json!({}), + }; + + let result = sandbox_from_object("default", obj); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("missing sandbox 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/client.rs b/crates/openshell-driver-podman/src/client.rs index fb2ecc0967..59cbda545d 100644 --- a/crates/openshell-driver-podman/src/client.rs +++ b/crates/openshell-driver-podman/src/client.rs @@ -484,12 +484,12 @@ impl PodmanClient { .await } - /// List containers matching a label filter (e.g. `"openshell.managed=true"`). + /// List containers matching label filters (e.g. `&["openshell.managed=true"]`). pub async fn list_containers( &self, - label_filter: &str, + label_filters: &[&str], ) -> Result, PodmanApiError> { - let filters = serde_json::json!({"label": [label_filter]}); + let filters = serde_json::json!({"label": label_filters}); let encoded = url_encode(&filters.to_string()); self.request_json( hyper::Method::GET, diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 9ee26c0735..d38a3b9eb1 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -36,19 +36,17 @@ 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"; +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. 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 +142,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 +464,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 +833,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); @@ -1247,8 +1248,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] @@ -1653,13 +1657,16 @@ 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.sandbox-name".to_string(), + "openshell.ai/sandbox-id".to_string(), + "spoofed-id".to_string(), + ); + label_overrides.insert( + "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 { @@ -1677,20 +1684,22 @@ 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" @@ -1743,6 +1752,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 a86d58dee0..d59cbb9e1d 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,75 +593,60 @@ impl PodmanComputeDriver { Ok(()) } + /// Find the Podman container ID for a sandbox by its sandbox ID using label lookup. + async fn find_container_id( + &self, + sandbox_id: &str, + ) -> Result, ComputeDriverError> { + let id_filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let entries = self + .client + .list_containers(&[LABEL_MANAGED_FILTER, &id_filter]) + .await + .map_err(ComputeDriverError::from)?; + Ok(entries.first().map(|e| e.id.clone())) + } + /// 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 container_id = self.find_container_id(sandbox_id).await?.ok_or_else(|| { + ComputeDriverError::Precondition("sandbox container not found".into()) + })?; + info!(sandbox_id = %sandbox_id, container = %container_id, "Stopping sandbox container"); self.client - .stop_container(&name, self.config.stop_timeout_secs) + .stop_container(&container_id, self.config.stop_timeout_secs) .await .map_err(ComputeDriverError::from) } /// 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(container_id) = self.find_container_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 = %container_id, "Deleting sandbox container"); // Stop (best-effort). let _ = self .client - .stop_container(&name, self.config.stop_timeout_secs) + .stop_container(&container_id, self.config.stop_timeout_secs) .await; - // Remove container. If NotFound, the container was removed between - // inspect 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 { + let container_existed = match self.client.remove_container(&container_id).await { Ok(()) => true, Err(PodmanApiError::NotFound(_)) => false, Err(e) => return Err(ComputeDriverError::from(e)), @@ -672,7 +657,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 +668,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 id_filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let entries = self + .client + .list_containers(&[LABEL_MANAGED_FILTER, &id_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 id_filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let entries = self + .client + .list_containers(&[LABEL_MANAGED_FILTER, &id_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)) } } @@ -713,7 +712,7 @@ impl PodmanComputeDriver { pub async fn list_sandboxes(&self) -> Result, ComputeDriverError> { let entries = self .client - .list_containers(LABEL_MANAGED_FILTER) + .list_containers(&[LABEL_MANAGED_FILTER]) .await .map_err(ComputeDriverError::from)?; @@ -1283,6 +1282,7 @@ mod tests { ..Default::default() }), status: None, + workspace: String::new(), } } @@ -1441,24 +1441,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"); @@ -1468,67 +1466,49 @@ 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_id = "abc123def456"; + let container_name = "openshell-default--demo-sandbox-request-id"; let volume_name = container::volume_name(sandbox_id); - let inspect_body = serde_json::json!({ - "Id": "container-id", - "Name": format!("/{container_name}"), - "State": { - "Status": "running", - "Running": true - }, - "Config": { - "Labels": { - LABEL_SANDBOX_ID: "sandbox-label-id" - } + let list_body = serde_json::json!([{ + "Id": container_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"); @@ -1538,12 +1518,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_id}/stop"))); + assert!(requests[2].contains(&format!("/libpod/containers/{container_id}"))); 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..4d397eb972 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::{ @@ -76,7 +78,7 @@ pub async fn start_watch(client: PodmanClient) -> Result Option Option Option ( @@ -316,6 +333,7 @@ pub fn driver_sandbox_from_list_entry(entry: &ContainerListEntry) -> Option(mut self, flush_callback: F) + /// `ready_gate` is checked before each flush. When it returns `false`, + /// the drain is skipped and events stay in the buffer until the next tick. + pub async fn run(mut self, flush_callback: F, ready_gate: G) where F: Fn(FlushableActivitySummary) -> Fut + Send + 'static, Fut: Future + Send + 'static, + G: Fn() -> bool, { let (flush_tx, mut flush_rx) = mpsc::channel::(ACTIVITY_FLUSH_QUEUE_CAPACITY); @@ -68,15 +71,26 @@ impl ActivityAggregator { if let Some(event) = event { self.ingest(event); } else { - if let Some(summary) = self.drain() { - queue_flush_summary(&flush_tx, summary); + if self.network_activity_count > 0 { + if ready_gate() { + if let Some(summary) = self.drain() { + queue_flush_summary(&flush_tx, summary); + } + } else { + warn!( + count = self.network_activity_count, + "ActivityAggregator: dropping unflushed events, workspace not yet known" + ); + } } debug!("ActivityAggregator: channel closed, exiting"); return; } } _ = flush_interval.tick() => { - if let Some(summary) = self.drain() { + if ready_gate() + && let Some(summary) = self.drain() + { debug!( count = summary.network_activity_count, denied = summary.denied_action_count, diff --git a/crates/openshell-sandbox/src/denial_aggregator.rs b/crates/openshell-sandbox/src/denial_aggregator.rs index 7d3f8e9913..adbca79b04 100644 --- a/crates/openshell-sandbox/src/denial_aggregator.rs +++ b/crates/openshell-sandbox/src/denial_aggregator.rs @@ -12,7 +12,7 @@ use std::collections::HashMap; use std::future::Future; use tokio::sync::mpsc; -use tracing::debug; +use tracing::{debug, warn}; use openshell_core::denial::DenialEvent; @@ -65,10 +65,14 @@ impl DenialAggregator { /// /// `flush_callback` is called periodically with the accumulated summaries. /// In production this calls `SubmitPolicyAnalysis` on the gateway. - pub async fn run(mut self, flush_callback: F) + /// + /// `ready_gate` is checked before each flush. When it returns `false`, + /// the drain is skipped and events stay in the buffer until the next tick. + pub async fn run(mut self, flush_callback: F, ready_gate: G) where F: Fn(Vec) -> Fut, Fut: Future, + G: Fn() -> bool, { let mut flush_interval = tokio::time::interval(std::time::Duration::from_secs(self.flush_interval_secs)); @@ -83,15 +87,22 @@ impl DenialAggregator { } else { // Channel closed; do a final flush and exit. if !self.summaries.is_empty() { - let batch = self.drain(); - flush_callback(batch).await; + if ready_gate() { + let batch = self.drain(); + flush_callback(batch).await; + } else { + warn!( + count = self.summaries.len(), + "DenialAggregator: dropping unflushed summaries, workspace not yet known" + ); + } } debug!("DenialAggregator: channel closed, exiting"); return; } } _ = flush_interval.tick() => { - if !self.summaries.is_empty() { + if ready_gate() && !self.summaries.is_empty() { let batch = self.drain(); debug!(count = batch.len(), "DenialAggregator: flushing summaries"); flush_callback(batch).await; diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 78e5027953..347e1509b6 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -361,6 +361,11 @@ pub async fn run_sandbox( #[cfg(not(target_os = "linux"))] drop(bypass_activity_tx); + // Workspace watch: the policy poll loop learns the workspace from + // GetSandboxConfig and broadcasts it. Flush tasks and the policy.local + // API read the current value so proposals target the correct workspace. + let (workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new()); + let networking = if network_enabled { #[cfg(target_os = "linux")] let proxy_bind_ip = netns @@ -384,6 +389,7 @@ pub async fn run_sandbox( inference_routes.as_deref(), denial_tx, activity_tx, + workspace_rx.clone(), ) .await?, ) @@ -482,20 +488,31 @@ pub async fn run_sandbox( .unwrap_or(10); let aggregator = denial_aggregator::DenialAggregator::new(rx, flush_interval_secs); + let denial_workspace_gate = workspace_rx.clone(); + 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(); - async move { - if let Err(e) = - flush_proposals_to_gateway(&endpoint, &sandbox_name, summaries).await - { - warn!(error = %e, "Failed to flush denial summaries to gateway"); + .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, + &workspace, + summaries, + ) + .await + { + warn!(error = %e, "Failed to flush denial summaries to gateway"); + } } - } - }) + }, + move || !denial_workspace_gate.borrow().is_empty(), + ) .await; }); } @@ -516,20 +533,31 @@ pub async fn run_sandbox( ); let aggregator = activity_aggregator::ActivityAggregator::new(rx, flush_interval_secs); + let activity_workspace_gate = workspace_rx.clone(); + 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(); - async move { - if let Err(e) = - flush_activity_to_gateway(&endpoint, &sandbox_name, summary).await - { - warn!(error = %e, "Failed to flush activity summary to gateway"); + .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, + &workspace, + summary, + ) + .await + { + warn!(error = %e, "Failed to flush activity summary to gateway"); + } } - } - }) + }, + move || !activity_workspace_gate.borrow().is_empty(), + ) .await; }); } @@ -565,6 +593,7 @@ pub async fn run_sandbox( policy_local_ctx: poll_policy_local, middleware_registry_status, sidecar_control_publisher: sidecar_control_publisher.clone(), + workspace_tx, }; tokio::spawn(async move { @@ -981,12 +1010,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() @@ -1049,12 +1080,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, @@ -1889,12 +1922,14 @@ async fn load_policy( // Sync and re-fetch over a single connection to avoid extra // TLS handshakes. + let ws = snapshot.workspace.clone(); snapshot = grpc_retry("Policy discovery sync", || { openshell_core::grpc_client::sync_policy_and_fetch_snapshot( endpoint, id, sandbox, &discovered, + &ws, ) }) .await?; @@ -1921,6 +1956,7 @@ async fn load_policy( id, sandbox_name, &sync_policy, + &snapshot.workspace, ) .await { @@ -2470,6 +2506,7 @@ struct PolicyPollLoopContext { policy_local_ctx: Option>, middleware_registry_status: MiddlewareRegistryStatus, sidecar_control_publisher: Option, + workspace_tx: tokio::sync::watch::Sender, } async fn connect_middleware_registry( @@ -2581,35 +2618,38 @@ 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_middleware_services = result.supervisor_middleware_services; - 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_middleware_services = result.supervisor_middleware_services; - 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_middleware_services = result.supervisor_middleware_services; + 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_middleware_services = result.supervisor_middleware_services; + 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"); } @@ -2622,7 +2662,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; @@ -3273,6 +3316,7 @@ filesystem_policy: global_policy_version: 0, provider_env_revision: 0, supervisor_middleware_services: Vec::new(), + workspace: String::new(), } } @@ -3551,4 +3595,21 @@ filesystem_policy: ); } } + + #[test] + fn settings_snapshot_carries_workspace_for_policy_sync() { + let mut snapshot = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + snapshot.workspace = "beta".to_string(); + + let revision = LoadedPolicyRevision::from_snapshot(&snapshot); + assert_eq!(revision.version, 1); + assert_eq!( + snapshot.workspace, "beta", + "workspace must survive the snapshot so sync_policy_and_fetch_snapshot receives it" + ); + } } diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index 67c1bf227e..0924ae4d62 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -16,6 +16,7 @@ use crate::refresh::{RefreshedToken, TokenSource}; use crate::transport; use crate::types::{ ExecOptions, ExecResult, Health, ListOptions, SandboxPhase, SandboxRef, SandboxSpec, + WorkspaceRef, }; use futures::StreamExt; use openshell_core::proto; @@ -168,6 +169,7 @@ impl OpenShellClient { .unary(|mut grpc| { let request = proto::GetSandboxRequest { name: name.to_string(), + workspace: String::new(), }; async move { grpc.get_sandbox(request).await } }) @@ -183,6 +185,8 @@ impl OpenShellClient { limit: opts.limit, offset: opts.offset, label_selector: opts.label_selector.clone().unwrap_or_default(), + workspace: String::new(), + all_workspaces: false, }; async move { grpc.list_sandboxes(request).await } }) @@ -205,6 +209,7 @@ impl OpenShellClient { .unary(|mut grpc| { let request = proto::DeleteSandboxRequest { name: name.to_string(), + workspace: String::new(), }; async move { grpc.delete_sandbox(request).await } }) @@ -252,6 +257,110 @@ impl OpenShellClient { } } + /// Return a workspace-scoped handle for sandbox operations. + /// + /// Analogous to `kube::Api::namespaced` — captures the workspace once and + /// injects it into every request. + pub fn workspace(&self, workspace: &str) -> WorkspaceScopedClient { + WorkspaceScopedClient { + client: self.clone(), + workspace: workspace.to_string(), + } + } + + /// List sandboxes across all workspaces. + pub async fn list_sandboxes_all_workspaces( + &self, + opts: ListOptions, + ) -> Result> { + let response = self + .unary(|mut grpc| { + let request = proto::ListSandboxesRequest { + limit: opts.limit, + offset: opts.offset, + label_selector: opts.label_selector.clone().unwrap_or_default(), + workspace: String::new(), + all_workspaces: true, + }; + async move { grpc.list_sandboxes(request).await } + }) + .await?; + Ok(response + .sandboxes + .into_iter() + .map(SandboxRef::from_proto) + .collect()) + } + + /// Create a new workspace. + pub async fn create_workspace( + &self, + name: &str, + labels: HashMap, + ) -> Result { + let response = self + .unary(|mut grpc| { + let request = proto::CreateWorkspaceRequest { + name: name.to_string(), + labels: labels.clone(), + }; + async move { grpc.create_workspace(request).await } + }) + .await?; + response + .workspace + .map(WorkspaceRef::from_proto) + .ok_or_else(|| SdkError::invalid_config("workspace missing from gateway response")) + } + + /// Fetch a workspace by name. + pub async fn get_workspace(&self, name: &str) -> Result { + let response = self + .unary(|mut grpc| { + let request = proto::GetWorkspaceRequest { + name: name.to_string(), + }; + async move { grpc.get_workspace(request).await } + }) + .await?; + response + .workspace + .map(WorkspaceRef::from_proto) + .ok_or_else(|| SdkError::invalid_config("workspace missing from gateway response")) + } + + /// List workspaces. + pub async fn list_workspaces(&self, opts: ListOptions) -> Result> { + let response = self + .unary(|mut grpc| { + let request = proto::ListWorkspacesRequest { + limit: opts.limit, + offset: opts.offset, + label_selector: opts.label_selector.clone().unwrap_or_default(), + }; + async move { grpc.list_workspaces(request).await } + }) + .await?; + Ok(response + .workspaces + .into_iter() + .map(WorkspaceRef::from_proto) + .collect()) + } + + /// Delete a workspace by name. + pub async fn delete_workspace(&self, name: &str) -> Result { + let response = self + .unary(|mut grpc| { + let request = proto::DeleteWorkspaceRequest { + name: name.to_string(), + }; + async move { grpc.delete_workspace(request).await } + }) + .await?; + Ok(response.deleted) + } + /// Run a command inside a sandbox and buffer stdout/stderr to the end. /// /// For streaming output, drop down to [`OpenShellClient::raw_grpc`] and @@ -395,6 +504,187 @@ impl OpenShellClient { } } +/// Workspace-scoped sandbox operations, analogous to `kube::Api::namespaced`. +/// +/// Constructed via [`OpenShellClient::workspace`]. Captures the workspace +/// once and injects it into every request. +#[derive(Clone)] +pub struct WorkspaceScopedClient { + client: OpenShellClient, + workspace: String, +} + +impl WorkspaceScopedClient { + /// The workspace this client targets. + pub fn workspace_name(&self) -> &str { + &self.workspace + } + + /// Create a new sandbox in this workspace. + pub async fn create_sandbox(&self, spec: SandboxSpec) -> Result { + let mut request = create_sandbox_request(spec); + request.workspace = self.workspace.clone(); + let response = self + .client + .unary(|mut grpc| { + let request = request.clone(); + async move { grpc.create_sandbox(request).await } + }) + .await?; + sandbox_from_response(response.sandbox) + } + + /// Fetch a sandbox by name in this workspace. + pub async fn get_sandbox(&self, name: &str) -> Result { + let response = self + .client + .unary(|mut grpc| { + let request = proto::GetSandboxRequest { + name: name.to_string(), + workspace: self.workspace.clone(), + }; + async move { grpc.get_sandbox(request).await } + }) + .await?; + sandbox_from_response(response.sandbox) + } + + /// List sandboxes in this workspace. + pub async fn list_sandboxes(&self, opts: ListOptions) -> Result> { + let response = self + .client + .unary(|mut grpc| { + let request = proto::ListSandboxesRequest { + limit: opts.limit, + offset: opts.offset, + label_selector: opts.label_selector.clone().unwrap_or_default(), + workspace: self.workspace.clone(), + all_workspaces: false, + }; + async move { grpc.list_sandboxes(request).await } + }) + .await?; + Ok(response + .sandboxes + .into_iter() + .map(SandboxRef::from_proto) + .collect()) + } + + /// Delete a sandbox by name in this workspace. + pub async fn delete_sandbox(&self, name: &str) -> Result { + let response = self + .client + .unary(|mut grpc| { + let request = proto::DeleteSandboxRequest { + name: name.to_string(), + workspace: self.workspace.clone(), + }; + async move { grpc.delete_sandbox(request).await } + }) + .await?; + Ok(response.deleted) + } + + /// Poll until the sandbox reaches [`SandboxPhase::Ready`] or the timeout + /// elapses. + pub async fn wait_ready(&self, name: &str, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let mut delay = Duration::from_millis(250); + loop { + let snapshot = self.get_sandbox(name).await?; + match snapshot.phase { + SandboxPhase::Ready => return Ok(snapshot), + SandboxPhase::Error => { + return Err(SdkError::connect(format!( + "sandbox '{name}' entered error phase" + ))); + } + _ => {} + } + if Instant::now() >= deadline { + return Err(SdkError::connect(format!( + "timed out waiting for sandbox '{name}'" + ))); + } + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(2)); + } + } + + /// Poll until the sandbox is gone (`NotFound`) or the timeout elapses. + pub async fn wait_deleted(&self, name: &str, timeout: Duration) -> Result<()> { + let deadline = Instant::now() + timeout; + let mut delay = Duration::from_millis(250); + loop { + match self.get_sandbox(name).await { + Err(SdkError::NotFound { .. }) => return Ok(()), + Err(other) => return Err(other), + Ok(_) => {} + } + if Instant::now() >= deadline { + return Err(SdkError::connect(format!( + "timed out waiting for sandbox '{name}' to delete" + ))); + } + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(2)); + } + } + + /// Run a command inside a sandbox and buffer stdout/stderr. + pub async fn exec(&self, name: &str, cmd: &[String], opts: ExecOptions) -> Result { + let sandbox = self.get_sandbox(name).await?; + let request = proto::ExecSandboxRequest { + sandbox_id: sandbox.id, + command: cmd.to_vec(), + workdir: opts.workdir.unwrap_or_default(), + environment: opts.environment, + timeout_seconds: opts + .timeout + .map_or(0, |d| u32::try_from(d.as_secs()).unwrap_or(u32::MAX)), + stdin: opts.stdin.unwrap_or_default(), + tty: false, + cols: 0, + rows: 0, + }; + + let mut stream = self + .client + .unary(|mut grpc| { + let request = request.clone(); + async move { grpc.exec_sandbox(request).await } + }) + .await?; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut exit_code: Option = None; + + while let Some(event) = stream.next().await { + let event = event.map_err(map_status)?; + match event.payload { + Some(proto::exec_sandbox_event::Payload::Stdout(chunk)) => { + stdout.extend_from_slice(&chunk.data); + } + Some(proto::exec_sandbox_event::Payload::Stderr(chunk)) => { + stderr.extend_from_slice(&chunk.data); + } + Some(proto::exec_sandbox_event::Payload::Exit(exit)) => { + exit_code = Some(exit.exit_code); + } + None => {} + } + } + + Ok(ExecResult { + exit_code: exit_code.unwrap_or(-1), + stdout, + stderr, + }) + } +} + fn interceptor_from_config(config: &ClientConfig) -> Result { match &config.auth { None => Ok(EdgeAuthInterceptor::noop()), @@ -470,6 +760,7 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest { name: name.unwrap_or_default(), labels, annotations: HashMap::new(), + workspace: String::new(), } } diff --git a/crates/openshell-sdk/src/lib.rs b/crates/openshell-sdk/src/lib.rs index 53bcb336a9..dbf2524a2a 100644 --- a/crates/openshell-sdk/src/lib.rs +++ b/crates/openshell-sdk/src/lib.rs @@ -41,11 +41,11 @@ pub mod transport; pub mod types; pub use auth::EdgeAuthInterceptor; -pub use client::OpenShellClient; +pub use client::{OpenShellClient, WorkspaceScopedClient}; pub use config::{AuthConfig, ClientConfig}; pub use error::SdkError; pub use refresh::{Refresh, RefreshError, RefreshedToken, TokenSource}; pub use types::{ ExecOptions, ExecResult, Health, ListOptions, SandboxPhase, SandboxRef, SandboxSpec, - ServiceStatus, + ServiceStatus, WorkspaceRef, }; diff --git a/crates/openshell-sdk/src/raw.rs b/crates/openshell-sdk/src/raw.rs index 0b3b18a04e..80d7453602 100644 --- a/crates/openshell-sdk/src/raw.rs +++ b/crates/openshell-sdk/src/raw.rs @@ -22,10 +22,11 @@ pub use openshell_core::proto; pub use openshell_core::proto::inference_client::InferenceClient; pub use openshell_core::proto::open_shell_client::OpenShellClient as GrpcClient; pub use openshell_core::proto::{ - CreateSandboxRequest, DeleteSandboxRequest, ExecSandboxRequest, GetSandboxRequest, - HealthRequest, ListProvidersRequest, ListSandboxesRequest, Sandbox, + CreateSandboxRequest, CreateWorkspaceRequest, DeleteSandboxRequest, DeleteWorkspaceRequest, + ExecSandboxRequest, GetSandboxRequest, GetWorkspaceRequest, HealthRequest, + ListProvidersRequest, ListSandboxesRequest, ListWorkspacesRequest, Sandbox, SandboxPhase as ProtoSandboxPhase, SandboxSpec as ProtoSandboxSpec, SandboxTemplate, - ServiceStatus as ProtoServiceStatus, + ServiceStatus as ProtoServiceStatus, Workspace, }; /// Type alias for the gRPC client wrapped in the SDK's auth interceptor. diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index a94f602c18..2eb4ef0a92 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -111,6 +111,7 @@ pub struct SandboxSpec { pub struct SandboxRef { pub id: String, pub name: String, + pub workspace: String, pub phase: SandboxPhase, pub labels: HashMap, pub resource_version: u64, @@ -123,6 +124,7 @@ impl SandboxRef { Self { id: meta.id, name: meta.name, + workspace: meta.workspace, phase, labels: meta.labels, resource_version: meta.resource_version, @@ -130,6 +132,34 @@ impl SandboxRef { } } +/// Reference to a workspace on the gateway. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct WorkspaceRef { + pub name: String, + pub phase: String, + pub labels: HashMap, +} + +impl WorkspaceRef { + pub(crate) fn from_proto(workspace: proto::Workspace) -> Self { + let meta = workspace.metadata.unwrap_or_default(); + let phase = workspace + .status + .and_then(|s| proto::datamodel::v1::WorkspacePhase::try_from(s.phase).ok()) + .map_or("Unknown", |p| match p { + proto::datamodel::v1::WorkspacePhase::Unspecified => "Unspecified", + proto::datamodel::v1::WorkspacePhase::Active => "Active", + proto::datamodel::v1::WorkspacePhase::Terminating => "Terminating", + }); + Self { + name: meta.name, + phase: phase.to_string(), + labels: meta.labels, + } + } +} + /// Options for listing sandboxes. #[derive(Clone, Debug, Default)] pub struct ListOptions { diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index f9244b3c82..d0512d26e1 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -27,10 +27,13 @@ use tonic::{Response, Status}; #[derive(Default)] struct MockState { last_get_name: Mutex>, + last_get_workspace: Mutex>, last_create: Mutex>, last_delete_name: Mutex>, + last_delete_workspace: Mutex>, last_list_request: Mutex>, last_exec_request: Mutex>, + last_workspace_request: Mutex>, get_calls: AtomicU32, phase_sequence: Vec, get_returns_not_found: bool, @@ -48,6 +51,14 @@ struct TestOpenShell { } fn sandbox_with_phase(name: &str, phase: proto::SandboxPhase) -> proto::Sandbox { + sandbox_with_phase_ws(name, phase, "default") +} + +fn sandbox_with_phase_ws( + name: &str, + phase: proto::SandboxPhase, + workspace: &str, +) -> proto::Sandbox { proto::Sandbox { metadata: Some(proto::datamodel::v1::ObjectMeta { id: format!("id-{name}"), @@ -56,6 +67,8 @@ fn sandbox_with_phase(name: &str, phase: proto::SandboxPhase) -> proto::Sandbox labels: HashMap::new(), annotations: HashMap::new(), resource_version: 1, + deletion_timestamp_ms: 0, + workspace: workspace.to_string(), }), spec: None, status: Some(proto::SandboxStatus { @@ -65,6 +78,24 @@ fn sandbox_with_phase(name: &str, phase: proto::SandboxPhase) -> proto::Sandbox } } +fn workspace_proto(name: &str, phase: proto::datamodel::v1::WorkspacePhase) -> proto::Workspace { + proto::Workspace { + metadata: Some(proto::datamodel::v1::ObjectMeta { + id: format!("ws-{name}"), + name: name.to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + annotations: HashMap::new(), + resource_version: 1, + deletion_timestamp_ms: 0, + workspace: String::new(), + }), + status: Some(proto::datamodel::v1::WorkspaceStatus { + phase: phase.into(), + }), + } +} + #[tonic::async_trait] impl OpenShell for TestOpenShell { async fn health( @@ -127,8 +158,10 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { - let name = request.into_inner().name; + let req = request.into_inner(); + let name = req.name; *self.state.last_get_name.lock().await = Some(name.clone()); + *self.state.last_get_workspace.lock().await = Some(req.workspace.clone()); let count = self.state.get_calls.fetch_add(1, Ordering::SeqCst); if self.state.get_returns_not_found { @@ -195,8 +228,9 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { - let name = request.into_inner().name; - *self.state.last_delete_name.lock().await = Some(name); + let req = request.into_inner(); + *self.state.last_delete_name.lock().await = Some(req.name); + *self.state.last_delete_workspace.lock().await = Some(req.workspace); Ok(Response::new(proto::DeleteSandboxResponse { deleted: true, })) @@ -576,6 +610,77 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("unused")) } + + async fn create_workspace( + &self, + request: tonic::Request, + ) -> Result, Status> { + let req = request.into_inner(); + *self.state.last_workspace_request.lock().await = Some(req.name.clone()); + Ok(Response::new(proto::CreateWorkspaceResponse { + workspace: Some(workspace_proto( + &req.name, + proto::datamodel::v1::WorkspacePhase::Active, + )), + })) + } + + async fn get_workspace( + &self, + request: tonic::Request, + ) -> Result, Status> { + let name = request.into_inner().name; + *self.state.last_workspace_request.lock().await = Some(name.clone()); + Ok(Response::new(proto::GetWorkspaceResponse { + workspace: Some(workspace_proto( + &name, + proto::datamodel::v1::WorkspacePhase::Active, + )), + })) + } + + async fn list_workspaces( + &self, + _: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(proto::ListWorkspacesResponse { + workspaces: vec![ + workspace_proto("default", proto::datamodel::v1::WorkspacePhase::Active), + workspace_proto("staging", proto::datamodel::v1::WorkspacePhase::Active), + ], + })) + } + + async fn delete_workspace( + &self, + request: tonic::Request, + ) -> Result, Status> { + *self.state.last_workspace_request.lock().await = Some(request.into_inner().name); + Ok(Response::new(proto::DeleteWorkspaceResponse { + deleted: true, + })) + } + + 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")) + } } /// Spin up the mock gateway, return its endpoint URL. @@ -892,6 +997,163 @@ async fn unauthenticated_without_refresher_surfaces_error() { ); } +// ---- Workspace-scoped client tests ---- + +#[tokio::test] +async fn workspace_scoped_create_passes_workspace() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let ws = client.workspace("staging"); + let spec = SandboxSpec { + name: Some("my-box".to_string()), + ..Default::default() + }; + let result = ws.create_sandbox(spec).await.unwrap(); + assert_eq!(result.name, "my-box"); + + let observed = state.last_create.lock().await.clone().unwrap(); + assert_eq!(observed.workspace, "staging"); +} + +#[tokio::test] +async fn workspace_scoped_get_passes_workspace() { + let state = Arc::new(MockState { + phase_sequence: vec![proto::SandboxPhase::Ready], + ..Default::default() + }); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let ws = client.workspace("production"); + let sandbox = ws.get_sandbox("my-box").await.unwrap(); + assert_eq!(sandbox.name, "my-box"); + + let observed_ws = state.last_get_workspace.lock().await.clone(); + assert_eq!(observed_ws.as_deref(), Some("production")); +} + +#[tokio::test] +async fn workspace_scoped_list_passes_workspace() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let ws = client.workspace("dev"); + let items = ws.list_sandboxes(ListOptions::default()).await.unwrap(); + assert_eq!(items.len(), 2); + + let observed = state.last_list_request.lock().await.clone().unwrap(); + assert_eq!(observed.workspace, "dev"); + assert!(!observed.all_workspaces); +} + +#[tokio::test] +async fn workspace_scoped_delete_passes_workspace() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let ws = client.workspace("staging"); + let deleted = ws.delete_sandbox("doomed").await.unwrap(); + assert!(deleted); + + let observed_ws = state.last_delete_workspace.lock().await.clone(); + assert_eq!(observed_ws.as_deref(), Some("staging")); +} + +#[tokio::test] +async fn list_sandboxes_all_workspaces_sets_flag() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let items = client + .list_sandboxes_all_workspaces(ListOptions::default()) + .await + .unwrap(); + assert_eq!(items.len(), 2); + + let observed = state.last_list_request.lock().await.clone().unwrap(); + assert!(observed.all_workspaces); + assert!(observed.workspace.is_empty()); +} + +// ---- Workspace CRUD tests ---- + +#[tokio::test] +async fn create_workspace_returns_ref() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let ws = client + .create_workspace("staging", HashMap::new()) + .await + .unwrap(); + assert_eq!(ws.name, "staging"); + assert_eq!(ws.phase, "Active"); + + let observed = state.last_workspace_request.lock().await.clone(); + assert_eq!(observed.as_deref(), Some("staging")); +} + +#[tokio::test] +async fn get_workspace_returns_ref() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let ws = client.get_workspace("production").await.unwrap(); + assert_eq!(ws.name, "production"); + assert_eq!(ws.phase, "Active"); + + let observed = state.last_workspace_request.lock().await.clone(); + assert_eq!(observed.as_deref(), Some("production")); +} + +#[tokio::test] +async fn list_workspaces_returns_all() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let workspaces = client + .list_workspaces(ListOptions::default()) + .await + .unwrap(); + assert_eq!(workspaces.len(), 2); + assert_eq!(workspaces[0].name, "default"); + assert_eq!(workspaces[1].name, "staging"); +} + +#[tokio::test] +async fn delete_workspace_returns_ack() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let deleted = client.delete_workspace("doomed").await.unwrap(); + assert!(deleted); + + let observed = state.last_workspace_request.lock().await.clone(); + assert_eq!(observed.as_deref(), Some("doomed")); +} + +#[tokio::test] +async fn sandbox_ref_includes_workspace_field() { + let state = Arc::new(MockState { + phase_sequence: vec![proto::SandboxPhase::Ready], + ..Default::default() + }); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let sandbox = client.get_sandbox("my-box").await.unwrap(); + assert_eq!(sandbox.workspace, "default"); +} + /// Regression (P1): raw RPCs do not drive refresh. A call through the plain /// `raw_grpc()` accessor keeps sending the seeded token, while /// `raw_grpc_fresh()` proactively refreshes a near-expiry token into the diff --git a/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql new file mode 100644 index 0000000000..a4b4dea97d --- /dev/null +++ b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql @@ -0,0 +1,15 @@ +-- Add workspace column for multi-tenant isolation. +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', 'sandbox_policy', 'draft_policy_chunk', 'sandbox_settings'); + +-- Replace global name uniqueness with workspace-scoped uniqueness. +DROP INDEX IF EXISTS objects_name_uq; +CREATE UNIQUE INDEX objects_name_uq + ON objects (object_type, workspace, name) + WHERE name IS NOT NULL; diff --git a/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql new file mode 100644 index 0000000000..a4b4dea97d --- /dev/null +++ b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql @@ -0,0 +1,15 @@ +-- Add workspace column for multi-tenant isolation. +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', 'sandbox_policy', 'draft_policy_chunk', 'sandbox_settings'); + +-- Replace global name uniqueness with workspace-scoped uniqueness. +DROP INDEX IF EXISTS objects_name_uq; +CREATE UNIQUE INDEX objects_name_uq + ON objects (object_type, workspace, name) + WHERE name IS NOT NULL; diff --git a/crates/openshell-server/src/auth/sandbox_methods.rs b/crates/openshell-server/src/auth/sandbox_methods.rs index 76d5e13245..b90841d85a 100644 --- a/crates/openshell-server/src/auth/sandbox_methods.rs +++ b/crates/openshell-server/src/auth/sandbox_methods.rs @@ -49,10 +49,10 @@ mod tests { "/openshell.v1.OpenShell/ApproveDraftChunk" )); assert!(!is_sandbox_callable( - "/openshell.inference.v1.Inference/GetClusterInference" + "/openshell.inference.v1.Inference/GetInferenceRoute" )); assert!(!is_sandbox_callable( - "/openshell.inference.v1.Inference/SetClusterInference" + "/openshell.inference.v1.Inference/SetInferenceRoute" )); } } diff --git a/crates/openshell-server/src/compute/lease.rs b/crates/openshell-server/src/compute/lease.rs index a2eac70625..1de4de4734 100644 --- a/crates/openshell-server/src/compute/lease.rs +++ b/crates/openshell-server/src/compute/lease.rs @@ -101,6 +101,7 @@ impl ReconcilerLease { LEASE_OBJECT_TYPE, LEASE_SINGLETON_ID, LEASE_SINGLETON_NAME, + "", &payload_bytes, None, WriteCondition::MustCreate, @@ -140,6 +141,7 @@ impl ReconcilerLease { LEASE_OBJECT_TYPE, LEASE_SINGLETON_ID, LEASE_SINGLETON_NAME, + "", &payload_bytes, None, WriteCondition::MatchResourceVersion(record.resource_version), @@ -181,6 +183,7 @@ impl ReconcilerLease { LEASE_OBJECT_TYPE, LEASE_SINGLETON_ID, LEASE_SINGLETON_NAME, + "", &payload_bytes, None, WriteCondition::MatchResourceVersion(guard.resource_version), diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 239feb74d5..da5d3efd45 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -13,7 +13,10 @@ pub use openshell_driver_podman::PodmanComputeConfig; pub use vm::VmComputeConfig; use crate::grpc::policy::SANDBOX_SETTINGS_OBJECT_TYPE; -use crate::persistence::{ObjectId, ObjectName, ObjectRecord, ObjectType, Store, WriteCondition}; +use crate::persistence::{ + DRAFT_CHUNK_OBJECT_TYPE, ObjectId, ObjectName, ObjectRecord, ObjectType, POLICY_OBJECT_TYPE, + Store, WriteCondition, +}; use crate::sandbox_index::SandboxIndex; use crate::sandbox_watch::SandboxWatchBus; use crate::supervisor_session::SupervisorSessionRegistry; @@ -32,14 +35,16 @@ use openshell_core::proto::compute::v1::{ }; use openshell_core::proto::{ PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxSpec, SandboxStatus, - SandboxTemplate, SshSession, + SandboxTemplate, ServiceEndpoint, SshSession, }; +use openshell_core::{ObjectLabels, ObjectWorkspace}; use openshell_driver_docker::DockerComputeDriver; use openshell_driver_kubernetes::{ ComputeDriverService as KubernetesDriverService, KubernetesComputeDriver, }; use openshell_driver_podman::{ComputeDriverService as PodmanDriverService, PodmanComputeDriver}; use prost::Message; +use std::collections::HashMap; use std::fmt; use std::net::SocketAddr; use std::path::{Path, PathBuf}; @@ -509,20 +514,22 @@ impl ComputeRuntime { // Create with MustCreate condition to prevent duplicate creation race self.sandbox_index.update_from_sandbox(&sandbox); let mut sandbox = sandbox; - let labels_json = sandbox - .metadata - .as_ref() - .map(|metadata| &metadata.labels) - .filter(|labels| !labels.is_empty()) - .map(serde_json::to_string) - .transpose() - .map_err(|e| Status::internal(format!("failed to serialize labels: {e}")))?; + let labels_map = sandbox.object_labels(); + let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { + None + } else { + Some( + serde_json::to_string(&labels_map) + .map_err(|e| Status::internal(format!("failed to serialize labels: {e}")))?, + ) + }; let result = self .store .put_if( Sandbox::object_type(), &sandbox_id, sandbox.object_name(), + sandbox.object_workspace(), &sandbox.encode_to_vec(), labels_json.as_deref(), WriteCondition::MustCreate, @@ -591,13 +598,13 @@ impl ComputeRuntime { } } - pub async fn delete_sandbox(&self, name: &str) -> 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}")))?; @@ -611,7 +618,11 @@ impl ComputeRuntime { self.sandbox_index.update_from_sandbox(&sandbox); self.sandbox_watch_bus.notify(&id); - self.cleanup_sandbox_owned_records(&sandbox).await; + self.cleanup_sandbox_owned_records(&sandbox) + .await + .map_err(|e| { + Status::internal(format!("cleanup owned records for sandbox {id}: {e}")) + })?; let deleted = self .driver @@ -726,6 +737,7 @@ impl ComputeRuntime { Sandbox::object_type(), &id, &name, + sandbox.object_workspace(), &sandbox.encode_to_vec(), labels_json.as_deref(), WriteCondition::MatchResourceVersion(expected_resource_version), @@ -782,7 +794,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())?; @@ -1082,7 +1094,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())?; @@ -1180,27 +1192,40 @@ impl ComputeRuntime { if supervisor_promoted { ensure_supervisor_ready_status(&mut status, &sandbox_name); } + let workspace = incoming.workspace.clone(); let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: incoming.id.clone(), name: sandbox_name, created_at_ms: now_ms, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace, + deletion_timestamp_ms: 0, }), spec: None, status, }; sandbox.set_phase(phase as i32); + let labels_map = sandbox.object_labels(); + let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { + None + } else { + Some( + serde_json::to_string(&labels_map) + .map_err(|e| format!("failed to serialize labels: {e}"))?, + ) + }; self.store .put_if( Sandbox::object_type(), &incoming.id, sandbox.object_name(), + sandbox.object_workspace(), &sandbox.encode_to_vec(), - None, + labels_json.as_deref(), WriteCondition::MustCreate, ) .await @@ -1385,7 +1410,7 @@ impl ComputeRuntime { .await .map_err(|e| e.to_string())?; if let Some(sandbox) = sandbox.as_ref() { - self.cleanup_sandbox_owned_records(sandbox).await; + self.cleanup_sandbox_owned_records(sandbox).await?; } let _ = self @@ -1399,42 +1424,88 @@ impl ComputeRuntime { Ok(()) } - async fn cleanup_sandbox_owned_records(&self, sandbox: &Sandbox) { - self.cleanup_sandbox_ssh_sessions(sandbox.object_id()).await; + async fn cleanup_sandbox_owned_records(&self, sandbox: &Sandbox) -> Result<(), String> { + self.cleanup_sandbox_ssh_sessions(sandbox.object_id(), sandbox.object_workspace()) + .await?; + self.cleanup_sandbox_service_endpoints(sandbox.object_id(), sandbox.object_workspace()) + .await?; + + self.store + .delete_by_name( + SANDBOX_SETTINGS_OBJECT_TYPE, + sandbox.object_workspace(), + sandbox.object_name(), + ) + .await + .map_err(|e| format!("delete sandbox settings: {e}"))?; + + for (object_type, label) in [ + (POLICY_OBJECT_TYPE, "policy revisions"), + (DRAFT_CHUNK_OBJECT_TYPE, "draft policy chunks"), + ] { + self.store + .delete_by_scope(object_type, sandbox.object_id()) + .await + .map_err(|e| format!("delete {label}: {e}"))?; + } + + Ok(()) + } - if let Err(e) = self + async fn cleanup_sandbox_ssh_sessions( + &self, + sandbox_id: &str, + workspace: &str, + ) -> Result<(), String> { + let records = self .store - .delete_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, sandbox.object_name()) + .list(SshSession::object_type(), workspace, 1000, 0) .await - { - warn!( - sandbox_id = %sandbox.object_id(), - sandbox_name = %sandbox.object_name(), - error = %e, - "Failed to delete sandbox settings during cleanup" - ); + .map_err(|e| format!("list SSH sessions: {e}"))?; + + for record in records { + if let Ok(session) = SshSession::decode(record.payload.as_slice()) + && session.sandbox_id == sandbox_id + { + self.store + .delete(SshSession::object_type(), session.object_id()) + .await + .map_err(|e| format!("delete SSH session {}: {e}", session.object_id()))?; + } } + + Ok(()) } - async fn cleanup_sandbox_ssh_sessions(&self, sandbox_id: &str) { - if let Ok(records) = self.store.list(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 - && let Err(e) = self - .store - .delete(SshSession::object_type(), session.object_id()) - .await - { - warn!( - sandbox_id = %sandbox_id, - session_id = %session.object_id(), - error = %e, - "Failed to delete SSH session during sandbox cleanup" - ); - } + // TODO: introduce a per-sandbox cap on service endpoints and paginate + // this cleanup loop, or query by sandbox label instead of scanning the + // full workspace. Without a cap the flat 1,000-record page could miss + // endpoints in large workspaces. + async fn cleanup_sandbox_service_endpoints( + &self, + sandbox_id: &str, + workspace: &str, + ) -> Result<(), String> { + let records = self + .store + .list(ServiceEndpoint::object_type(), workspace, 1000, 0) + .await + .map_err(|e| format!("list service endpoints: {e}"))?; + + for record in records { + if let Ok(endpoint) = ServiceEndpoint::decode(record.payload.as_slice()) + && endpoint.sandbox_id == sandbox_id + { + self.store + .delete(ServiceEndpoint::object_type(), endpoint.object_id()) + .await + .map_err(|e| { + format!("delete service endpoint {}: {e}", endpoint.object_id()) + })?; } } + + Ok(()) } fn cleanup_sandbox_state(&self, sandbox_id: &str) { @@ -1590,6 +1661,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(), }) } @@ -2424,6 +2496,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), ..Default::default() }; @@ -2440,6 +2514,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), sandbox_id: sandbox_id.to_string(), token: format!("token-{id}"), @@ -2853,6 +2929,7 @@ mod tests { }], deleting: false, }), + workspace: "default".to_string(), }) .await .unwrap(); @@ -2895,6 +2972,7 @@ mod tests { namespace: "default".to_string(), spec: None, status: None, + workspace: "default".to_string(), }) .await .unwrap(); @@ -2943,6 +3021,7 @@ mod tests { "Starting", "VM is starting", ))), + workspace: "default".to_string(), }) .await .unwrap(); @@ -3062,6 +3141,7 @@ mod tests { }], deleting: false, }), + workspace: "default".to_string(), }], current_sandboxes: vec![DriverSandbox { id: "sb-1".to_string(), @@ -3082,6 +3162,7 @@ mod tests { }], deleting: false, }), + workspace: "default".to_string(), }], })) .await; @@ -3130,6 +3211,7 @@ mod tests { "DependenciesNotReady", "Pod exists with phase: Pending; Service Exists", ))), + workspace: "default".to_string(), }], current_sandboxes: vec![DriverSandbox { id: "sb-1".to_string(), @@ -3143,6 +3225,7 @@ mod tests { message: "Pod is Ready".to_string(), last_transition_time: String::new(), })), + workspace: "default".to_string(), }], })) .await; @@ -3184,6 +3267,7 @@ mod tests { }], deleting: false, }), + workspace: "default".to_string(), }], ..Default::default() })) @@ -3222,11 +3306,36 @@ mod tests { SANDBOX_SETTINGS_OBJECT_TYPE, "settings-sb-1", sandbox.object_name(), + sandbox.object_workspace(), br#"{"revision":1,"settings":{}}"#, None, ) .await .unwrap(); + runtime + .store + .put( + POLICY_OBJECT_TYPE, + "policy-sb-1", + sandbox.object_id(), + sandbox.object_workspace(), + br#"{"version":1}"#, + None, + ) + .await + .unwrap(); + runtime + .store + .put( + DRAFT_CHUNK_OBJECT_TYPE, + "draft-sb-1", + sandbox.object_id(), + sandbox.object_workspace(), + br#"{"chunk":1}"#, + None, + ) + .await + .unwrap(); let session = ssh_session_record("session-1", sandbox.object_id()); runtime.store.put_message(&session).await.unwrap(); @@ -3248,17 +3357,37 @@ mod tests { assert!( runtime .sandbox_index - .sandbox_id_for_sandbox_name(sandbox.object_name()) + .sandbox_id_for_sandbox_name(sandbox.object_workspace(), sandbox.object_name()) .is_none() ); 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() ); + assert!( + runtime + .store + .list_by_scope(POLICY_OBJECT_TYPE, sandbox.object_id(), 100, 0) + .await + .unwrap() + .is_empty() + ); + assert!( + runtime + .store + .list_by_scope(DRAFT_CHUNK_OBJECT_TYPE, sandbox.object_id(), 100, 0) + .await + .unwrap() + .is_empty() + ); assert!( runtime .store @@ -3529,7 +3658,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:?}"); @@ -3586,6 +3720,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }); let created = runtime.create_sandbox(sandbox, None).await.unwrap(); @@ -3627,7 +3763,7 @@ mod tests { let matching = runtime .store - .list_messages_with_selector::("env=prod", 10, 0) + .list_messages_with_selector::("default", "env=prod", 10, 0) .await .unwrap(); assert_eq!(matching.len(), 1); @@ -3684,7 +3820,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!( @@ -3697,4 +3833,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_preserves_workspace() { + 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: "alpha".to_string(), + }) + .await + .unwrap(); + + let stored = runtime + .store + .get_message::("sb-w2") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.object_workspace(), "alpha"); + } } diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index 944b9b3ed7..f4cb6a872a 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -201,6 +201,8 @@ mod tests { labels: HashMap::default(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), 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..ecd96ea90d 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -9,16 +9,19 @@ 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, - CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, - DeleteProviderProfileRequest, DeleteProviderProfileResponse, DeleteProviderRefreshRequest, - DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, - DeleteSandboxRequest, DeleteSandboxResponse, DeleteServiceRequest, DeleteServiceResponse, + AddWorkspaceMemberRequest, AddWorkspaceMemberResponse, ApproveAllDraftChunksRequest, + ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, ApproveDraftChunkResponse, + AttachSandboxProviderRequest, AttachSandboxProviderResponse, ClearDraftChunksRequest, + ClearDraftChunksResponse, ComputeDriverCapabilities, ComputeDriverInfo, + ConfigureProviderRefreshRequest, ConfigureProviderRefreshResponse, CreateProviderRequest, + CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, + 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, @@ -28,22 +31,24 @@ use openshell_core::proto::{ 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; @@ -102,6 +107,10 @@ pub fn persistence_error_to_status( /// Maximum length for a sandbox or provider name (Kubernetes name limit). const MAX_NAME_LEN: usize = 253; +/// Maximum length for DNS-routable names (workspace, sandbox, service). +/// Three segments plus two `--` delimiters must fit a 63-char DNS label: +/// 19 + 2 + 19 + 2 + 19 = 61. +const MAX_ROUTABLE_NAME_LEN: usize = 19; /// Maximum number of providers that can be attached to a sandbox. const MAX_PROVIDERS: usize = 32; /// Maximum length for the `log_level` field. @@ -245,6 +254,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, @@ -261,6 +273,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, @@ -305,6 +319,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, @@ -316,6 +331,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, @@ -336,6 +352,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, @@ -360,6 +377,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, @@ -402,6 +421,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, @@ -698,6 +719,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 +804,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 59774bbcc8..78056a6408 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -12,7 +12,9 @@ use crate::ServerState; use crate::auth::principal::Principal; -use crate::persistence::{DraftChunkRecord, ObjectId, ObjectName, ObjectType, PolicyRecord, Store}; +use crate::persistence::{ + DraftChunkRecord, ObjectId, ObjectName, ObjectType, ObjectWorkspace, PolicyRecord, Store, +}; use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt}; use crate::provider_profile_sources::EffectiveProviderProfileCatalog; #[cfg(test)] @@ -523,13 +525,14 @@ fn run_prover_findings( 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 { @@ -881,6 +884,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 +894,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 +907,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 +935,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(); @@ -980,6 +986,7 @@ async fn auto_approve_chunk( async fn current_effective_policy_for_sandbox( state: &ServerState, catalog: &EffectiveProviderProfileCatalog, + workspace: &str, sandbox: &Sandbox, sandbox_id: &str, ) -> Result { @@ -1019,6 +1026,7 @@ async fn current_effective_policy_for_sandbox( let provider_layers = profile_provider_policy_layers_with_catalog( state.store.as_ref(), catalog, + workspace, &provider_names, ) .await?; @@ -1167,11 +1175,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 +1227,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() @@ -1225,7 +1235,7 @@ pub(super) async fn handle_get_sandbox_config( .unwrap_or_default(); let provider_profile_catalog = state .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) + .snapshot_catalog(state.store.as_ref(), &workspace) .await?; // Try to get the latest policy from the policy history table. @@ -1271,7 +1281,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 +1313,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)?; @@ -1332,6 +1342,7 @@ pub(super) async fn handle_get_sandbox_config( let provider_layers = profile_provider_policy_layers_with_catalog( state.store.as_ref(), &provider_profile_catalog, + &workspace, &sandbox_provider_names, ) .await?; @@ -1371,6 +1382,7 @@ pub(super) async fn handle_get_sandbox_config( let provider_env_revision = compute_provider_env_revision_with_catalog( state.store.as_ref(), &provider_profile_catalog, + &workspace, &sandbox_provider_names, ) .await?; @@ -1385,23 +1397,26 @@ pub(super) async fn handle_get_sandbox_config( global_policy_version, provider_env_revision, supervisor_middleware_services, + workspace, })) } #[cfg(test)] async fn compute_provider_env_revision( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result { let catalog = ProviderProfileSources::with_default_sources() - .snapshot_catalog(store) + .snapshot_catalog(store, workspace) .await?; - compute_provider_env_revision_with_catalog(store, &catalog, provider_names).await + compute_provider_env_revision_with_catalog(store, &catalog, workspace, provider_names).await } pub(super) async fn compute_provider_env_revision_with_catalog( store: &Store, catalog: &EffectiveProviderProfileCatalog, + workspace: &str, provider_names: &[String], ) -> Result { let mut hasher = Sha256::new(); @@ -1410,7 +1425,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}")) @@ -1461,24 +1476,26 @@ fn hash_provider_profile_revision( #[cfg(test)] async fn profile_provider_policy_layers( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result, Status> { let catalog = ProviderProfileSources::with_default_sources() - .snapshot_catalog(store) + .snapshot_catalog(store, workspace) .await?; - profile_provider_policy_layers_with_catalog(store, &catalog, provider_names).await + profile_provider_policy_layers_with_catalog(store, &catalog, workspace, provider_names).await } async fn profile_provider_policy_layers_with_catalog( store: &Store, catalog: &EffectiveProviderProfileCatalog, + workspace: &str, provider_names: &[String], ) -> Result, Status> { let mut layers = Vec::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")))?; @@ -1542,6 +1559,7 @@ 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 @@ -1550,17 +1568,19 @@ pub(super) async fn handle_get_sandbox_provider_environment( let provider_names = spec.providers; let provider_profile_catalog = state .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) + .snapshot_catalog(state.store.as_ref(), &workspace) .await?; let provider_env_revision = compute_provider_env_revision_with_catalog( state.store.as_ref(), &provider_profile_catalog, + &workspace, &provider_names, ) .await?; let provider_environment = super::provider::resolve_provider_environment_with_catalog( state.store.as_ref(), &provider_profile_catalog, + &workspace, &provider_names, ) .await?; @@ -1609,10 +1629,14 @@ async fn handle_update_config_inner( ) -> 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? + .name; 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"), @@ -1710,6 +1734,7 @@ async fn handle_update_config_inner( .put_policy_revision( &policy_id, GLOBAL_POLICY_SANDBOX_ID, + "", next_version, &payload, &hash, @@ -1816,7 +1841,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"))?; @@ -1843,12 +1868,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, ) @@ -1886,12 +1913,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, ) @@ -1938,6 +1966,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), @@ -2047,7 +2076,7 @@ async fn handle_update_config_inner( let payload = new_policy.encode_to_vec(); let hash = deterministic_policy_hash(&new_policy); - let (next_version, committed_annotations) = { + let (_next_version, committed_annotations) = { let mut committed = None; for attempt in 1..=MERGE_RETRY_LIMIT { let latest = state @@ -2088,6 +2117,7 @@ async fn handle_update_config_inner( let write = AtomicPolicyRevisionWrite { id: uuid::Uuid::new_v4().to_string(), sandbox_id: sandbox_id.clone(), + workspace: workspace.clone(), version: next_version, policy_payload: payload.clone(), policy_hash: hash.clone(), @@ -2135,6 +2165,48 @@ 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, + annotations: response_annotations, + })); + } + + 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!( @@ -2163,6 +2235,13 @@ 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? + .name + }; let (policy_id, active_version) = if req.global { (GLOBAL_POLICY_SANDBOX_ID.to_string(), 0_u32) @@ -2172,7 +2251,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"))?; @@ -2214,6 +2293,13 @@ 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? + .name + }; let policy_id = if req.global { GLOBAL_POLICY_SANDBOX_ID.to_string() @@ -2223,7 +2309,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"))?; @@ -2335,6 +2421,11 @@ 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? + .name; if req.sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); } @@ -2449,12 +2540,20 @@ 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? + .name; 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 @@ -2483,11 +2582,12 @@ pub(super) async fn handle_submit_policy_analysis( // fix is to recompute baseline after each successful auto-approve. let provider_profile_catalog = state .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) + .snapshot_catalog(state.store.as_ref(), &workspace) .await?; let current_policy = current_effective_policy_for_sandbox( state, &provider_profile_catalog, + &workspace, &sandbox, &sandbox_id, ) @@ -2499,7 +2599,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 @@ -2513,6 +2614,7 @@ pub(super) async fn handle_submit_policy_analysis( let credential_set = build_credential_set_for_sandbox_with_catalog( state.store.as_ref(), &provider_profile_catalog, + &workspace, &provider_names_for_creds, ) .await?; @@ -2628,7 +2730,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; @@ -2682,6 +2784,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, @@ -2729,12 +2832,20 @@ 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? + .name; 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() { @@ -2793,6 +2904,9 @@ 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? + .name; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2804,7 +2918,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"))?; @@ -2837,7 +2951,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(); @@ -2893,6 +3007,9 @@ 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? + .name; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2902,7 +3019,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"))?; @@ -2938,7 +3055,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(), @@ -2990,6 +3108,9 @@ 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? + .name; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2998,7 +3119,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"))?; @@ -3049,7 +3170,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)?; @@ -3111,6 +3232,9 @@ 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? + .name; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -3123,7 +3247,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"))?; @@ -3175,6 +3299,9 @@ 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? + .name; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -3184,7 +3311,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"))?; @@ -3214,7 +3341,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 @@ -3260,13 +3387,16 @@ 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? + .name; 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"))?; @@ -3296,13 +3426,16 @@ 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? + .name; 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"))?; @@ -3793,6 +3926,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<'_>>, @@ -3839,6 +3973,7 @@ async fn apply_merge_operations_with_retry( .put_policy_revision_atomic(&AtomicPolicyRevisionWrite { id: policy_id, sandbox_id: sandbox_id.to_string(), + workspace: workspace.to_string(), version: next_version, policy_payload: payload, policy_hash: hash.clone(), @@ -3851,7 +3986,14 @@ async fn apply_merge_operations_with_retry( .map(Some) } else { store - .put_policy_revision(&policy_id, sandbox_id, next_version, &payload, &hash) + .put_policy_revision( + &policy_id, + sandbox_id, + workspace, + next_version, + &payload, + &hash, + ) .await .map(|()| None) }; @@ -3903,6 +4045,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()) @@ -3912,7 +4055,7 @@ 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) + apply_merge_operations_with_retry(store, sandbox_id, workspace, None, &operations, None) .await .map(|(version, hash, _)| (version, hash)) } @@ -3920,11 +4063,13 @@ pub(super) async fn merge_chunk_into_policy( 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(), @@ -4029,7 +4174,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 } /// Whether a boolean global setting is enabled, loading global settings from the @@ -4063,6 +4208,7 @@ pub(super) async fn save_global_settings( save_settings_record( store, GLOBAL_SETTINGS_OBJECT_TYPE, + "", GLOBAL_SETTINGS_NAME, settings, ) @@ -4071,32 +4217,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 { @@ -4107,6 +4262,7 @@ async fn load_settings_record( async fn save_settings_record( store: &Store, object_type: &str, + workspace: &str, name: &str, settings: &StoredSettings, ) -> Result<(), Status> { @@ -4116,13 +4272,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"))?; @@ -4133,9 +4286,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 { .. } => { @@ -4404,6 +4556,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -4438,6 +4592,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -4471,6 +4627,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -4507,6 +4665,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -4521,6 +4681,7 @@ mod tests { Request::new(GetDraftPolicyRequest { name: "sandbox-b".to_string(), status_filter: String::new(), + workspace: "default".to_string(), }), "sb-a", ); @@ -4572,6 +4733,7 @@ mod tests { Request::new(GetDraftPolicyRequest { name: "missing-sandbox".to_string(), status_filter: String::new(), + workspace: "default".to_string(), }), "sb-a", ); @@ -4595,6 +4757,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -4676,6 +4840,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -4703,12 +4869,15 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: provider_type.to_string(), credentials: std::iter::once(("GITHUB_TOKEN".to_string(), "ghp-test".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } @@ -4747,6 +4916,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(policy), @@ -4992,9 +5163,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()); } @@ -5015,6 +5187,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), profile: Some(openshell_core::proto::ProviderProfile { id: "generic".to_string(), @@ -5037,9 +5211,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"); @@ -5062,6 +5237,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), @@ -5098,9 +5275,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"); @@ -5129,6 +5307,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), @@ -5151,9 +5331,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"); @@ -5167,9 +5348,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"); @@ -5316,6 +5498,8 @@ mod tests { labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), profile: Some(ProviderProfile { id: "tls-skip".to_string(), @@ -5476,6 +5660,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), profile: Some(ProviderProfile { id: "custom-policy".to_string(), @@ -5528,7 +5714,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() @@ -5545,6 +5731,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-policy".to_string(), + workspace: "default".to_string(), })), ) .await @@ -5569,7 +5756,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(); @@ -5762,6 +5949,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), profile: Some(ProviderProfile { id: "custom-token".to_string(), @@ -5805,10 +5994,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") @@ -5816,7 +6008,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() @@ -5833,15 +6025,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, @@ -5899,6 +6095,7 @@ mod tests { sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), })), ) .await @@ -5935,6 +6132,7 @@ mod tests { sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), }), ) .await @@ -6020,6 +6218,7 @@ mod tests { discovery: None, }), }], + workspace: "default".to_string(), }), ) .await @@ -6032,15 +6231,15 @@ mod tests { state .store .put_message(&test_sandbox( - "sb-custom-attach-lifecycle", - "custom-attach-lifecycle", + "sb-attach-lifecycle", + "attach-lifecycle", test_policy_with_rule("sandbox_only", "sandbox.example.com"), Vec::new(), )) .await .unwrap(); - let baseline_policy = get_sandbox_policy(&state, "sb-custom-attach-lifecycle").await; + let baseline_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await; assert!( !baseline_policy .network_policies @@ -6049,7 +6248,7 @@ mod tests { let baseline_env = handle_get_sandbox_provider_environment( &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { - sandbox_id: "sb-custom-attach-lifecycle".to_string(), + sandbox_id: "sb-attach-lifecycle".to_string(), })), ) .await @@ -6059,15 +6258,16 @@ mod tests { handle_attach_sandbox_provider( &state, with_user(Request::new(AttachSandboxProviderRequest { - sandbox_name: "custom-attach-lifecycle".to_string(), + sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-custom".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), })), ) .await .unwrap(); - let attached_policy = get_sandbox_policy(&state, "sb-custom-attach-lifecycle").await; + let attached_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await; let custom_rule = attached_policy .network_policies .get("_provider_work_custom") @@ -6080,7 +6280,7 @@ mod tests { let attached_env = handle_get_sandbox_provider_environment( &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { - sandbox_id: "sb-custom-attach-lifecycle".to_string(), + sandbox_id: "sb-attach-lifecycle".to_string(), })), ) .await @@ -6098,15 +6298,16 @@ mod tests { handle_detach_sandbox_provider( &state, Request::new(DetachSandboxProviderRequest { - sandbox_name: "custom-attach-lifecycle".to_string(), + sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-custom".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), }), ) .await .unwrap(); - let detached_policy = get_sandbox_policy(&state, "sb-custom-attach-lifecycle").await; + let detached_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await; assert!( !detached_policy .network_policies @@ -6115,7 +6316,7 @@ mod tests { let detached_env = handle_get_sandbox_provider_environment( &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { - sandbox_id: "sb-custom-attach-lifecycle".to_string(), + sandbox_id: "sb-attach-lifecycle".to_string(), })), ) .await @@ -6166,6 +6367,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(sandbox_policy), @@ -6256,6 +6459,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -6308,7 +6513,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( @@ -6316,7 +6521,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(); } @@ -6361,6 +6566,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -6417,6 +6624,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6435,6 +6643,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -6447,6 +6656,7 @@ mod tests { &state, Request::new(GetDraftHistoryRequest { name: sandbox_name.clone(), + workspace: "default".to_string(), }), ) .await @@ -6464,6 +6674,7 @@ mod tests { limit: 10, offset: 0, global: false, + workspace: "default".to_string(), }), ) .await @@ -6477,6 +6688,7 @@ mod tests { Request::new(UndoDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -6490,6 +6702,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6502,6 +6715,7 @@ mod tests { &state, Request::new(GetDraftHistoryRequest { name: sandbox_name.clone(), + workspace: "default".to_string(), }), ) .await @@ -6517,6 +6731,7 @@ mod tests { limit: 10, offset: 0, global: false, + workspace: "default".to_string(), }), ) .await @@ -6530,6 +6745,7 @@ mod tests { &state, Request::new(ClearDraftChunksRequest { name: sandbox_name.clone(), + workspace: "default".to_string(), }), ) .await @@ -6542,6 +6758,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6551,7 +6768,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() @@ -6577,6 +6797,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -6625,6 +6847,7 @@ mod tests { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: guidance.to_string(), + workspace: "default".to_string(), }), ) .await @@ -6635,6 +6858,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6674,6 +6898,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6739,6 +6965,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6790,6 +7017,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6848,6 +7077,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6923,6 +7153,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6994,6 +7225,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7050,6 +7283,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7092,6 +7326,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7151,6 +7387,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7198,6 +7435,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7252,6 +7491,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7292,6 +7532,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7346,6 +7588,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7378,6 +7621,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7431,6 +7676,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7466,6 +7712,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7520,6 +7768,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7554,6 +7803,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7612,6 +7863,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7647,6 +7899,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7760,7 +8014,7 @@ mod tests { }; state .store - .put_draft_chunk(&chunk, None) + .put_draft_chunk(&chunk, None, "default") .await .expect("draft chunk should persist"); @@ -7769,6 +8023,7 @@ mod tests { with_user(Request::new(ApproveDraftChunkRequest { name: sandbox_name.to_string(), chunk_id: chunk.id.clone(), + workspace: "default".to_string(), })), ) .await @@ -7822,6 +8077,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7875,6 +8132,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7919,6 +8177,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7972,6 +8232,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8005,6 +8266,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -8058,6 +8321,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8100,6 +8364,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), profile: Some(ProviderProfile { id: "custom-api".to_string(), @@ -8140,6 +8406,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -8202,6 +8470,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8263,6 +8532,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -8370,6 +8641,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8450,6 +8722,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -8518,6 +8792,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8539,6 +8814,7 @@ mod tests { name: sandbox_name, chunk_id: second.accepted_chunk_ids[0].clone(), reason: "redraft test".to_string(), + workspace: "default".to_string(), }), ) .await @@ -8565,6 +8841,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -8620,6 +8898,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8670,6 +8949,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -8733,6 +9014,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8753,6 +9035,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8824,7 +9107,11 @@ mod tests { async fn conditionally_reject_transitions_pending_chunk() { let store = test_store().await; store - .put_draft_chunk(&pending_draft_chunk("cas-pending", "sb-cas-pending"), None) + .put_draft_chunk( + &pending_draft_chunk("cas-pending", "sb-cas-pending"), + None, + "default", + ) .await .unwrap(); @@ -8847,6 +9134,7 @@ mod tests { .put_draft_chunk( &pending_draft_chunk("cas-approved", "sb-cas-approved"), None, + "default", ) .await .unwrap(); @@ -8882,7 +9170,11 @@ mod tests { async fn conditionally_reject_loses_race_to_approval() { let store = test_store().await; store - .put_draft_chunk(&pending_draft_chunk("cas-race", "sb-cas-race"), None) + .put_draft_chunk( + &pending_draft_chunk("cas-race", "sb-cas-race"), + None, + "default", + ) .await .unwrap(); @@ -8930,6 +9222,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -8976,6 +9270,7 @@ mod tests { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: "scope too broad".to_string(), + workspace: "default".to_string(), }), ) .await @@ -8986,6 +9281,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -8996,6 +9292,7 @@ mod tests { Request::new(UndoDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -9006,6 +9303,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -9045,6 +9343,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -9062,6 +9362,8 @@ mod tests { labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -9112,6 +9414,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_a.object_name().to_string(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -9125,6 +9428,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: other_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -9137,6 +9441,7 @@ mod tests { name: other_name.clone(), chunk_id: chunk_id.clone(), reason: "wrong sandbox".to_string(), + workspace: "default".to_string(), }), ) .await @@ -9149,6 +9454,7 @@ mod tests { name: other_name.clone(), chunk_id: chunk_id.clone(), proposed_rule: Some(proposed_rule.clone()), + workspace: "default".to_string(), }), ) .await @@ -9160,6 +9466,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: sandbox_a.object_name().to_string(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -9170,6 +9477,7 @@ mod tests { Request::new(UndoDraftChunkRequest { name: other_name, chunk_id, + workspace: "default".to_string(), }), ) .await @@ -9388,7 +9696,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(); @@ -9442,6 +9750,7 @@ mod tests { .put_policy_revision( "p-seed", sandbox_id, + "default", 1, &initial_policy.encode_to_vec(), "seed-hash", @@ -9484,7 +9793,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); @@ -9543,6 +9852,7 @@ mod tests { .put_policy_revision( "p-seed", sandbox_id, + "default", 1, &initial_policy.encode_to_vec(), "seed-hash", @@ -9585,7 +9895,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); @@ -9630,6 +9940,7 @@ mod tests { .put_policy_revision( "p-seed", sandbox_id, + "default", 1, &initial_policy.encode_to_vec(), "seed-hash", @@ -9665,8 +9976,10 @@ 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, None + ), + apply_merge_operations_with_retry(&store, sandbox_id, "default", None, &add_deny, None), ); let mut versions = vec![left.unwrap().0, right.unwrap().0]; @@ -10432,7 +10745,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); } @@ -10476,11 +10791,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), @@ -10645,7 +10962,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", @@ -10653,17 +10972,117 @@ 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(), + annotations: HashMap::new(), + resource_version: 0, + workspace: ws.to_string(), + deletion_timestamp_ms: 0, + }), + 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(); @@ -10768,6 +11187,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, // No policy yet - will be backfilled @@ -10782,7 +11203,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(); @@ -10803,6 +11224,7 @@ mod tests { merge_operations: vec![], expected_resource_version: current_version, annotations: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -10815,7 +11237,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(); @@ -10846,6 +11268,8 @@ mod tests { "openshell.nvidia.com/existing".to_string(), "keep".to_string(), )]), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -10859,7 +11283,7 @@ mod tests { let current = state .store - .get_message_by_name::("annotated-backfill") + .get_message_by_name::("default", "annotated-backfill") .await .unwrap() .unwrap(); @@ -10887,6 +11311,7 @@ mod tests { merge_operations: vec![], expected_resource_version: current_version, annotations: annotations.clone(), + workspace: "default".to_string(), }), ) .await @@ -10904,7 +11329,7 @@ mod tests { let stored = state .store - .get_message_by_name::("annotated-backfill") + .get_message_by_name::("default", "annotated-backfill") .await .unwrap() .unwrap(); @@ -10942,6 +11367,7 @@ mod tests { .put_policy_revision( "policy-same-hash-v1", "sb-same-hash", + "default", 1, &policy.encode_to_vec(), &hash, @@ -10976,7 +11402,7 @@ mod tests { let stored = state .store - .get_message_by_name::("same-hash") + .get_message_by_name::("default", "same-hash") .await .unwrap() .unwrap(); @@ -11084,6 +11510,7 @@ mod tests { .put_policy_revision( "policy-preserve-full-v1", "sb-preserve-full", + "default", 1, &baseline.encode_to_vec(), &deterministic_policy_hash(&baseline), @@ -11114,7 +11541,7 @@ mod tests { ); let stored = state .store - .get_message_by_name::("preserve-full") + .get_message_by_name::("default", "preserve-full") .await .unwrap() .unwrap(); @@ -11178,7 +11605,7 @@ mod tests { ); let stored = state .store - .get_message_by_name::("preserve-merge") + .get_message_by_name::("default", "preserve-merge") .await .unwrap() .unwrap(); @@ -11269,6 +11696,8 @@ mod tests { "openshell.nvidia.com/policy-signature".to_string(), "keep".to_string(), )]), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -11282,7 +11711,7 @@ mod tests { let current = state .store - .get_message_by_name::("preserve-backfill") + .get_message_by_name::("default", "preserve-backfill") .await .unwrap() .unwrap(); @@ -11310,7 +11739,7 @@ mod tests { ); let stored = state .store - .get_message_by_name::("preserve-backfill") + .get_message_by_name::("default", "preserve-backfill") .await .unwrap() .unwrap(); @@ -11431,6 +11860,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -11444,7 +11875,7 @@ mod tests { let current = state .store - .get_message_by_name::("sync-strip") + .get_message_by_name::("default", "sync-strip") .await .unwrap() .unwrap(); @@ -11484,7 +11915,7 @@ mod tests { let updated_sandbox = state .store - .get_message_by_name::("sync-strip") + .get_message_by_name::("default", "sync-strip") .await .unwrap() .unwrap(); @@ -11535,6 +11966,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -11549,7 +11982,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(); @@ -11570,6 +12003,7 @@ mod tests { merge_operations: vec![], expected_resource_version: 99, // stale version annotations: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -11587,7 +12021,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(); @@ -11627,6 +12061,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { policy: None, @@ -11641,7 +12077,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(); @@ -11666,6 +12102,7 @@ mod tests { merge_operations: vec![], expected_resource_version: initial_version, annotations: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -11698,7 +12135,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 cae751b42a..f6f8d309cb 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -10,8 +10,9 @@ use crate::persistence::{ }; use crate::provider_profile_sources::{ EffectiveProviderProfileCatalog, ProviderProfileSources, profile_response_payload, - profile_storage_payload, stored_profile_resource_version, stored_provider_profile, + profile_storage_payload, stored_profile_resource_version, }; +use openshell_core::metadata::ObjectWorkspace; use openshell_core::proto::{ Provider, ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, ProviderProfileCredential, Sandbox, @@ -20,6 +21,7 @@ use openshell_core::telemetry::{ LifecycleOperation, ProviderProfile as TelemetryProviderProfile, TelemetryOutcome, }; use prost::Message; +use std::collections::HashMap; use tonic::Status; use tracing::warn; @@ -45,9 +47,9 @@ fn redact_provider_credentials(mut provider: Provider) -> Provider { #[derive(Debug, Clone, Default, PartialEq)] pub(super) struct ProviderEnvironment { - pub environment: std::collections::HashMap, - pub credential_expires_at_ms: std::collections::HashMap, - pub dynamic_credentials: std::collections::HashMap, + pub environment: HashMap, + pub credential_expires_at_ms: HashMap, + pub dynamic_credentials: HashMap, } impl ProviderEnvironment { @@ -70,17 +72,19 @@ impl ProviderEnvironment { #[cfg(test)] pub(super) async fn create_provider_record( store: &Store, + workspace: &str, provider: Provider, ) -> Result { let catalog = ProviderProfileSources::with_default_sources() - .snapshot_catalog(store) + .snapshot_catalog(store, workspace) .await?; - create_provider_record_with_catalog(store, &catalog, provider).await + create_provider_record_with_catalog(store, &catalog, workspace, 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}; @@ -92,9 +96,11 @@ pub(super) async fn create_provider_record_with_catalog( id: uuid::Uuid::new_v4().to_string(), name: generate_name(), created_at_ms: now_ms, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, }); } @@ -114,6 +120,11 @@ pub(super) async fn create_provider_record_with_catalog( 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", + )); + } if provider.credentials.is_empty() && !provider_type_allows_empty_credentials(catalog, &provider.r#type) { @@ -133,13 +144,23 @@ pub(super) async fn create_provider_record_with_catalog( } // Create with MustCreate condition to prevent duplicate creation race + let labels_map = provider.object_labels(); + let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { + None + } else { + Some( + serde_json::to_string(&labels_map) + .map_err(|e| Status::internal(format!("failed to serialize labels: {e}")))?, + ) + }; let result = store .put_if( Provider::object_type(), &provider_id, provider.object_name(), + workspace, &provider.encode_to_vec(), - None, + labels_json.as_deref(), WriteCondition::MustCreate, ) .await @@ -161,13 +182,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 +201,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,17 +219,19 @@ 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() - .snapshot_catalog(store) + .snapshot_catalog(store, workspace) .await?; - update_provider_record_with_catalog(store, &catalog, provider).await + update_provider_record_with_catalog(store, &catalog, workspace, provider).await } pub(super) async fn update_provider_record_with_catalog( store: &Store, catalog: &EffectiveProviderProfileCatalog, + workspace: &str, provider: Provider, ) -> Result { use crate::persistence::{ObjectId, ObjectName}; @@ -217,7 +245,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}")))?; @@ -233,6 +261,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); @@ -259,15 +294,14 @@ 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_with_catalog( + store, catalog, workspace, &candidate, + ) + .await?; // Serialize labels for storage let labels_map = candidate.object_labels(); - let labels_json = if labels_map - .as_ref() - .is_none_or(std::collections::HashMap::is_empty) - { + let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { None } else { Some( @@ -282,6 +316,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 +346,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 +375,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,17 +385,39 @@ 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, f: F) -> Result, Status> +where + F: FnMut(Sandbox) -> Option, +{ + scan_sandboxes_inner(store, Some(workspace), f).await +} + +async fn scan_sandboxes_all(store: &Store, f: F) -> Result, Status> +where + F: FnMut(Sandbox) -> Option, +{ + scan_sandboxes_inner(store, None, f).await +} + +async fn scan_sandboxes_inner( + store: &Store, + workspace: Option<&str>, + mut f: F, +) -> Result, Status> where F: FnMut(Sandbox) -> Option, { let mut out = Vec::new(); let mut offset = 0u32; loop { - let records = store - .list(Sandbox::object_type(), 1000, offset) - .await - .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))?; + let records = if let Some(ws) = workspace { + store.list(Sandbox::object_type(), ws, 1000, offset).await + } else { + store + .list_by_type(Sandbox::object_type(), 1000, offset) + .await + } + .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))?; if records.is_empty() { break; } @@ -379,10 +440,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 +460,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) @@ -418,9 +481,9 @@ async fn sandboxes_using_provider_records( /// - Otherwise, upsert all incoming entries into `existing`. /// - Entries with an empty-string value are removed (delete semantics). fn merge_map( - mut existing: std::collections::HashMap, - incoming: std::collections::HashMap, -) -> std::collections::HashMap { + mut existing: HashMap, + incoming: HashMap, +) -> HashMap { if incoming.is_empty() { return existing; } @@ -435,9 +498,9 @@ fn merge_map( } fn merge_i64_map( - mut existing: std::collections::HashMap, - incoming: std::collections::HashMap, -) -> std::collections::HashMap { + mut existing: HashMap, + incoming: HashMap, +) -> HashMap { if incoming.is_empty() { return existing; } @@ -464,33 +527,42 @@ 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() - .snapshot_catalog(store) + .snapshot_catalog(store, workspace) .await?; - resolve_provider_environment_with_catalog(store, &catalog, provider_names).await + resolve_provider_environment_with_catalog(store, &catalog, workspace, provider_names).await } pub(super) async fn resolve_provider_environment_with_catalog( store: &Store, catalog: &EffectiveProviderProfileCatalog, + workspace: &str, provider_names: &[String], ) -> Result { if provider_names.is_empty() { return Ok(ProviderEnvironment::default()); } - let mut env = std::collections::HashMap::new(); - let mut expires = std::collections::HashMap::new(); + let mut env = HashMap::new(); + let mut expires = HashMap::new(); let now_ms = crate::persistence::current_time_ms(); - validate_provider_environment_keys_unique_at(store, catalog, provider_names, None, now_ms) - .await?; + validate_provider_environment_keys_unique_at( + store, + catalog, + 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")))?; @@ -541,6 +613,7 @@ pub(super) async fn resolve_provider_environment_with_catalog( dynamic_credentials: resolve_dynamic_credentials_with_catalog( store, catalog, + workspace, provider_names, ) .await?, @@ -555,17 +628,18 @@ pub(super) async fn resolve_provider_environment_with_catalog( pub(super) async fn resolve_dynamic_credentials_with_catalog( store: &Store, catalog: &EffectiveProviderProfileCatalog, + workspace: &str, provider_names: &[String], -) -> Result, Status> { +) -> Result, Status> { if provider_names.is_empty() { - return Ok(std::collections::HashMap::new()); + return Ok(HashMap::new()); } - let mut dynamic_creds = std::collections::HashMap::new(); + let mut dynamic_creds = HashMap::new(); 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}")) @@ -591,7 +665,7 @@ pub(super) async fn resolve_dynamic_credentials_with_catalog( } fn insert_dynamic_credentials_for_profile( - dynamic_creds: &mut std::collections::HashMap, + dynamic_creds: &mut HashMap, profile: &ProviderProfile, provider_name: &str, ) { @@ -640,7 +714,7 @@ fn dynamic_credential_key( } fn insert_dynamic_credentials_for_endpoint( - dynamic_creds: &mut std::collections::HashMap, + dynamic_creds: &mut HashMap, endpoint_host: &str, endpoint_port: u32, endpoint_path: &str, @@ -865,22 +939,31 @@ 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() - .snapshot_catalog(store) + .snapshot_catalog(store, workspace) .await?; - validate_provider_environment_keys_unique_with_catalog(store, &catalog, provider_names).await + validate_provider_environment_keys_unique_with_catalog( + store, + &catalog, + workspace, + provider_names, + ) + .await } pub async fn validate_provider_environment_keys_unique_with_catalog( store: &Store, catalog: &EffectiveProviderProfileCatalog, + workspace: &str, provider_names: &[String], ) -> Result<(), Status> { validate_provider_environment_keys_unique_at( store, catalog, + workspace, provider_names, None, crate::persistence::current_time_ms(), @@ -891,6 +974,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,28 +984,34 @@ 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_with_catalog( + store, catalog, 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() - .snapshot_catalog(store) + .snapshot_catalog(store, workspace) .await?; - validate_provider_update_against_attached_sandboxes_with_catalog(store, &catalog, provider) - .await + validate_provider_update_against_attached_sandboxes_with_catalog( + store, &catalog, workspace, provider, + ) + .await } pub async fn validate_provider_update_against_attached_sandboxes_with_catalog( store: &Store, catalog: &EffectiveProviderProfileCatalog, + workspace: &str, 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; @@ -929,6 +1019,7 @@ pub async fn validate_provider_update_against_attached_sandboxes_with_catalog( validate_provider_environment_keys_unique_at( store, catalog, + workspace, &spec.providers, Some(provider), crate::persistence::current_time_ms(), @@ -947,17 +1038,18 @@ 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, ) -> Result<(), Status> { - let mut seen = std::collections::HashMap::::new(); + let mut seen = HashMap::::new(); let mut dynamic_bindings = Vec::new(); for name in provider_names { 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(|| { @@ -1222,7 +1314,7 @@ use openshell_core::proto::{ UpdateProviderProfilesResponse, UpdateProviderRequest, }; use openshell_providers::{ - CredentialRefreshProfile, ProfileValidationDiagnostic, ProviderTypeProfile, + CredentialRefreshProfile, ProfileValidationDiagnostic, ProviderTypeProfile, builtin_profiles, normalize_profile_id, normalize_provider_type, strategy_output_env_key, strategy_output_spec, strategy_primary_env_key, validate_profile_set, }; @@ -1234,7 +1326,10 @@ 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? + .ensure_active()?; + let Some(mut provider) = req.provider else { emit_provider_lifecycle( "custom", LifecycleOperation::Create, @@ -1242,13 +1337,17 @@ 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()) + .snapshot_catalog(state.store.as_ref(), &workspace) .await?; let result = - create_provider_record_with_catalog(state.store.as_ref(), &catalog, provider).await; + create_provider_record_with_catalog(state.store.as_ref(), &catalog, &workspace, provider) + .await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -1275,8 +1374,11 @@ 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? + .name; + let provider = get_provider_record(state.store.as_ref(), &workspace, &req.name).await?; Ok(Response::new(ProviderResponse { provider: Some(provider), @@ -1288,25 +1390,72 @@ 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? + .name; + list_provider_records(state.store.as_ref(), &workspace, limit, request.offset).await? + }; Ok(Response::new(ListProvidersResponse { providers })) } +/// Return provider profiles visible in the given workspace scope. +/// +/// Workspace-scoped profiles come from the store (user-managed, stored with the +/// workspace key). Builtin/static profiles are always visible regardless of +/// workspace. +async fn workspace_scoped_profiles( + store: &Store, + workspace: &str, +) -> Result, Status> { + // User-managed profiles scoped to this workspace. + let stored: Vec = store + .list_messages(workspace, 10_000, 0) + .await + .map_err(|e| Status::internal(format!("list provider profiles failed: {e}")))?; + let mut profiles: Vec = stored + .into_iter() + .filter_map(|sp| { + let rv = sp.metadata.as_ref().map_or(0, |m| m.resource_version); + sp.profile.map(|p| profile_response_payload(p, rv)) + }) + .collect(); + // Builtin profiles are workspace-agnostic. + for builtin in builtin_profiles() { + profiles.push(builtin.to_proto()); + } + Ok(profiles) +} + pub(super) async fn handle_list_provider_profiles( state: &Arc, request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) + .await? + .name; 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 profiles = workspace_scoped_profiles(state.store.as_ref(), &workspace) + .await? .into_iter() .skip(offset) .take(limit) @@ -1319,14 +1468,18 @@ 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_profile_workspace(state.store.as_ref(), &req.workspace) + .await? + .name; + 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) + let id_norm = normalize_profile_id(&id); + let profiles = workspace_scoped_profiles(state.store.as_ref(), &workspace).await?; + let profile = profiles + .into_iter() + .find(|p| normalize_profile_id(&p.id) == id_norm) .ok_or_else(|| Status::not_found("provider profile not found"))?; Ok(Response::new(ProviderProfileResponse { @@ -1339,21 +1492,27 @@ pub(super) async fn handle_import_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) + .await? + .ensure_active()?; 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()) + .snapshot_catalog(state.store.as_ref(), &workspace) .await?; - diagnostics - .extend(profile_conflict_diagnostics(state.store.as_ref(), &catalog, &profiles).await?); + diagnostics.extend( + profile_conflict_diagnostics(state.store.as_ref(), &catalog, &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", ) @@ -1371,15 +1530,25 @@ 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 profile_labels = stored.object_labels(); + let profile_labels_json = if profile_labels.as_ref().is_none_or(HashMap::is_empty) { + None + } else { + Some( + serde_json::to_string(&profile_labels) + .map_err(|e| Status::internal(format!("failed to serialize labels: {e}")))?, + ) + }; let result = state .store .put_if( StoredProviderProfile::object_type(), stored.object_id(), stored.object_name(), + &workspace, &stored.encode_to_vec(), - None, + profile_labels_json.as_deref(), WriteCondition::MustCreate, ) .await @@ -1406,6 +1575,10 @@ pub(super) async fn handle_update_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) + .await? + .ensure_active()?; let items = request.profile.into_iter().collect::>(); let (profiles, mut diagnostics) = profiles_from_import_items(&items); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); @@ -1413,11 +1586,17 @@ pub(super) async fn handle_update_provider_profiles( let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let catalog = state .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) + .snapshot_catalog(state.store.as_ref(), &workspace) .await?; diagnostics.extend( - profile_update_target_diagnostics(state.store.as_ref(), &catalog, &profiles, &target_id) - .await?, + profile_update_target_diagnostics( + state.store.as_ref(), + &catalog, + &workspace, + &profiles, + &target_id, + ) + .await?, ); diagnostics.extend(validate_profile_set(&profiles)); let expected_resource_version = if request.expected_resource_version != 0 { @@ -1443,6 +1622,7 @@ pub(super) async fn handle_update_provider_profiles( profile_attached_sandbox_diagnostics( state.store.as_ref(), &catalog, + &workspace, &profiles, "update", ) @@ -1465,7 +1645,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"))?; @@ -1494,6 +1674,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), @@ -1518,14 +1699,19 @@ pub(super) async fn handle_lint_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) + .await? + .name; 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()) + .snapshot_catalog(state.store.as_ref(), &workspace) .await?; - diagnostics - .extend(profile_conflict_diagnostics(state.store.as_ref(), &catalog, &profiles).await?); + diagnostics.extend( + profile_conflict_diagnostics(state.store.as_ref(), &catalog, &workspace, &profiles).await?, + ); diagnostics.extend(validate_profile_set(&profiles)); let valid = !has_errors(&diagnostics); @@ -1539,12 +1725,17 @@ 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_profile_workspace(state.store.as_ref(), &req.workspace) + .await? + .name; + let id = req.id; let id = normalize_profile_id_request(&id)?; let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let catalog = state .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) + .snapshot_catalog(state.store.as_ref(), &workspace) .await?; if let Some(source_id) = catalog.static_source_for_profile(&id) { return Err(Status::failed_precondition(format!( @@ -1554,14 +1745,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: {}", @@ -1571,7 +1762,7 @@ 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}")))?; @@ -1585,6 +1776,38 @@ pub(super) fn get_provider_type_profile_with_catalog( catalog.get_type_profile(id) } +#[cfg(test)] +pub(super) async fn get_provider_type_profile( + store: &Store, + workspace: &str, + id: &str, +) -> Result, Status> { + // Query stored profiles scoped to the requested workspace. + let stored: Vec = store + .list_messages(workspace, 10_000, 0) + .await + .map_err(|e| Status::internal(format!("list provider profiles failed: {e}")))?; + let id_norm = normalize_profile_id(id); + for sp in &stored { + if let Some(profile) = sp.profile.as_ref() + && normalize_profile_id(&profile.id) == id_norm + { + return Ok(Some(ProviderTypeProfile::from_proto(profile))); + } + } + // Fall back to builtin profiles (workspace-agnostic). + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store, workspace) + .await?; + if let Some(profile) = catalog.get_type_profile(id) { + // Only return builtins that are not user-managed (i.e., always available). + if catalog.static_source_for_profile(id).is_some() { + return Ok(Some(profile)); + } + } + Ok(None) +} + fn provider_refresh_defaults( catalog: &EffectiveProviderProfileCatalog, provider: &Provider, @@ -1612,9 +1835,9 @@ fn resolved_additional_output_keys( catalog: &EffectiveProviderProfileCatalog, provider: &Provider, credential_key: &str, -) -> std::collections::HashMap { +) -> HashMap { let Some(profile) = get_provider_type_profile_with_catalog(catalog, &provider.r#type) else { - return std::collections::HashMap::new(); + return HashMap::new(); }; profile.resolved_additional_output_keys(credential_key) } @@ -1633,7 +1856,7 @@ fn validate_strategy_profile_binding( strategy: ProviderCredentialRefreshStrategy, credential_key: &str, refresh_defaults: Option<&CredentialRefreshProfile>, - additional_output_keys: &std::collections::HashMap, + additional_output_keys: &HashMap, ) -> Result<(), Status> { let Some(expected_primary) = strategy_primary_env_key(strategy) else { return Ok(()); @@ -1668,7 +1891,7 @@ fn validate_strategy_profile_binding( } fn validate_refresh_material( - material: &std::collections::HashMap, + material: &HashMap, refresh_defaults: Option<&CredentialRefreshProfile>, ) -> Result<(), Status> { let Some(refresh_defaults) = refresh_defaults else { @@ -1754,6 +1977,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(); @@ -1774,7 +1998,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() @@ -1794,6 +2018,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> { @@ -1827,7 +2052,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() @@ -1862,11 +2087,11 @@ 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> { - let mut candidate_profiles = - std::collections::HashMap::::new(); + let mut candidate_profiles = HashMap::::new(); for (source, profile) in profiles { let Some(id) = normalize_profile_id(&profile.id) else { continue; @@ -1877,29 +2102,56 @@ async fn profile_attached_sandbox_diagnostics( return Ok(Vec::new()); } - let sandboxes = scan_sandboxes(store, |sandbox| { - sandbox - .spec - .as_ref() - .is_some_and(|spec| !spec.providers.is_empty()) - .then_some(sandbox) - }) - .await?; + let is_platform_scope = workspace.is_empty(); + + let sandboxes = if is_platform_scope { + scan_sandboxes_all(store, |sandbox| { + sandbox + .spec + .as_ref() + .is_some_and(|spec| !spec.providers.is_empty()) + .then_some(sandbox) + }) + .await? + } else { + scan_sandboxes(store, workspace, |sandbox| { + sandbox + .spec + .as_ref() + .is_some_and(|spec| !spec.providers.is_empty()) + .then_some(sandbox) + }) + .await? + }; let mut diagnostics = Vec::new(); for sandbox in sandboxes { let sandbox_name = sandbox.object_name().to_string(); + let sandbox_workspace = sandbox.object_workspace().to_string(); let spec = sandbox.spec.as_ref().expect("filtered by scan_sandboxes"); let mut bindings = Vec::new(); let mut imported_profiles_used = Vec::<(String, String)>::new(); for provider_name in &spec.providers { + let provider_ws = if is_platform_scope { + &sandbox_workspace + } else { + workspace + }; let Some(provider) = store - .get_message_by_name::(provider_name) + .get_message_by_name::(provider_ws, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? else { continue; }; + let scope_mismatch = (is_platform_scope && !provider.profile_workspace.is_empty()) + || (!is_platform_scope && provider.profile_workspace.is_empty()); + if scope_mismatch { + bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( + catalog, &provider, + )); + continue; + } let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); if let Some((source, profile)) = candidate_profiles.get(profile_id) { @@ -1940,6 +2192,33 @@ 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: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, + }), + profile: Some(profile), + } +} + +#[cfg(test)] +fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { + stored_provider_profile_for_workspace(profile, "default") +} + fn proto_diagnostic(diagnostic: ProfileValidationDiagnostic) -> ProviderProfileDiagnostic { ProviderProfileDiagnostic { source: diagnostic.source, @@ -1956,31 +2235,63 @@ fn has_errors(diagnostics: &[ProfileValidationDiagnostic]) -> bool { .any(|diagnostic| diagnostic.severity == "error") } -async fn sandboxes_using_profile(store: &Store, 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 has_providers = sandbox - .spec - .as_ref() - .is_some_and(|s| !s.providers.is_empty()); - has_providers.then_some(sandbox) - }) - .await?; +async fn sandboxes_using_profile( + store: &Store, + workspace: &str, + profile_id: &str, +) -> Result, Status> { + let is_platform_scope = workspace.is_empty(); + + let candidates = if is_platform_scope { + scan_sandboxes_all(store, |sandbox| { + let has_providers = sandbox + .spec + .as_ref() + .is_some_and(|s| !s.providers.is_empty()); + has_providers.then_some(sandbox) + }) + .await? + } else { + scan_sandboxes(store, workspace, |sandbox| { + let has_providers = sandbox + .spec + .as_ref() + .is_some_and(|s| !s.providers.is_empty()); + has_providers.then_some(sandbox) + }) + .await? + }; let mut blocking = Vec::new(); for sandbox in candidates { + let sandbox_workspace = sandbox.object_workspace().to_string(); let spec = sandbox.spec.as_ref().expect("filtered by scan_sandboxes"); for provider_name in &spec.providers { + let provider_ws = if is_platform_scope { + &sandbox_workspace + } else { + workspace + }; let Some(provider) = store - .get_message_by_name::(provider_name) + .get_message_by_name::(provider_ws, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? else { continue; }; + if is_platform_scope && !provider.profile_workspace.is_empty() { + continue; + } + if !is_platform_scope && provider.profile_workspace.is_empty() { + continue; + } if normalize_profile_id(&provider.r#type).as_deref() == Some(profile_id) { - blocking.push(sandbox.object_name().to_string()); + let label = if is_platform_scope { + format!("{}/{}", sandbox_workspace, sandbox.object_name()) + } else { + sandbox.object_name().to_string() + }; + blocking.push(label); break; } } @@ -1995,6 +2306,9 @@ 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? + .name; let Some(mut provider) = req.provider else { emit_provider_lifecycle( "custom", @@ -2009,10 +2323,11 @@ pub(super) async fn handle_update_provider( .extend(req.credential_expires_at_ms); let catalog = state .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) + .snapshot_catalog(state.store.as_ref(), &workspace) .await?; let result = - update_provider_record_with_catalog(state.store.as_ref(), &catalog, provider).await; + update_provider_record_with_catalog(state.store.as_ref(), &catalog, &workspace, provider) + .await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -2040,12 +2355,15 @@ 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? + .name; 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"))?; @@ -2059,6 +2377,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(), ) @@ -2080,6 +2399,9 @@ 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? + .name; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2218,17 +2540,18 @@ 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"))?; let catalog = state .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) + .snapshot_catalog(state.store.as_ref(), &workspace) .await?; validate_provider_credential_key_available_for_attached_sandboxes_with_catalog( state.store.as_ref(), &catalog, + &workspace, &provider, credential_key, ) @@ -2243,6 +2566,7 @@ pub(super) async fn handle_configure_provider_refresh( validate_provider_credential_key_available_for_attached_sandboxes_with_catalog( state.store.as_ref(), &catalog, + &workspace, &provider, additional_key, ) @@ -2297,6 +2621,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, ) @@ -2309,6 +2634,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, @@ -2334,19 +2660,20 @@ pub(super) async fn handle_configure_provider_refresh( id: String::new(), name: provider_name.to_string(), created_at_ms: 0, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, }), r#type: String::new(), - credentials: std::collections::HashMap::new(), - config: std::collections::HashMap::new(), - credential_expires_at_ms: std::collections::HashMap::from([( - credential_key.to_string(), - expires_at_ms, - )]), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::from([(credential_key.to_string(), expires_at_ms)]), + profile_workspace: String::new(), }; - update_provider_record_with_catalog(state.store.as_ref(), &catalog, updated).await?; + update_provider_record_with_catalog(state.store.as_ref(), &catalog, &workspace, updated) + .await?; } Ok(Response::new(ConfigureProviderRefreshResponse { @@ -2361,6 +2688,9 @@ 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? + .name; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2371,6 +2701,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, ) @@ -2416,6 +2747,9 @@ 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? + .name; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2426,18 +2760,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, ) @@ -2478,9 +2814,13 @@ 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? + .name; + 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); @@ -2519,9 +2859,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,16 +2900,15 @@ 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; use tonic::{Code, Request}; #[test] @@ -2723,6 +3066,7 @@ mod tests { profile: Some(profile), source: format!("{id}.yaml"), }], + workspace: "default".to_string(), }), ) .await @@ -2736,6 +3080,7 @@ mod tests { ) -> Provider { create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -2744,11 +3089,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -2766,6 +3114,7 @@ mod tests { let err = validate_provider_environment_keys_unique( store, + "default", &["provider-a".to_string(), "provider-b".to_string()], ) .await @@ -2794,6 +3143,7 @@ mod tests { validate_provider_environment_keys_unique( store, + "default", &["provider-default".to_string(), "provider-admin".to_string()], ) .await @@ -2809,6 +3159,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 @@ -2822,6 +3173,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { providers: vec![ @@ -2851,6 +3204,7 @@ mod tests { profile: Some(profile), source: "grant-new.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -2878,6 +3232,7 @@ mod tests { profile: Some(custom_profile("guarded-import")), source: "guarded-import.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -2910,7 +3265,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(); @@ -2932,6 +3287,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2946,7 +3302,7 @@ mod tests { ); let after: StoredProviderProfile = state .store - .get_message_by_name("custom-api") + .get_message_by_name("default", "custom-api") .await .unwrap() .unwrap(); @@ -2976,6 +3332,7 @@ mod tests { }), expected_resource_version: 0, id: "github".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2997,6 +3354,7 @@ mod tests { }), expected_resource_version: 0, id: "missing-custom".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3028,6 +3386,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3050,6 +3409,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3077,7 +3437,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() @@ -3098,6 +3458,7 @@ mod tests { }), expected_resource_version: 0, id: "profile-a".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3113,7 +3474,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(); @@ -3138,6 +3499,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { providers: vec![ @@ -3153,7 +3516,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() @@ -3178,6 +3541,7 @@ mod tests { }), expected_resource_version: 0, id: "grant-updated".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3201,6 +3565,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: provider_type.to_string(), credentials: [ @@ -3216,6 +3582,7 @@ mod tests { .into_iter() .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } @@ -3292,6 +3659,7 @@ mod tests { profile: Some(profile), source: format!("{id}.yaml"), }], + workspace: "default".to_string(), }), ) .await @@ -3351,6 +3719,7 @@ mod tests { Request::new(ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: "default".to_string(), }), ) .await @@ -3399,6 +3768,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "github".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3416,6 +3786,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "generic".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3433,6 +3804,7 @@ mod tests { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3447,6 +3819,7 @@ mod tests { Request::new(ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: "default".to_string(), }), ) .await @@ -3463,6 +3836,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3483,6 +3857,7 @@ mod tests { profile: Some(custom_profile("github")), source: "github.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3510,6 +3885,7 @@ mod tests { profile: Some(custom_profile("custom-llm")), source: "custom-llm.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3523,6 +3899,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "custom-llm".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3553,6 +3930,7 @@ mod tests { source: "case.yaml".to_string(), }, ], + workspace: "default".to_string(), }), ) .await @@ -3580,6 +3958,7 @@ mod tests { profile: Some(custom_profile("alex-api")), source: "alex-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3589,6 +3968,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: " Alex-API ".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3602,6 +3982,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: " Alex-API ".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3630,6 +4011,7 @@ mod tests { source: "bulk-two.yaml".to_string(), }, ], + workspace: "default".to_string(), }), ) .await @@ -3647,7 +4029,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(); @@ -3696,6 +4081,7 @@ mod tests { }), source: "advanced-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3708,6 +4094,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "advanced-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3751,6 +4138,7 @@ mod tests { source: "lint-two.yaml".to_string(), }, ], + workspace: "default".to_string(), }), ) .await @@ -3767,7 +4155,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(); @@ -3775,6 +4166,79 @@ 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; @@ -3785,6 +4249,7 @@ mod tests { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3794,6 +4259,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: "github".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3802,6 +4268,7 @@ mod tests { create_provider_record( state.store.as_ref(), + "default", provider_with_values("custom-provider", "custom-api"), ) .await @@ -3816,6 +4283,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { providers: vec!["custom-provider".to_string()], @@ -3830,6 +4299,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3844,6 +4314,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(), @@ -3852,6 +4323,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3861,6 +4334,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -3880,6 +4354,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: Some(expires_at_ms), + workspace: "default".to_string(), }), ) .await @@ -3894,6 +4369,7 @@ mod tests { Request::new(GetProviderRefreshStatusRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3904,7 +4380,7 @@ mod tests { let provider = state .store - .get_message_by_name::("msgraph") + .get_message_by_name::("default", "msgraph") .await .unwrap() .expect("provider"); @@ -3920,6 +4396,7 @@ mod tests { Request::new(DeleteProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3932,6 +4409,7 @@ mod tests { Request::new(GetProviderRefreshStatusRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3941,7 +4419,7 @@ mod tests { let provider_after_delete = state .store - .get_message_by_name::("msgraph") + .get_message_by_name::("default", "msgraph") .await .unwrap() .expect("provider"); @@ -3968,6 +4446,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], @@ -4000,6 +4480,7 @@ mod tests { material: HashMap::new(), secret_material_keys: Vec::new(), expires_at_ms: Some(expires_at_ms), + workspace: "default".to_string(), }), ) .await @@ -4007,7 +4488,7 @@ mod tests { let provider = state .store - .get_message_by_name::("provider-a") + .get_message_by_name::("default", "provider-a") .await .unwrap() .expect("provider-a"); @@ -4029,22 +4510,27 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::from([("REFRESH_TOKEN".to_string(), expires_at_ms)]), + profile_workspace: "default".to_string(), }; let catalog = state .provider_profile_sources - .snapshot_catalog(state.store.as_ref()) - .await - .unwrap(); - let provider = update_provider_record_with_catalog(state.store.as_ref(), &catalog, updated) + .snapshot_catalog(state.store.as_ref(), "default") .await .unwrap(); + let provider = + update_provider_record_with_catalog(state.store.as_ref(), &catalog, "default", updated) + .await + .unwrap(); let refresh_state = crate::provider_refresh::new_refresh_state( &provider, + "default", "REFRESH_TOKEN", crate::provider_refresh::NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials, @@ -4068,6 +4554,7 @@ mod tests { Request::new(DeleteProviderRefreshRequest { provider: "provider-a".to_string(), credential_key: "REFRESH_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4075,7 +4562,7 @@ mod tests { let provider = state .store - .get_message_by_name::("provider-a") + .get_message_by_name::("default", "provider-a") .await .unwrap() .expect("provider-a"); @@ -4091,6 +4578,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(), @@ -4099,6 +4587,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -4108,6 +4598,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4131,6 +4622,7 @@ mod tests { ]), secret_material_keys: vec!["private_key".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4155,6 +4647,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(), @@ -4163,6 +4656,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -4172,6 +4667,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4191,6 +4687,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: Some(refresh_expires_at_ms), + workspace: "default".to_string(), }), ) .await @@ -4199,6 +4696,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(), @@ -4207,6 +4705,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: String::new(), credentials: HashMap::new(), @@ -4215,6 +4715,7 @@ mod tests { "MS_GRAPH_ACCESS_TOKEN".to_string(), manual_expires_at_ms, )]), + profile_workspace: "default".to_string(), }, ) .await @@ -4225,6 +4726,7 @@ mod tests { Request::new(DeleteProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4234,7 +4736,7 @@ mod tests { let provider_after_delete = state .store - .get_message_by_name::("msgraph") + .get_message_by_name::("default", "msgraph") .await .unwrap() .expect("provider"); @@ -4261,6 +4763,7 @@ mod tests { create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4269,11 +4772,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4292,6 +4798,7 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: Some(refresh_expires_at_ms), + workspace: "default".to_string(), }), ) .await @@ -4303,6 +4810,7 @@ mod tests { let independent_expires_at_ms = refresh_expires_at_ms + 120_000; update_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4311,6 +4819,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: String::new(), credentials: HashMap::new(), @@ -4319,6 +4829,7 @@ mod tests { ("AWS_SECRET_ACCESS_KEY".to_string(), refresh_expires_at_ms), ("AWS_SESSION_TOKEN".to_string(), independent_expires_at_ms), ]), + profile_workspace: "default".to_string(), }, ) .await @@ -4329,6 +4840,7 @@ mod tests { Request::new(DeleteProviderRefreshRequest { provider: "aws-delete".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4336,7 +4848,7 @@ mod tests { let provider = state .store - .get_message_by_name::("aws-delete") + .get_message_by_name::("default", "aws-delete") .await .unwrap() .unwrap(); @@ -4376,6 +4888,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: HashMap::new(), @@ -4385,6 +4899,7 @@ mod tests { ("AWS_SECRET_ACCESS_KEY".to_string(), refresh_expires_at_ms), ("AWS_SESSION_TOKEN".to_string(), concurrently_changed), ]), + profile_workspace: "default".to_string(), }; let owned_keys = vec![ "AWS_ACCESS_KEY_ID".to_string(), @@ -4416,6 +4931,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(), @@ -4424,6 +4940,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -4433,12 +4951,14 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_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(), @@ -4447,12 +4967,15 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(("OTHER_TOKEN".to_string(), "other".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4467,6 +4990,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -4490,6 +5015,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4511,6 +5037,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(), @@ -4519,11 +5046,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4539,6 +5069,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { providers: vec!["first-graph".to_string(), "second-graph".to_string()], @@ -4562,6 +5094,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4580,6 +5113,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4598,6 +5132,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(), @@ -4606,6 +5141,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -4615,6 +5152,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4637,6 +5175,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4653,6 +5192,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 @@ -4666,6 +5206,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(), @@ -4674,6 +5215,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -4683,6 +5226,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4701,6 +5245,7 @@ mod tests { material: HashMap::new(), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4729,6 +5274,7 @@ mod tests { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -4738,6 +5284,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4749,6 +5296,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4772,6 +5320,7 @@ mod tests { &task_state, Request::new(DeleteProviderProfileRequest { id: "guarded-delete".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4798,7 +5347,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"); @@ -4806,18 +5355,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(), @@ -4826,6 +5382,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -4836,6 +5394,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 @@ -4852,7 +5411,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(); @@ -4870,17 +5429,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); @@ -4892,6 +5451,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") @@ -4901,6 +5461,7 @@ mod tests { .unwrap(); let refresh_state = crate::provider_refresh::new_refresh_state( &provider, + "default", "API_TOKEN", crate::provider_refresh::NewRefreshStateConfig { additional_output_keys: HashMap::new(), @@ -4922,7 +5483,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); @@ -4938,9 +5499,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 { @@ -4950,6 +5515,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { providers: vec!["gitlab-local".to_string()], @@ -4960,7 +5527,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); @@ -4977,7 +5544,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, @@ -4987,6 +5556,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(), @@ -4995,6 +5565,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -5004,6 +5576,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5017,6 +5590,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(), @@ -5025,6 +5599,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -5034,6 +5610,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5052,6 +5629,7 @@ mod tests { let create_missing_type = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5060,11 +5638,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5073,6 +5654,7 @@ mod tests { let create_missing_credentials = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5081,11 +5663,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "gitlab".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5144,12 +5729,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(), @@ -5158,11 +5745,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "delegated-refresh-api".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5181,12 +5771,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(), @@ -5195,11 +5787,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "mixed-required-api".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5218,12 +5813,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(), @@ -5232,11 +5829,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "optional-static-api".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5245,6 +5845,7 @@ mod tests { let vertex_empty = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5253,25 +5854,31 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "google-vertex-ai".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .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(); - assert_eq!(delete_err.code(), Code::InvalidArgument); + 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(), @@ -5280,11 +5887,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5297,10 +5907,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(), @@ -5309,11 +5922,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5333,7 +5949,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(); @@ -5345,10 +5961,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(), @@ -5357,11 +5976,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: String::new(), 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 @@ -5380,7 +6002,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(); @@ -5397,10 +6019,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(), @@ -5409,11 +6034,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5427,10 +6055,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(), @@ -5439,11 +6070,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "openai".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5458,11 +6092,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(), @@ -5471,11 +6108,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: String::new(), 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 @@ -5500,16 +6140,20 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: oversized_type.clone(), 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(); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5518,12 +6162,15 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: String::new(), credentials: std::iter::once(("API_TOKEN".to_string(), "new".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5535,7 +6182,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()); } @@ -5550,6 +6199,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "claude".to_string(), credentials: [ @@ -5564,10 +6215,13 @@ mod tests { )) .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; - 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())); @@ -5580,14 +6234,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()); @@ -5605,6 +6261,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "test".to_string(), credentials: [ @@ -5620,12 +6278,16 @@ mod tests { ] .into_iter() .collect(), + profile_workspace: "default".to_string(), }; - 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!( @@ -5637,7 +6299,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); @@ -5655,6 +6317,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "test".to_string(), credentials: [ @@ -5666,12 +6330,16 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; - 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")); @@ -5682,6 +6350,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(), @@ -5690,6 +6359,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -5699,12 +6370,14 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5713,12 +6386,15 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "gitlab".to_string(), credentials: std::iter::once(("GITLAB_TOKEN".to_string(), "glpat-xyz".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5726,6 +6402,7 @@ mod tests { let result = resolve_provider_environment( &store, + "default", &["claude-local".to_string(), "gitlab-local".to_string()], ) .await @@ -5739,6 +6416,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(), @@ -5747,18 +6425,22 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "claude".to_string(), credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5767,6 +6449,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -5776,6 +6460,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5783,6 +6468,7 @@ mod tests { let err = resolve_provider_environment( &store, + "default", &["provider-a".to_string(), "provider-b".to_string()], ) .await @@ -5798,6 +6484,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(), @@ -5806,6 +6493,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5823,12 +6512,13 @@ mod tests { .into_iter() .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .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(); @@ -5873,6 +6563,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(), @@ -5881,6 +6572,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5897,14 +6590,16 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .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!( @@ -5918,6 +6613,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(), @@ -5926,6 +6622,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5935,14 +6633,16 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .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")); @@ -5967,6 +6667,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(), @@ -5975,6 +6676,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5994,14 +6697,16 @@ mod tests { .into_iter() .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .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!( @@ -6015,6 +6720,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(), @@ -6023,18 +6729,21 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .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(); @@ -6054,6 +6763,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(), @@ -6062,6 +6772,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -6071,12 +6783,14 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -6085,6 +6799,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "google-drive".to_string(), credentials: std::iter::once(( @@ -6094,6 +6810,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6106,6 +6823,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], @@ -6117,6 +6836,7 @@ mod tests { let err = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -6125,6 +6845,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: String::new(), credentials: std::iter::once(( @@ -6134,6 +6856,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6152,6 +6875,7 @@ mod tests { create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -6160,6 +6884,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -6169,6 +6895,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6182,6 +6909,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { providers: vec!["my-claude".to_string()], @@ -6198,7 +6927,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(); @@ -6219,6 +6948,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec::default()), status: None, @@ -6232,7 +6963,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(); @@ -6254,7 +6985,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(); @@ -6267,11 +6998,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: String::new(), // Empty type is ignored in update 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) @@ -6280,7 +7014,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"); @@ -6298,7 +7032,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"); @@ -6330,11 +7064,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(); @@ -6366,7 +7106,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!( @@ -6393,6 +7133,7 @@ mod tests { &state, Request::new(CreateProviderRequest { provider: Some(provider.clone()), + workspace: "default".to_string(), }), ) .await @@ -6401,7 +7142,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(); @@ -6420,6 +7161,7 @@ mod tests { Request::new(UpdateProviderRequest { provider: Some(updated_provider.clone()), credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -6461,6 +7203,7 @@ mod tests { &state, Request::new(CreateProviderRequest { provider: Some(provider.clone()), + workspace: "default".to_string(), }), ) .await @@ -6469,7 +7212,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(); @@ -6488,6 +7231,7 @@ mod tests { Request::new(UpdateProviderRequest { provider: Some(stale_provider), credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -6504,7 +7248,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(); @@ -6528,6 +7272,7 @@ mod tests { &state, Request::new(CreateProviderRequest { provider: Some(provider.clone()), + workspace: "default".to_string(), }), ) .await @@ -6536,7 +7281,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(); @@ -6558,6 +7303,7 @@ mod tests { Request::new(UpdateProviderRequest { provider: Some(updated), credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -6590,7 +7336,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(); @@ -6611,6 +7357,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(), @@ -6619,6 +7366,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: std::iter::once(( @@ -6628,6 +7377,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6645,6 +7395,7 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -6677,6 +7428,7 @@ mod tests { create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -6685,6 +7437,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: std::iter::once(( @@ -6694,6 +7448,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6711,6 +7466,7 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -6748,6 +7504,7 @@ mod tests { create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -6756,11 +7513,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6784,6 +7544,7 @@ mod tests { ]), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -6795,13 +7556,14 @@ mod tests { // The rejected configuration must not leave a refresh state behind. let provider = state .store - .get_message_by_name::("aws-endpoint-override") + .get_message_by_name::("default", "aws-endpoint-override") .await .unwrap() .unwrap(); assert!( crate::provider_refresh::get_refresh_state( state.store.as_ref(), + "default", provider.object_id(), "AWS_ACCESS_KEY_ID", ) @@ -6833,6 +7595,7 @@ mod tests { create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -6841,11 +7604,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6868,6 +7634,7 @@ mod tests { ]), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -6892,6 +7659,7 @@ mod tests { create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -6900,11 +7668,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6928,6 +7699,7 @@ mod tests { ]), secret_material_keys: vec!["aws_session_token".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -6959,6 +7731,7 @@ mod tests { create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -6967,11 +7740,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -6989,6 +7765,7 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -6996,12 +7773,13 @@ mod tests { let provider = state .store - .get_message_by_name::("aws-outputs") + .get_message_by_name::("default", "aws-outputs") .await .unwrap() .unwrap(); let stored = crate::provider_refresh::get_refresh_state( state.store.as_ref(), + "default", provider.object_id(), "AWS_ACCESS_KEY_ID", ) @@ -7043,6 +7821,7 @@ mod tests { // binding. STS must not be configurable against it. create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -7051,6 +7830,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "generic".to_string(), credentials: std::iter::once(( @@ -7060,6 +7841,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -7077,6 +7859,7 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -7108,6 +7891,7 @@ mod tests { create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -7116,11 +7900,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -7140,6 +7927,7 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -7165,6 +7953,7 @@ mod tests { create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -7173,11 +7962,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -7195,6 +7987,7 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -7214,6 +8007,7 @@ mod tests { Request::new(RotateProviderCredentialRequest { provider: "aws-gate".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), + workspace: "default".to_string(), }), ) .await @@ -7226,13 +8020,14 @@ mod tests { // was minted into the provider. let provider = state .store - .get_message_by_name::("aws-gate") + .get_message_by_name::("default", "aws-gate") .await .unwrap() .unwrap(); assert!(!provider.credentials.contains_key("AWS_ACCESS_KEY_ID")); let refresh_state = crate::provider_refresh::get_refresh_state( state.store.as_ref(), + "default", provider.object_id(), "AWS_ACCESS_KEY_ID", ) @@ -7247,6 +8042,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(), @@ -7255,18 +8051,21 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); let provider = state .store - .get_message_by_name::("aws-env") + .get_message_by_name::("default", "aws-env") .await .unwrap() .unwrap(); @@ -7275,6 +8074,7 @@ mod tests { // provider.credentials yet, but the reserved keys must still surface. let refresh_state = crate::provider_refresh::new_refresh_state( &provider, + "default", "AWS_ACCESS_KEY_ID", crate::provider_refresh::NewRefreshStateConfig { additional_output_keys: HashMap::from([ @@ -7336,17 +8136,20 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; existing_provider.credentials.insert( "AWS_SECRET_ACCESS_KEY".to_string(), "existing-key".to_string(), ); - create_provider_record(state.store.as_ref(), existing_provider) + create_provider_record(state.store.as_ref(), "default", existing_provider) .await .unwrap(); @@ -7358,6 +8161,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: std::iter::once(( @@ -7367,8 +8172,9 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; - create_provider_record(state.store.as_ref(), new_provider) + create_provider_record(state.store.as_ref(), "default", new_provider) .await .unwrap(); @@ -7382,6 +8188,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { providers: vec![ @@ -7407,6 +8215,7 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -7432,6 +8241,7 @@ mod tests { for name in ["aws-a", "aws-b"] { create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -7440,11 +8250,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -7463,6 +8276,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { providers: vec!["aws-a".to_string(), "aws-b".to_string()], @@ -7484,6 +8299,7 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }) }; @@ -7518,11 +8334,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "google-cloud".to_string(), credentials: HashMap::new(), config, credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } @@ -7621,11 +8440,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "github".to_string(), 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); @@ -7634,4 +8456,557 @@ 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(), + profile_workspace: String::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, + annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, + }); + 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, + annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, + }); + 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, + annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, + }); + 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); + } + + #[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, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "claude".to_string(), + 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(), + }; + 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, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "claude".to_string(), + 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(), + }; + 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, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "claude".to_string(), + 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(), + }; + 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, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "claude".to_string(), + 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(), + }; + 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, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + 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, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "ws-custom".to_string(), + credentials: HashMap::from([("TOKEN".to_string(), "val".to_string())]), + 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()); + } + + #[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/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index ad03741481..725626c609 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -10,7 +10,7 @@ #![allow(clippy::cast_possible_wrap)] // Intentional u32->i32 conversions for proto compat use crate::ServerState; -use crate::persistence::{ObjectType, WriteCondition, generate_name}; +use crate::persistence::{ObjectLabels, ObjectType, WriteCondition, generate_name}; use futures::future; use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateSandboxRequest, @@ -27,8 +27,9 @@ 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::collections::HashMap; use std::net::IpAddr; use std::pin::Pin; use std::sync::Arc; @@ -49,11 +50,18 @@ use super::validation::{ level_matches, source_matches, validate_exec_request_fields, validate_no_reserved_provider_policy_keys, validate_policy_safety, validate_sandbox_spec, }; -use super::{MAX_PAGE_SIZE, MAX_PROVIDERS, clamp_limit}; +use super::{MAX_PAGE_SIZE, MAX_PROVIDERS, MAX_ROUTABLE_NAME_LEN, clamp_limit}; use crate::persistence::current_time_ms; const TCP_FORWARD_CHUNK_SIZE: usize = 64 * 1024; +fn generate_routable_name() -> String { + let name = petname::petname(2, "-").unwrap_or_else(generate_name); + let mut truncated = &name[..name.len().min(MAX_ROUTABLE_NAME_LEN)]; + truncated = truncated.trim_end_matches('-'); + truncated.to_string() +} + // --------------------------------------------------------------------------- // Sandbox lifecycle handlers // --------------------------------------------------------------------------- @@ -135,6 +143,10 @@ 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? + .ensure_active()?; + let _sandbox_sync_guard = if spec.providers.is_empty() { None } else { @@ -145,12 +157,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; @@ -170,7 +183,7 @@ async fn handle_create_sandbox_inner( let id = uuid::Uuid::new_v4().to_string(); let name = if request.name.is_empty() { - petname::petname(2, "-").unwrap_or_else(generate_name) + generate_routable_name() } else { request.name.clone() }; @@ -185,6 +198,8 @@ async fn handle_create_sandbox_inner( labels: request.labels.clone(), resource_version: 0, annotations: request.annotations.clone(), + workspace, + deletion_timestamp_ms: 0, }), spec: Some(spec), status: None, @@ -239,14 +254,17 @@ 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? + .name; 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}")))?; @@ -261,21 +279,54 @@ 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() { - state - .store - .list_messages(limit, request.offset) - .await - .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))? + let sandboxes: Vec = if request.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}")))? + } } 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? + .name; + 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 })) @@ -285,8 +336,12 @@ 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? + .name; + 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 })) } @@ -295,6 +350,9 @@ 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? + .ensure_active()?; if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -309,7 +367,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 { @@ -323,7 +381,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() @@ -359,8 +417,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)); @@ -409,6 +471,9 @@ 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? + .name; if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -423,7 +488,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() @@ -500,19 +565,23 @@ 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? + .name; 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); } @@ -520,14 +589,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")) @@ -536,6 +609,7 @@ async fn sandbox_by_name(state: &Arc, name: &str) -> Result, sandbox: &Sandbox, + workspace: &str, ) -> Result, Status> { let provider_names = sandbox .spec @@ -545,7 +619,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 { @@ -1052,8 +1126,8 @@ async fn validate_ssh_forward_token( } fn acquire_ssh_connection_slots( - token_counts: &std::sync::Mutex>, - sandbox_counts: &std::sync::Mutex>, + token_counts: &std::sync::Mutex>, + sandbox_counts: &std::sync::Mutex>, token: &str, sandbox_id: &str, ) -> Result<(), Status> { @@ -1086,10 +1160,7 @@ fn acquire_ssh_connection_slots( Ok(()) } -fn decrement_ssh_connection_count( - counts: &std::sync::Mutex>, - key: &str, -) { +fn decrement_ssh_connection_count(counts: &std::sync::Mutex>, key: &str) { let mut counts = counts.lock().unwrap(); if let Some(count) = counts.get_mut(key) { *count = count.saturating_sub(1); @@ -1362,9 +1433,11 @@ pub(super) async fn handle_create_ssh_session( id: token.clone(), name: generate_name(), created_at_ms: now_ms, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace: sandbox.object_workspace().to_string(), + deletion_timestamp_ms: 0, }), sandbox_id: req.sandbox_id.clone(), token: token.clone(), @@ -1376,14 +1449,24 @@ pub(super) async fn handle_create_ssh_session( super::validation::validate_object_metadata(session.metadata.as_ref(), "ssh_session")?; // Use MustCreate to atomically ensure the session token is unique + let session_labels = session.object_labels(); + let session_labels_json = if session_labels.as_ref().is_none_or(HashMap::is_empty) { + None + } else { + Some( + serde_json::to_string(&session_labels) + .map_err(|e| Status::internal(format!("failed to serialize labels: {e}")))?, + ) + }; state .store .put_if( SshSession::object_type(), &token, session.object_name(), + session.object_workspace(), &session.encode_to_vec(), - None, + session_labels_json.as_deref(), WriteCondition::MustCreate, ) .await @@ -1434,14 +1517,24 @@ pub(super) async fn handle_revoke_ssh_session( session.revoked = true; // Use CAS to prevent lost updates from concurrent revocations + let session_labels = session.object_labels(); + let session_labels_json = if session_labels.as_ref().is_none_or(HashMap::is_empty) { + None + } else { + Some( + serde_json::to_string(&session_labels) + .map_err(|e| Status::internal(format!("failed to serialize labels: {e}")))?, + ) + }; state .store .put_if( SshSession::object_type(), session.object_id(), session.object_name(), + session.object_workspace(), &session.encode_to_vec(), - None, + session_labels_json.as_deref(), WriteCondition::MatchResourceVersion(resource_version), ) .await @@ -2011,7 +2104,6 @@ mod tests { use super::*; use crate::grpc::test_support::test_server_state; use openshell_core::proto::datamodel::v1::ObjectMeta; - use std::collections::HashMap; // ---- shell_escape ---- @@ -2282,6 +2374,18 @@ mod tests { } } + #[test] + fn generate_routable_name_respects_length_limit() { + for _ in 0..200 { + let name = generate_routable_name(); + assert!( + name.len() <= MAX_ROUTABLE_NAME_LEN, + "generated name '{name}' exceeds {MAX_ROUTABLE_NAME_LEN} chars" + ); + assert!(!name.is_empty(), "generated name should not be empty"); + } + } + #[test] fn generate_name_fallback_is_valid() { for _ in 0..50 { @@ -2311,12 +2415,15 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: provider_type.to_string(), credentials: std::iter::once((credential_key.to_string(), "secret".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } @@ -2329,6 +2436,8 @@ mod tests { labels: std::iter::once(("team".to_string(), "agents".to_string())).collect(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(openshell_core::proto::SandboxSpec { log_level: "debug".to_string(), @@ -2363,6 +2472,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2372,7 +2482,7 @@ mod tests { assert!(response.attached); let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -2406,6 +2516,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2415,7 +2526,7 @@ mod tests { assert!(!response.attached); let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2447,6 +2558,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2456,7 +2568,7 @@ mod tests { assert!(response.detached); let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2471,6 +2583,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2497,6 +2610,7 @@ mod tests { &state, Request::new(ListSandboxProvidersRequest { sandbox_name: "work".to_string(), + workspace: String::new(), }), ) .await @@ -2526,6 +2640,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "missing".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2700,6 +2815,7 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), + workspace: String::new(), }), ) .await @@ -2733,6 +2849,7 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), + workspace: String::new(), }), ) .await @@ -2756,6 +2873,7 @@ mod tests { spec: Some(openshell_core::proto::SandboxSpec::default()), labels: HashMap::new(), annotations: HashMap::from([(annotation_key.clone(), annotation_value.clone())]), + workspace: String::new(), }), ) .await @@ -2775,6 +2893,7 @@ mod tests { &state, Request::new(GetSandboxRequest { name: "annotated".to_string(), + workspace: String::new(), }), ) .await @@ -2801,6 +2920,7 @@ mod tests { spec: Some(openshell_core::proto::SandboxSpec::default()), labels: HashMap::from([("team".to_string(), "x".repeat(512))]), annotations: HashMap::new(), + workspace: String::new(), }), ) .await @@ -2832,6 +2952,7 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), + workspace: String::new(), }), ) .await @@ -2881,6 +3002,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "provider-b".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2927,6 +3049,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "provider-31".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2936,7 +3059,7 @@ mod tests { assert!(response.attached); let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2981,6 +3104,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "provider-32".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2992,7 +3116,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() @@ -3027,6 +3151,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -3053,6 +3178,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -3204,7 +3330,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(); @@ -3217,6 +3343,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, + workspace: String::new(), }), ) .await @@ -3228,7 +3355,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(); @@ -3255,7 +3382,7 @@ mod tests { // Get current version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3268,6 +3395,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, + workspace: String::new(), }), ) .await @@ -3285,7 +3413,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(); @@ -3317,7 +3445,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(); @@ -3330,6 +3458,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, + workspace: String::new(), }), ) .await @@ -3341,7 +3470,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(); @@ -3368,7 +3497,7 @@ mod tests { // Get current version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3381,6 +3510,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, + workspace: String::new(), }), ) .await @@ -3398,7 +3528,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(); @@ -3441,7 +3571,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() @@ -3461,6 +3591,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: format!("provider-{i}"), expected_resource_version: initial_version, + workspace: String::new(), }), ) .await @@ -3497,7 +3628,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(); @@ -3507,4 +3638,214 @@ 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); + } + + #[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"); + } } diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 01d8dbfe8e..7f042ae18d 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; @@ -18,14 +18,17 @@ use crate::ServerState; use crate::persistence::{ObjectType, WriteCondition}; use crate::service_routing; -const MAX_SERVICE_NAME_LEN: usize = 28; -const MAX_SANDBOX_NAME_LEN: usize = 28; +const MAX_SERVICE_NAME_LEN: usize = super::MAX_ROUTABLE_NAME_LEN; +const MAX_SANDBOX_NAME_LEN: usize = super::MAX_ROUTABLE_NAME_LEN; pub(super) async fn handle_expose_service( state: &Arc, request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) + .await? + .ensure_active()?; 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 +37,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 +48,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}")))?; @@ -88,6 +91,8 @@ pub(super) async fn handle_expose_service( labels: HashMap::from([("sandbox".to_string(), req.sandbox.clone())]), resource_version: 0, annotations: HashMap::new(), + workspace: workspace.clone(), + deletion_timestamp_ms: 0, }), sandbox_id: sandbox.object_id().to_string(), sandbox_name: req.sandbox.clone(), @@ -103,6 +108,7 @@ pub(super) async fn handle_expose_service( ServiceEndpoint::object_type(), &id, &key, + &workspace, &endpoint.encode_to_vec(), Some(&labels_json), condition, @@ -115,7 +121,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 +136,13 @@ 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? + .name; 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 +154,43 @@ 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? + .name; + 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 +207,13 @@ 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? + .name; 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 +221,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 +234,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 +250,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, ) @@ -288,6 +328,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(openshell_core::proto::SandboxSpec::default()), ..Default::default() @@ -333,6 +375,7 @@ mod tests { service: "web".to_string(), target_port: 8080, domain: true, + workspace: "default".to_string(), }), ) .await @@ -346,6 +389,8 @@ mod tests { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, + workspace: "default".to_string(), + all_workspaces: false, }), ) .await @@ -362,6 +407,7 @@ mod tests { Request::new(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -374,6 +420,7 @@ mod tests { Request::new(DeleteServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -386,6 +433,7 @@ mod tests { Request::new(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -398,6 +446,8 @@ mod tests { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, + workspace: "default".to_string(), + all_workspaces: false, }), ) .await @@ -421,6 +471,7 @@ mod tests { service: "web".to_string(), target_port: 8080, domain: true, + workspace: "default".to_string(), }), ) .await @@ -435,6 +486,7 @@ mod tests { service: "web".to_string(), target_port: 9090, domain: true, + workspace: "default".to_string(), }), ) .await @@ -459,6 +511,8 @@ mod tests { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, + workspace: "default".to_string(), + all_workspaces: false, }), ) .await @@ -480,6 +534,7 @@ mod tests { service: "web".to_string(), target_port: 7070, domain: true, + workspace: "default".to_string(), }), ) .await @@ -495,6 +550,7 @@ mod tests { service: "web".to_string(), target_port: 8080, domain: true, + workspace: "default".to_string(), }), ) .await @@ -509,6 +565,7 @@ mod tests { service: "web".to_string(), target_port: 9090, domain: true, + workspace: "default".to_string(), }), ) .await @@ -531,6 +588,7 @@ mod tests { Request::new(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -543,4 +601,225 @@ 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(), + annotations: HashMap::new(), + resource_version: 0, + workspace: "beta".to_string(), + deletion_timestamp_ms: 0, + }), + 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 9e47a01524..2f0ad8d139 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -17,7 +17,7 @@ use tonic::Status; use super::{ MAX_ENVIRONMENT_ENTRIES, MAX_LOG_LEVEL_LEN, MAX_MAP_KEY_LEN, MAX_MAP_VALUE_LEN, MAX_METADATA_ANNOTATIONS_ENTRIES, MAX_NAME_LEN, MAX_POLICY_SIZE, MAX_PROVIDER_CONFIG_ENTRIES, - MAX_PROVIDER_CREDENTIALS_ENTRIES, MAX_PROVIDER_TYPE_LEN, MAX_PROVIDERS, + MAX_PROVIDER_CREDENTIALS_ENTRIES, MAX_PROVIDER_TYPE_LEN, MAX_PROVIDERS, MAX_ROUTABLE_NAME_LEN, MAX_TEMPLATE_MAP_ENTRIES, MAX_TEMPLATE_STRING_LEN, MAX_TEMPLATE_STRUCT_SIZE, }; @@ -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,12 +149,13 @@ pub(super) fn validate_sandbox_spec( spec: &openshell_core::proto::SandboxSpec, ) -> Result<(), Status> { // --- request.name --- - if name.len() > MAX_NAME_LEN { + if !name.is_empty() && name.len() > MAX_ROUTABLE_NAME_LEN { return Err(Status::invalid_argument(format!( - "name exceeds maximum length ({} > {MAX_NAME_LEN})", + "name exceeds maximum length ({} > {MAX_ROUTABLE_NAME_LEN})", name.len() ))); } + validate_dns1123_label(name, "name")?; // --- spec.providers --- if spec.providers.len() > MAX_PROVIDERS { @@ -859,18 +900,41 @@ mod tests { #[test] fn validate_sandbox_spec_accepts_at_limit_name() { - let name = "a".repeat(MAX_NAME_LEN); + let name = "a".repeat(MAX_ROUTABLE_NAME_LEN); assert!(validate_sandbox_spec(&name, &default_spec()).is_ok()); } #[test] fn validate_sandbox_spec_rejects_over_limit_name() { - let name = "a".repeat(MAX_NAME_LEN + 1); + let name = "a".repeat(MAX_ROUTABLE_NAME_LEN + 1); let err = validate_sandbox_spec(&name, &default_spec()).unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument); 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 { @@ -1181,11 +1245,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, }), r#type: provider_type.to_string(), credentials, config, 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 new file mode 100644 index 0000000000..e7357153a2 --- /dev/null +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -0,0 +1,1385 @@ +// 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, WorkspacePhase, WorkspaceStatus}; +use openshell_core::proto::{ + AddWorkspaceMemberRequest, AddWorkspaceMemberResponse, CreateWorkspaceRequest, + CreateWorkspaceResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, GetWorkspaceRequest, + GetWorkspaceResponse, InferenceRoute, ListWorkspaceMembersRequest, + ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, Provider, + RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, Sandbox, ServiceEndpoint, + SshSession, StoredProviderCredentialRefreshState, StoredProviderProfile, Workspace, + WorkspaceMember, WorkspaceRole, +}; +use prost::Message; +use tonic::{Request, Response, Status}; + +use crate::ServerState; +use crate::persistence::{ + DRAFT_CHUNK_OBJECT_TYPE, ObjectLabels, ObjectType, POLICY_OBJECT_TYPE, WriteCondition, + current_time_ms, +}; +use std::collections::HashMap; + +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() > crate::grpc::MAX_ROUTABLE_NAME_LEN { + return Err(Status::invalid_argument(format!( + "workspace name exceeds maximum length ({} > {})", + name.len(), + crate::grpc::MAX_ROUTABLE_NAME_LEN, + ))); + } + super::validation::validate_dns1123_label(name, "workspace name") +} + +/// A resolved workspace name with its current lifecycle state. +#[derive(Debug)] +pub struct ResolvedWorkspace { + pub name: String, + pub terminating: bool, +} + +impl ResolvedWorkspace { + /// Consume the resolved workspace and return the name, or reject the + /// operation if the workspace is being deleted. + pub fn ensure_active(self) -> Result { + if self.terminating { + return Err(Status::failed_precondition(format!( + "workspace '{}' is being deleted", + self.name + ))); + } + Ok(self.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(ResolvedWorkspace { + name: String::new(), + terminating: false, + }); + } + 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. The returned [`ResolvedWorkspace`] +/// carries the workspace's termination state so create-path handlers can reject +/// operations on workspaces that are being deleted. +/// +/// 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, +) -> Result { + let name = if workspace.is_empty() { + DEFAULT_WORKSPACE_NAME.to_string() + } else { + workspace.to_string() + }; + + let ws: Option = store + .get_message_by_name("", &name) + .await + .map_err(|e| Status::internal(format!("workspace lookup failed: {e}")))?; + + match ws { + Some(ws) => { + let terminating = ws + .metadata + .as_ref() + .is_some_and(|m| m.deletion_timestamp_ms != 0); + Ok(ResolvedWorkspace { name, terminating }) + } + None => Err(Status::not_found(format!("workspace '{name}' not found"))), + } +} + +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, + annotations: HashMap::new(), + resource_version: 0, + workspace: String::new(), + deletion_timestamp_ms: 0, + }), + status: Some(WorkspaceStatus { + phase: WorkspacePhase::Active.into(), + }), + }; + + super::validation::validate_object_metadata(workspace.metadata.as_ref(), "workspace")?; + + let meta = workspace.metadata.as_ref().unwrap(); + let labels_json = + if meta.labels.is_empty() { + None + } else { + Some(serde_json::to_string(&meta.labels).map_err(|e| { + Status::internal(format!("failed to serialize workspace labels: {e}")) + })?) + }; + + let result = state + .store + .put_if( + Workspace::object_type(), + &workspace_id, + workspace.object_name(), + "", + &workspace.encode_to_vec(), + labels_json.as_deref(), + 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 ws: 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(format!("workspace '{name}' not found")))?; + + let ws_id = ws + .metadata + .as_ref() + .map(|m| m.id.clone()) + .unwrap_or_default(); + + let already_terminating = ws + .metadata + .as_ref() + .is_some_and(|m| m.deletion_timestamp_ms != 0); + + // Track the resource_version so the final delete targets exactly this + // workspace instance (prevents ABA if a same-name workspace is recreated + // between the blocker scan and the delete). + let mut delete_version = ws.metadata.as_ref().map_or(0, |m| m.resource_version); + + if !already_terminating { + let cas_result = state + .store + .update_message_cas::(&ws_id, 0, |w| { + let now_ms = current_time_ms(); + if let Some(meta) = w.metadata.as_mut() { + meta.deletion_timestamp_ms = now_ms; + } + w.status = Some(WorkspaceStatus { + phase: WorkspacePhase::Terminating.into(), + }); + }) + .await; + match cas_result { + Ok(updated) => { + delete_version = updated.metadata.as_ref().map_or(0, |m| m.resource_version); + } + Err(e) => { + if matches!(e, crate::persistence::PersistenceError::Conflict { .. }) { + let refreshed: Option = state + .store + .get_message_by_name("", &name) + .await + .map_err(|e| Status::internal(format!("workspace re-fetch failed: {e}")))?; + let refreshed = refreshed.ok_or_else(|| { + Status::not_found(format!("workspace '{name}' not found")) + })?; + let now_terminating = refreshed + .metadata + .as_ref() + .is_some_and(|m| m.deletion_timestamp_ms != 0); + if !now_terminating { + return Err(Status::aborted( + "workspace was concurrently modified, please retry", + )); + } + delete_version = refreshed + .metadata + .as_ref() + .map_or(0, |m| m.resource_version); + } else { + return Err(Status::internal(format!( + "mark workspace terminating failed: {e}" + ))); + } + } + } + } + + // The workspace is now Terminating — concurrent create-path operations + // will be rejected by resolve_workspace + ensure_active. + 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"), + (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 + .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(", ") + ))); + } + + // Cascade-delete non-blocking resources before the final CAS delete. + // This is safe without a transaction: the workspace is Terminating, so + // ensure_active rejects new resource creation. If delete_if conflicts + // below, the retry will find no routes/members to delete and succeed. + state + .store + .delete_all_in_workspace(InferenceRoute::object_type(), &name) + .await + .map_err(|e| Status::internal(format!("delete inference routes failed: {e}")))?; + + state + .store + .delete_all_in_workspace(WorkspaceMember::object_type(), &name) + .await + .map_err(|e| Status::internal(format!("delete workspace members failed: {e}")))?; + + let deleted = state + .store + .delete_if(Workspace::object_type(), &ws_id, delete_version) + .await + .map_err(|e| { + if matches!(e, crate::persistence::PersistenceError::Conflict { .. }) { + Status::aborted("workspace was concurrently modified, please retry") + } else { + 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? + .ensure_active()?; + + 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 count = state + .store + .count_in_workspace(WorkspaceMember::object_type(), &workspace) + .await + .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" + ))); + } + + 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: HashMap::new(), + annotations: HashMap::new(), + resource_version: 0, + workspace: workspace.clone(), + deletion_timestamp_ms: 0, + }), + principal_subject: req.principal_subject, + role: req.role, + }; + + let member_labels = member.object_labels(); + let member_labels_json = if member_labels.as_ref().is_none_or(HashMap::is_empty) { + None + } else { + Some( + serde_json::to_string(&member_labels) + .map_err(|e| Status::internal(format!("failed to serialize labels: {e}")))?, + ) + }; + let result = state + .store + .put_if( + WorkspaceMember::object_type(), + &member_id, + member.object_name(), + &workspace, + &member.encode_to_vec(), + member_labels_json.as_deref(), + 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?.name; + + 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?.name; + + 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 openshell_core::proto::datamodel::v1::ObjectMeta; + use tonic::{Code, Request}; + + use crate::grpc::test_support::test_server_state; + + #[tokio::test] + async fn create_workspace_returns_metadata() { + let state = test_server_state().await; + + let resp = handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "new-ws".to_string(), + labels: HashMap::from([("env".to_string(), "test".to_string())]), + }), + ) + .await + .unwrap() + .into_inner(); + + let ws = resp.workspace.unwrap(); + let meta = ws.metadata.as_ref().unwrap(); + assert_eq!(meta.name, "new-ws"); + assert!(!meta.id.is_empty(), "id should be a generated UUID"); + assert!(meta.created_at_ms > 0, "created_at_ms should be set"); + assert_eq!(meta.labels.get("env").map(String::as_str), Some("test")); + assert!(meta.resource_version > 0, "resource_version should be set"); + assert_eq!(meta.deletion_timestamp_ms, 0); + + let status = ws.status.as_ref().unwrap(); + assert_eq!(status.phase, i32::from(WorkspacePhase::Active)); + } + + #[tokio::test] + async fn create_workspace_already_exists() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "dup-ws".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let err = handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "dup-ws".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::AlreadyExists); + } + + #[tokio::test] + async fn get_workspace_round_trip() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "fetch-me".to_string(), + labels: HashMap::from([("team".to_string(), "infra".to_string())]), + }), + ) + .await + .unwrap(); + + let resp = handle_get_workspace( + &state, + Request::new(GetWorkspaceRequest { + name: "fetch-me".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + + let ws = resp.workspace.unwrap(); + let meta = ws.metadata.as_ref().unwrap(); + assert_eq!(meta.name, "fetch-me"); + assert_eq!(meta.labels.get("team").map(String::as_str), Some("infra")); + } + + #[tokio::test] + async fn get_workspace_not_found() { + let state = test_server_state().await; + + let err = handle_get_workspace( + &state, + Request::new(GetWorkspaceRequest { + name: "no-such-ws".to_string(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::NotFound); + } + + #[tokio::test] + async fn get_workspace_empty_name_rejected() { + let state = test_server_state().await; + + let err = handle_get_workspace( + &state, + Request::new(GetWorkspaceRequest { + name: String::new(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[tokio::test] + async fn resolve_workspace_not_found_rejects_sandbox_create() { + let state = test_server_state().await; + + let err = resolve_workspace(&state.store, "ghost-ws") + .await + .unwrap_err(); + assert_eq!(err.code(), Code::NotFound); + assert!(err.message().contains("ghost-ws")); + } + + #[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(), + annotations: HashMap::new(), + resource_version: 0, + workspace: "ephemeral".to_string(), + deletion_timestamp_ms: 0, + }), + ..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_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(), + annotations: HashMap::new(), + resource_version: 0, + workspace: "sessioned".to_string(), + deletion_timestamp_ms: 0, + }), + 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; + + 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(), + annotations: HashMap::new(), + resource_version: 0, + workspace: "profiles-ws".to_string(), + deletion_timestamp_ms: 0, + }), + ..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); + } + + #[test] + fn validate_workspace_name_rejects_consecutive_hyphens() { + let err = validate_workspace_name("team--ml").unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_workspace_name_accepts_max_length() { + validate_workspace_name(&"a".repeat(crate::grpc::MAX_ROUTABLE_NAME_LEN)).unwrap(); + } + + #[test] + fn validate_workspace_name_rejects_over_max_length() { + let err = validate_workspace_name(&"a".repeat(crate::grpc::MAX_ROUTABLE_NAME_LEN + 1)) + .unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("exceeds maximum length")); + } + + #[tokio::test] + async fn delete_workspace_marks_terminating_before_scan() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "term-test".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let sbx = Sandbox { + metadata: Some(ObjectMeta { + id: "sbx-term-1".to_string(), + name: "blocker".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + annotations: HashMap::new(), + resource_version: 0, + workspace: "term-test".to_string(), + deletion_timestamp_ms: 0, + }), + ..Default::default() + }; + state.store.put_message(&sbx).await.unwrap(); + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "term-test".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + + let ws: Workspace = state + .store + .get_message_by_name("", "term-test") + .await + .unwrap() + .unwrap(); + assert_ne!( + ws.metadata.as_ref().unwrap().deletion_timestamp_ms, + 0, + "workspace should have deletion_timestamp set" + ); + assert_eq!( + ws.status.as_ref().unwrap().phase, + i32::from(WorkspacePhase::Terminating), + ); + } + + #[tokio::test] + async fn create_rejected_in_terminating_workspace() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "dying-ws".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let sbx = Sandbox { + metadata: Some(ObjectMeta { + id: "sbx-dying-1".to_string(), + name: "hold".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + annotations: HashMap::new(), + resource_version: 0, + workspace: "dying-ws".to_string(), + deletion_timestamp_ms: 0, + }), + ..Default::default() + }; + state.store.put_message(&sbx).await.unwrap(); + + // Mark workspace as Terminating via a blocked delete. + let _ = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "dying-ws".to_string(), + }), + ) + .await; + + let resolved = resolve_workspace(&state.store, "dying-ws").await.unwrap(); + assert!(resolved.terminating); + let err = resolved.ensure_active().unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("being deleted")); + } + + #[tokio::test] + async fn delete_workspace_idempotent_on_terminating() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "idempotent-ws".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let sbx = Sandbox { + metadata: Some(ObjectMeta { + id: "sbx-idem-1".to_string(), + name: "temp".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + annotations: HashMap::new(), + resource_version: 0, + workspace: "idempotent-ws".to_string(), + deletion_timestamp_ms: 0, + }), + ..Default::default() + }; + state.store.put_message(&sbx).await.unwrap(); + + // First call: marks Terminating, fails due to blocker. + let _ = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "idempotent-ws".to_string(), + }), + ) + .await; + + // Remove the blocker. + state + .store + .delete_by_name(Sandbox::object_type(), "idempotent-ws", "temp") + .await + .unwrap(); + + // Second call: idempotent re-entry on already-Terminating workspace. + let resp = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "idempotent-ws".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.deleted); + } + + #[tokio::test] + async fn create_workspace_persists_labels_for_selector() { + let state = test_server_state().await; + + let mut labels = HashMap::new(); + labels.insert("env".to_string(), "staging".to_string()); + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "labeled-ws".to_string(), + labels: labels.clone(), + }), + ) + .await + .unwrap(); + + let resp = handle_list_workspaces( + &state, + Request::new(ListWorkspacesRequest { + label_selector: "env=staging".to_string(), + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(resp.workspaces.len(), 1); + assert_eq!( + resp.workspaces[0].metadata.as_ref().unwrap().name, + "labeled-ws" + ); + assert_eq!(resp.workspaces[0].metadata.as_ref().unwrap().labels, labels); + + let empty = handle_list_workspaces( + &state, + Request::new(ListWorkspacesRequest { + label_selector: "env=production".to_string(), + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + + assert!(empty.workspaces.is_empty()); + } + + #[test] + fn resolved_workspace_ensure_active_passes_for_active() { + let rw = ResolvedWorkspace { + name: "test".to_string(), + terminating: false, + }; + assert_eq!(rw.ensure_active().unwrap(), "test"); + } + + #[test] + fn resolved_workspace_ensure_active_rejects_terminating() { + let rw = ResolvedWorkspace { + name: "doomed".to_string(), + terminating: true, + }; + let err = rw.ensure_active().unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("being deleted")); + } + + #[tokio::test] + async fn delete_workspace_cascade_deletes_inference_routes() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "route-test".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let route = InferenceRoute { + metadata: Some(ObjectMeta { + id: "route-1".to_string(), + name: "inference.local".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + annotations: HashMap::new(), + resource_version: 0, + workspace: "route-test".to_string(), + deletion_timestamp_ms: 0, + }), + config: Some(openshell_core::proto::InferenceRouteConfig { + provider_name: "test-provider".to_string(), + model_id: "gpt-4o".to_string(), + timeout_secs: 0, + }), + version: 1, + }; + state.store.put_message(&route).await.unwrap(); + + // Inference route should NOT block workspace deletion. + let resp = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "route-test".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.deleted); + + // Inference route should have been cascade-deleted. + let remaining: Vec = state + .store + .list_messages("route-test", 100, 0) + .await + .unwrap(); + assert!( + remaining.is_empty(), + "inference routes should be cascade-deleted with workspace" + ); + } +} diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index 2b58bd15cd..97217c2565 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -3,21 +3,23 @@ #![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, + DeleteInferenceRouteRequest, DeleteInferenceRouteResponse, GetInferenceBundleRequest, + GetInferenceBundleResponse, GetInferenceRouteRequest, GetInferenceRouteResponse, + InferenceRoute, InferenceRouteConfig, Provider, ResolvedRoute, Sandbox, + SetInferenceRouteRequest, SetInferenceRouteResponse, ValidatedEndpoint, inference_server::Inference, }; +use openshell_core::{ObjectId, ObjectLabels, ObjectWorkspace}; use openshell_providers::normalize_provider_type; use openshell_router::config::ResolvedRoute as RouterResolvedRoute; use openshell_router::{ValidationFailureKind, verify_backend_endpoint}; use openshell_server_macros::rpc_authz; use prost::Message as _; +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use tonic::{Request, Response, Status}; @@ -68,26 +70,39 @@ 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(); + 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? + .ensure_active()?; 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 +117,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 +125,30 @@ 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? + .name; 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 +163,40 @@ 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, })) } + + #[rpc_auth(auth = "bearer", scope = "inference:write", role = "admin")] + async fn delete_inference_route( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let workspace = + crate::grpc::workspace::resolve_workspace(self.state.store.as_ref(), &req.workspace) + .await? + .name; + let route_name = effective_route_name(&req.route_name)?; + let deleted = self + .state + .store + .delete_by_name(InferenceRoute::object_type(), &workspace, route_name) + .await + .map_err(|e| Status::internal(format!("delete route failed: {e}")))?; + Ok(Response::new(DeleteInferenceRouteResponse { deleted })) + } } -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 +211,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 +227,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,15 +245,16 @@ 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(), name: route_name.to_string(), created_at_ms: now_ms, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, }); (new_id, new_metadata, 1, WriteCondition::MustCreate) }; @@ -222,17 +265,25 @@ 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 + let labels_map = route.object_labels(); + let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { + None + } else { + Some( + serde_json::to_string(&labels_map) + .map_err(|e| Status::internal(format!("failed to serialize labels: {e}")))?, + ) + }; store .put_if( InferenceRoute::object_type(), &id, route_name, + workspace, &route.encode_to_vec(), - None, + labels_json.as_deref(), condition, ) .await @@ -241,12 +292,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, @@ -301,7 +352,7 @@ fn infer_vertex_publisher(model_id: &str) -> Option<&'static str> { /// Return a required Vertex AI config value, or a `FailedPrecondition` status. fn required_vertex_config<'a>( - config: &'a std::collections::HashMap, + config: &'a HashMap, key: &str, ) -> Result<&'a str, Status> { config @@ -548,7 +599,7 @@ fn build_vertex_route( /// Resolve a Vertex AI route given provider config, model, and bearer token. fn resolve_vertex_ai_route( - config: &std::collections::HashMap, + config: &HashMap, model_id: &str, route_name: &str, api_key: &str, @@ -876,9 +927,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 +939,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 +987,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 +1016,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 )) })?; @@ -1029,11 +1084,13 @@ mod tests { id: format!("id-{name}"), name: name.to_string(), created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), - config: Some(ClusterInferenceConfig { + config: Some(InferenceRouteConfig { provider_name: provider_name.to_string(), model_id: model_id.to_string(), timeout_secs: 0, @@ -1048,14 +1105,17 @@ mod tests { id: format!("provider-{name}"), name: name.to_string(), created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: provider_type.to_string(), 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(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), } } @@ -1096,8 +1156,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 +1169,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", @@ -1146,9 +1208,11 @@ mod tests { id: "provider-bedrock-bridge".to_string(), name: "bedrock-bridge".to_string(), created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws-bedrock".to_string(), // Placeholder credential — the router ignores it because @@ -1164,15 +1228,17 @@ mod tests { "http://bedrock-bridge.demo.svc.cluster.local:8080".to_string(), )) .collect(), - credential_expires_at_ms: std::collections::HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) .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 +1257,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"); @@ -1223,9 +1289,11 @@ mod tests { id: "provider-bedrock-misconfigured".to_string(), name: "bedrock-misconfigured".to_string(), created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws-bedrock".to_string(), credentials: std::iter::once(( @@ -1234,16 +1302,18 @@ mod tests { )) .collect(), // Intentionally no BEDROCK_BASE_URL. - config: std::collections::HashMap::new(), - credential_expires_at_ms: std::collections::HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) .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", @@ -1272,18 +1342,21 @@ mod tests { id: "provider-bedrock-bridge".to_string(), name: "bedrock-bridge".to_string(), created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "aws-bedrock".to_string(), - credentials: std::collections::HashMap::new(), + credentials: HashMap::new(), config: std::iter::once(( "BEDROCK_BASE_URL".to_string(), "http://bedrock-bridge.demo.svc.cluster.local:8080".to_string(), )) .collect(), - credential_expires_at_ms: std::collections::HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) @@ -1302,8 +1375,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 +1403,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 +1422,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 +1460,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 +1502,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 +1527,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 +1550,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"); @@ -1498,9 +1572,11 @@ mod tests { id: "provider-1".to_string(), name: "openai-dev".to_string(), created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) @@ -1510,7 +1586,8 @@ mod tests { "https://station.example.com/v1".to_string(), )) .collect(), - credential_expires_at_ms: std::collections::HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) @@ -1522,11 +1599,13 @@ mod tests { id: "r-1".to_string(), name: CLUSTER_INFERENCE_ROUTE_NAME.to_string(), created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), - config: Some(ClusterInferenceConfig { + config: Some(InferenceRouteConfig { provider_name: "openai-dev".to_string(), model_id: "test/model".to_string(), timeout_secs: 0, @@ -1538,7 +1617,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 +1653,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"); @@ -1587,13 +1666,14 @@ 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) .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 +1687,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 +1706,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. @@ -1634,9 +1715,11 @@ mod tests { id: "provider-vertex-test".to_string(), name: "vertex-test".to_string(), created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -1653,15 +1736,17 @@ mod tests { ] .into_iter() .collect(), - credential_expires_at_ms: std::collections::HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) .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 +1763,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 +1817,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 +1837,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 +1853,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 +1866,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 +1911,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 +1952,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 +1973,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 +1996,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", @@ -1963,18 +2052,17 @@ mod tests { // resolve_vertex_ai_route tests // ------------------------------------------------------------------------- - fn make_vertex_provider_with_config( - name: &str, - config: std::collections::HashMap, - ) -> Provider { + fn make_vertex_provider_with_config(name: &str, config: HashMap) -> Provider { Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("provider-{name}"), name: name.to_string(), created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 1, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -1983,7 +2071,8 @@ mod tests { )) .collect(), config, - credential_expires_at_ms: std::collections::HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), } } @@ -2428,7 +2517,7 @@ mod tests { #[test] fn resolve_vertex_ai_route_missing_project_fails() { - let config = std::collections::HashMap::new(); + let config = HashMap::new(); let provider = make_vertex_provider_with_config("vertex-no-proj", config); let err = resolve_provider_route(&provider, "claude-3-5-sonnet@20241022") @@ -2472,10 +2561,7 @@ mod tests { )) .collect(), config, - ..make_vertex_provider_with_config( - "vertex-bootstrap-only", - std::collections::HashMap::new(), - ) + ..make_vertex_provider_with_config("vertex-bootstrap-only", HashMap::new()) }; let err = resolve_provider_route(&provider, "claude-3-5-sonnet@20241022") @@ -2879,8 +2965,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 +2979,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 +3038,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 +3054,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 +3069,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 +3083,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 +3108,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 +3128,229 @@ 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: HashMap::new(), + annotations: HashMap::new(), + resource_version: 0, + workspace: "alpha".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "openai".to_string(), + credentials: std::iter::once(( + "OPENAI_API_KEY".to_string(), + "sk-alpha-key".to_string(), + )) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::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: HashMap::new(), + annotations: HashMap::new(), + resource_version: 0, + workspace: "beta".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "anthropic".to_string(), + credentials: std::iter::once(( + "ANTHROPIC_API_KEY".to_string(), + "sk-beta-key".to_string(), + )) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::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" + ); + } + + #[tokio::test] + async fn delete_route_removes_existing_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"); + + upsert_inference_route( + &store, + "default", + CLUSTER_INFERENCE_ROUTE_NAME, + "openai-dev", + "gpt-4o", + 0, + false, + ) + .await + .expect("upsert should succeed"); + + let deleted = store + .delete_by_name( + InferenceRoute::object_type(), + "default", + CLUSTER_INFERENCE_ROUTE_NAME, + ) + .await + .expect("delete should succeed"); + assert!(deleted, "route should have been deleted"); + + let route: Option = store + .get_message_by_name("default", CLUSTER_INFERENCE_ROUTE_NAME) + .await + .expect("fetch should succeed"); + assert!(route.is_none(), "route should no longer exist"); + } + + #[tokio::test] + async fn delete_route_returns_false_when_not_found() { + let store = test_store().await; + + let deleted = store + .delete_by_name( + InferenceRoute::object_type(), + "default", + CLUSTER_INFERENCE_ROUTE_NAME, + ) + .await + .expect("delete should succeed"); + assert!(!deleted, "nothing to delete"); + } + + #[tokio::test] + async fn delete_route_bundle_becomes_empty() { + let store = test_store().await; + + let provider = make_provider("openai-dev", "openai", "OPENAI_API_KEY", "sk-test"); + store.put_message(&provider).await.expect("persist"); + + upsert_inference_route( + &store, + "default", + CLUSTER_INFERENCE_ROUTE_NAME, + "openai-dev", + "gpt-4o", + 0, + false, + ) + .await + .expect("upsert should succeed"); + + let bundle = resolve_inference_bundle(&store, "default") + .await + .expect("bundle should resolve"); + assert_eq!(bundle.routes.len(), 1); + + store + .delete_by_name( + InferenceRoute::object_type(), + "default", + CLUSTER_INFERENCE_ROUTE_NAME, + ) + .await + .expect("delete should succeed"); + + let bundle = resolve_inference_bundle(&store, "default") + .await + .expect("bundle should resolve"); + assert!( + bundle.routes.is_empty(), + "bundle should be empty after route deletion" + ); + } } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index dc00bc4561..a75534d397 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -54,7 +54,7 @@ pub mod tracing_bus; mod ws_tunnel; use metrics_exporter_prometheus::PrometheusBuilder; -use openshell_core::{ComputeDriverKind, Config, Error, Result}; +use openshell_core::{ComputeDriverKind, Config, Error, ObjectLabels, Result}; use openshell_supervisor_middleware::MiddlewareRegistry; use std::collections::HashMap; use std::io::ErrorKind; @@ -429,6 +429,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"); } @@ -955,6 +957,65 @@ 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(), + annotations: HashMap::new(), + resource_version: 0, + workspace: String::new(), + deletion_timestamp_ms: 0, + }), + status: Some(openshell_core::proto::datamodel::v1::WorkspaceStatus { + phase: openshell_core::proto::datamodel::v1::WorkspacePhase::Active.into(), + }), + }; + + let labels_map = workspace.object_labels(); + let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { + None + } else { + Some( + serde_json::to_string(&labels_map).map_err(|e| Error::Config { + message: format!("failed to serialize labels: {e}"), + })?, + ) + }; + match store + .put_if( + WORKSPACE_OBJECT_TYPE, + &id, + DEFAULT_WORKSPACE_NAME, + "", + &workspace.encode_to_vec(), + labels_json.as_deref(), + 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::{ @@ -1089,7 +1150,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 { @@ -1220,7 +1281,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..291e2eafd7 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. @@ -139,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`. @@ -221,6 +227,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 +236,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 +271,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 +294,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,22 +309,64 @@ 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)) + /// 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)) } - /// List objects by type. + /// Delete all objects of a given type with a matching scope. + pub async fn delete_by_scope(&self, object_type: &str, scope: &str) -> PersistenceResult { + store_dispatch!(self.delete_by_scope(object_type, scope)) + } + + /// 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 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. + /// + /// 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, @@ -323,16 +377,34 @@ 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, + workspace, + label_selector, + limit, + offset + )) + } + + /// 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_with_selector(object_type, label_selector, limit, offset)) + store_dispatch!(self.list_all_with_selector(object_type, label_selector, limit, offset)) } // ----------------------------------------------------------------------- @@ -341,12 +413,18 @@ 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<()> { + 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 @@ -360,6 +438,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 +457,59 @@ 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(), limit, offset) + 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_by_type(T::object_type(), limit, offset) + .await? + .into_iter() + .map(decode_record) + .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) @@ -409,11 +522,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 +564,7 @@ impl Store { + ObjectId + ObjectName + ObjectLabels + + ObjectWorkspace + SetResourceVersion + GetResourceVersion + Clone, @@ -491,12 +606,34 @@ impl Store { })?) }; + if T::requires_workspace() && updated.object_workspace().is_empty() { + return Err(PersistenceError::Encode(format!( + "{} requires a non-empty workspace", + T::object_type(), + ))); + } + + if updated.object_name() != current.object_name() { + return Err(PersistenceError::Encode(format!( + "{} name cannot be changed after creation", + T::object_type(), + ))); + } + + if updated.object_workspace() != current.object_workspace() { + return Err(PersistenceError::Encode(format!( + "{} workspace cannot be changed after creation", + 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 +668,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 +733,25 @@ 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<()> { + 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 @@ -618,6 +764,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 12299805e9..f1bc182add 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) @@ -180,9 +185,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, @@ -193,6 +198,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) @@ -244,11 +250,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>, @@ -261,9 +269,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, @@ -274,6 +282,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) @@ -291,7 +300,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 ", @@ -308,16 +317,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 @@ -336,28 +347,103 @@ 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") + 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(u64::try_from(row.0).unwrap_or(0)) + } + + 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_scope(&self, object_type: &str, scope: &str) -> PersistenceResult { + let result = sqlx::query("DELETE FROM objects WHERE object_type = $1 AND scope = $2") .bind(object_type) - .bind(name) + .bind(scope) .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, + 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 +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, workspace ASC, id ASC LIMIT $2 OFFSET $3 ", ) @@ -380,7 +466,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, resource_version FROM objects WHERE object_type = $1 AND scope = $2 ORDER BY created_at_ms ASC, name ASC @@ -401,6 +487,7 @@ LIMIT $3 OFFSET $4 pub async fn list_with_selector( &self, object_type: &str, + workspace: &str, label_selector: &str, limit: u32, offset: u32, @@ -413,10 +500,44 @@ 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 $4 OFFSET $5 +", + ) + .bind(object_type) + .bind(workspace) + .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 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, workspace ASC, id ASC LIMIT $3 OFFSET $4 ", ) @@ -435,6 +556,7 @@ LIMIT $3 OFFSET $4 &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -457,9 +579,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) @@ -469,6 +591,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))?; @@ -534,9 +657,9 @@ WHERE object_type = 'sandbox' AND id = $1 AND resource_version = $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) @@ -546,6 +669,7 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $7) .bind("pending") .bind(wrapped_payload) .bind(now_ms) + .bind(&write.workspace) .execute(&mut *tx) .await .map_err(|e| map_db_error(&e))?; @@ -732,6 +856,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 @@ -740,9 +865,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 @@ -758,6 +883,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))?; @@ -987,6 +1113,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 57d5c20e31..86f79a69e6 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,73 @@ 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 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(u64::try_from(row.0).unwrap_or(0)) + } + + 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_scope(&self, object_type: &str, scope: &str) -> PersistenceResult { let result = sqlx::query( r#" DELETE FROM "objects" -WHERE "object_type" = ?1 AND "name" = ?2 +WHERE "object_type" = ?1 AND "scope" = ?2 "#, ) .bind(object_type) + .bind(scope) + .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, + workspace: &str, + name: &str, + ) -> PersistenceResult { + let result = sqlx::query( + r#" +DELETE FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 AND "name" = ?3 +"#, + ) + .bind(object_type) + .bind(workspace) .bind(name) .execute(&self.pool) .await @@ -376,15 +449,42 @@ 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", "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 +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, "workspace" ASC, "id" ASC LIMIT ?2 OFFSET ?3 "#, ) @@ -407,7 +507,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", "resource_version" FROM "objects" WHERE "object_type" = ?1 AND "scope" = ?2 ORDER BY "created_at_ms" ASC, "name" ASC @@ -425,6 +525,37 @@ LIMIT ?3 OFFSET ?4 Ok(rows.into_iter().map(row_to_object_record).collect()) } pub async fn list_with_selector( + &self, + object_type: &str, + workspace: &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(object_type, workspace, 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 list_all_with_selector( &self, object_type: &str, label_selector: &str, @@ -434,7 +565,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_by_type(object_type, u32::MAX, 0).await?; let filtered: Vec = all_records .into_iter() @@ -457,6 +588,7 @@ LIMIT ?3 OFFSET ?4 &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -479,9 +611,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 +623,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))?; @@ -559,9 +692,9 @@ WHERE "object_type" = 'sandbox' AND "id" = ?1 AND "resource_version" = ?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) @@ -571,6 +704,7 @@ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) .bind("pending") .bind(wrapped_payload) .bind(now_ms) + .bind(&write.workspace) .execute(&mut *tx) .await .map_err(|e| map_db_error(&e))?; @@ -757,6 +891,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 +901,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 +919,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 +1180,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..9539eab49d 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,9 +406,60 @@ 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(); +} + +#[tokio::test] +async fn sqlite_name_unique_scoped_by_workspace() { + let store = test_store().await; + + store + .put("sandbox", "id-1", "shared-name", "alpha", b"payload1", None) + .await + .unwrap(); + + // Same name, same type, different workspace -> separate records. + store + .put("sandbox", "id-2", "shared-name", "beta", b"payload2", None) + .await + .unwrap(); + + let alpha = store + .get_by_name("sandbox", "alpha", "shared-name") + .await + .unwrap() + .unwrap(); + let beta = store + .get_by_name("sandbox", "beta", "shared-name") + .await + .unwrap() + .unwrap(); + assert_eq!(alpha.id, "id-1"); + assert_eq!(beta.id, "id-2"); + assert_eq!(alpha.payload, b"payload1"); + assert_eq!(beta.payload, b"payload2"); + + // Same name, same type, same workspace -> upsert (existing behavior). + store + .put("sandbox", "id-3", "shared-name", "alpha", b"payload3", None) + .await + .unwrap(); + let alpha = store + .get_by_name("sandbox", "alpha", "shared-name") .await + .unwrap() .unwrap(); + assert_eq!(alpha.id, "id-1", "upsert should keep original id"); + assert_eq!(alpha.payload, b"payload3", "upsert should update payload"); } #[tokio::test] @@ -393,14 +467,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 +520,7 @@ async fn labels_round_trip() { "sandbox", "id-1", "labeled-sandbox", + "default", b"payload", Some(labels), ) @@ -461,11 +536,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 +562,7 @@ async fn label_selector_single_match() { "sandbox", "id-3", "s3", + "default", b"p3", Some(r#"{"env":"prod","team":"platform"}"#), ) @@ -480,7 +570,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 +589,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-1", "s1", + "default", b"p1", Some(r#"{"env":"prod","team":"platform"}"#), ) @@ -509,6 +600,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-2", "s2", + "default", b"p2", Some(r#"{"env":"prod","team":"data"}"#), ) @@ -519,6 +611,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-3", "s3", + "default", b"p3", Some(r#"{"env":"dev","team":"platform"}"#), ) @@ -526,7 +619,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 +632,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 +659,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 +695,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(); @@ -615,6 +729,7 @@ fn policy_test_sandbox(id: &str, name: &str) -> Sandbox { id: id.to_string(), name: name.to_string(), created_at_ms: 1, + workspace: "default".to_string(), ..Default::default() }), spec: Some(SandboxSpec::default()), @@ -645,6 +760,7 @@ async fn policy_atomic_write_commits_revision_provenance_and_sandbox_projection( .put_policy_revision_atomic(&AtomicPolicyRevisionWrite { id: "policy-atomic-1".to_string(), sandbox_id: "sandbox-atomic".to_string(), + workspace: "default".to_string(), version: 1, policy_payload: policy.encode_to_vec(), policy_hash: "hash-atomic-1".to_string(), @@ -695,6 +811,7 @@ async fn policy_atomic_write_rolls_back_sandbox_when_revision_insert_conflicts() .put_policy_revision( "existing-policy", "sandbox-rollback", + "default", 1, &policy.encode_to_vec(), "existing-hash", @@ -712,6 +829,7 @@ async fn policy_atomic_write_rolls_back_sandbox_when_revision_insert_conflicts() .put_policy_revision_atomic(&AtomicPolicyRevisionWrite { id: "conflicting-policy".to_string(), sandbox_id: "sandbox-rollback".to_string(), + workspace: "default".to_string(), version: 1, policy_payload: policy.encode_to_vec(), policy_hash: "conflicting-hash".to_string(), @@ -737,13 +855,52 @@ async fn policy_atomic_write_rolls_back_sandbox_when_revision_insert_conflicts() assert!(after.spec.as_ref().unwrap().policy.is_none()); } +#[tokio::test] +async fn policy_atomic_write_persists_workspace() { + let store = test_store().await; + store + .put_message(&policy_test_sandbox("sandbox-ws", "ws-test")) + .await + .unwrap(); + let current = store + .get_message::("sandbox-ws") + .await + .unwrap() + .unwrap(); + let current_version = current.metadata.as_ref().unwrap().resource_version; + let policy = SandboxPolicy::default(); + + store + .put_policy_revision_atomic(&AtomicPolicyRevisionWrite { + id: "policy-ws-1".to_string(), + sandbox_id: "sandbox-ws".to_string(), + workspace: "my-workspace".to_string(), + version: 1, + policy_payload: policy.encode_to_vec(), + policy_hash: "hash-ws-1".to_string(), + provenance: StdHashMap::new(), + expected_resource_version: current_version, + annotations: StdHashMap::new(), + backfill_policy: Some(policy), + }) + .await + .unwrap(); + + let record = store + .get("sandbox_policy", "policy-ws-1") + .await + .unwrap() + .unwrap(); + assert_eq!(record.workspace, "my-workspace"); +} + #[tokio::test] async fn policy_put_and_get_latest() { let store = test_store().await; 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 +917,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 +937,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 +971,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 +1002,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 +1026,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 +1079,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 +1115,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 +1217,7 @@ async fn cas_put_if_must_create_succeeds() { "sandbox", "id-1", "new-sandbox", + "default", b"payload", None, WriteCondition::MustCreate, @@ -1086,6 +1244,7 @@ async fn cas_put_if_must_create_fails_on_duplicate() { "sandbox", "id-1", "sandbox-1", + "default", b"payload1", None, WriteCondition::MustCreate, @@ -1099,6 +1258,7 @@ async fn cas_put_if_must_create_fails_on_duplicate() { "sandbox", "id-1", "sandbox-2", + "default", b"payload2", None, WriteCondition::MustCreate, @@ -1123,6 +1283,7 @@ async fn cas_put_if_match_version_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1136,6 +1297,7 @@ async fn cas_put_if_match_version_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(1), @@ -1162,6 +1324,7 @@ async fn cas_put_if_match_version_fails_on_mismatch() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1175,6 +1338,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 +1369,7 @@ async fn cas_delete_if_succeeds_with_correct_version() { "sandbox", "id-1", "sandbox-1", + "default", b"payload", None, WriteCondition::MustCreate, @@ -1230,6 +1395,7 @@ async fn cas_delete_if_fails_with_wrong_version() { "sandbox", "id-1", "sandbox-1", + "default", b"payload", None, WriteCondition::MustCreate, @@ -1262,6 +1428,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1276,6 +1443,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(1), @@ -1290,6 +1458,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v3", None, WriteCondition::MatchResourceVersion(2), @@ -1315,6 +1484,7 @@ async fn cas_concurrent_updates_one_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"initial", None, WriteCondition::MustCreate, @@ -1332,6 +1502,7 @@ async fn cas_concurrent_updates_one_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", format!("update-{i}").as_bytes(), None, WriteCondition::MatchResourceVersion(1), @@ -1374,6 +1545,8 @@ async fn cas_update_message_cas_succeeds() { labels: std::collections::HashMap::new(), resource_version: 0, annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: None, status: None, @@ -1414,6 +1587,8 @@ async fn cas_update_message_cas_conflicts_on_concurrent_updates() { labels: std::collections::HashMap::new(), resource_version: 0, annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: None, status: None, @@ -1467,3 +1642,131 @@ async fn cas_update_message_cas_conflicts_on_concurrent_updates() { "resource_version should be 2 (initial 1 + 1 successful update)" ); } + +#[tokio::test] +async fn cas_update_message_cas_rejects_workspace_change() { + use openshell_core::proto::Sandbox; + + let store = test_store().await; + + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "ws-immutable".to_string(), + name: "test-sandbox".to_string(), + created_at_ms: 1000, + labels: std::collections::HashMap::new(), + annotations: std::collections::HashMap::new(), + resource_version: 0, + workspace: "alpha".to_string(), + deletion_timestamp_ms: 0, + }), + spec: None, + status: None, + }; + + store.put_message(&sandbox).await.unwrap(); + + let err = store + .update_message_cas::("ws-immutable", 0, |s| { + if let Some(meta) = s.metadata.as_mut() { + meta.workspace = "beta".to_string(); + } + }) + .await + .unwrap_err(); + + assert!( + matches!(err, PersistenceError::Encode(_)), + "expected Encode error, got: {err:?}" + ); + assert!( + err.to_string().contains("cannot be changed"), + "error should mention immutability: {err}" + ); +} + +#[tokio::test] +async fn cas_update_message_cas_rejects_name_change() { + use openshell_core::proto::Sandbox; + + let store = test_store().await; + + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "name-immutable".to_string(), + name: "original".to_string(), + created_at_ms: 1000, + labels: std::collections::HashMap::new(), + annotations: std::collections::HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + spec: None, + status: None, + }; + + store.put_message(&sandbox).await.unwrap(); + + let err = store + .update_message_cas::("name-immutable", 0, |s| { + if let Some(meta) = s.metadata.as_mut() { + meta.name = "renamed".to_string(); + } + }) + .await + .unwrap_err(); + + assert!( + matches!(err, PersistenceError::Encode(_)), + "expected Encode error, got: {err:?}" + ); + assert!( + err.to_string().contains("name cannot be changed"), + "error should mention name immutability: {err}" + ); +} + +#[tokio::test] +async fn list_by_scope_returns_resource_version() { + let store = test_store().await; + + // Create a scoped record via put_scoped (resource_version = 1). + store + .put_scoped( + "ssh_session", + "sess-1", + "sess-1", + "default", + "sandbox-owner-id", + b"payload-v1", + None, + ) + .await + .unwrap(); + + // Update via put_scoped (ON CONFLICT increments resource_version to 2). + store + .put_scoped( + "ssh_session", + "sess-1", + "sess-1", + "default", + "sandbox-owner-id", + b"payload-v2", + None, + ) + .await + .unwrap(); + + let records = store + .list_by_scope("ssh_session", "sandbox-owner-id", 100, 0) + .await + .unwrap(); + + assert_eq!(records.len(), 1); + assert_eq!( + records[0].resource_version, 2, + "list_by_scope must return the actual resource_version, not a default" + ); +} diff --git a/crates/openshell-server/src/policy_store.rs b/crates/openshell-server/src/policy_store.rs index 7e3b7856a4..1a49e35a1f 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -15,6 +15,7 @@ use std::collections::HashMap; pub struct AtomicPolicyRevisionWrite { pub id: String, pub sandbox_id: String, + pub workspace: String, pub version: i64, pub policy_payload: Vec, pub policy_hash: String, @@ -99,6 +100,7 @@ pub trait PolicyStoreExt { &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -165,6 +167,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 +213,7 @@ impl PolicyStoreExt for Store { &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -217,12 +221,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 +327,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_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index f6882172b3..d59d4306f7 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -44,7 +44,11 @@ pub trait ProviderProfileSource: Send + Sync + std::fmt::Debug { fn source_id(&self) -> &str; fn user_managed(&self) -> bool; fn allow_empty(&self) -> bool; - async fn snapshot(&self, store: &Store) -> Result; + async fn snapshot( + &self, + store: &Store, + workspace: &str, + ) -> Result; } #[derive(Debug, Clone, Default)] @@ -64,7 +68,11 @@ impl ProviderProfileSource for BuiltinProviderProfileSource { false } - async fn snapshot(&self, _store: &Store) -> Result { + async fn snapshot( + &self, + _store: &Store, + _workspace: &str, + ) -> Result { let profiles = builtin_profiles() .iter() .map(ProviderTypeProfile::to_proto) @@ -93,8 +101,12 @@ impl ProviderProfileSource for UserProviderProfileSource { true } - async fn snapshot(&self, store: &Store) -> Result { - let stored = user_provider_profiles(store).await?; + async fn snapshot( + &self, + store: &Store, + workspace: &str, + ) -> Result { + let stored = user_provider_profiles(store, workspace).await?; let mut profiles = Vec::new(); let mut hasher = Sha256::new(); hasher.update(b"openshell-user-provider-profile-source-v1"); @@ -128,7 +140,11 @@ impl ProviderProfileSource for GatewayInterceptorProfileSource { false } - async fn snapshot(&self, _store: &Store) -> Result { + async fn snapshot( + &self, + _store: &Store, + _workspace: &str, + ) -> Result { let InterceptorProfileSnapshot { revision, profiles } = Self::snapshot(self).await.map_err(|err| { Status::unavailable(format!( @@ -264,8 +280,9 @@ impl ProviderProfileSources { pub(crate) async fn snapshot_catalog( &self, store: &Store, + workspace: &str, ) -> Result { - let snapshots = self.snapshots(store).await?; + let snapshots = self.snapshots(store, workspace).await?; let catalog = build_effective_profiles(snapshots)?; debug!( catalog_revision = %catalog.revision(), @@ -279,10 +296,11 @@ impl ProviderProfileSources { async fn snapshots( &self, store: &Store, + workspace: &str, ) -> Result, Status> { let mut snapshots = Vec::with_capacity(self.sources.len()); for source in &self.sources { - let snapshot = source.snapshot(store).await?; + let snapshot = source.snapshot(store, workspace).await?; snapshots.push(CollectedProviderProfileSnapshot { source_id: source.source_id().to_string(), revision: snapshot.revision, @@ -304,6 +322,7 @@ impl EffectiveProviderProfileCatalog { self.source_count } + #[allow(dead_code)] pub(crate) fn list_profiles(&self) -> Vec { self.profiles .values() @@ -311,6 +330,7 @@ impl EffectiveProviderProfileCatalog { .collect() } + #[allow(dead_code)] pub(crate) fn get_profile(&self, id: &str) -> Option { let id = normalize_profile_id(id)?; self.profiles.get(&id).map(|entry| entry.response.clone()) @@ -482,14 +502,27 @@ fn profile_snapshot_revision(profiles: &[ProviderProfile]) -> String { format!("sha256:{:x}", hasher.finalize()) } -pub async fn user_provider_profiles(store: &Store) -> Result, Status> { - let profiles: Vec = store - .list_messages(10_000, 0) +pub async fn user_provider_profiles( + store: &Store, + workspace: &str, +) -> Result, Status> { + let mut profiles: Vec = store + .list_messages("", 10_000, 0) .await - .map_err(|e| Status::internal(format!("list provider profiles failed: {e}")))?; + .map_err(|e| Status::internal(format!("list platform provider profiles failed: {e}")))?; + if !workspace.is_empty() { + let ws_profiles: Vec = store + .list_messages(workspace, 10_000, 0) + .await + .map_err(|e| { + Status::internal(format!("list workspace provider profiles failed: {e}")) + })?; + profiles.extend(ws_profiles); + } Ok(profiles) } +#[expect(dead_code)] pub fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { use crate::persistence::current_time_ms; let now_ms = current_time_ms(); @@ -502,6 +535,8 @@ pub fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfil labels: std::collections::HashMap::new(), resource_version: 0, annotations: std::collections::HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, }), profile: Some(profile), } @@ -548,7 +583,11 @@ impl ProviderProfileSource for StaticProviderProfileSource { false } - async fn snapshot(&self, _store: &Store) -> Result { + async fn snapshot( + &self, + _store: &Store, + _workspace: &str, + ) -> Result { Ok(self.snapshot.clone()) } } @@ -575,7 +614,11 @@ impl ProviderProfileSource for SequencedProviderProfileSource { false } - async fn snapshot(&self, _store: &Store) -> Result { + async fn snapshot( + &self, + _store: &Store, + _workspace: &str, + ) -> Result { let index = self .fetch_count .fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -692,7 +735,7 @@ mod tests { ); let store = crate::persistence::test_store().await; - let first = sources.snapshot_catalog(&store).await.unwrap(); + let first = sources.snapshot_catalog(&store, "default").await.unwrap(); assert_eq!(fetch_count.load(Ordering::SeqCst), 1); assert_eq!(first.source_count(), 1); assert_eq!( @@ -705,7 +748,7 @@ mod tests { let first_profile_hash = first_profile_hash.finalize(); assert_eq!(fetch_count.load(Ordering::SeqCst), 1); - let second = sources.snapshot_catalog(&store).await.unwrap(); + let second = sources.snapshot_catalog(&store, "default").await.unwrap(); assert_eq!(fetch_count.load(Ordering::SeqCst), 2); assert_eq!( second.get_profile("moving-profile").unwrap().display_name, @@ -864,7 +907,7 @@ mod tests { let store = crate::persistence::test_store().await; let profiles = sources - .snapshot_catalog(&store) + .snapshot_catalog(&store, "default") .await .unwrap() .list_profiles(); @@ -899,7 +942,10 @@ mod tests { .unwrap(); let store = crate::persistence::test_store().await; - let err = sources.snapshot_catalog(&store).await.unwrap_err(); + let err = sources + .snapshot_catalog(&store, "default") + .await + .unwrap_err(); assert!(err.message().contains("returned no profiles")); task.abort(); } @@ -923,7 +969,10 @@ mod tests { .unwrap(); let store = crate::persistence::test_store().await; - let err = sources.snapshot_catalog(&store).await.unwrap_err(); + let err = sources + .snapshot_catalog(&store, "default") + .await + .unwrap_err(); assert!(err.message().contains("is invalid")); task.abort(); } @@ -951,7 +1000,7 @@ mod tests { let store = crate::persistence::test_store().await; let profiles = sources - .snapshot_catalog(&store) + .snapshot_catalog(&store, "default") .await .unwrap() .list_profiles(); @@ -986,7 +1035,10 @@ mod tests { .unwrap(); let store = crate::persistence::test_store().await; - let err = sources.snapshot_catalog(&store).await.unwrap_err(); + let err = sources + .snapshot_catalog(&store, "default") + .await + .unwrap_err(); assert!( err.message() .contains("duplicate provider profile id 'github'") @@ -1013,7 +1065,10 @@ mod tests { .unwrap(); let store = crate::persistence::test_store().await; - let err = sources.snapshot_catalog(&store).await.unwrap_err(); + let err = sources + .snapshot_catalog(&store, "default") + .await + .unwrap_err(); assert!(err.message().contains("duplicate provider profile id")); task.abort(); } @@ -1040,7 +1095,10 @@ mod tests { .unwrap(); let store = crate::persistence::test_store().await; - let err = sources.snapshot_catalog(&store).await.unwrap_err(); + let err = sources + .snapshot_catalog(&store, "default") + .await + .unwrap_err(); assert_eq!(err.code(), tonic::Code::Unavailable); task.abort(); } @@ -1076,4 +1134,76 @@ mod tests { assert_eq!(entry.source_id, "interceptor/test"); assert!(!entry.user_managed); } + + fn stored_profile_in_workspace(id: &str, workspace: &str) -> StoredProviderProfile { + use crate::persistence::current_time_ms; + let now_ms = current_time_ms(); + let proto = profile(id); + let proto = profile_storage_payload(proto); + StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: uuid::Uuid::new_v4().to_string(), + name: proto.id.clone(), + created_at_ms: now_ms, + labels: std::collections::HashMap::new(), + resource_version: 0, + annotations: std::collections::HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, + }), + profile: Some(proto), + } + } + + #[tokio::test] + async fn cross_workspace_duplicate_profile_ids_do_not_collide() { + let store = crate::persistence::test_store().await; + store + .put_message(&stored_profile_in_workspace("my-api", "alpha")) + .await + .unwrap(); + store + .put_message(&stored_profile_in_workspace("my-api", "beta")) + .await + .unwrap(); + + let sources = ProviderProfileSources::with_default_sources(); + + let alpha = sources.snapshot_catalog(&store, "alpha").await.unwrap(); + assert!(alpha.get_profile("my-api").is_some()); + + let beta = sources.snapshot_catalog(&store, "beta").await.unwrap(); + assert!(beta.get_profile("my-api").is_some()); + } + + #[tokio::test] + async fn platform_scoped_profiles_visible_from_workspace_catalog() { + let store = crate::persistence::test_store().await; + store + .put_message(&stored_profile_in_workspace("platform-api", "")) + .await + .unwrap(); + + let sources = ProviderProfileSources::with_default_sources(); + + let catalog = sources.snapshot_catalog(&store, "default").await.unwrap(); + assert!(catalog.get_profile("platform-api").is_some()); + } + + #[tokio::test] + async fn workspace_profiles_not_visible_from_other_workspace() { + let store = crate::persistence::test_store().await; + store + .put_message(&stored_profile_in_workspace("ws-only", "alpha")) + .await + .unwrap(); + + let sources = ProviderProfileSources::with_default_sources(); + + let alpha = sources.snapshot_catalog(&store, "alpha").await.unwrap(); + assert!(alpha.get_profile("ws-only").is_some()); + + let beta = sources.snapshot_catalog(&store, "beta").await.unwrap(); + assert!(beta.get_profile("ws-only").is_none()); + } } diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index 8c9b9c4d59..f8fa867c1b 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, PersistenceError, Store, WriteCondition, current_time_ms}; +use openshell_core::ObjectWorkspace; use openshell_core::proto::{ Provider, ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, StoredProviderCredentialRefreshState, @@ -65,6 +66,7 @@ async fn persist_refresh_state_if_current( StoredProviderCredentialRefreshState::object_type(), state.object_id(), state.object_name(), + state.object_workspace(), &state.encode_to_vec(), None, WriteCondition::MatchResourceVersion(expected_version), @@ -114,7 +116,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, @@ -143,24 +145,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}"))) } @@ -171,10 +179,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 @@ -220,6 +229,7 @@ pub struct NewRefreshStateConfig { #[allow(clippy::unnecessary_wraps)] pub fn new_refresh_state( provider: &Provider, + workspace: &str, credential_key: &str, config: NewRefreshStateConfig, ) -> Result { @@ -240,6 +250,8 @@ pub fn new_refresh_state( labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, }), provider_id, provider_name, @@ -330,15 +342,17 @@ pub use openshell_providers::is_gateway_mintable_strategy; 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")); }; @@ -433,7 +447,7 @@ pub async fn refresh_provider_credential( // Generation is ours; write the minted credentials into the provider. 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(); @@ -490,6 +504,7 @@ pub async fn refresh_provider_credential( async fn apply_minted_credential( store: &Store, + workspace: &str, provider: &Provider, credential_key: &str, minted: &MintedCredential, @@ -516,8 +531,10 @@ async fn apply_minted_credential( updated.credential_expires_at_ms.remove(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 @@ -1040,8 +1057,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, @@ -1140,6 +1162,7 @@ mod tests { let before_refresh_ms = crate::persistence::current_time_ms(); let state = new_refresh_state( &provider, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { additional_output_keys: HashMap::new(), @@ -1159,9 +1182,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); @@ -1169,7 +1193,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(); @@ -1214,6 +1238,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -1225,6 +1251,7 @@ mod tests { .unwrap(); let state = new_refresh_state( &provider_b, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { additional_output_keys: HashMap::new(), @@ -1244,21 +1271,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(); @@ -1294,6 +1330,7 @@ mod tests { store.put_message(&provider).await.unwrap(); let state = new_refresh_state( &provider, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { additional_output_keys: HashMap::new(), @@ -1313,15 +1350,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(); @@ -1336,10 +1377,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()) @@ -1374,6 +1420,7 @@ mod tests { store.put_message(&provider).await.unwrap(); let state = new_refresh_state( &provider, + "default", "GOOGLE_DRIVE_ACCESS_TOKEN", NewRefreshStateConfig { additional_output_keys: HashMap::new(), @@ -1397,14 +1444,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(); @@ -1421,6 +1468,7 @@ mod tests { store.put_message(&provider).await.unwrap(); let state = new_refresh_state( &provider, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { additional_output_keys: HashMap::new(), @@ -1439,15 +1487,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(); @@ -1507,6 +1560,7 @@ mod tests { let state = new_refresh_state( &prov, + "default", "AWS_ACCESS_KEY_ID", NewRefreshStateConfig { additional_output_keys: HashMap::from([ @@ -1541,14 +1595,15 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let refreshed = refresh_provider_credential(&store, "aws-sts-test", "AWS_ACCESS_KEY_ID") - .await - .unwrap(); + let refreshed = + refresh_provider_credential(&store, "default", "aws-sts-test", "AWS_ACCESS_KEY_ID") + .await + .unwrap(); assert_eq!(refreshed.status, "refreshed"); assert!(refreshed.expires_at_ms > 0); let stored = store - .get_message_by_name::("aws-sts-test") + .get_message_by_name::("default", "aws-sts-test") .await .unwrap() .unwrap(); @@ -1601,6 +1656,7 @@ mod tests { // hardcoded AWS names. let state = new_refresh_state( &prov, + "default", "AWS_ACCESS_KEY_ID", NewRefreshStateConfig { additional_output_keys: HashMap::from([ @@ -1631,12 +1687,12 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - refresh_provider_credential(&store, "aws-sts-custom", "AWS_ACCESS_KEY_ID") + refresh_provider_credential(&store, "default", "aws-sts-custom", "AWS_ACCESS_KEY_ID") .await .unwrap(); let stored = store - .get_message_by_name::("aws-sts-custom") + .get_message_by_name::("default", "aws-sts-custom") .await .unwrap() .unwrap(); @@ -1672,6 +1728,7 @@ mod tests { // mint must fail rather than fall back to the gateway's ambient identity. let state = new_refresh_state( &prov, + "default", "AWS_ACCESS_KEY_ID", NewRefreshStateConfig { additional_output_keys: HashMap::from([ @@ -1700,14 +1757,15 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let err = refresh_provider_credential(&store, "aws-sts-partial", "AWS_ACCESS_KEY_ID") - .await - .unwrap_err(); + let err = + refresh_provider_credential(&store, "default", "aws-sts-partial", "AWS_ACCESS_KEY_ID") + .await + .unwrap_err(); assert_eq!(err.code(), tonic::Code::InvalidArgument); assert!(err.message().contains("both be set or both omitted")); let stored = store - .get_message_by_name::("aws-sts-partial") + .get_message_by_name::("default", "aws-sts-partial") .await .unwrap() .unwrap(); @@ -1740,12 +1798,12 @@ mod tests { ]), }; - apply_minted_credential(&store, &prov, "AWS_ACCESS_KEY_ID", &minted) + apply_minted_credential(&store, "default", &prov, "AWS_ACCESS_KEY_ID", &minted) .await .unwrap(); let stored = store - .get_message_by_name::("aws-test") + .get_message_by_name::("default", "aws-test") .await .unwrap() .unwrap(); @@ -1798,6 +1856,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { providers: vec!["existing-aws".to_string(), "refreshing-aws".to_string()], @@ -1821,10 +1881,15 @@ mod tests { ]), }; - let err = - apply_minted_credential(&store, &refreshing_provider, "AWS_ACCESS_KEY_ID", &minted) - .await - .unwrap_err(); + let err = apply_minted_credential( + &store, + "default", + &refreshing_provider, + "AWS_ACCESS_KEY_ID", + &minted, + ) + .await + .unwrap_err(); assert_eq!(err.code(), tonic::Code::FailedPrecondition); assert!(err.message().contains("AWS_SECRET_ACCESS_KEY")); } @@ -1883,6 +1948,7 @@ mod tests { // Temporary source credentials: access key + secret + session token. let state = new_refresh_state( &prov, + "default", "AWS_ACCESS_KEY_ID", NewRefreshStateConfig { additional_output_keys: HashMap::from([ @@ -1923,12 +1989,13 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let refreshed = refresh_provider_credential(&store, "aws-sts-session", "AWS_ACCESS_KEY_ID") - .await - .unwrap(); + let refreshed = + refresh_provider_credential(&store, "default", "aws-sts-session", "AWS_ACCESS_KEY_ID") + .await + .unwrap(); assert_eq!(refreshed.status, "refreshed"); let stored = store - .get_message_by_name::("aws-sts-session") + .get_message_by_name::("default", "aws-sts-session") .await .unwrap() .unwrap(); @@ -1953,6 +2020,7 @@ mod tests { let state = new_refresh_state( &prov, + "default", "AWS_ACCESS_KEY_ID", NewRefreshStateConfig { additional_output_keys: HashMap::from([ @@ -1984,9 +2052,14 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let err = refresh_provider_credential(&store, "aws-sts-lonesession", "AWS_ACCESS_KEY_ID") - .await - .unwrap_err(); + let err = refresh_provider_credential( + &store, + "default", + "aws-sts-lonesession", + "AWS_ACCESS_KEY_ID", + ) + .await + .unwrap_err(); assert_eq!(err.code(), tonic::Code::InvalidArgument); assert!(err.message().contains("aws_session_token requires")); } @@ -2030,6 +2103,7 @@ mod tests { let state = new_refresh_state( &prov, + "default", "AWS_ACCESS_KEY_ID", NewRefreshStateConfig { additional_output_keys: HashMap::from([ @@ -2063,7 +2137,8 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let rotate = refresh_provider_credential(&store, "aws-race", "AWS_ACCESS_KEY_ID"); + let rotate = + refresh_provider_credential(&store, "default", "aws-race", "AWS_ACCESS_KEY_ID"); let interfere = async { // Wait until the rotation is inside the STS call (its state read has // already happened), then delete the refresh and release STS. @@ -2073,7 +2148,7 @@ mod tests { { return; } - delete_refresh_state(&store, &provider_id, "AWS_ACCESS_KEY_ID") + delete_refresh_state(&store, "default", &provider_id, "AWS_ACCESS_KEY_ID") .await .unwrap(); let _ = release_tx.send(()); @@ -2087,7 +2162,7 @@ mod tests { ); // The deleted refresh state must not be resurrected. assert!( - get_refresh_state(&store, &provider_id, "AWS_ACCESS_KEY_ID") + get_refresh_state(&store, "default", &provider_id, "AWS_ACCESS_KEY_ID") .await .unwrap() .is_none(), @@ -2095,7 +2170,7 @@ mod tests { ); // No credentials were minted into the provider. let stored = store - .get_message_by_name::("aws-race") + .get_message_by_name::("default", "aws-race") .await .unwrap() .unwrap(); @@ -2143,6 +2218,7 @@ mod tests { let state = new_refresh_state( &prov, + "default", "AWS_ACCESS_KEY_ID", NewRefreshStateConfig { additional_output_keys: HashMap::from([ @@ -2176,7 +2252,8 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let rotate = refresh_provider_credential(&store, "aws-superseded", "AWS_ACCESS_KEY_ID"); + let rotate = + refresh_provider_credential(&store, "default", "aws-superseded", "AWS_ACCESS_KEY_ID"); let interfere = async { if tokio::time::timeout(std::time::Duration::from_secs(15), hit_rx) .await @@ -2187,10 +2264,11 @@ mod tests { // Simulate a concurrent rotation or reconfigure winning the // generation: any write to the refresh state bumps its version, so // the in-flight rotation's version-matched persist will lose. - let mut winner = get_refresh_state(&store, &provider_id, "AWS_ACCESS_KEY_ID") - .await - .unwrap() - .unwrap(); + let mut winner = + get_refresh_state(&store, "default", &provider_id, "AWS_ACCESS_KEY_ID") + .await + .unwrap() + .unwrap(); winner.last_error = "won-by-concurrent-writer".to_string(); put_refresh_state(&store, &winner).await.unwrap(); let _ = release_tx.send(()); @@ -2204,7 +2282,7 @@ mod tests { ); // It must not write its stale-generation credentials into the provider. let stored = store - .get_message_by_name::("aws-superseded") + .get_message_by_name::("default", "aws-superseded") .await .unwrap() .unwrap(); @@ -2212,7 +2290,7 @@ mod tests { assert!(!stored.credentials.contains_key("AWS_SECRET_ACCESS_KEY")); assert!(!stored.credentials.contains_key("AWS_SESSION_TOKEN")); // The concurrent writer's refresh state must survive untouched. - let refresh = get_refresh_state(&store, &provider_id, "AWS_ACCESS_KEY_ID") + let refresh = get_refresh_state(&store, "default", &provider_id, "AWS_ACCESS_KEY_ID") .await .unwrap() .expect("refresh state should still exist"); @@ -2229,11 +2307,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } diff --git a/crates/openshell-server/src/sandbox_index.rs b/crates/openshell-server/src/sandbox_index.rs index 9ad7c941b5..589f88fd88 100644 --- a/crates/openshell-server/src/sandbox_index.rs +++ b/crates/openshell-server/src/sandbox_index.rs @@ -15,7 +15,7 @@ pub struct SandboxIndex { #[derive(Debug, Default)] struct Inner { - sandbox_name_to_id: HashMap, + sandbox_name_to_id: HashMap<(String, String), String>, agent_pod_to_id: HashMap, } @@ -29,9 +29,10 @@ impl SandboxIndex { let mut inner = self.inner.write().expect("sandbox index lock poisoned"); if let Some(metadata) = &sandbox.metadata { if !metadata.name.is_empty() { - inner - .sandbox_name_to_id - .insert(metadata.name.clone(), metadata.id.clone()); + inner.sandbox_name_to_id.insert( + (metadata.workspace.clone(), metadata.name.clone()), + metadata.id.clone(), + ); } if let Some(status) = sandbox.status.as_ref() @@ -51,9 +52,12 @@ impl SandboxIndex { } #[must_use] - pub fn sandbox_id_for_sandbox_name(&self, name: &str) -> Option { + pub fn sandbox_id_for_sandbox_name(&self, workspace: &str, name: &str) -> Option { let inner = self.inner.read().expect("sandbox index lock poisoned"); - inner.sandbox_name_to_id.get(name).cloned() + inner + .sandbox_name_to_id + .get(&(workspace.to_string(), name.to_string())) + .cloned() } #[must_use] diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 1dabb77442..c3b4375b4f 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,50 @@ 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()?; Some(if service.is_empty() { - format!("{sandbox}.{base_domain}") + format!("{workspace}--{sandbox}.{base_domain}") } else { - format!("{sandbox}--{service}.{base_domain}") + format!("{workspace}--{sandbox}--{service}.{base_domain}") }) } -pub fn parse_host(host: &str, config: &ServiceRoutingConfig) -> Option<(String, String)> { +// 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 { 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("--") { + 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; } (sandbox, service) } else { - (encoded, "") + (rest, "") }; if sandbox.is_empty() || sandbox.contains("--") { return None; } - return Some((sandbox.to_string(), service.to_string())); + return Some(( + workspace.to_string(), + sandbox.to_string(), + service.to_string(), + )); } None } @@ -116,11 +133,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 +228,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 +430,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 +594,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( @@ -805,6 +829,8 @@ mod tests { labels: std::collections::HashMap::default(), resource_version: 0, annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), 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,43 @@ 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 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() @@ -1101,4 +1198,37 @@ 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, + annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + 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..490f0dbb25 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())?; @@ -87,6 +87,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), 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..a839791ad4 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -842,6 +842,8 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, }), ..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-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index 3cbc315026..53c038dec1 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -88,6 +88,7 @@ pub struct PolicyLocalContext { gateway_endpoint: Option, sandbox_name: Option, shorthand_log_dir: PathBuf, + workspace_rx: tokio::sync::watch::Receiver, } impl PolicyLocalContext { @@ -95,12 +96,14 @@ impl PolicyLocalContext { current_policy: Option, gateway_endpoint: Option, sandbox_name: Option, + workspace_rx: tokio::sync::watch::Receiver, ) -> Self { Self::with_log_dir( current_policy, gateway_endpoint, sandbox_name, PathBuf::from(LOG_DIR), + workspace_rx, ) } @@ -109,12 +112,14 @@ impl PolicyLocalContext { gateway_endpoint: Option, sandbox_name: Option, shorthand_log_dir: PathBuf, + workspace_rx: tokio::sync::watch::Receiver, ) -> Self { Self { current_policy: Arc::new(RwLock::new(current_policy)), gateway_endpoint, sandbox_name, shorthand_log_dir, + workspace_rx, } } @@ -484,13 +489,27 @@ async fn submit_proposal(ctx: &PolicyLocalContext, body: &[u8]) -> (u16, serde_j ); }; + let workspace = ctx.workspace_rx.borrow().clone(); + if workspace.is_empty() { + return ( + 503, + serde_json::json!({ + "error": "workspace_unavailable", + "detail": "sandbox workspace has not been discovered yet; retry shortly" + }), + ); + } + let chunks = match proposal_chunks_from_body(body) { Ok(chunks) => chunks, Err(error) => return (400, error_payload("invalid_proposal", error)), }; let client = match openshell_core::grpc_client::CachedOpenShellClient::connect(endpoint).await { - Ok(client) => client, + Ok(client) => { + client.set_workspace(workspace); + client + } Err(error) => { return ( 502, @@ -914,9 +933,20 @@ async fn open_lookup_session( ), ) })?; + let workspace = ctx.workspace_rx.borrow().clone(); + if workspace.is_empty() { + return Err(( + 503, + error_payload( + "workspace_unavailable", + "sandbox workspace has not been discovered yet; retry shortly".to_string(), + ), + )); + } let client = openshell_core::grpc_client::CachedOpenShellClient::connect(endpoint) .await .map_err(|e| (502, error_payload("gateway_connect_failed", e.to_string())))?; + client.set_workspace(workspace); Ok(LookupSession { client, sandbox_name, @@ -1326,6 +1356,10 @@ struct L7DenyRuleJson { mod tests { use super::*; + fn test_workspace_rx() -> tokio::sync::watch::Receiver { + tokio::sync::watch::channel(String::new()).1 + } + #[test] fn proposal_chunks_from_body_accepts_add_rule_operation() { let body = br#"{ @@ -1467,7 +1501,13 @@ mod tests { "; std::fs::write(&log_path, body).unwrap(); - let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf()); + let ctx = PolicyLocalContext::with_log_dir( + None, + None, + None, + dir.path().to_path_buf(), + test_workspace_rx(), + ); let (status, payload) = recent_denials_response(&ctx, "last=10").await; assert_eq!(status, 200); assert_eq!(payload["log_available"], true); @@ -1498,7 +1538,13 @@ mod tests { ) .unwrap(); - let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf()); + let ctx = PolicyLocalContext::with_log_dir( + None, + None, + None, + dir.path().to_path_buf(), + test_workspace_rx(), + ); let (status, payload) = recent_denials_response(&ctx, "").await; assert_eq!(status, 200); assert_eq!(payload["log_available"], false); @@ -1508,7 +1554,13 @@ mod tests { #[tokio::test] async fn recent_denials_signals_when_log_is_missing() { let dir = tempfile::tempdir().unwrap(); - let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf()); + let ctx = PolicyLocalContext::with_log_dir( + None, + None, + None, + dir.path().to_path_buf(), + test_workspace_rx(), + ); let (status, payload) = recent_denials_response(&ctx, "").await; assert_eq!(status, 200); assert_eq!(payload["log_available"], false); @@ -1571,7 +1623,13 @@ mod tests { ); std::fs::write(&log_path, line).unwrap(); - let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf()); + let ctx = PolicyLocalContext::with_log_dir( + None, + None, + None, + dir.path().to_path_buf(), + test_workspace_rx(), + ); let (_, payload) = recent_denials_response(&ctx, "last=1").await; let denials = payload["denials"].as_array().unwrap(); assert_eq!(denials.len(), 1); @@ -1633,6 +1691,7 @@ mod tests { }), None, None, + test_workspace_rx(), ); // Even the otherwise-public `current_policy` route returns 404 with @@ -1660,6 +1719,7 @@ mod tests { }), None, None, + test_workspace_rx(), ); let (mut client, mut server) = tokio::io::duplex(4096); @@ -1753,7 +1813,7 @@ mod tests { #[tokio::test] async fn proposal_routes_reject_malformed_paths() { let _guard = ProposalsFlagGuard::set(true).await; - let ctx = PolicyLocalContext::new(None, None, None); + let ctx = PolicyLocalContext::new(None, None, None, test_workspace_rx()); // Empty chunk_id after the prefix is 404, not a wildcard list. let (status, _) = route_request(&ctx, "GET", "/v1/proposals/", &[]).await; @@ -1774,7 +1834,12 @@ mod tests { #[tokio::test] async fn proposal_status_route_returns_503_when_no_gateway() { let _guard = ProposalsFlagGuard::set(true).await; - let ctx = PolicyLocalContext::new(None, None, Some("test-sandbox".to_string())); + let ctx = PolicyLocalContext::new( + None, + None, + Some("test-sandbox".to_string()), + test_workspace_rx(), + ); let (status, body) = route_request(&ctx, "GET", "/v1/proposals/chunk-id", &[]).await; assert_eq!(status, 503); @@ -1784,7 +1849,12 @@ mod tests { #[tokio::test] async fn proposal_wait_route_returns_503_when_no_gateway() { let _guard = ProposalsFlagGuard::set(true).await; - let ctx = PolicyLocalContext::new(None, None, Some("test-sandbox".to_string())); + let ctx = PolicyLocalContext::new( + None, + None, + Some("test-sandbox".to_string()), + test_workspace_rx(), + ); let (status, body) = route_request(&ctx, "GET", "/v1/proposals/chunk-id/wait?timeout=1", &[]).await; @@ -1795,7 +1865,12 @@ mod tests { #[tokio::test] async fn proposal_routes_return_feature_disabled_when_flag_off() { let _guard = ProposalsFlagGuard::set(false).await; - let ctx = PolicyLocalContext::new(None, None, Some("test-sandbox".to_string())); + let ctx = PolicyLocalContext::new( + None, + None, + Some("test-sandbox".to_string()), + test_workspace_rx(), + ); let (status, body) = route_request(&ctx, "GET", "/v1/proposals/abc", &[]).await; assert_eq!(status, 404); @@ -1871,7 +1946,12 @@ mod tests { // whole-policy diff would never see another change and burn the // full timeout. Rule-coverage must return immediately. let proposed = proposed_curl_rule_for_github(); - let ctx = PolicyLocalContext::new(Some(policy_with_rule(proposed.clone())), None, None); + let ctx = PolicyLocalContext::new( + Some(policy_with_rule(proposed.clone())), + None, + None, + test_workspace_rx(), + ); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); let start = tokio::time::Instant::now(); @@ -1897,7 +1977,7 @@ mod tests { version: 1, ..Default::default() }; - let ctx = PolicyLocalContext::new(Some(initial), None, None); + let ctx = PolicyLocalContext::new(Some(initial), None, None, test_workspace_rx()); // Concurrently, an unrelated rule lands. We must not return. let unrelated_load = { @@ -1948,6 +2028,7 @@ mod tests { }), None, None, + test_workspace_rx(), ); let matching_load = { @@ -1985,6 +2066,7 @@ mod tests { }), None, None, + test_workspace_rx(), ); let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(300); let start = tokio::time::Instant::now(); diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index ab2313b890..e7609811ff 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -1673,6 +1673,12 @@ fn resolve_owner_identity( #[cfg(target_os = "linux")] fn collect_ancestor_identities(start_pid: u32, stop_pid: u32) -> Vec<(u32, PathBuf)> { const MAX_DEPTH: usize = 64; + + // When the socket owner IS the entrypoint, there are no intermediate + // ancestors to verify — the chain is empty by definition. + if start_pid == stop_pid { + return vec![]; + } let mut ancestors = Vec::new(); let mut current = start_pid; diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index d68db6f1c3..9284a55ca1 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -83,9 +83,10 @@ pub async fn run_networking( sandbox_id: Option<&str>, sandbox_name: Option<&str>, openshell_endpoint: Option<&str>, - inference_routes: Option<&str>, + #[allow(unused_variables)] inference_routes: Option<&str>, denial_tx: Option>, activity_tx: Option, + workspace_rx: tokio::sync::watch::Receiver, ) -> Result { // Build the policy-local route context. The orchestrator's policy poll // loop also holds an `Arc` clone (via `Networking::policy_local_ctx`) so @@ -96,6 +97,7 @@ pub async fn run_networking( sandbox_name .map(str::to_string) .or_else(|| sandbox_id.map(str::to_string)), + workspace_rx, )); // Readiness signal for the proxy accept loop: the proxy binds the TCP diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index e5ef60c95a..6db3ac14f2 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -318,7 +318,8 @@ pub struct CreateSandboxForm { /// When the create animation started (for pacman timing). pub anim_start: Option, /// Buffered create result — held until min display time elapses. - pub create_result: Option>, + /// `Ok((name, workspace))` or `Err(message)`. + pub create_result: Option>, } // --------------------------------------------------------------------------- @@ -529,12 +530,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, @@ -577,6 +585,7 @@ pub struct App { 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, @@ -862,6 +871,7 @@ impl App { client: OpenShellClient>, gateway_name: String, endpoint: String, + workspace: String, theme: crate::theme::Theme, ) -> Self { Self { @@ -890,11 +900,16 @@ impl App { confirm_setting_delete: None, pending_setting_set: false, pending_setting_delete: false, + current_workspace: workspace, + 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, @@ -914,6 +929,7 @@ impl App { 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, @@ -1064,6 +1080,43 @@ 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; + self.current_workspace = "default".to_string(); + } 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.current_workspace = "default".to_string(); + } + } + } + // 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; + } + + 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; @@ -1280,7 +1333,12 @@ impl App { } } KeyCode::Char('c') if !self.providers_v2_enabled => { - self.open_create_provider_form(); + if self.all_workspaces { + self.status_text = + "Switch to a specific workspace to create providers.".to_string(); + } else { + self.open_create_provider_form(); + } } // Fetch and show provider detail. KeyCode::Enter if self.provider_count > 0 => { @@ -1459,6 +1517,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_count - 1 { self.sandbox_selected += 1; @@ -1474,7 +1535,12 @@ impl App { } } KeyCode::Char('c') => { - self.open_create_form(); + if self.all_workspaces { + self.status_text = + "Switch to a specific workspace to create sandboxes.".to_string(); + } else { + self.open_create_form(); + } } KeyCode::Enter if self.sandbox_count > 0 => { self.screen = Screen::Sandbox; @@ -2626,6 +2692,25 @@ 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 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 @@ -2640,7 +2725,7 @@ impl App { let profile = self .provider_entries .iter() - .find(|entry| provider_name(&entry.provider) == provider_name(provider)) + .find(|entry| provider_id(&entry.provider) == provider_id(provider)) .and_then(|entry| entry.profile.as_ref()); let mut credential_keys = provider.credentials.keys().cloned().collect::>(); @@ -2875,6 +2960,7 @@ impl App { self.sandbox_labels.clear(); self.sandbox_annotations.clear(); self.sandbox_policy_versions.clear(); + self.sandbox_workspaces.clear(); self.sandbox_selected = 0; self.sandbox_count = 0; self.sandbox_log_lines.clear(); @@ -2894,6 +2980,7 @@ impl App { self.provider_names.clear(); self.provider_types.clear(); self.provider_cred_keys.clear(); + self.provider_workspaces.clear(); self.provider_selected = 0; self.provider_count = 0; self.confirm_provider_delete = false; @@ -3040,4 +3127,48 @@ 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"); + } + + // -- 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/event.rs b/crates/openshell-tui/src/event.rs index daa425bfbd..151589e8c5 100644 --- a/crates/openshell-tui/src/event.rs +++ b/crates/openshell-tui/src/event.rs @@ -20,8 +20,8 @@ pub enum Event { Resize(u16, u16), /// A batch of log lines from the streaming log task. LogLines(Vec), - /// Result of a create sandbox request: `Ok(name)` or `Err(message)`. - CreateResult(Result), + /// Result of a create sandbox request: `Ok((name, workspace))` or `Err(message)`. + CreateResult(Result<(String, String), String>), /// Result of creating a provider on the gateway: `Ok(name)` or `Err(message)`. ProviderCreateResult(Result), /// Provider detail fetched from gateway. diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index bdca8932b8..0d9d9fe613 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; @@ -47,6 +47,7 @@ pub async fn run( interceptor: EdgeAuthInterceptor, gateway_name: &str, endpoint: &str, + workspace: &str, theme_mode: ThemeMode, ) -> Result<()> { // Detect theme *before* entering raw/alternate-screen mode. @@ -59,6 +60,7 @@ pub async fn run( client, gateway_name.to_string(), endpoint.to_string(), + workspace.to_string(), detected_theme, ); @@ -158,6 +160,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); @@ -343,7 +350,7 @@ pub async fn run( h.abort(); } match result { - Some(Ok(name)) => { + Some(Ok((name, create_workspace))) => { app.create_form = None; let ports = std::mem::take(&mut app.pending_forward_ports); let command = std::mem::take(&mut app.pending_exec_command); @@ -368,6 +375,7 @@ pub async fn run( &mut events, &name, &command, + &create_workspace, ) .await?; } @@ -625,6 +633,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 +643,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 +744,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 +779,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 +865,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)) => { @@ -1001,11 +1016,13 @@ async fn handle_exec_command( events: &mut EventHandler, sandbox_name: &str, command: &str, + workspace: &str, ) -> Result<()> { // Step 1: Resolve sandbox → SSH session (same as handle_shell_connect). let sandbox_id = { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.to_string(), + workspace: workspace.to_string(), }; match tokio::time::timeout(Duration::from_secs(5), app.client.get_sandbox(req)).await { Ok(Ok(resp)) => { @@ -1347,6 +1364,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(); @@ -1380,6 +1398,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { }), labels: HashMap::new(), annotations: HashMap::new(), + workspace: workspace.clone(), }; let sandbox_name = @@ -1420,6 +1439,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 @@ -1454,7 +1474,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { } } - let _ = tx.send(Event::CreateResult(Ok(sandbox_name))); + let _ = tx.send(Event::CreateResult(Ok((sandbox_name, workspace)))); }); } @@ -1626,6 +1646,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. @@ -1645,12 +1666,16 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: workspace.clone(), + deletion_timestamp_ms: 0, }), r#type: ptype.clone(), credentials: credentials.clone(), config: HashMap::default(), credential_expires_at_ms: HashMap::default(), + profile_workspace: workspace.clone(), }), + workspace: workspace.clone(), }; match client.create_provider(req).await { @@ -1688,9 +1713,10 @@ fn spawn_get_provider(app: &App, tx: mpsc::UnboundedSender) { Some(n) => n.to_string(), None => return, }; + let workspace = app.selected_provider_workspace(); 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 +1750,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.selected_provider_workspace(); tokio::spawn(async move { let mut credentials = HashMap::new(); @@ -1738,13 +1765,17 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), + workspace: workspace.clone(), + deletion_timestamp_ms: 0, }), r#type: ptype, credentials, config: HashMap::default(), credential_expires_at_ms: HashMap::default(), + profile_workspace: String::new(), }), credential_expires_at_ms: HashMap::default(), + workspace, }; match tokio::time::timeout(Duration::from_secs(5), client.update_provider(req)).await { @@ -1770,9 +1801,10 @@ fn spawn_delete_provider(app: &App, tx: mpsc::UnboundedSender) { Some(n) => n.to_string(), None => return, }; + let workspace = app.selected_provider_workspace(); 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 +1841,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 +1885,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 +1928,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,87 +1976,124 @@ 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_providers(app: &mut App) { - let profiles = if app.providers_v2_enabled { - let req = openshell_core::proto::ListProviderProfilesRequest { - limit: 100, - offset: 0, - }; - match tokio::time::timeout( - Duration::from_secs(5), - app.client.list_provider_profiles(req), - ) - .await - { - Ok(Ok(resp)) => resp +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() - .profiles + .workspaces .into_iter() - .map(|profile| (profile.id.clone(), profile)) - .collect::>(), - Ok(Err(e)) => { - app.status_text = format!("failed to list provider profiles: {}", e.message()); - HashMap::new() - } - Err(_) => { - app.status_text = "list provider profiles timed out".to_string(); - HashMap::new() - } + .filter_map(|w| w.metadata.map(|m| m.name)) + .collect(); } - } else { - HashMap::new() - }; + Ok(Err(e)) => { + app.status_text = format!("failed to list workspaces: {}", e.message()); + } + Err(_) => { + app.status_text = "list workspaces timed out".to_string(); + } + } +} +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 { - Ok(Err(e)) => { - app.status_text = format!("failed to list providers: {}", e.message()); - } - Err(_) => { - app.status_text = "list providers timed out".to_string(); - } - Ok(Ok(resp)) => { - let providers = resp.into_inner().providers; - app.provider_count = providers.len(); - app.provider_entries = if app.providers_v2_enabled { - providers - .iter() - .cloned() - .map(|provider| app::ProviderV2Entry { - profile: profiles.get(&provider.r#type).cloned(), - provider, - }) - .collect() - } else { - Vec::new() - }; - app.provider_names = providers - .iter() - .map(|p| app::provider_name(p).to_string()) - .collect(); - app.provider_types = providers.iter().map(|p| p.r#type.clone()).collect(); - app.provider_cred_keys = providers + let providers = + match tokio::time::timeout(Duration::from_secs(5), app.client.list_providers(req)).await { + Ok(Ok(resp)) => resp.into_inner().providers, + Ok(Err(e)) => { + app.status_text = format!("failed to list providers: {}", e.message()); + return; + } + Err(_) => { + app.status_text = "list providers timed out".to_string(); + return; + } + }; + + let profiles: HashMap<(String, String), openshell_core::proto::ProviderProfile> = + if app.providers_v2_enabled { + let workspaces: std::collections::HashSet = providers .iter() - .map(|p| { - p.credentials - .keys() - .next() - .cloned() - .unwrap_or_else(|| "-".to_string()) - }) + .map(|p| p.profile_workspace.clone()) .collect(); - if app.provider_selected >= app.provider_count && app.provider_count > 0 { - app.provider_selected = app.provider_count - 1; + let mut all_profiles = HashMap::new(); + for ws in &workspaces { + let req = openshell_core::proto::ListProviderProfilesRequest { + limit: 100, + offset: 0, + workspace: ws.clone(), + }; + if let Ok(Ok(resp)) = tokio::time::timeout( + Duration::from_secs(5), + app.client.list_provider_profiles(req), + ) + .await + { + for profile in resp.into_inner().profiles { + all_profiles.insert((ws.clone(), profile.id.clone()), profile); + } + } } - } + all_profiles + } else { + HashMap::new() + }; + + app.provider_count = providers.len(); + app.provider_entries = if app.providers_v2_enabled { + providers + .iter() + .cloned() + .map(|provider| app::ProviderV2Entry { + profile: profiles + .get(&(provider.profile_workspace.clone(), provider.r#type.clone())) + .cloned(), + provider, + }) + .collect() + } else { + Vec::new() + }; + app.provider_names = providers + .iter() + .map(|p| app::provider_name(p).to_string()) + .collect(); + app.provider_types = providers.iter().map(|p| p.r#type.clone()).collect(); + app.provider_cred_keys = providers + .iter() + .map(|p| { + p.credentials + .keys() + .next() + .cloned() + .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 +2120,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,6 +2189,7 @@ fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { setting_key: key, setting_value: Some(SettingValue { value: Some(value) }), global: true, + workspace: String::new(), ..Default::default() }; @@ -2143,6 +2223,7 @@ fn spawn_delete_global_setting(app: &App, tx: mpsc::UnboundedSender) { setting_key: key, delete_setting: true, global: true, + workspace: String::new(), ..Default::default() }; @@ -2175,6 +2256,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,6 +2291,7 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { name, setting_key: key, setting_value: Some(SettingValue { value: Some(value) }), + workspace, ..Default::default() }; @@ -2237,6 +2320,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,6 +2329,7 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { name, setting_key: key, delete_setting: true, + workspace, ..Default::default() }; @@ -2288,6 +2373,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 { @@ -2374,6 +2465,11 @@ async fn refresh_sandboxes(app: &mut App) { }) .collect(); + app.sandbox_workspaces = sandboxes + .iter() + .map(|s| s.object_workspace().to_string()) + .collect(); + if app.sandbox_selected >= app.sandbox_count && app.sandbox_count > 0 { app.sandbox_selected = app.sandbox_count - 1; } @@ -2431,6 +2527,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 +2548,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/create_sandbox.rs b/crates/openshell-tui/src/ui/create_sandbox.rs index 154a94e7e8..5628dffc53 100644 --- a/crates/openshell-tui/src/ui/create_sandbox.rs +++ b/crates/openshell-tui/src/ui/create_sandbox.rs @@ -282,7 +282,7 @@ fn draw_creating(frame: &mut Frame<'_>, app: &App, area: Rect) { // Header — changes once result arrives. let (header, header_style) = match &form.create_result { - Some(Ok(name)) => (format!("Created sandbox: {name}"), t.status_ok), + Some(Ok((name, _workspace))) => (format!("Created sandbox: {name}"), t.status_ok), Some(Err(msg)) => (format!("Failed: {msg}"), t.status_err), None => ("Creating sandbox...".to_string(), t.text), }; 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/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 86d4053978..05418f8f7d 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -229,10 +229,10 @@ The Python SDK accepts the same gateway labels and selectors. Labels passed to from openshell import SandboxClient with SandboxClient.from_active_cluster() as client: - sandbox = client.create(name="deep-research-1", labels={"env": "dev", "team": "platform"}) + sandbox = client.create(workspace="default", name="deep-research-1", labels={"env": "dev", "team": "platform"}) assert sandbox.labels["team"] == "platform" - matches = client.list(label_selector="env=dev,team=platform") + matches = client.list(workspace="default", label_selector="env=dev,team=platform") assert sandbox.id in {s.id for s in matches} ``` diff --git a/e2e/mcp-conformance.sh b/e2e/mcp-conformance.sh index 409b5b9f8f..87b1f22e9a 100755 --- a/e2e/mcp-conformance.sh +++ b/e2e/mcp-conformance.sh @@ -320,7 +320,7 @@ create_client_sandbox() { fi local sandbox_name policy_file openshell - sandbox_name="openshell-mcp-client-$$" + sandbox_name="mcp-client-$$" policy_file="$(mktemp "${TMPDIR:-/tmp}/openshell-mcp-conformance-base-policy.XXXXXX.yaml")" openshell="$(openshell_bin)" diff --git a/e2e/policy-advisor/mechanistic-smoke.sh b/e2e/policy-advisor/mechanistic-smoke.sh index fad80166a5..d4f3601286 100755 --- a/e2e/policy-advisor/mechanistic-smoke.sh +++ b/e2e/policy-advisor/mechanistic-smoke.sh @@ -28,7 +28,7 @@ if [[ -z "${OPENSHELL_BIN:-}" ]]; then fi RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}" -SANDBOX="${SANDBOX:-mechanistic-smoke-${RUN_ID}}" +SANDBOX="${SANDBOX:-ms-${RUN_ID}}" KEEP_SANDBOX="${KEEP_SANDBOX:-0}" # Allow override so CI can set a shorter interval via OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS. FLUSH_WAIT="${FLUSH_WAIT:-15}" diff --git a/e2e/policy-advisor/test.sh b/e2e/policy-advisor/test.sh index 511b244247..431079020c 100755 --- a/e2e/policy-advisor/test.sh +++ b/e2e/policy-advisor/test.sh @@ -22,7 +22,7 @@ DEMO_BRANCH="${DEMO_BRANCH:-main}" DEMO_RUN_ID="${DEMO_RUN_ID:-$(date +%Y%m%d-%H%M%S)}" DEMO_FILE_DIR="${DEMO_FILE_DIR:-openshell-policy-advisor-validation}" DEMO_FILE_PATH="${DEMO_FILE_PATH:-${DEMO_FILE_DIR}/${DEMO_RUN_ID}.md}" -DEMO_SANDBOX_NAME="${DEMO_SANDBOX_NAME:-policy-agent-validation-${DEMO_RUN_ID}}" +DEMO_SANDBOX_NAME="${DEMO_SANDBOX_NAME:-pav-${DEMO_RUN_ID}}" DEMO_GITHUB_PROVIDER_NAME="${DEMO_GITHUB_PROVIDER_NAME:-github-policy-validation-${DEMO_RUN_ID}}" DEMO_KEEP_SANDBOX="${DEMO_KEEP_SANDBOX:-0}" DEMO_RETRY_ATTEMPTS="${DEMO_RETRY_ATTEMPTS:-30}" diff --git a/e2e/policy-advisor/wait-smoke.sh b/e2e/policy-advisor/wait-smoke.sh index 71c1e903d7..a1f2bcf730 100755 --- a/e2e/policy-advisor/wait-smoke.sh +++ b/e2e/policy-advisor/wait-smoke.sh @@ -57,7 +57,7 @@ if [[ -z "${OPENSHELL_BIN:-}" ]]; then fi RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}" -SANDBOX="${SANDBOX:-policy-wait-smoke-${RUN_ID}}" +SANDBOX="${SANDBOX:-pws-${RUN_ID}}" KEEP_SANDBOX="${KEEP_SANDBOX:-0}" WAIT_TIMEOUT="${WAIT_TIMEOUT:-30}" diff --git a/e2e/python/conftest.py b/e2e/python/conftest.py index 07b7feb34b..136a182d3d 100644 --- a/e2e/python/conftest.py +++ b/e2e/python/conftest.py @@ -10,7 +10,7 @@ import grpc import pytest -from openshell import InferenceRouteClient, Sandbox, SandboxClient +from openshell import InferenceRouteClient, Sandbox, SandboxClient, WorkspaceClient if TYPE_CHECKING: from collections.abc import Callable, Iterator @@ -31,7 +31,7 @@ def sandbox_client(cluster_name: str | None) -> Iterator[SandboxClient]: def ensure_sandbox_persistence_ready(sandbox_client: SandboxClient) -> None: for _ in range(60): try: - sandbox_client.list_ids(limit=1) + sandbox_client.list_ids(workspace="default", limit=1) return except grpc.RpcError as exc: details = exc.details() or "" @@ -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, @@ -72,6 +73,11 @@ def inference_client(sandbox_client: SandboxClient) -> InferenceRouteClient: return InferenceRouteClient.from_sandbox_client(sandbox_client) +@pytest.fixture(scope="session") +def workspace_client(sandbox_client: SandboxClient) -> WorkspaceClient: + return WorkspaceClient.from_sandbox_client(sandbox_client) + + @pytest.fixture(scope="session") def _worker_suffix(worker_id: str) -> str: """Return a suffix for worker-unique resource names. 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/e2e/python/test_sandbox_api.py b/e2e/python/test_sandbox_api.py index 929728d7c3..f10c6a1bf1 100644 --- a/e2e/python/test_sandbox_api.py +++ b/e2e/python/test_sandbox_api.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from openshell import Sandbox, SandboxClient + from openshell import Sandbox, SandboxClient, WorkspaceClient def test_sandbox_api_crud_and_exec( @@ -36,10 +36,10 @@ 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)) + ids = set(sandbox_client.list_ids(workspace="default", limit=100)) assert sb.id in ids result = sb.exec(["python", "-c", "print('sandbox-ok')"]) @@ -60,56 +60,109 @@ def read(self, path: str) -> str: assert verify_file.stdout.strip() == "ok" +def test_list_scoped_and_for_all_workspaces( + sandbox_client: SandboxClient, + workspace_client: "WorkspaceClient", +) -> None: + import contextlib + import uuid + + suffix = uuid.uuid4().hex[:8] + other_ws = f"list-ws-{suffix}" + created_default: list[str] = [] + created_other: list[str] = [] + + try: + workspace_client.create(other_ws) + + ref_default = sandbox_client.create( + workspace="default", name=f"ls-def-{suffix}" + ) + created_default.append(ref_default.name) + + ref_other = sandbox_client.create( + workspace=other_ws, name=f"ls-oth-{suffix}" + ) + created_other.append(ref_other.name) + + default_ids = set(sandbox_client.list_ids(workspace="default")) + assert ref_default.id in default_ids + assert ref_other.id not in default_ids + + other_ids = set(sandbox_client.list_ids(workspace=other_ws)) + assert ref_other.id in other_ids + assert ref_default.id not in other_ids + + all_ids = set(sandbox_client.list_ids_for_all_workspaces()) + assert ref_default.id in all_ids + assert ref_other.id in all_ids + finally: + for name in created_default: + with contextlib.suppress(Exception): + sandbox_client.delete(name, workspace="default") + sandbox_client.wait_deleted(name, workspace="default") + for name in created_other: + with contextlib.suppress(Exception): + sandbox_client.delete(name, workspace=other_ws) + sandbox_client.wait_deleted(name, workspace=other_ws) + with contextlib.suppress(Exception): + workspace_client.delete(other_ws) + + def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: import contextlib import uuid suffix = uuid.uuid4().hex[:8] - job_a = f"aiq-labels-a-{suffix}" - job_b = f"aiq-labels-b-{suffix}" + job_a = f"lbl-a-{suffix}" + job_b = f"lbl-b-{suffix}" group_selector = f"aiq-test={suffix}" primary_selector = f"aiq-test={suffix},role=primary" 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 { - s.name for s in sandbox_client.list(label_selector=primary_selector) + s.name for s in sandbox_client.list(workspace="default", label_selector=primary_selector) } == {job_a} # The shared group label returns both. - assert {s.name for s in sandbox_client.list(label_selector=group_selector)} == { + assert {s.name for s in sandbox_client.list(workspace="default", label_selector=group_selector)} == { job_a, job_b, } # 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)} == { + assert {s.name for s in sandbox_client.list(workspace="default", 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) + assert not sandbox_client.list(workspace="default", 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..13911a1426 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator - from openshell import Sandbox, SandboxClient + from openshell import Sandbox, SandboxClient, WorkspaceClient # --------------------------------------------------------------------------- @@ -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,145 @@ 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() + + +def test_cross_workspace_profile_ids_do_not_collide( + sandbox_client: "SandboxClient", + workspace_client: "WorkspaceClient", +) -> None: + """Same profile ID in two workspaces must not cause a catalog collision.""" + import contextlib + import uuid + + stub = sandbox_client._stub + profile_id = f"e2e-xws-{uuid.uuid4().hex[:8]}" + ws_a = f"ws-a-{uuid.uuid4().hex[:8]}" + ws_b = f"ws-b-{uuid.uuid4().hex[:8]}" + + def _make_profile() -> 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", + ) + + workspace_client.create(ws_a) + workspace_client.create(ws_b) + try: + resp_a = stub.ImportProviderProfiles( + openshell_pb2.ImportProviderProfilesRequest( + profiles=[_make_profile()], + workspace=ws_a, + ) + ) + assert resp_a.imported, "import into ws-a should succeed" + + resp_b = stub.ImportProviderProfiles( + openshell_pb2.ImportProviderProfilesRequest( + profiles=[_make_profile()], + workspace=ws_b, + ) + ) + assert resp_b.imported, "import into ws-b should succeed" + + list_a = stub.ListProviderProfiles( + openshell_pb2.ListProviderProfilesRequest(limit=200, workspace=ws_a) + ) + assert any(p.id == profile_id for p in list_a.profiles), ( + "profile should appear in ws-a" + ) + + list_b = stub.ListProviderProfiles( + openshell_pb2.ListProviderProfilesRequest(limit=200, workspace=ws_b) + ) + assert any(p.id == profile_id for p in list_b.profiles), ( + "profile should appear in ws-b" + ) + finally: + for ws in [ws_a, ws_b]: + with contextlib.suppress(Exception): + stub.DeleteProviderProfile( + openshell_pb2.DeleteProviderProfileRequest( + id=profile_id, workspace=ws + ) + ) + with contextlib.suppress(Exception): + workspace_client.delete(ws) diff --git a/e2e/python/test_workspace_api.py b/e2e/python/test_workspace_api.py new file mode 100644 index 0000000000..01be5c341e --- /dev/null +++ b/e2e/python/test_workspace_api.py @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import contextlib +import uuid +from typing import TYPE_CHECKING + +import grpc +import pytest + +if TYPE_CHECKING: + from openshell import WorkspaceClient + + +def test_workspace_crud(workspace_client: WorkspaceClient) -> None: + name = f"ws-crud-{uuid.uuid4().hex[:8]}" + + try: + ws = workspace_client.create(name) + assert ws.name == name + assert ws.phase == "WORKSPACE_PHASE_ACTIVE" + + fetched = workspace_client.get(name) + assert fetched.name == name + assert fetched.phase == "WORKSPACE_PHASE_ACTIVE" + finally: + with contextlib.suppress(Exception): + workspace_client.delete(name) + + +def test_workspace_create_with_labels(workspace_client: WorkspaceClient) -> None: + name = f"ws-lbl-{uuid.uuid4().hex[:8]}" + + try: + ws = workspace_client.create(name, labels={"env": "test", "team": "infra"}) + assert ws.labels["env"] == "test" + assert ws.labels["team"] == "infra" + + fetched = workspace_client.get(name) + assert fetched.labels["env"] == "test" + assert fetched.labels["team"] == "infra" + finally: + with contextlib.suppress(Exception): + workspace_client.delete(name) + + +def test_workspace_list_includes_created(workspace_client: WorkspaceClient) -> None: + name = f"ws-list-{uuid.uuid4().hex[:8]}" + + try: + workspace_client.create(name) + + names = {ws.name for ws in workspace_client.list()} + assert name in names + assert "default" in names + finally: + with contextlib.suppress(Exception): + workspace_client.delete(name) + + +def test_workspace_delete_nonexistent_raises_not_found( + workspace_client: WorkspaceClient, +) -> None: + with pytest.raises(grpc.RpcError) as exc_info: + workspace_client.delete(f"no-such-ws-{uuid.uuid4().hex[:8]}") + assert exc_info.value.code() == grpc.StatusCode.NOT_FOUND + + +def test_workspace_get_nonexistent_raises_not_found( + workspace_client: WorkspaceClient, +) -> None: + with pytest.raises(grpc.RpcError) as exc_info: + workspace_client.get(f"no-such-ws-{uuid.uuid4().hex[:8]}") + assert exc_info.value.code() == grpc.StatusCode.NOT_FOUND diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 6e8fa70bdf..e4d99dc45c 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/src/harness/sandbox.rs b/e2e/rust/src/harness/sandbox.rs index de3a69c198..0aeb25038c 100644 --- a/e2e/rust/src/harness/sandbox.rs +++ b/e2e/rust/src/harness/sandbox.rs @@ -7,6 +7,7 @@ //! is dropped, replacing the `trap cleanup EXIT` pattern from the bash tests. use std::process::Stdio; +use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::io::{AsyncBufReadExt, BufReader}; @@ -153,6 +154,19 @@ impl SandboxGuard { let stdout = child.stdout.take().expect("stdout must be piped"); let mut reader = BufReader::new(stdout).lines(); + let stderr_handle = child.stderr.take().expect("stderr must be piped"); + let stderr_buf = Arc::new(Mutex::new(String::new())); + let stderr_buf_clone = Arc::clone(&stderr_buf); + let stderr_task = tokio::spawn(async move { + let mut reader = BufReader::new(stderr_handle).lines(); + while let Ok(Some(line)) = reader.next_line().await { + let clean = strip_ansi(&line); + let mut buf = stderr_buf_clone.lock().unwrap(); + buf.push_str(&clean); + buf.push('\n'); + } + }); + let mut accumulated = String::new(); let mut name: Option = None; let mut ready = false; @@ -179,26 +193,38 @@ impl SandboxGuard { }) .await; + let collect_stderr = || { + stderr_task.abort(); + let buf = stderr_buf.lock().unwrap(); + buf.clone() + }; + if poll_result.is_err() { // Timeout — kill the child and report. let _ = child.kill().await; + let stderr_output = collect_stderr(); return Err(format!( "sandbox did not become ready within {SANDBOX_READY_TIMEOUT:?}.\n\ - Output so far:\n{accumulated}" + Stdout:\n{accumulated}\nStderr:\n{stderr_output}" )); } if !ready { // The line reader ended before seeing the marker (process exited). let _ = child.kill().await; + let stderr_output = collect_stderr(); return Err(format!( "sandbox create exited before ready marker '{ready_marker}' was seen.\n\ - Output:\n{accumulated}" + Stdout:\n{accumulated}\nStderr:\n{stderr_output}" )); } let sandbox_name = name.ok_or_else(|| { - format!("could not parse sandbox name from create output:\n{accumulated}") + let stderr_output = collect_stderr(); + format!( + "could not parse sandbox name from create output:\n\ + Stdout:\n{accumulated}\nStderr:\n{stderr_output}" + ) })?; Ok(Self { diff --git a/e2e/rust/tests/driver_config_volume.rs b/e2e/rust/tests/driver_config_volume.rs index 51e56b97c8..ad8cffc2f9 100644 --- a/e2e/rust/tests/driver_config_volume.rs +++ b/e2e/rust/tests/driver_config_volume.rs @@ -126,7 +126,8 @@ async fn sandbox_mounts_enabled_driver_config_bind() { "type": "bind", "source": bind_source, "target": BIND_TARGET, - "read_only": false + "read_only": false, + "selinux_label": "private" }); let driver_config = driver_config_mount_json(&driver, &bind_mount); // Host bind mounts are explicitly unsafe: this test validates driver mount diff --git a/e2e/rust/tests/live_policy_update.rs b/e2e/rust/tests/live_policy_update.rs index 1ee9ea1046..423b260946 100644 --- a/e2e/rust/tests/live_policy_update.rs +++ b/e2e/rust/tests/live_policy_update.rs @@ -567,7 +567,7 @@ async fn initial_sparse_policy_is_acknowledged_as_loaded() { let mut guard = SandboxGuard::create_keep_with_args( &[ "--name", - "e2e-2159-sparse-enrich", + "e2e-sparse-enrich", "--policy", sparse_policy, "--no-tty", @@ -648,7 +648,7 @@ async fn local_policy_override_survives_gateway_policy_polls() { let mut guard = SandboxGuard::create_keep_with_args( &[ "--name", - "e2e-local-policy-override", + "e2e-lcl-pol-ovrd", "--from", dockerfile, "--policy", diff --git a/e2e/rust/tests/sandbox_labels.rs b/e2e/rust/tests/sandbox_labels.rs index 018a27615f..890b1ad960 100644 --- a/e2e/rust/tests/sandbox_labels.rs +++ b/e2e/rust/tests/sandbox_labels.rs @@ -118,29 +118,22 @@ async fn delete_sandbox(name: &str) { #[allow(clippy::too_many_lines)] // end-to-end test exercises full label lifecycle async fn sandbox_labels_are_stored_and_filterable() { // Create sandboxes with different labels - let name1 = create_sandbox_with_labels( - "e2e-label-test-dev-backend", - &[("env", "dev"), ("team", "backend")], - ) - .await; + let name1 = + create_sandbox_with_labels("e2e-lbl-dev-back", &[("env", "dev"), ("team", "backend")]) + .await; let name2 = create_sandbox_with_labels( - "e2e-label-test-staging-backend", + "e2e-lbl-stg-back", &[("env", "staging"), ("team", "backend")], ) .await; - let name3 = create_sandbox_with_labels( - "e2e-label-test-prod-frontend", - &[("env", "prod"), ("team", "frontend")], - ) - .await; + let name3 = + create_sandbox_with_labels("e2e-lbl-prd-frnt", &[("env", "prod"), ("team", "frontend")]) + .await; - let name4 = create_sandbox_with_labels( - "e2e-label-test-dev-data", - &[("env", "dev"), ("team", "data")], - ) - .await; + let name4 = + create_sandbox_with_labels("e2e-lbl-dev-data", &[("env", "dev"), ("team", "data")]).await; // Test 1: Verify labels are stored in sandbox metadata let details = get_sandbox_details(&name1).await; diff --git a/e2e/rust/tests/workspace_lifecycle.rs b/e2e/rust/tests/workspace_lifecycle.rs new file mode 100644 index 0000000000..fa1d2cb2c9 --- /dev/null +++ b/e2e/rust/tests/workspace_lifecycle.rs @@ -0,0 +1,278 @@ +// 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 WORKSPACE_TERM: &str = "terminating-test"; +const PROVIDER: &str = "lifecycle-prov"; +const PROVIDER_TERM: &str = "term-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", + "generic", + "--credential", + "TOKEN=test-value", + "--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 + ); +} + +struct TerminatingCleanup; + +impl Drop for TerminatingCleanup { + fn drop(&mut self) { + let bin = openshell_bin(); + let _ = std::process::Command::new(&bin) + .args([ + "provider", + "delete", + PROVIDER_TERM, + "--workspace", + WORKSPACE_TERM, + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + let _ = std::process::Command::new(&bin) + .args(["workspace", "delete", WORKSPACE_TERM]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +#[tokio::test] +async fn workspace_terminating_rejects_creates() { + let _cleanup = TerminatingCleanup; + + // 1. Create workspace. + let res = run_cli(&["workspace", "create", "--name", WORKSPACE_TERM]).await; + assert!(res.success, "workspace create failed: {}", res.output); + + // 2. Create a provider to block deletion. + let res = run_cli(&[ + "provider", + "create", + "--name", + PROVIDER_TERM, + "--type", + "generic", + "--credential", + "TOKEN=test-value", + "--workspace", + WORKSPACE_TERM, + ]) + .await; + assert!(res.success, "provider create failed: {}", res.output); + + // 3. Attempt deletion — fails, but workspace is now Terminating. + let res = run_cli(&["workspace", "delete", WORKSPACE_TERM]).await; + assert!( + !res.success, + "delete should fail with blocker: {}", + res.output + ); + + // 4. Workspace list should show Terminating status. + let res = run_cli(&["workspace", "list"]).await; + assert!(res.success, "workspace list failed: {}", res.output); + assert!( + res.output.contains("Terminating"), + "workspace list should show Terminating status: {}", + res.output + ); + + // 5. Creating a new provider should be rejected. + let res = run_cli(&[ + "provider", + "create", + "--name", + "should-fail", + "--type", + "generic", + "--credential", + "TOKEN=test-value", + "--workspace", + WORKSPACE_TERM, + ]) + .await; + assert!( + !res.success, + "provider create should fail in terminating workspace: {}", + res.output + ); + assert!( + res.output.contains("being deleted"), + "error should mention workspace is being deleted: {}", + res.output + ); + + // 6. Delete the blocking provider (deletes still work in Terminating). + let res = run_cli(&[ + "provider", + "delete", + PROVIDER_TERM, + "--workspace", + WORKSPACE_TERM, + ]) + .await; + assert!(res.success, "provider delete failed: {}", res.output); + + // 7. Retry deletion — should succeed (idempotent on already-Terminating). + let res = run_cli(&["workspace", "delete", WORKSPACE_TERM]).await; + assert!( + res.success, + "workspace delete should succeed after cleanup: {}", + res.output + ); +} diff --git a/examples/agent-driven-policy-management/demo.sh b/examples/agent-driven-policy-management/demo.sh index 1a451da384..36bef4ded6 100755 --- a/examples/agent-driven-policy-management/demo.sh +++ b/examples/agent-driven-policy-management/demo.sh @@ -34,7 +34,7 @@ DEMO_BRANCH="${DEMO_BRANCH:-main}" DEMO_RUN_ID="${DEMO_RUN_ID:-$(date +%Y%m%d-%H%M%S)}" DEMO_FILE_DIR="${DEMO_FILE_DIR:-openshell-policy-advisor-demo}" DEMO_FILE_PATH="${DEMO_FILE_DIR}/${DEMO_RUN_ID}.md" -DEMO_SANDBOX_NAME="${DEMO_SANDBOX_NAME:-policy-demo-${DEMO_RUN_ID}}" +DEMO_SANDBOX_NAME="${DEMO_SANDBOX_NAME:-pd-${DEMO_RUN_ID}}" DEMO_CODEX_PROVIDER_NAME="${DEMO_CODEX_PROVIDER_NAME:-codex-policy-demo-${DEMO_RUN_ID}}" DEMO_GITHUB_PROVIDER_NAME="${DEMO_GITHUB_PROVIDER_NAME:-github-policy-demo-${DEMO_RUN_ID}}" DEMO_CODEX_MODEL="${DEMO_CODEX_MODEL:-gpt-5.4-mini}" diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index 351d73fea5..34f93fa2c6 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -53,7 +53,9 @@ if [[ "$RUN_TEST_SUITE" -eq 1 ]]; then else RUN_ID="governance-interactive-$$-$RANDOM" fi -SANDBOX_NAME="$RUN_ID-sandbox" +# Sandbox names are capped at 19 characters. Use a short prefix with +# the PID for uniqueness; the full RUN_ID is kept for log directory naming. +SANDBOX_NAME="gs-$$-$RANDOM" mkdir -p "$LOG_DIR" "$PROFILE_DIR" cp "$EXAMPLE_DIR"/profiles/*.yaml "$PROFILE_DIR"/ diff --git a/examples/multi-agent-notepad/demo.sh b/examples/multi-agent-notepad/demo.sh index 7ee88f9f85..e56d303c17 100755 --- a/examples/multi-agent-notepad/demo.sh +++ b/examples/multi-agent-notepad/demo.sh @@ -14,6 +14,10 @@ DEMO_TOPIC="${DEMO_TOPIC:-How should teams evaluate sandboxed coding agents?}" DEMO_AGENT_COUNT="${DEMO_AGENT_COUNT:-5}" DEMO_BRANCH="${DEMO_BRANCH:-main}" DEMO_RUN_ID="${DEMO_RUN_ID:-$(date +%Y%m%d-%H%M%S)}" +# Sandbox names are capped at 19 characters. Derive a short tag from the +# run ID for sandbox naming while keeping the full ID for providers and +# GitHub paths. +SANDBOX_TAG="${DEMO_RUN_ID:2:12}" # e.g. "260718-12345" (12 chars) DEMO_KEEP_SANDBOXES="${DEMO_KEEP_SANDBOXES:-0}" DEMO_CODEX_PROVIDER_NAME="${DEMO_CODEX_PROVIDER_NAME:-codex-oauth-${DEMO_RUN_ID}}" DEMO_GITHUB_PROVIDER_NAME="${DEMO_GITHUB_PROVIDER_NAME:-github-memory-${DEMO_RUN_ID}}" @@ -63,9 +67,9 @@ cleanup() { if [[ "$DEMO_KEEP_SANDBOXES" != "1" ]]; then for i in $(seq 1 "$DEMO_AGENT_COUNT"); do - "$OPENSHELL_BIN" sandbox delete "codex-gh-${DEMO_RUN_ID}-a${i}" >/dev/null 2>&1 || true + "$OPENSHELL_BIN" sandbox delete "mn-${SANDBOX_TAG}-a${i}" >/dev/null 2>&1 || true done - "$OPENSHELL_BIN" sandbox delete "codex-gh-${DEMO_RUN_ID}-summary" >/dev/null 2>&1 || true + "$OPENSHELL_BIN" sandbox delete "mn-${SANDBOX_TAG}-sum" >/dev/null 2>&1 || true else printf "\n${YELLOW}Keeping sandboxes because DEMO_KEEP_SANDBOXES=1.${RESET}\n" fi @@ -167,7 +171,7 @@ run_sandbox() { run_worker() { local index="$1" local slice_index=$(( (index - 1) % ${#WORKER_SLICES[@]} )) - local name="codex-gh-${DEMO_RUN_ID}-a${index}" + local name="mn-${SANDBOX_TAG}-a${index}" run_sandbox "$name" worker "$DEMO_GITHUB_OWNER" "$DEMO_GITHUB_REPO" "$DEMO_BRANCH" "$DEMO_RUN_ID" "$index" "$DEMO_AGENT_COUNT" "$DEMO_TOPIC" "${WORKER_SLICES[$slice_index]}" } @@ -197,7 +201,7 @@ run_workers() { } run_synthesis() { - local name="codex-gh-${DEMO_RUN_ID}-summary" + local name="mn-${SANDBOX_TAG}-sum" run_sandbox "$name" synthesis "$DEMO_GITHUB_OWNER" "$DEMO_GITHUB_REPO" "$DEMO_BRANCH" "$DEMO_RUN_ID" "0" "$DEMO_AGENT_COUNT" "$DEMO_TOPIC" \ >"${LOG_DIR}/summary.log" 2>&1 || { printf "\n${RED}synthesis failed; log follows:${RESET}\n" diff --git a/examples/vscode-remote-sandbox.md b/examples/vscode-remote-sandbox.md index 9477932e2b..b5cfe1bdd2 100644 --- a/examples/vscode-remote-sandbox.md +++ b/examples/vscode-remote-sandbox.md @@ -32,7 +32,7 @@ To reopen an existing sandbox in VS Code: openshell sandbox connect my-sandbox --editor vscode ``` -OpenShell maintains the generated `Host openshell-my-sandbox` entry in its own +OpenShell maintains the generated `Host openshell-my-sandbox.default` entry in its own managed SSH config include file. ### 3. Optional manual workflow @@ -40,7 +40,7 @@ managed SSH config include file. If you want to inspect the generated SSH stanza directly: ```text -Host openshell-my-sandbox +Host openshell-my-sandbox.default User sandbox StrictHostKeyChecking no UserKnownHostsFile /dev/null @@ -58,13 +58,13 @@ openshell sandbox ssh-config my-sandbox ### 4. Open VS Code manually Open VSCode and run **Remote-SSH: Connect to Host...** from the command -palette (`Cmd+Shift+P` / `Ctrl+Shift+P`). Select `openshell-my-sandbox` from the +palette (`Cmd+Shift+P` / `Ctrl+Shift+P`). Select `openshell-my-sandbox.default` from the list. VSCode will open a remote window connected to the sandbox. Alternatively, from the terminal: ```bash -code --remote ssh-remote+openshell-my-sandbox /sandbox +code --remote ssh-remote+openshell-my-sandbox.default /sandbox ``` ### 5. Clean up 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..1fc22a965a 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -33,6 +33,39 @@ message ObjectMeta { // 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 = 7; + + // Milliseconds since Unix epoch when graceful deletion was initiated. + // Zero means the object is not being deleted. Once set, this field is + // immutable — the only path forward is completing deletion. + int64 deletion_timestamp_ms = 8; +} + +// Phase of a workspace's lifecycle. +enum WorkspacePhase { + WORKSPACE_PHASE_UNSPECIFIED = 0; + WORKSPACE_PHASE_ACTIVE = 1; + WORKSPACE_PHASE_TERMINATING = 2; +} + +// Status of a workspace. +message WorkspaceStatus { + WorkspacePhase phase = 1; +} + +// 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; + + // Current lifecycle status. + WorkspaceStatus status = 2; } // Provider model stored by OpenShell. @@ -48,4 +81,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; } diff --git a/proto/inference.proto b/proto/inference.proto index e982a2700e..f6fd2af0e0 100644 --- a/proto/inference.proto +++ b/proto/inference.proto @@ -8,29 +8,34 @@ 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); + + // Delete an inference route from a workspace. + rpc DeleteInferenceRoute(DeleteInferenceRouteRequest) + returns (DeleteInferenceRouteResponse); } -// 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 +44,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 +66,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 +75,7 @@ message ValidatedEndpoint { string protocol = 2; } -message SetClusterInferenceResponse { +message SetInferenceRouteResponse { string provider_name = 1; string model_id = 2; uint64 version = 3; @@ -80,15 +87,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 +107,21 @@ 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 DeleteInferenceRouteRequest { + // Route name to delete. 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 DeleteInferenceRouteResponse { + // Whether a route was actually deleted. + bool deleted = 1; } message GetInferenceBundleRequest {} diff --git a/proto/openshell.proto b/proto/openshell.proto index 710cb2d8f6..6d2f6c856a 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 @@ -487,12 +512,16 @@ message CreateSandboxRequest { map labels = 3; // Optional annotations for the sandbox (non-selector metadata). map annotations = 4; + // Workspace for the sandbox. Empty defaults to "default". + string workspace = 5; } // 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 +530,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 +555,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 +570,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 +666,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 +676,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 +688,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 +705,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 +936,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 +963,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 +988,18 @@ message ListProvidersResponse { message ListProviderProfilesRequest { uint32 limit = 1; uint32 offset = 2; + // Workspace scope. When set, returns workspace-scoped + built-in profiles. + // 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. @@ -1088,6 +1158,8 @@ message StoredProviderCredentialRefreshState { message GetProviderRefreshStatusRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message GetProviderRefreshStatusResponse { @@ -1101,6 +1173,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 { @@ -1110,6 +1184,8 @@ message ConfigureProviderRefreshResponse { message RotateProviderCredentialRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message RotateProviderCredentialResponse { @@ -1119,6 +1195,8 @@ message RotateProviderCredentialResponse { message DeleteProviderRefreshRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message DeleteProviderRefreshResponse { @@ -1175,6 +1253,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. @@ -1194,6 +1275,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. @@ -1206,6 +1290,9 @@ message UpdateProviderProfilesResponse { // Lint provider profiles request. message LintProviderProfilesRequest { repeated ProviderProfileImportItem profiles = 1; + // Workspace scope. Used to check for conflicts against existing profiles + // in the target workspace. + string workspace = 2; } // Lint provider profiles response. @@ -1222,6 +1309,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. @@ -1291,6 +1381,8 @@ message UpdateConfigRequest { // 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 = 10; } message PolicyMergeOperation { @@ -1358,6 +1450,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. @@ -1376,6 +1470,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. @@ -1447,6 +1543,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. @@ -1746,6 +1844,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 { @@ -1767,6 +1867,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 { @@ -1786,6 +1888,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 { @@ -1803,6 +1907,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 {} @@ -1813,6 +1919,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 { @@ -1834,6 +1942,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 {} @@ -1844,6 +1954,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 { @@ -1857,6 +1969,8 @@ message UndoDraftChunkResponse { message ClearDraftChunksRequest { // Sandbox name. string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } message ClearDraftChunksResponse { @@ -1868,6 +1982,8 @@ message ClearDraftChunksResponse { message GetDraftHistoryRequest { // Sandbox name. string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } message DraftHistoryEntry { @@ -1969,3 +2085,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 9e32b2d306..16b3ca998d 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -364,6 +364,9 @@ message GetSandboxConfigResponse { // Operator-registered supervisor middleware services required by the // effective policy. Built-in middleware is not included. repeated SupervisorMiddlewareService supervisor_middleware_services = 9; + // Workspace the sandbox belongs to. Allows the supervisor to learn its + // workspace context for subsequent workspace-scoped RPCs. + string workspace = 10; } // Connection details for one operator-registered supervisor middleware service. diff --git a/python/openshell/__init__.py b/python/openshell/__init__.py index 94a391d4ef..0aa343ed60 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, @@ -17,6 +17,8 @@ SandboxSession, SandboxStatusRef, TlsConfig, + WorkspaceClient, + WorkspaceRef, ) try: @@ -27,10 +29,10 @@ __version__ = "0.0.0" __all__ = [ - "ClusterInferenceConfig", "ExecChunk", "ExecResult", "InferenceRouteClient", + "InferenceRouteConfig", "Sandbox", "SandboxClient", "SandboxError", @@ -38,5 +40,7 @@ "SandboxSession", "SandboxStatusRef", "TlsConfig", + "WorkspaceClient", + "WorkspaceRef", "__version__", ] diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 9b00ebfb70..b62a9c8855 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -21,6 +21,7 @@ import httpx from ._proto import ( + datamodel_pb2, inference_pb2, inference_pb2_grpc, openshell_pb2, @@ -159,6 +160,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 +199,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 +248,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 +430,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 +441,7 @@ def create( spec=request_spec, name=name or "", labels=dict(labels) if labels else {}, + workspace=workspace, ), timeout=self._timeout, ) @@ -448,42 +453,62 @@ 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, limit: int = 100, offset: int = 0, label_selector: str | None = None, ) -> builtins.list[SandboxRef]: - response = self._stub.ListSandboxes( - openshell_pb2.ListSandboxesRequest( - limit=limit, - offset=offset, - label_selector=label_selector or "", - ), - timeout=self._timeout, + request = openshell_pb2.ListSandboxesRequest( + workspace=workspace, + 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_for_all_workspaces( + self, + *, + limit: int = 100, + offset: int = 0, + label_selector: str | None = None, + ) -> builtins.list[SandboxRef]: + 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, limit: int = 100, offset: int = 0, label_selector: str | None = None, @@ -491,22 +516,43 @@ 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 list_ids_for_all_workspaces( + self, + *, + limit: int = 100, + offset: int = 0, + label_selector: str | None = None, + ) -> builtins.list[str]: + return [ + item.id + for item in self.list_for_all_workspaces( + limit=limit, + offset=offset, + label_selector=label_selector, + ) + ] + + 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 +564,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 +692,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,38 +709,129 @@ 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, ) + def delete_route( + self, + *, + workspace: str, + route_name: str = "", + ) -> bool: + response = self._stub.DeleteInferenceRoute( + inference_pb2.DeleteInferenceRouteRequest( + workspace=workspace, + route_name=route_name, + ), + timeout=self._timeout, + ) + return response.deleted + + +@dataclass(frozen=True) +class WorkspaceRef: + name: str + phase: str + labels: dict[str, str] + + +def _workspace_ref(ws: datamodel_pb2.Workspace) -> WorkspaceRef: + meta = ws.metadata + return WorkspaceRef( + name=meta.name, + phase=datamodel_pb2.WorkspacePhase.Name(ws.status.phase), + labels=dict(meta.labels), + ) + + +class WorkspaceClient: + """gRPC client for workspace lifecycle operations.""" + + def __init__(self, channel: grpc.Channel, *, timeout: float = 30.0) -> None: + self._stub = openshell_pb2_grpc.OpenShellStub(channel) + self._timeout = timeout + + @classmethod + def from_sandbox_client(cls, client: SandboxClient) -> WorkspaceClient: + return cls(client._channel, timeout=client._timeout) + + def create( + self, + name: str, + *, + labels: Mapping[str, str] | None = None, + ) -> WorkspaceRef: + response = self._stub.CreateWorkspace( + openshell_pb2.CreateWorkspaceRequest( + name=name, + labels=dict(labels) if labels else {}, + ), + timeout=self._timeout, + ) + return _workspace_ref(response.workspace) + + def get(self, name: str) -> WorkspaceRef: + response = self._stub.GetWorkspace( + openshell_pb2.GetWorkspaceRequest(name=name), + timeout=self._timeout, + ) + return _workspace_ref(response.workspace) + + def list( + self, + *, + limit: int = 100, + offset: int = 0, + label_selector: str | None = None, + ) -> builtins.list[WorkspaceRef]: + response = self._stub.ListWorkspaces( + openshell_pb2.ListWorkspacesRequest( + limit=limit, + offset=offset, + label_selector=label_selector or "", + ), + timeout=self._timeout, + ) + return [_workspace_ref(ws) for ws in response.workspaces] + + def delete(self, name: str) -> bool: + response = self._stub.DeleteWorkspace( + openshell_pb2.DeleteWorkspaceRequest(name=name), + timeout=self._timeout, + ) + return response.deleted + class Sandbox: """Context-managed sandbox session bound to one sandbox id.""" @@ -702,6 +839,7 @@ class Sandbox: def __init__( self, *, + workspace: str, cluster: str | None = None, sandbox: str | SandboxRef | None = None, delete_on_exit: bool = True, @@ -723,6 +861,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 +910,23 @@ 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 + ) + + self._workspace = getattr(self._session, "_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 +943,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 +1035,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..f1cb06148c 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)) @@ -1293,6 +1305,7 @@ def fake_from_active_cluster(**kwargs: Any) -> Any: ) sandbox = Sandbox( + workspace="default", cluster="my-gw", timeout=42.0, auto_refresh=False, @@ -1334,27 +1347,44 @@ 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 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 +1463,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 +1480,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 +1515,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 +1541,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 +1570,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 +1584,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 +1597,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 +1608,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 +1622,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 +1643,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 +1672,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 +1704,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 +1718,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 +1740,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"}, @@ -1675,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__() @@ -1685,9 +1766,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"}) + sandbox = Sandbox(workspace="default", 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_for_all_workspaces_sets_flag() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + + client.list_for_all_workspaces() + + 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" diff --git a/rfc/0011-multi-player-design/README.md b/rfc/0011-multi-player-design/README.md new file mode 100644 index 0000000000..51cb7ac3f7 --- /dev/null +++ b/rfc/0011-multi-player-design/README.md @@ -0,0 +1,1445 @@ +--- +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` uses a two-phase graceful deletion protocol inspired by +Kubernetes namespace deletion. A workspace has two lifecycle phases: **Active** +(default) and **Terminating**. + +1. **Mark Terminating.** When `DeleteWorkspace` is called, the workspace is + atomically marked Terminating via a CAS write that sets + `ObjectMeta.deletion_timestamp_ms` and `WorkspaceStatus.phase = + TERMINATING`. This is non-reversible — once Terminating, the workspace + cannot return to Active. + +2. **Reject new resources.** `resolve_workspace` returns the workspace's + termination state. Create-path handlers (`CreateSandbox`, + `CreateProvider`, `ExposeService`, `SetInferenceRoute`, + `AddWorkspaceMember`, etc.) call `ensure_active()` and reject operations + on Terminating workspaces with `FAILED_PRECONDITION`. Read and delete + operations continue to work so administrators can clean up. + +3. **Scan for blockers.** After marking Terminating, the handler scans for + remaining workspace-scoped resources. If any exist, the call returns + `FAILED_PRECONDITION` listing them. The workspace stays Terminating; + the administrator cleans up resources and retries. + +4. **Complete deletion.** If no blocking resources remain, membership records + are cleaned up and the workspace is deleted. + +The Terminating phase closes the TOCTOU race in the original design: without +it, a concurrent `CreateSandbox` could commit a new resource between the +blocker scan and the delete, orphaning the sandbox. By marking Terminating +first, concurrent creates are rejected during the scan window. + +Re-entry is idempotent: calling `DeleteWorkspace` on an already-Terminating +workspace skips the CAS write and proceeds directly to the blocker scan. + +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` and `deletion_timestamp_ms` are + the only new fields on `ObjectMeta`. The `deletion_timestamp_ms` field is set + when graceful deletion begins (0 = not deleting) and serves as the + authoritative source of truth for termination state. + +#### 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. + +#### Future work: server-initiated workspace cleanup + +The current graceful deletion requires the administrator to manually remove all +workspace-scoped resources before deletion completes. A future enhancement +could add a background GC controller that automatically deletes resources when +a workspace enters the Terminating phase: + +- The administrator calls `DeleteWorkspace` once; the server handles cleanup + asynchronously (sandboxes first, then providers, profiles, services, etc.). +- `DeleteWorkspace` could return immediately with a status indicating cleanup + is in progress, or block until complete with a configurable timeout. +- The GC controller would respect resource-specific ordering constraints (e.g., + detach providers before deleting sandboxes) and retry transient failures. + +This is deferred to Phase 2. For now, administrators must manually clean up +resources before deletion completes. + +### 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: + +```rust +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: + +```rust +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 +``` + +### 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 +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. The `EffectiveProviderProfileCatalog` + (`snapshot_catalog`) takes a `workspace` parameter so that profile resolution + is workspace-scoped on both read and write paths. The `UserProviderProfileSource` + loads platform-scoped profiles (workspace `""`) plus the target workspace's + profiles, merging both sets. Builtins and interceptor-provided profiles remain + workspace-agnostic. This ensures that two workspaces can independently import + profiles with the same ID without causing a global catalog collision. + 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 + 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}`. + 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 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 + 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 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. + **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 + `openshell-{sandbox}` to `openshell-{sandbox}.{workspace}` — 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, + 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.** + +```text +CreateWorkspace { name: "team-ml" } +→ global_role = "platform_admin" → pass +→ persist Workspace { name: "team-ml" } +``` + +**2. Platform Admin adds a Workspace Admin.** + +```text +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.** + +```text +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.** + +```text +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.** + +```text +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.** + +```text +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.** + +```text +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.** + +```text +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.** + +```text +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.** + +```text +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.** + +```text +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. diff --git a/scripts/agents/gator/agent.yaml b/scripts/agents/gator/agent.yaml index 8839e9f81d..a36f85bc03 100644 --- a/scripts/agents/gator/agent.yaml +++ b/scripts/agents/gator/agent.yaml @@ -6,7 +6,7 @@ display_name: Gator Gate Agent description: Validate and monitor OpenShell GitHub issues and pull requests through the gator state machine. sandbox: - name_prefix: gator + name_prefix: gtr from: agent://. gateway: docker-dev background_log_dir: logs