Skip to content

Commit 1d3b741

Browse files
authored
feat(providers): support sandbox provider attach lifecycle (#1242)
* feat(providers): support sandbox provider attach lifecycle Closes #1171 Adds sandbox provider list, attach, and detach API/CLI support while keeping provider policy and credential resolution derived from current sandbox attachments. * fix(providers): refresh sandbox provider credentials Adds provider environment revisions and generation-scoped sandbox credential snapshots so future SSH and exec launches pick up provider attach, detach, and credential updates without mutating already-running processes. Also blocks provider deletion while attached to prevent stale sandbox provider references. * fix(providers): serialize sandbox object mutations * test(providers): cover sandbox provider attach lifecycle * test(providers): accept versioned credential placeholders
1 parent 3cfc915 commit 1d3b741

29 files changed

Lines changed: 2222 additions & 160 deletions

crates/openshell-cli/src/main.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,6 +1260,45 @@ enum SandboxCommands {
12601260
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
12611261
name: Option<String>,
12621262
},
1263+
1264+
/// Manage providers attached to a sandbox.
1265+
#[command(subcommand)]
1266+
Provider(SandboxProviderCommands),
1267+
}
1268+
1269+
#[derive(Subcommand, Debug)]
1270+
enum SandboxProviderCommands {
1271+
/// List providers attached to a sandbox.
1272+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
1273+
List {
1274+
/// Sandbox name (defaults to last-used sandbox).
1275+
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
1276+
name: Option<String>,
1277+
},
1278+
1279+
/// Attach a provider to a sandbox.
1280+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
1281+
Attach {
1282+
/// Sandbox name.
1283+
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
1284+
name: String,
1285+
1286+
/// Provider name to attach.
1287+
#[arg(add = ArgValueCompleter::new(completers::complete_provider_names))]
1288+
provider: String,
1289+
},
1290+
1291+
/// Detach a provider from a sandbox.
1292+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
1293+
Detach {
1294+
/// Sandbox name.
1295+
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
1296+
name: String,
1297+
1298+
/// Provider name to detach.
1299+
#[arg(add = ArgValueCompleter::new(completers::complete_provider_names))]
1300+
provider: String,
1301+
},
12631302
}
12641303

12651304
#[derive(Subcommand, Debug)]
@@ -2385,6 +2424,20 @@ async fn main() -> Result<()> {
23852424
let name = resolve_sandbox_name(name, &ctx.name)?;
23862425
run::print_ssh_config(&ctx.name, &name);
23872426
}
2427+
SandboxCommands::Provider(command) => match command {
2428+
SandboxProviderCommands::List { name } => {
2429+
let name = resolve_sandbox_name(name, &ctx.name)?;
2430+
run::sandbox_provider_list(endpoint, &name, &tls).await?;
2431+
}
2432+
SandboxProviderCommands::Attach { name, provider } => {
2433+
run::sandbox_provider_attach(endpoint, &name, &provider, &tls)
2434+
.await?;
2435+
}
2436+
SandboxProviderCommands::Detach { name, provider } => {
2437+
run::sandbox_provider_detach(endpoint, &name, &provider, &tls)
2438+
.await?;
2439+
}
2440+
},
23882441
}
23892442
}
23902443
}
@@ -2721,6 +2774,30 @@ mod tests {
27212774
);
27222775
}
27232776

