Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,45 @@ enum SandboxCommands {
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: Option<String>,
},

/// Manage providers attached to a sandbox.
#[command(subcommand)]
Provider(SandboxProviderCommands),
}

#[derive(Subcommand, Debug)]
enum SandboxProviderCommands {
/// List providers attached to a sandbox.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
List {
/// Sandbox name (defaults to last-used sandbox).
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: Option<String>,
},

/// Attach a provider to a sandbox.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Attach {
/// Sandbox name.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: String,

/// Provider name to attach.
#[arg(add = ArgValueCompleter::new(completers::complete_provider_names))]
provider: String,
},

/// Detach a provider from a sandbox.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Detach {
/// Sandbox name.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: String,

/// Provider name to detach.
#[arg(add = ArgValueCompleter::new(completers::complete_provider_names))]
provider: String,
},
}

#[derive(Subcommand, Debug)]
Expand Down Expand Up @@ -2385,6 +2424,20 @@ async fn main() -> Result<()> {
let name = resolve_sandbox_name(name, &ctx.name)?;
run::print_ssh_config(&ctx.name, &name);
}
SandboxCommands::Provider(command) => match command {
SandboxProviderCommands::List { name } => {
let name = resolve_sandbox_name(name, &ctx.name)?;
run::sandbox_provider_list(endpoint, &name, &tls).await?;
}
SandboxProviderCommands::Attach { name, provider } => {
run::sandbox_provider_attach(endpoint, &name, &provider, &tls)
.await?;
}
SandboxProviderCommands::Detach { name, provider } => {
run::sandbox_provider_detach(endpoint, &name, &provider, &tls)
.await?;
}
},
}
}
}
Expand Down Expand Up @@ -2721,6 +2774,30 @@ mod tests {
);
}

#[test]
fn sandbox_provider_subcommands_parse() {
let cli = Cli::try_parse_from([
"openshell",
"sandbox",
"provider",
"attach",
"work-sandbox",
"work-github",
])
.expect("sandbox provider attach should parse");

let Some(Commands::Sandbox {
command:
Some(SandboxCommands::Provider(SandboxProviderCommands::Attach { name, provider })),
}) = cli.command
else {
panic!("expected sandbox provider attach command");
};

assert_eq!(name, "work-sandbox");
assert_eq!(provider, "work-github");
}

#[test]
fn completions_policy_flag_falls_back_to_file_paths() {
let temp = tempfile::tempdir().expect("failed to create tempdir");
Expand Down
203 changes: 189 additions & 14 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,19 @@ use openshell_bootstrap::{
};
use openshell_core::proto::ProviderProfileCategory;
use openshell_core::proto::{
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest,
CreateProviderRequest, CreateSandboxRequest, DeleteProviderProfileRequest,
DeleteProviderRequest, DeleteSandboxRequest, ExecSandboxRequest, GetClusterInferenceRequest,
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, AttachSandboxProviderRequest,
ClearDraftChunksRequest, CreateProviderRequest, CreateSandboxRequest,
DeleteProviderProfileRequest, DeleteProviderRequest, DeleteSandboxRequest,
DetachSandboxProviderRequest, ExecSandboxRequest, GetClusterInferenceRequest,
GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest,
GetProviderProfileRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest,
GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ImportProviderProfilesRequest,
LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest,
ListSandboxPoliciesRequest, ListSandboxesRequest, PolicySource, PolicyStatus, Provider,
ProviderProfile, ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest,
Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest,
SettingScope, SettingValue, UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest,
exec_sandbox_event, setting_value,
ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, PolicySource,
PolicyStatus, Provider, ProviderProfile, ProviderProfileDiagnostic, ProviderProfileImportItem,
RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate,
SetClusterInferenceRequest, SettingScope, SettingValue, UpdateConfigRequest,
UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value,
};
use openshell_core::settings::{self, SettingValueKind};
use openshell_core::{ObjectId, ObjectName};
Expand Down Expand Up @@ -2512,6 +2513,143 @@ pub async fn sandbox_list(
Ok(())
}

pub async fn sandbox_provider_list(server: &str, name: &str, tls: &TlsOptions) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.list_sandbox_providers(ListSandboxProvidersRequest {
sandbox_name: name.to_string(),
})
.await
.into_diagnostic()?;
let providers = response.into_inner().providers;

if providers.is_empty() {
println!("No providers attached to sandbox {name}.");
return Ok(());
}

print_provider_attachment_table(&providers);
Ok(())
}

pub async fn sandbox_provider_attach(
server: &str,
name: &str,
provider: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.attach_sandbox_provider(AttachSandboxProviderRequest {
sandbox_name: name.to_string(),
provider_name: provider.to_string(),
})
.await
.into_diagnostic()?
.into_inner();

if response.attached {
println!(
"{} Attached provider {} to sandbox {}",
"✓".green().bold(),
provider,
name
);
} else {
println!("Provider {provider} is already attached to sandbox {name}.");
}
Ok(())
}

pub async fn sandbox_provider_detach(
server: &str,
name: &str,
provider: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.detach_sandbox_provider(DetachSandboxProviderRequest {
sandbox_name: name.to_string(),
provider_name: provider.to_string(),
})
.await
.into_diagnostic()?
.into_inner();

if response.detached {
println!(
"{} Detached provider {} from sandbox {}",
"✓".green().bold(),
provider,
name
);
} else {
println!("Provider {provider} was not attached to sandbox {name}.");
}
Ok(())
}

fn print_provider_attachment_table(providers: &[Provider]) {
print!("{}", format_provider_attachment_table(providers, true));
}

fn format_provider_attachment_table(providers: &[Provider], color: bool) -> String {
use std::fmt::Write as _;

let name_width = providers
.iter()
.map(|provider| provider.object_name().len())
.max()
.unwrap_or(4)
.max(4);
let type_width = providers
.iter()
.map(|provider| provider.r#type.len())
.max()
.unwrap_or(4)
.max(4);

let name_header = if color {
"NAME".bold().to_string()
} else {
"NAME".to_string()
};
let type_header = if color {
"TYPE".bold().to_string()
} else {
"TYPE".to_string()
};
let credential_keys_header = if color {
"CREDENTIAL_KEYS".bold().to_string()
} else {
"CREDENTIAL_KEYS".to_string()
};
let config_keys_header = if color {
"CONFIG_KEYS".bold().to_string()
} else {
"CONFIG_KEYS".to_string()
};

let mut output = String::new();
let _ = writeln!(
output,
"{name_header:<name_width$} {type_header:<type_width$} {credential_keys_header:<16} {config_keys_header}",
);

for provider in providers {
let provider_name = provider.object_name();
let provider_type = &provider.r#type;
let credential_keys = provider.credentials.len();
let config_keys = provider.config.len();
let _ = writeln!(
output,
"{provider_name:<name_width$} {provider_type:<type_width$} {credential_keys:<16} {config_keys}",
);
}
output
}

/// Delete a sandbox by name, or all sandboxes when `all` is true.
pub async fn sandbox_delete(
server: &str,
Expand Down Expand Up @@ -5251,11 +5389,12 @@ fn format_timestamp_ms(ms: i64) -> String {
mod tests {
use super::{
TlsOptions, dockerfile_sources_supported_for_gateway, format_gateway_select_header,
format_gateway_select_items, gateway_add, gateway_auth_label, gateway_env_override_warning,
gateway_select_with, gateway_type_label, git_sync_files, http_health_check,
image_requests_gpu, inferred_provider_type, parse_cli_setting_value,
parse_credential_pairs, plaintext_gateway_is_remote, provisioning_timeout_message,
ready_false_condition_message, resolve_from, sandbox_should_persist,
format_gateway_select_items, format_provider_attachment_table, gateway_add,
gateway_auth_label, gateway_env_override_warning, gateway_select_with, gateway_type_label,
git_sync_files, http_health_check, image_requests_gpu, inferred_provider_type,
parse_cli_setting_value, parse_credential_pairs, plaintext_gateway_is_remote,
provisioning_timeout_message, ready_false_condition_message, resolve_from,
sandbox_should_persist,
};
use crate::TEST_ENV_LOCK;
use hyper::StatusCode;
Expand All @@ -5268,7 +5407,9 @@ mod tests {
use std::thread;

use openshell_bootstrap::GatewayMetadata;
use openshell_core::proto::{SandboxCondition, SandboxStatus};
use openshell_core::proto::{
Provider, SandboxCondition, SandboxStatus, datamodel::v1::ObjectMeta,
};

struct EnvVarGuard {
key: &'static str,
Expand Down Expand Up @@ -5371,6 +5512,40 @@ mod tests {
));
}

#[test]
fn provider_attachment_table_formats_provider_counts() {
let output = format_provider_attachment_table(
&[Provider {
metadata: Some(ObjectMeta {
name: "work-custom".to_string(),
..Default::default()
}),
r#type: "custom-api".to_string(),
credentials: [
("CUSTOM_API_KEY".to_string(), "REDACTED".to_string()),
("CUSTOM_API_SECRET".to_string(), "REDACTED".to_string()),
]
.into_iter()
.collect(),
config: std::iter::once((
"BASE_URL".to_string(),
"https://api.custom.example".to_string(),
))
.collect(),
}],
false,
);

assert!(output.contains("NAME"));
assert!(output.contains("TYPE"));
assert!(output.contains("CREDENTIAL_KEYS"));
assert!(output.contains("CONFIG_KEYS"));
assert!(output.contains("work-custom"));
assert!(output.contains("custom-api"));
assert!(output.contains('2'));
assert!(output.contains('1'));
}

#[cfg(feature = "dev-settings")]
#[test]
fn parse_cli_setting_value_parses_bool_aliases() {
Expand Down
43 changes: 33 additions & 10 deletions crates/openshell-cli/tests/ensure_providers_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@ use openshell_cli::run;
use openshell_cli::tls::TlsOptions;
use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer};
use openshell_core::proto::{
CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse,
DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse,
ExecSandboxEvent, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest,
GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest,
GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest,
GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse,
ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse,
Provider, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse,
SandboxStreamEvent, ServiceStatus, SupervisorMessage, UpdateProviderRequest,
WatchSandboxRequest,
AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest,
CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest,
DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse,
DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent,
ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse,
GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse,
GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest,
HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse,
ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest,
ListSandboxesResponse, Provider, ProviderResponse, RevokeSshSessionRequest,
RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, ServiceStatus,
SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest,
};
use openshell_core::{ObjectId, ObjectName};
use rcgen::{
Expand Down Expand Up @@ -153,6 +155,27 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(ListSandboxesResponse::default()))
}

async fn list_sandbox_providers(
&self,
_request: tonic::Request<ListSandboxProvidersRequest>,
) -> Result<Response<ListSandboxProvidersResponse>, Status> {
Ok(Response::new(ListSandboxProvidersResponse::default()))
}

async fn attach_sandbox_provider(
&self,
_request: tonic::Request<AttachSandboxProviderRequest>,
) -> Result<Response<AttachSandboxProviderResponse>, Status> {
Ok(Response::new(AttachSandboxProviderResponse::default()))
}

async fn detach_sandbox_provider(
&self,
_request: tonic::Request<DetachSandboxProviderRequest>,
) -> Result<Response<DetachSandboxProviderResponse>, Status> {
Ok(Response::new(DetachSandboxProviderResponse::default()))
}

async fn delete_sandbox(
&self,
_request: tonic::Request<DeleteSandboxRequest>,
Expand Down
Loading
Loading