2777+
#[test]
2778+
fn sandbox_provider_subcommands_parse() {
2779+
let cli = Cli::try_parse_from([
2780+
"openshell",
2781+
"sandbox",
2782+
"provider",
2783+
"attach",
2784+
"work-sandbox",
2785+
"work-github",
2786+
])
2787+
.expect("sandbox provider attach should parse");
2788+
2789+
let Some(Commands::Sandbox {
2790+
command:
2791+
Some(SandboxCommands::Provider(SandboxProviderCommands::Attach { name, provider })),
2792+
}) = cli.command
2793+
else {
2794+
panic!("expected sandbox provider attach command");
2795+
};
2796+
2797+
assert_eq!(name, "work-sandbox");
2798+
assert_eq!(provider, "work-github");
2799+
}
2800+
27242801
#[test]
27252802
fn completions_policy_flag_falls_back_to_file_paths() {
27262803
let temp = tempfile::tempdir().expect("failed to create tempdir");

crates/openshell-cli/src/run.rs

Lines changed: 189 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,19 @@ use openshell_bootstrap::{
2525
};
2626
use openshell_core::proto::ProviderProfileCategory;
2727
use openshell_core::proto::{
28-
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest,
29-
CreateProviderRequest, CreateSandboxRequest, DeleteProviderProfileRequest,
30-
DeleteProviderRequest, DeleteSandboxRequest, ExecSandboxRequest, GetClusterInferenceRequest,
28+
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, AttachSandboxProviderRequest,
29+
ClearDraftChunksRequest, CreateProviderRequest, CreateSandboxRequest,
30+
DeleteProviderProfileRequest, DeleteProviderRequest, DeleteSandboxRequest,
31+
DetachSandboxProviderRequest, ExecSandboxRequest, GetClusterInferenceRequest,
3132
GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest,
3233
GetProviderProfileRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest,
3334
GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ImportProviderProfilesRequest,
3435
LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest,
35-
ListSandboxPoliciesRequest, ListSandboxesRequest, PolicySource, PolicyStatus, Provider,
36-
ProviderProfile, ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest,
37-
Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest,
38-
SettingScope, SettingValue, UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest,
39-
exec_sandbox_event, setting_value,
36+
ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, PolicySource,
37+
PolicyStatus, Provider, ProviderProfile, ProviderProfileDiagnostic, ProviderProfileImportItem,
38+
RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate,
39+
SetClusterInferenceRequest, SettingScope, SettingValue, UpdateConfigRequest,
40+
UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value,
4041
};
4142
use openshell_core::settings::{self, SettingValueKind};
4243
use openshell_core::{ObjectId, ObjectName};
@@ -2512,6 +2513,143 @@ pub async fn sandbox_list(
25122513
Ok(())
25132514
}
25142515

2516+
pub async fn sandbox_provider_list(server: &str, name: &str, tls: &TlsOptions) -> Result<()> {
2517+
let mut client = grpc_client(server, tls).await?;
2518+
let response = client
2519+
.list_sandbox_providers(ListSandboxProvidersRequest {
2520+
sandbox_name: name.to_string(),
2521+
})
2522+
.await
2523+
.into_diagnostic()?;
2524+
let providers = response.into_inner().providers;
2525+
2526+
if providers.is_empty() {
2527+
println!("No providers attached to sandbox {name}.");
2528+
return Ok(());
2529+
}
2530+
2531+
print_provider_attachment_table(&providers);
2532+
Ok(())
2533+
}
2534+
2535+
pub async fn sandbox_provider_attach(
2536+
server: &str,
2537+
name: &str,
2538+
provider: &str,
2539+
tls: &TlsOptions,
2540+
) -> Result<()> {
2541+
let mut client = grpc_client(server, tls).await?;
2542+
let response = client
2543+
.attach_sandbox_provider(AttachSandboxProviderRequest {
2544+
sandbox_name: name.to_string(),
2545+
provider_name: provider.to_string(),
2546+
})
2547+
.await
2548+
.into_diagnostic()?
2549+
.into_inner();
2550+
2551+
if response.attached {
2552+
println!(
2553+
"{} Attached provider {} to sandbox {}",
2554+
"✓".green().bold(),
2555+
provider,
2556+
name
2557+
);
2558+
} else {
2559+
println!("Provider {provider} is already attached to sandbox {name}.");
2560+
}
2561+
Ok(())
2562+
}
2563+
2564+
pub async fn sandbox_provider_detach(
2565+
server: &str,
2566+
name: &str,
2567+
provider: &str,
2568+
tls: &TlsOptions,
2569+
) -> Result<()> {
2570+
let mut client = grpc_client(server, tls).await?;
2571+
let response = client
2572+
.detach_sandbox_provider(DetachSandboxProviderRequest {
2573+
sandbox_name: name.to_string(),
2574+
provider_name: provider.to_string(),
2575+
})
2576+
.await
2577+
.into_diagnostic()?
2578+
.into_inner();
2579+
2580+
if response.detached {
2581+
println!(
2582+
"{} Detached provider {} from sandbox {}",
2583+
"✓".green().bold(),
2584+
provider,
2585+
name
2586+
);
2587+
} else {
2588+
println!("Provider {provider} was not attached to sandbox {name}.");
2589+
}
2590+
Ok(())
2591+
}
2592+
2593+
fn print_provider_attachment_table(providers: &[Provider]) {
2594+
print!("{}", format_provider_attachment_table(providers, true));
2595+
}
2596+
2597+
fn format_provider_attachment_table(providers: &[Provider], color: bool) -> String {
2598+
use std::fmt::Write as _;
2599+
2600+
let name_width = providers
2601+
.iter()
2602+
.map(|provider| provider.object_name().len())
2603+
.max()
2604+
.unwrap_or(4)
2605+
.max(4);
2606+
let type_width = providers
2607+
.iter()
2608+
.map(|provider| provider.r#type.len())
2609+
.max()
2610+
.unwrap_or(4)
2611+
.max(4);
2612+
2613+
let name_header = if color {
2614+
"NAME".bold().to_string()
2615+
} else {
2616+
"NAME".to_string()
2617+
};
2618+
let type_header = if color {
2619+
"TYPE".bold().to_string()
2620+
} else {
2621+
"TYPE".to_string()
2622+
};
2623+
let credential_keys_header = if color {
2624+
"CREDENTIAL_KEYS".bold().to_string()
2625+
} else {
2626+
"CREDENTIAL_KEYS".to_string()
2627+
};
2628+
let config_keys_header = if color {
2629+
"CONFIG_KEYS".bold().to_string()
2630+
} else {
2631+
"CONFIG_KEYS".to_string()
2632+
};
2633+
2634+
let mut output = String::new();
2635+
let _ = writeln!(
2636+
output,
2637+
"{name_header:<name_width$} {type_header:<type_width$} {credential_keys_header:<16} {config_keys_header}",
2638+
);
2639+
2640+
for provider in providers {
2641+
let provider_name = provider.object_name();
2642+
let provider_type = &provider.r#type;
2643+
let credential_keys = provider.credentials.len();
2644+
let config_keys = provider.config.len();
2645+
let _ = writeln!(
2646+
output,
2647+
"{provider_name:<name_width$} {provider_type:<type_width$} {credential_keys:<16} {config_keys}",
2648+
);
2649+
}
2650+
output
2651+
}
2652+
25152653
/// Delete a sandbox by name, or all sandboxes when `all` is true.
25162654
pub async fn sandbox_delete(
25172655
server: &str,
@@ -5251,11 +5389,12 @@ fn format_timestamp_ms(ms: i64) -> String {
52515389
mod tests {
52525390
use super::{
52535391
TlsOptions, dockerfile_sources_supported_for_gateway, format_gateway_select_header,
5254-
format_gateway_select_items, gateway_add, gateway_auth_label, gateway_env_override_warning,
5255-
gateway_select_with, gateway_type_label, git_sync_files, http_health_check,
5256-
image_requests_gpu, inferred_provider_type, parse_cli_setting_value,
5257-
parse_credential_pairs, plaintext_gateway_is_remote, provisioning_timeout_message,
5258-
ready_false_condition_message, resolve_from, sandbox_should_persist,
5392+
format_gateway_select_items, format_provider_attachment_table, gateway_add,
5393+
gateway_auth_label, gateway_env_override_warning, gateway_select_with, gateway_type_label,
5394+
git_sync_files, http_health_check, image_requests_gpu, inferred_provider_type,
5395+
parse_cli_setting_value, parse_credential_pairs, plaintext_gateway_is_remote,
5396+
provisioning_timeout_message, ready_false_condition_message, resolve_from,
5397+
sandbox_should_persist,
52595398
};
52605399
use crate::TEST_ENV_LOCK;
52615400
use hyper::StatusCode;
@@ -5268,7 +5407,9 @@ mod tests {
52685407
use std::thread;
52695408

52705409
use openshell_bootstrap::GatewayMetadata;
5271-
use openshell_core::proto::{SandboxCondition, SandboxStatus};
5410+
use openshell_core::proto::{
5411+
Provider, SandboxCondition, SandboxStatus, datamodel::v1::ObjectMeta,
5412+
};
52725413

52735414
struct EnvVarGuard {
52745415
key: &'static str,
@@ -5371,6 +5512,40 @@ mod tests {
53715512
));
53725513
}
53735514

5515+
#[test]
5516+
fn provider_attachment_table_formats_provider_counts() {
5517+
let output = format_provider_attachment_table(
5518+
&[Provider {
5519+
metadata: Some(ObjectMeta {
5520+
name: "work-custom".to_string(),
5521+
..Default::default()
5522+
}),
5523+
r#type: "custom-api".to_string(),
5524+
credentials: [
5525+
("CUSTOM_API_KEY".to_string(), "REDACTED".to_string()),
5526+
("CUSTOM_API_SECRET".to_string(), "REDACTED".to_string()),
5527+
]
5528+
.into_iter()
5529+
.collect(),
5530+
config: std::iter::once((
5531+
"BASE_URL".to_string(),
5532+
"https://api.custom.example".to_string(),
5533+
))
5534+
.collect(),
5535+
}],
5536+
false,
5537+
);
5538+
5539+
assert!(output.contains("NAME"));
5540+
assert!(output.contains("TYPE"));
5541+
assert!(output.contains("CREDENTIAL_KEYS"));
5542+
assert!(output.contains("CONFIG_KEYS"));
5543+
assert!(output.contains("work-custom"));
5544+
assert!(output.contains("custom-api"));
5545+
assert!(output.contains('2'));
5546+
assert!(output.contains('1'));
5547+
}
5548+
53745549
#[cfg(feature = "dev-settings")]
53755550
#[test]
53765551
fn parse_cli_setting_value_parses_bool_aliases() {

crates/openshell-cli/tests/ensure_providers_integration.rs

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,18 @@ use openshell_cli::run;
99
use openshell_cli::tls::TlsOptions;
1010
use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer};
1111
use openshell_core::proto::{
12-
CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse,
13-
DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse,
14-
ExecSandboxEvent, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest,
15-
GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest,
16-
GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest,
17-
GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse,
18-
ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse,
19-
Provider, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse,
20-
SandboxStreamEvent, ServiceStatus, SupervisorMessage, UpdateProviderRequest,
21-
WatchSandboxRequest,
12+
AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest,
13+
CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest,
14+
DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse,
15+
DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent,
16+
ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse,
17+
GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse,
18+
GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest,
19+
HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse,
20+
ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest,
21+
ListSandboxesResponse, Provider, ProviderResponse, RevokeSshSessionRequest,
22+
RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, ServiceStatus,
23+
SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest,
2224
};
2325
use openshell_core::{ObjectId, ObjectName};
2426
use rcgen::{
@@ -153,6 +155,27 @@ impl OpenShell for TestOpenShell {
153155
Ok(Response::new(ListSandboxesResponse::default()))
154156
}
155157

158+
async fn list_sandbox_providers(
159+
&self,
160+
_request: tonic::Request<ListSandboxProvidersRequest>,
161+
) -> Result<Response<ListSandboxProvidersResponse>, Status> {
162+
Ok(Response::new(ListSandboxProvidersResponse::default()))
163+
}
164+
165+
async fn attach_sandbox_provider(
166+
&self,
167+
_request: tonic::Request<AttachSandboxProviderRequest>,
168+
) -> Result<Response<AttachSandboxProviderResponse>, Status> {
169+
Ok(Response::new(AttachSandboxProviderResponse::default()))
170+
}
171+
172+
async fn detach_sandbox_provider(
173+
&self,
174+
_request: tonic::Request<DetachSandboxProviderRequest>,
175+
) -> Result<Response<DetachSandboxProviderResponse>, Status> {
176+
Ok(Response::new(DetachSandboxProviderResponse::default()))
177+
}
178+
156179
async fn delete_sandbox(
157180
&self,
158181
_request: tonic::Request<DeleteSandboxRequest>,

0 commit comments

Comments
 (0)