mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-29 07:23:25 +08:00
8418 lines
272 KiB
Rust
8418 lines
272 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! CLI command implementations.
|
|
|
|
pub use crate::commands::common::{
|
|
PolicyGetView, parse_credential_expiry_cli_value, parse_env_pairs, parse_key_value_pairs,
|
|
parse_secret_material_env_pairs,
|
|
};
|
|
use crate::commands::common::{
|
|
ProvisioningDisplay, ProvisioningStep, confirm_global_setting_delete,
|
|
confirm_global_setting_takeover, format_epoch_ms, format_optional_epoch_ms,
|
|
format_setting_value, format_timestamp, format_timestamp_ms, handle_platform_progress_event,
|
|
is_provisioning_progress_event, non_empty_or, parse_cli_setting_value,
|
|
parse_credential_expiry_pairs, parse_credential_pairs, parse_duration_to_ms, phase_name,
|
|
print_policy_merge_warnings, print_sandbox_header, print_sandbox_policy,
|
|
provisioning_timeout_message, ready_false_condition_message, scrub_git_env, short_hash,
|
|
truncate_display, truncate_status_field,
|
|
};
|
|
pub use crate::commands::gateway::{
|
|
gateway_add, gateway_info, gateway_info_not_configured, gateway_list, gateway_login,
|
|
gateway_logout, gateway_remove, gateway_select, gateway_status, gateway_use,
|
|
};
|
|
|
|
use crate::policy_update::build_policy_update_plan;
|
|
use crate::tls::{TlsOptions, grpc_client, grpc_inference_client};
|
|
use dialoguer::Confirm;
|
|
use futures::StreamExt;
|
|
use indicatif::{ProgressBar, ProgressStyle};
|
|
use miette::{IntoDiagnostic, Result, WrapErr, miette};
|
|
use openshell_bootstrap::{
|
|
GatewayMetadata, clear_last_sandbox_if_matches, get_gateway_metadata, save_last_sandbox,
|
|
};
|
|
use openshell_core::net::set_tcp_nodelay_best_effort;
|
|
use openshell_core::proto::ProviderProfileCategory;
|
|
use openshell_core::proto::{
|
|
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, AttachSandboxProviderRequest,
|
|
ClearDraftChunksRequest, ConfigureProviderRefreshRequest, CreateProviderRequest,
|
|
CreateSandboxRequest, CreateSshSessionRequest, DeleteInferenceRouteRequest,
|
|
DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest,
|
|
DeleteSandboxRequest, DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest,
|
|
ExposeServiceRequest, GetCurrentUserRequest, GetDraftHistoryRequest, GetDraftPolicyRequest,
|
|
GetGatewayConfigRequest, GetInferenceRouteRequest, GetProviderProfileRequest,
|
|
GetProviderRefreshStatusRequest, GetProviderRequest, GetSandboxConfigRequest,
|
|
GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest,
|
|
GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, ImportProviderProfilesRequest,
|
|
LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest,
|
|
ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest,
|
|
ListServicesRequest, PolicySource, PolicyStatus, Provider, ProviderCredentialRefreshStatus,
|
|
ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic,
|
|
ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements,
|
|
RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetInferenceRouteRequest, SettingScope,
|
|
TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest,
|
|
UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event,
|
|
setting_value, tcp_forward_init,
|
|
};
|
|
use openshell_core::settings;
|
|
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,
|
|
profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml,
|
|
};
|
|
use owo_colors::OwoColorize;
|
|
use std::borrow::Cow;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::io::{ErrorKind, IsTerminal, Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::time::{Duration, Instant};
|
|
use tonic::{Code, Status};
|
|
|
|
// Re-export SSH functions for backward compatibility
|
|
pub use crate::ssh::{Editor, print_ssh_config};
|
|
pub use crate::ssh::{
|
|
sandbox_connect, sandbox_connect_editor, sandbox_exec, sandbox_forward, sandbox_ssh_proxy,
|
|
sandbox_ssh_proxy_by_name, sandbox_sync_down, sandbox_sync_up, sandbox_sync_up_files,
|
|
};
|
|
pub use openshell_core::forward::{
|
|
ForwardSpec, find_forward_by_port, list_forwards, stop_forward, stop_forwards_for_sandbox,
|
|
};
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum SandboxUploadPlan {
|
|
GitAware {
|
|
base_dir: PathBuf,
|
|
files: Vec<String>,
|
|
},
|
|
Regular,
|
|
GitFilteredEmpty,
|
|
}
|
|
|
|
enum ProgressOutput {
|
|
Interactive(ProvisioningDisplay),
|
|
Plain,
|
|
Silent,
|
|
}
|
|
|
|
impl ProgressOutput {
|
|
fn as_interactive_mut(&mut self) -> Option<&mut ProvisioningDisplay> {
|
|
match self {
|
|
Self::Interactive(d) => Some(d),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn as_interactive(&self) -> Option<&ProvisioningDisplay> {
|
|
match self {
|
|
Self::Interactive(d) => Some(d),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn is_plain(&self) -> bool {
|
|
matches!(self, Self::Plain)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct CurrentUserView {
|
|
subject: String,
|
|
display_name: Option<String>,
|
|
roles: Vec<String>,
|
|
scopes: Vec<String>,
|
|
identity_provider: String,
|
|
}
|
|
|
|
/// Show the identity validated by the selected gateway.
|
|
pub async fn whoami(server: &str, tls: &TlsOptions, output: &str) -> Result<()> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
let identity = client
|
|
.get_current_user(GetCurrentUserRequest {})
|
|
.await
|
|
.map_err(|err| match err.code() {
|
|
Code::Unimplemented => miette!("whoami is not supported by this gateway version"),
|
|
Code::Unauthenticated => miette!("whoami requires authentication: {err}"),
|
|
_ => miette!("get_current_user failed: {err}"),
|
|
})?
|
|
.into_inner();
|
|
|
|
let view = CurrentUserView {
|
|
subject: identity.subject,
|
|
display_name: (!identity.display_name.is_empty()).then_some(identity.display_name),
|
|
roles: identity.roles,
|
|
scopes: identity.scopes,
|
|
identity_provider: identity.identity_provider,
|
|
};
|
|
print_current_user(&view, output)
|
|
}
|
|
|
|
fn print_current_user(view: &CurrentUserView, output: &str) -> Result<()> {
|
|
if crate::output::print_output_single(output, view, current_user_to_json)? {
|
|
return Ok(());
|
|
}
|
|
|
|
println!("{}", "Current User".cyan().bold());
|
|
println!();
|
|
println!(" {} {}", "Subject:".dimmed(), view.subject);
|
|
if let Some(display_name) = &view.display_name {
|
|
println!(" {} {}", "Name:".dimmed(), display_name);
|
|
}
|
|
println!(" {} {}", "Provider:".dimmed(), view.identity_provider);
|
|
println!(" {} {}", "Roles:".dimmed(), view.roles.join(", "));
|
|
println!(" {} {}", "Scopes:".dimmed(), view.scopes.join(", "));
|
|
Ok(())
|
|
}
|
|
|
|
fn current_user_to_json(view: &CurrentUserView) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"subject": &view.subject,
|
|
"display_name": &view.display_name,
|
|
"roles": &view.roles,
|
|
"scopes": &view.scopes,
|
|
"identity_provider": &view.identity_provider,
|
|
})
|
|
}
|
|
|
|
/// Validate system prerequisites for running a gateway.
|
|
///
|
|
/// Checks Docker connectivity and reports the result. Returns exit code 0
|
|
/// if all checks pass, 1 otherwise.
|
|
pub fn doctor_check() -> Result<()> {
|
|
use std::io::Write;
|
|
let mut stdout = std::io::stdout().lock();
|
|
|
|
writeln!(stdout, "Checking system prerequisites...\n").into_diagnostic()?;
|
|
|
|
// --- Docker connectivity ---
|
|
write!(stdout, " Docker ............. ").into_diagnostic()?;
|
|
stdout.flush().into_diagnostic()?;
|
|
|
|
let output = Command::new("docker")
|
|
.args(["info", "--format", "{{.ServerVersion}}"])
|
|
.output()
|
|
.into_diagnostic()
|
|
.wrap_err("failed to execute docker info")?;
|
|
|
|
if output.status.success() {
|
|
let version = String::from_utf8_lossy(&output.stdout);
|
|
let version_str = version.trim();
|
|
writeln!(stdout, "ok (version {version_str})").into_diagnostic()?;
|
|
|
|
// --- DOCKER_HOST ---
|
|
write!(stdout, " DOCKER_HOST ........ ").into_diagnostic()?;
|
|
match std::env::var("DOCKER_HOST") {
|
|
Ok(val) => writeln!(stdout, "{val}").into_diagnostic()?,
|
|
Err(_) => writeln!(stdout, "(not set, using default socket)").into_diagnostic()?,
|
|
}
|
|
|
|
writeln!(stdout, "\nAll checks passed.").into_diagnostic()?;
|
|
return Ok(());
|
|
}
|
|
|
|
writeln!(stdout, "FAILED").into_diagnostic()?;
|
|
writeln!(stdout).into_diagnostic()?;
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
Err(miette::miette!("docker info failed: {}", stderr.trim()))
|
|
}
|
|
|
|
fn sandbox_should_persist(keep: bool, forward: Option<&ForwardSpec>) -> bool {
|
|
keep || forward.is_some()
|
|
}
|
|
|
|
fn build_sandbox_resource_limits(
|
|
cpu: Option<&str>,
|
|
memory: Option<&str>,
|
|
) -> Result<Option<prost_types::Struct>> {
|
|
use prost_types::{Struct, Value, value::Kind};
|
|
|
|
fn string_value(value: String) -> Value {
|
|
Value {
|
|
kind: Some(Kind::StringValue(value)),
|
|
}
|
|
}
|
|
|
|
let mut limits = std::collections::BTreeMap::new();
|
|
if let Some(cpu) = cpu {
|
|
limits.insert("cpu".to_string(), string_value(validate_cpu_quantity(cpu)?));
|
|
}
|
|
if let Some(memory) = memory {
|
|
limits.insert(
|
|
"memory".to_string(),
|
|
string_value(validate_memory_quantity(memory)?),
|
|
);
|
|
}
|
|
|
|
if limits.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let mut fields = std::collections::BTreeMap::new();
|
|
fields.insert(
|
|
"limits".to_string(),
|
|
Value {
|
|
kind: Some(Kind::StructValue(Struct { fields: limits })),
|
|
},
|
|
);
|
|
Ok(Some(Struct { fields }))
|
|
}
|
|
|
|
fn parse_driver_config_json(value: &str) -> Result<prost_types::Struct> {
|
|
let parsed: serde_json::Value = serde_json::from_str(value)
|
|
.into_diagnostic()
|
|
.wrap_err("--driver-config-json must be valid JSON")?;
|
|
|
|
let serde_json::Value::Object(fields) = parsed else {
|
|
return Err(miette!(
|
|
"--driver-config-json must be a JSON object keyed by driver name"
|
|
));
|
|
};
|
|
|
|
openshell_core::proto_struct::json_object_to_struct(fields)
|
|
.into_diagnostic()
|
|
.wrap_err("--driver-config-json contains a value that cannot be represented")
|
|
}
|
|
|
|
fn validate_cpu_quantity(value: &str) -> Result<String> {
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
return Err(miette!("--cpu must not be empty"));
|
|
}
|
|
|
|
if let Some(millicores) = value.strip_suffix('m') {
|
|
if millicores.is_empty() || !millicores.bytes().all(|b| b.is_ascii_digit()) {
|
|
return Err(miette!(
|
|
"invalid --cpu value '{value}': expected positive cores or millicores, for example 2, 0.5, or 500m"
|
|
));
|
|
}
|
|
let millicores = millicores.parse::<u64>().into_diagnostic()?;
|
|
if millicores == 0 {
|
|
return Err(miette!("--cpu must be greater than zero"));
|
|
}
|
|
return Ok(value.to_string());
|
|
}
|
|
|
|
let cores = value.parse::<f64>().map_err(|_| {
|
|
miette!(
|
|
"invalid --cpu value '{value}': expected positive cores or millicores, for example 2, 0.5, or 500m"
|
|
)
|
|
})?;
|
|
if !cores.is_finite() || cores <= 0.0 {
|
|
return Err(miette!("--cpu must be greater than zero"));
|
|
}
|
|
Ok(value.to_string())
|
|
}
|
|
|
|
fn validate_memory_quantity(value: &str) -> Result<String> {
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
return Err(miette!("--memory must not be empty"));
|
|
}
|
|
|
|
let number_end = value
|
|
.find(|ch: char| !ch.is_ascii_digit())
|
|
.unwrap_or(value.len());
|
|
let (number, suffix) = value.split_at(number_end);
|
|
if number.is_empty()
|
|
|| !matches!(
|
|
suffix,
|
|
"" | "Ki" | "Mi" | "Gi" | "Ti" | "Pi" | "Ei" | "K" | "M" | "G" | "T" | "P" | "E"
|
|
)
|
|
{
|
|
return Err(miette!(
|
|
"invalid --memory value '{value}': expected positive bytes or a quantity such as 512Mi, 4Gi, or 8G"
|
|
));
|
|
}
|
|
|
|
let amount = number.parse::<u128>().into_diagnostic()?;
|
|
if amount == 0 {
|
|
return Err(miette!("--memory must be greater than zero"));
|
|
}
|
|
Ok(value.to_string())
|
|
}
|
|
|
|
async fn finalize_sandbox_create_session(
|
|
server: &str,
|
|
sandbox_name: &str,
|
|
persist: bool,
|
|
session_result: Result<()>,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
gateway: &str,
|
|
) -> Result<()> {
|
|
if persist {
|
|
return session_result;
|
|
}
|
|
|
|
let names = [sandbox_name.to_string()];
|
|
if let Err(err) = sandbox_delete(server, &names, false, workspace, tls, gateway).await {
|
|
if session_result.is_ok() {
|
|
return Err(err);
|
|
}
|
|
eprintln!("Failed to delete sandbox {sandbox_name}: {err}");
|
|
}
|
|
|
|
session_result
|
|
}
|
|
|
|
/// Configuration for creating a sandbox via the CLI.
|
|
///
|
|
/// Infrastructure parameters (`server`, `gateway_name`, `tls`) remain positional
|
|
/// on the function signature, following the `provider_refresh_config(server, input, tls)`
|
|
/// precedent. This struct captures sandbox-specific options.
|
|
#[derive(Debug)]
|
|
pub struct SandboxCreateConfig<'a> {
|
|
pub name: Option<&'a str>,
|
|
pub from: Option<&'a str>,
|
|
pub uploads: &'a [(String, Option<String>, bool)],
|
|
pub keep: bool,
|
|
pub gpu_requirements: Option<GpuResourceRequirements>,
|
|
pub cpu: Option<&'a str>,
|
|
pub memory: Option<&'a str>,
|
|
pub driver_config_json: Option<&'a str>,
|
|
pub editor: Option<Editor>,
|
|
pub providers: &'a [String],
|
|
pub policy: Option<&'a str>,
|
|
pub forward: Option<ForwardSpec>,
|
|
pub command: &'a [String],
|
|
pub tty_override: Option<bool>,
|
|
pub auto_providers_override: Option<bool>,
|
|
pub labels: HashMap<String, String>,
|
|
pub environment: HashMap<String, String>,
|
|
pub approval_mode: &'a str,
|
|
pub output: &'a str,
|
|
}
|
|
|
|
impl Default for SandboxCreateConfig<'_> {
|
|
fn default() -> Self {
|
|
Self {
|
|
name: None,
|
|
from: None,
|
|
uploads: &[],
|
|
keep: false,
|
|
gpu_requirements: None,
|
|
cpu: None,
|
|
memory: None,
|
|
driver_config_json: None,
|
|
editor: None,
|
|
providers: &[],
|
|
policy: None,
|
|
forward: None,
|
|
command: &[],
|
|
tty_override: None,
|
|
auto_providers_override: None,
|
|
labels: HashMap::new(),
|
|
environment: HashMap::new(),
|
|
approval_mode: "manual",
|
|
output: "table",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a sandbox with default settings.
|
|
pub async fn sandbox_create(
|
|
server: &str,
|
|
gateway_name: &str,
|
|
config: SandboxCreateConfig<'_>,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let SandboxCreateConfig {
|
|
name,
|
|
from,
|
|
uploads,
|
|
keep,
|
|
gpu_requirements,
|
|
cpu,
|
|
memory,
|
|
driver_config_json,
|
|
editor,
|
|
providers,
|
|
policy,
|
|
forward,
|
|
command,
|
|
tty_override,
|
|
auto_providers_override,
|
|
labels,
|
|
environment,
|
|
approval_mode,
|
|
output,
|
|
} = config;
|
|
|
|
if editor.is_some() && !command.is_empty() {
|
|
return Err(miette::miette!(
|
|
"--editor cannot be used with a trailing command; use `openshell sandbox connect <name> --editor ...` after the sandbox is ready"
|
|
));
|
|
}
|
|
|
|
// Check port availability *before* creating the sandbox so we don't
|
|
// leave an orphaned sandbox behind when the forward would fail.
|
|
if let Some(ref spec) = forward {
|
|
openshell_core::forward::check_port_available(spec)?;
|
|
}
|
|
|
|
let mut client = grpc_client(server, tls).await.wrap_err_with(|| {
|
|
format!(
|
|
"failed to connect to gateway '{gateway_name}' at {server}. \
|
|
Start the gateway service with the installed package manager, \
|
|
or register a different endpoint with `openshell gateway add <endpoint>`."
|
|
)
|
|
})?;
|
|
let effective_server = server.to_string();
|
|
let effective_tls = tls.clone();
|
|
|
|
// Resolve the --from flag into a container image reference, building from
|
|
// a Dockerfile first if necessary.
|
|
let image: Option<String> = match from {
|
|
Some(val) => {
|
|
let resolved = resolve_from(val)?;
|
|
match resolved {
|
|
ResolvedSource::Image(img) => Some(img),
|
|
ResolvedSource::Dockerfile {
|
|
dockerfile,
|
|
context,
|
|
} => {
|
|
let tag = build_from_dockerfile(&dockerfile, &context, gateway_name).await?;
|
|
Some(tag)
|
|
}
|
|
}
|
|
}
|
|
None => None,
|
|
};
|
|
let inferred_provider = inferred_provider_type(command);
|
|
let providers_v2_enabled =
|
|
if inferred_provider.is_some() && auto_providers_override != Some(false) {
|
|
gateway_providers_v2_enabled(&mut client).await?
|
|
} else {
|
|
false
|
|
};
|
|
let inferred_types: Vec<String> = if providers_v2_enabled {
|
|
Vec::new()
|
|
} else {
|
|
inferred_provider.into_iter().collect()
|
|
};
|
|
let configured_providers = ensure_required_providers(
|
|
&mut client,
|
|
providers,
|
|
&inferred_types,
|
|
auto_providers_override,
|
|
workspace,
|
|
)
|
|
.await?;
|
|
|
|
let policy = load_sandbox_policy(policy)?;
|
|
let resource_limits = build_sandbox_resource_limits(cpu, memory)?;
|
|
let driver_config = driver_config_json
|
|
.map(parse_driver_config_json)
|
|
.transpose()?;
|
|
|
|
let template = if image.is_some() || resource_limits.is_some() || driver_config.is_some() {
|
|
Some(SandboxTemplate {
|
|
image: image.unwrap_or_default(),
|
|
resources: resource_limits,
|
|
driver_config,
|
|
..SandboxTemplate::default()
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let resource_requirements = gpu_requirements.map(|gpu| ResourceRequirements { gpu: Some(gpu) });
|
|
|
|
let request = CreateSandboxRequest {
|
|
spec: Some(SandboxSpec {
|
|
resource_requirements,
|
|
environment,
|
|
policy,
|
|
providers: configured_providers,
|
|
template,
|
|
..SandboxSpec::default()
|
|
}),
|
|
name: name.unwrap_or_default().to_string(),
|
|
labels,
|
|
annotations: HashMap::new(),
|
|
workspace: workspace.to_string(),
|
|
};
|
|
|
|
let response = match client.create_sandbox(request).await {
|
|
Ok(resp) => resp,
|
|
Err(status) if status.code() == Code::AlreadyExists => {
|
|
return Err(miette::miette!(
|
|
"{}\n\nhint: delete it first with: openshell sandbox delete <name>\n or use a different name",
|
|
status.message()
|
|
));
|
|
}
|
|
Err(status) => return Err(miette::miette!(status.to_string())),
|
|
};
|
|
let sandbox = response
|
|
.into_inner()
|
|
.sandbox
|
|
.ok_or_else(|| miette::miette!("sandbox missing from response"))?;
|
|
|
|
let interactive = std::io::stdout().is_terminal();
|
|
let persist = sandbox_should_persist(keep, forward.as_ref());
|
|
let sandbox_name = if sandbox.object_name().is_empty() {
|
|
"unknown".to_string()
|
|
} else {
|
|
sandbox.object_name().to_string()
|
|
};
|
|
|
|
// 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, workspace, &sandbox_name);
|
|
}
|
|
|
|
// Persist `--approval-mode` as a sandbox-scoped setting now that the
|
|
// sandbox exists. `manual` is the implicit default (no setting needed);
|
|
// any other value is written so it survives sandbox restarts and can be
|
|
// flipped later via `openshell settings set <name> proposal_approval_mode`.
|
|
// If the write fails the sandbox still runs in default `manual` — surface
|
|
// the recovery command so the user can retry.
|
|
if approval_mode != "manual" {
|
|
let setting = parse_cli_setting_value(settings::PROPOSAL_APPROVAL_MODE_KEY, approval_mode)?;
|
|
match client
|
|
.update_config(UpdateConfigRequest {
|
|
name: sandbox_name.clone(),
|
|
setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
|
|
setting_value: Some(setting),
|
|
workspace: workspace.to_string(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(status) => {
|
|
eprintln!(
|
|
"{} failed to set approval mode '{approval_mode}' on sandbox '{sandbox_name}': {}\n retry with: openshell settings set {sandbox_name} proposal_approval_mode {approval_mode}",
|
|
"warning:".yellow().bold(),
|
|
status.message(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let structured_output = output != "table";
|
|
|
|
// Set up display — interactive terminals get a step-based checklist with
|
|
// spinners; non-interactive (pipes / CI) get timestamped lines;
|
|
// structured output suppresses all stdout progress.
|
|
let mut display = if structured_output {
|
|
ProgressOutput::Silent
|
|
} else if interactive {
|
|
ProgressOutput::Interactive(ProvisioningDisplay::new())
|
|
} else {
|
|
ProgressOutput::Plain
|
|
};
|
|
|
|
if structured_output {
|
|
eprintln!("Provisioning sandbox (structured output on stdout)...");
|
|
} else {
|
|
// Print header
|
|
print_sandbox_header(&sandbox, display.as_interactive());
|
|
|
|
// Set initial active step on the spinner.
|
|
match &mut display {
|
|
ProgressOutput::Interactive(d) => {
|
|
d.set_active_step(ProvisioningStep::RequestingSandbox);
|
|
}
|
|
ProgressOutput::Plain => {
|
|
let ts = format_timestamp(Duration::ZERO);
|
|
println!(" {} Requesting compute...", ts.dimmed());
|
|
}
|
|
ProgressOutput::Silent => {}
|
|
}
|
|
}
|
|
|
|
// Non-interactive mode: track start time for timestamps.
|
|
let provision_start = Instant::now();
|
|
|
|
// Don't use stop_on_terminal on the server — the Kubernetes CRD may
|
|
// briefly report a stale Ready status before the controller reconciles
|
|
// a newly created sandbox. Instead we handle termination client-side:
|
|
// we wait until we have observed at least one non-Ready phase followed
|
|
// by Ready (a genuine Provisioning → Ready transition).
|
|
let sandbox_id = if sandbox.object_id().is_empty() {
|
|
"unknown".to_string()
|
|
} else {
|
|
sandbox.object_id().to_string()
|
|
};
|
|
let mut stream = client
|
|
.watch_sandbox(WatchSandboxRequest {
|
|
id: sandbox_id.clone(),
|
|
follow_status: true,
|
|
follow_logs: true,
|
|
follow_events: true,
|
|
log_tail_lines: 200,
|
|
event_tail: 50,
|
|
stop_on_terminal: false,
|
|
log_since_ms: 0,
|
|
log_sources: vec!["gateway".to_string()],
|
|
log_min_level: String::new(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
let mut last_phase = sandbox.phase();
|
|
let mut last_sandbox = sandbox.clone();
|
|
let mut last_error_reason = String::new();
|
|
let mut last_condition_message = ready_false_condition_message(sandbox.status.as_ref());
|
|
// Track whether we have seen a non-Ready phase during the watch.
|
|
let mut saw_non_ready = SandboxPhase::try_from(sandbox.phase()) != Ok(SandboxPhase::Ready);
|
|
let provision_timeout = Duration::from_secs(
|
|
std::env::var("OPENSHELL_PROVISION_TIMEOUT")
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(300),
|
|
);
|
|
let mut provisioning_idle_deadline = Instant::now() + provision_timeout;
|
|
// Track whether we saw the gateway become ready (from log messages).
|
|
let mut saw_gateway_ready = false;
|
|
|
|
loop {
|
|
// Timeout only when provisioning goes idle. VM first-create can spend
|
|
// longer than the default timeout pulling and preparing large images,
|
|
// but only recognized progress events extend the idle deadline. Logs
|
|
// and generic status churn must not keep a stuck sandbox alive forever.
|
|
let remaining = provisioning_idle_deadline.saturating_duration_since(Instant::now());
|
|
if remaining.is_zero() {
|
|
let timeout_message = provisioning_timeout_message(
|
|
provision_timeout.as_secs(),
|
|
resource_requirements.as_ref(),
|
|
last_condition_message.as_deref(),
|
|
);
|
|
if let Some(d) = display.as_interactive_mut() {
|
|
d.finish_error(&timeout_message);
|
|
}
|
|
if display.is_plain() {
|
|
println!();
|
|
}
|
|
return Err(miette::miette!(timeout_message));
|
|
}
|
|
|
|
let maybe_item = tokio::time::timeout(remaining, stream.next()).await;
|
|
|
|
let item = match maybe_item {
|
|
Ok(Some(item)) => item,
|
|
Ok(None) => break, // stream ended
|
|
Err(_elapsed) => {
|
|
// Timeout fired — the stream was idle for too long.
|
|
let timeout_message = provisioning_timeout_message(
|
|
provision_timeout.as_secs(),
|
|
resource_requirements.as_ref(),
|
|
last_condition_message.as_deref(),
|
|
);
|
|
if let Some(d) = display.as_interactive_mut() {
|
|
d.finish_error(&timeout_message);
|
|
}
|
|
if display.is_plain() {
|
|
println!();
|
|
}
|
|
return Err(miette::miette!(timeout_message));
|
|
}
|
|
};
|
|
|
|
let evt = item.into_diagnostic()?;
|
|
match evt.payload {
|
|
Some(openshell_core::proto::sandbox_stream_event::Payload::Sandbox(s)) => {
|
|
let phase = SandboxPhase::try_from(s.phase()).unwrap_or(SandboxPhase::Unknown);
|
|
last_phase = s.phase();
|
|
last_sandbox = s.clone();
|
|
if let Some(message) = ready_false_condition_message(s.status.as_ref()) {
|
|
last_condition_message = Some(message);
|
|
}
|
|
|
|
if phase != SandboxPhase::Ready {
|
|
saw_non_ready = true;
|
|
}
|
|
|
|
// Capture error reason from conditions only when phase is Error
|
|
// to avoid showing stale transient error reasons
|
|
if phase == SandboxPhase::Error
|
|
&& let Some(status) = &s.status
|
|
{
|
|
for condition in &status.conditions {
|
|
if condition.r#type == "Ready"
|
|
&& condition.status.eq_ignore_ascii_case("false")
|
|
{
|
|
last_error_reason =
|
|
format!("{}: {}", condition.reason, condition.message);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Only accept Ready as terminal after we've observed a
|
|
// non-Ready phase, proving the controller has reconciled.
|
|
if saw_non_ready && phase == SandboxPhase::Ready {
|
|
if let Some(d) = display.as_interactive_mut() {
|
|
d.clear();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
Some(openshell_core::proto::sandbox_stream_event::Payload::Log(line)) => {
|
|
// Detect gateway readiness from log messages.
|
|
if !saw_gateway_ready && line.message.contains("listening") {
|
|
saw_gateway_ready = true;
|
|
}
|
|
}
|
|
Some(openshell_core::proto::sandbox_stream_event::Payload::Event(ev)) => {
|
|
let extends_timeout = is_provisioning_progress_event(&ev);
|
|
// Silent mode suppresses all progress output; only update
|
|
// the deadline when applicable.
|
|
let handled = match &mut display {
|
|
ProgressOutput::Interactive(d) => {
|
|
let mut opt = Some(std::mem::replace(d, ProvisioningDisplay::new()));
|
|
let h = handle_platform_progress_event(&ev, &mut opt, provision_start);
|
|
if let Some(inner) = opt {
|
|
*d = inner;
|
|
}
|
|
h
|
|
}
|
|
ProgressOutput::Plain => {
|
|
let mut opt: Option<ProvisioningDisplay> = None;
|
|
handle_platform_progress_event(&ev, &mut opt, provision_start)
|
|
}
|
|
ProgressOutput::Silent => false,
|
|
};
|
|
if handled {
|
|
if extends_timeout {
|
|
provisioning_idle_deadline = Instant::now() + provision_timeout;
|
|
}
|
|
continue;
|
|
}
|
|
if extends_timeout {
|
|
provisioning_idle_deadline = Instant::now() + provision_timeout;
|
|
}
|
|
|
|
if let Some(d) = display.as_interactive_mut()
|
|
&& !ev.message.is_empty()
|
|
{
|
|
d.set_active_detail(&ev.message);
|
|
}
|
|
}
|
|
Some(openshell_core::proto::sandbox_stream_event::Payload::Warning(w)) => {
|
|
match &display {
|
|
ProgressOutput::Interactive(d) => {
|
|
d.println(&format!(" {} {}", "!".yellow().bold(), w.message.yellow()));
|
|
}
|
|
ProgressOutput::Plain | ProgressOutput::Silent => {
|
|
let ts = format_timestamp(provision_start.elapsed());
|
|
eprintln!(" {} {} {}", ts.dimmed(), "WARN".yellow(), w.message);
|
|
}
|
|
}
|
|
}
|
|
Some(openshell_core::proto::sandbox_stream_event::Payload::DraftPolicyUpdate(_))
|
|
| None => {
|
|
// Draft policy updates are handled in the draft panel, not during provisioning.
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we exited the loop without hitting the Ready break, finish the display.
|
|
let final_phase = SandboxPhase::try_from(last_phase).unwrap_or(SandboxPhase::Unknown);
|
|
if final_phase != SandboxPhase::Ready
|
|
&& let Some(d) = display.as_interactive_mut()
|
|
{
|
|
if final_phase == SandboxPhase::Error {
|
|
let msg = if last_error_reason.is_empty() {
|
|
"Sandbox entered error phase".to_string()
|
|
} else {
|
|
format!("Error: {last_error_reason}")
|
|
};
|
|
d.finish_error(&msg);
|
|
} else {
|
|
d.finish_error("Provisioning stream ended unexpectedly");
|
|
}
|
|
}
|
|
drop(display);
|
|
let _ = std::io::stdout().flush();
|
|
let _ = std::io::stderr().flush();
|
|
|
|
match final_phase {
|
|
SandboxPhase::Ready => {
|
|
drop(stream);
|
|
drop(client);
|
|
|
|
let upload_count = uploads.len();
|
|
for (idx, (local_path, sandbox_path, git_ignore)) in uploads.iter().enumerate() {
|
|
let dest = sandbox_path.as_deref();
|
|
let dest_display = dest.unwrap_or("~");
|
|
if upload_count > 1 {
|
|
eprintln!(
|
|
" {} Uploading [{}/{}] files to {dest_display}...",
|
|
"\u{2022}".dimmed(),
|
|
idx + 1,
|
|
upload_count,
|
|
);
|
|
} else {
|
|
eprintln!(
|
|
" {} Uploading files to {dest_display}...",
|
|
"\u{2022}".dimmed(),
|
|
);
|
|
}
|
|
let local = Path::new(local_path);
|
|
match sandbox_upload_plan(local, *git_ignore)? {
|
|
SandboxUploadPlan::GitAware { base_dir, files } => {
|
|
sandbox_sync_up_files(
|
|
&effective_server,
|
|
&sandbox_name,
|
|
&base_dir,
|
|
&files,
|
|
local,
|
|
dest,
|
|
&effective_tls,
|
|
workspace,
|
|
)
|
|
.await?;
|
|
}
|
|
SandboxUploadPlan::GitFilteredEmpty => {
|
|
eprintln!(
|
|
" {} .gitignore filtering excluded all files in {}; uploading unfiltered",
|
|
"⚠".yellow().bold(),
|
|
local.display(),
|
|
);
|
|
sandbox_sync_up(
|
|
&effective_server,
|
|
&sandbox_name,
|
|
local,
|
|
dest,
|
|
&effective_tls,
|
|
workspace,
|
|
)
|
|
.await?;
|
|
}
|
|
SandboxUploadPlan::Regular => {
|
|
sandbox_sync_up(
|
|
&effective_server,
|
|
&sandbox_name,
|
|
local,
|
|
dest,
|
|
&effective_tls,
|
|
workspace,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
eprintln!(" {} Files uploaded", "\u{2713}".green().bold());
|
|
}
|
|
|
|
// If --forward was requested, start the background port forward
|
|
// *before* running the command so that long-running processes
|
|
// (e.g. a web gateway) are reachable immediately.
|
|
if let Some(ref spec) = forward {
|
|
sandbox_forward(
|
|
&effective_server,
|
|
&sandbox_name,
|
|
spec,
|
|
true, // background
|
|
&effective_tls,
|
|
workspace,
|
|
)
|
|
.await?;
|
|
eprintln!(
|
|
" {} Forwarding port {} to sandbox {sandbox_name} in the background\n",
|
|
"\u{2713}".green().bold(),
|
|
spec.port,
|
|
);
|
|
eprintln!(" Access at: {}", spec.access_url());
|
|
eprintln!(
|
|
" Stop with: openshell forward stop {} {sandbox_name}",
|
|
spec.port,
|
|
);
|
|
}
|
|
|
|
if structured_output {
|
|
crate::output::print_output_single(output, &last_sandbox, sandbox_to_json)?;
|
|
return Ok(());
|
|
}
|
|
|
|
if let Some(editor) = editor {
|
|
let ssh_gateway_name = effective_tls.gateway_name().unwrap_or(gateway_name);
|
|
sandbox_connect_editor(
|
|
&effective_server,
|
|
ssh_gateway_name,
|
|
&sandbox_name,
|
|
editor,
|
|
&effective_tls,
|
|
workspace,
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
|
|
if command.is_empty() {
|
|
let connect_result = if persist {
|
|
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
|
|
};
|
|
|
|
return finalize_sandbox_create_session(
|
|
&effective_server,
|
|
&sandbox_name,
|
|
persist,
|
|
connect_result,
|
|
workspace,
|
|
&effective_tls,
|
|
gateway_name,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// Resolve TTY mode: explicit --tty / --no-tty wins, otherwise
|
|
// auto-detect from the local terminal.
|
|
let tty = tty_override.unwrap_or_else(|| {
|
|
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
|
|
});
|
|
let exec_result = if persist {
|
|
sandbox_exec(
|
|
&effective_server,
|
|
&sandbox_name,
|
|
command,
|
|
tty,
|
|
&effective_tls,
|
|
workspace,
|
|
)
|
|
.await
|
|
} else {
|
|
crate::ssh::sandbox_exec_without_exec(
|
|
&effective_server,
|
|
&sandbox_name,
|
|
command,
|
|
tty,
|
|
&effective_tls,
|
|
workspace,
|
|
)
|
|
.await
|
|
};
|
|
|
|
finalize_sandbox_create_session(
|
|
&effective_server,
|
|
&sandbox_name,
|
|
persist,
|
|
exec_result,
|
|
workspace,
|
|
&effective_tls,
|
|
gateway_name,
|
|
)
|
|
.await
|
|
}
|
|
SandboxPhase::Error => {
|
|
if last_error_reason.is_empty() {
|
|
Err(miette::miette!(
|
|
"sandbox entered error phase while provisioning"
|
|
))
|
|
} else {
|
|
Err(miette::miette!(
|
|
"sandbox entered error phase while provisioning: {}",
|
|
last_error_reason
|
|
))
|
|
}
|
|
}
|
|
_ => Err(miette::miette!(
|
|
"sandbox provisioning stream ended before reaching terminal phase"
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Resolved source for the `--from` flag on `sandbox create`.
|
|
#[derive(Debug)]
|
|
enum ResolvedSource {
|
|
/// A ready-to-use container image reference.
|
|
Image(String),
|
|
/// A Dockerfile that must be built before creating the sandbox.
|
|
Dockerfile {
|
|
dockerfile: PathBuf,
|
|
context: PathBuf,
|
|
},
|
|
}
|
|
|
|
/// Classify the `--from` value into an image reference or a Dockerfile that
|
|
/// needs building.
|
|
///
|
|
/// Resolution order:
|
|
/// 1. Existing file whose name contains "Dockerfile" → build from file.
|
|
/// 2. Existing directory that contains a `Dockerfile` → build from directory.
|
|
/// 3. Missing explicit local paths → local error, not image pull.
|
|
/// 4. Value contains `/`, `:`, or `.` → treat as a full image reference.
|
|
/// 5. Otherwise → community sandbox name, expanded via the registry prefix.
|
|
fn resolve_from(value: &str) -> Result<ResolvedSource> {
|
|
let path = Path::new(value);
|
|
|
|
// 1. Existing file that looks like a Dockerfile.
|
|
if path.is_file() {
|
|
if filename_looks_like_dockerfile(path) {
|
|
let dockerfile = path
|
|
.canonicalize()
|
|
.into_diagnostic()
|
|
.wrap_err_with(|| format!("failed to resolve path: {}", path.display()))?;
|
|
let context = dockerfile
|
|
.parent()
|
|
.ok_or_else(|| miette::miette!("Dockerfile has no parent directory"))?
|
|
.to_path_buf();
|
|
return Ok(ResolvedSource::Dockerfile {
|
|
dockerfile,
|
|
context,
|
|
});
|
|
}
|
|
|
|
if value_looks_like_local_source(value) {
|
|
return Err(miette::miette!(
|
|
"local --from file is not a Dockerfile: {}",
|
|
path.display()
|
|
));
|
|
}
|
|
}
|
|
|
|
// 2. Existing directory containing a Dockerfile.
|
|
if path.is_dir() {
|
|
let candidate = path.join("Dockerfile");
|
|
if candidate.is_file() {
|
|
let context = path
|
|
.canonicalize()
|
|
.into_diagnostic()
|
|
.wrap_err_with(|| format!("failed to resolve path: {}", path.display()))?;
|
|
let dockerfile = context.join("Dockerfile");
|
|
return Ok(ResolvedSource::Dockerfile {
|
|
dockerfile,
|
|
context,
|
|
});
|
|
}
|
|
return Err(miette::miette!(
|
|
"No Dockerfile found in directory: {}",
|
|
path.display()
|
|
));
|
|
}
|
|
|
|
if path.exists() {
|
|
return Err(miette::miette!(
|
|
"local --from path is not a regular file or directory: {}",
|
|
path.display()
|
|
));
|
|
}
|
|
|
|
// 3. Missing explicit local paths should fail locally. Otherwise values
|
|
// like `./Dockerfile` reach the gateway as image references and fail as
|
|
// Docker pull errors.
|
|
if value_looks_like_local_source(value) {
|
|
return Err(miette::miette!(
|
|
"local --from path does not exist: {}\n\
|
|
Use an existing Dockerfile, a directory containing Dockerfile, or a container image reference.",
|
|
path.display()
|
|
));
|
|
}
|
|
|
|
// 4. Full image reference or community sandbox name — delegate to shared
|
|
// resolution in openshell-core.
|
|
Ok(ResolvedSource::Image(
|
|
openshell_core::image::resolve_community_image(value),
|
|
))
|
|
}
|
|
|
|
fn filename_looks_like_dockerfile(path: &Path) -> bool {
|
|
let name = path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy())
|
|
.unwrap_or_default();
|
|
let lower = name.to_lowercase();
|
|
lower.contains("dockerfile") || lower.ends_with(".dockerfile")
|
|
}
|
|
|
|
fn value_looks_like_local_source(value: &str) -> bool {
|
|
value_is_explicit_local_path(value) || value_looks_like_bare_dockerfile_name(value)
|
|
}
|
|
|
|
fn value_is_explicit_local_path(value: &str) -> bool {
|
|
let path = Path::new(value);
|
|
path.is_absolute()
|
|
|| matches!(value, "." | "..")
|
|
|| value.starts_with("./")
|
|
|| value.starts_with("../")
|
|
|| value.starts_with("~/")
|
|
}
|
|
|
|
fn value_looks_like_bare_dockerfile_name(value: &str) -> bool {
|
|
!value.contains('/') && !value.contains(':') && filename_looks_like_dockerfile(Path::new(value))
|
|
}
|
|
|
|
fn dockerfile_sources_supported_for_gateway(metadata: Option<&GatewayMetadata>) -> bool {
|
|
!metadata.is_some_and(|metadata| metadata.is_remote)
|
|
}
|
|
|
|
/// Build a Dockerfile and return the local Docker tag.
|
|
///
|
|
/// Package-managed local gateways use the same Docker daemon that the CLI
|
|
/// builds into, so the tag is passed through directly and the active compute
|
|
/// driver resolves it.
|
|
async fn build_from_dockerfile(
|
|
dockerfile: &Path,
|
|
context: &Path,
|
|
gateway_name: &str,
|
|
) -> Result<String> {
|
|
let metadata = get_gateway_metadata(gateway_name);
|
|
if !dockerfile_sources_supported_for_gateway(metadata.as_ref()) {
|
|
return Err(miette!(
|
|
"local Dockerfile sources are only supported for local gateways; gateway '{}' is remote",
|
|
gateway_name
|
|
));
|
|
}
|
|
|
|
let timestamp = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
let tag = format!("openshell/sandbox-from:{timestamp}");
|
|
|
|
eprintln!(
|
|
"Building image {} from {}",
|
|
tag.cyan(),
|
|
dockerfile.display()
|
|
);
|
|
eprintln!(" {} {}", "Context:".dimmed(), context.display());
|
|
eprintln!(" {} {}", "Gateway:".dimmed(), gateway_name);
|
|
eprintln!();
|
|
|
|
let mut on_log = |msg: String| {
|
|
eprintln!(" {msg}");
|
|
};
|
|
|
|
openshell_bootstrap::build::build_local_image(
|
|
dockerfile,
|
|
&tag,
|
|
context,
|
|
&HashMap::new(),
|
|
&mut on_log,
|
|
)
|
|
.await?;
|
|
|
|
eprintln!();
|
|
eprintln!(
|
|
"{} Image {} is available in the local Docker daemon for gateway '{}'.",
|
|
"✓".green().bold(),
|
|
tag.cyan(),
|
|
gateway_name,
|
|
);
|
|
eprintln!();
|
|
|
|
Ok(tag)
|
|
}
|
|
|
|
/// Load sandbox policy YAML.
|
|
///
|
|
/// Resolution order: `--policy` flag > `OPENSHELL_SANDBOX_POLICY` env var.
|
|
/// Returns `None` when no policy source is configured, allowing the server
|
|
/// to apply its own default.
|
|
fn load_sandbox_policy(cli_path: Option<&str>) -> Result<Option<SandboxPolicy>> {
|
|
openshell_policy::load_sandbox_policy(cli_path)
|
|
}
|
|
|
|
/// Sync files to or from a sandbox.
|
|
///
|
|
/// Dispatches to `sandbox_sync_up` or `sandbox_sync_down` based on the
|
|
/// `--up` / `--down` flags.
|
|
pub async fn sandbox_sync_command(
|
|
server: &str,
|
|
name: &str,
|
|
up: Option<&str>,
|
|
down: Option<&str>,
|
|
dest: Option<&str>,
|
|
tls: &TlsOptions,
|
|
workspace: &str,
|
|
) -> Result<()> {
|
|
match (up, down) {
|
|
(Some(local_path), None) => {
|
|
let local = Path::new(local_path);
|
|
if !local.exists() {
|
|
return Err(miette::miette!(
|
|
"local path does not exist: {}",
|
|
local.display()
|
|
));
|
|
}
|
|
let dest_display = dest.unwrap_or("~");
|
|
eprintln!("Syncing {} -> sandbox:{}", local.display(), dest_display);
|
|
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, workspace).await?;
|
|
eprintln!("{} Sync complete", "✓".green().bold());
|
|
}
|
|
_ => {
|
|
return Err(miette::miette!(
|
|
"specify either --up <local-path> or --down <sandbox-path>"
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Fetch a sandbox by name.
|
|
///
|
|
/// Policy always comes from [`GetSandboxConfig`] (effective active policy, sandbox
|
|
/// or global). With `policy_only`, prints only that YAML to stdout; otherwise
|
|
/// prints sandbox metadata and the same policy with formatted YAML.
|
|
pub async fn sandbox_get(
|
|
server: &str,
|
|
name: &str,
|
|
policy_only: bool,
|
|
output: &str,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
|
|
let response = client
|
|
.get_sandbox(GetSandboxRequest {
|
|
name: name.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
let sandbox = response
|
|
.into_inner()
|
|
.sandbox
|
|
.ok_or_else(|| miette::miette!("sandbox missing from response"))?;
|
|
|
|
let sandbox_id = if sandbox.object_id().is_empty() {
|
|
return Err(miette::miette!("sandbox missing metadata"));
|
|
} else {
|
|
sandbox.object_id().to_string()
|
|
};
|
|
|
|
let config = client
|
|
.get_sandbox_config(GetSandboxConfigRequest { sandbox_id })
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
if policy_only {
|
|
let Some(ref policy) = config.policy else {
|
|
return Err(miette::miette!(
|
|
"no active policy configured for this sandbox"
|
|
));
|
|
};
|
|
let yaml_str = openshell_policy::serialize_sandbox_policy(policy)
|
|
.wrap_err("failed to serialize policy to YAML")?;
|
|
print!("{yaml_str}");
|
|
return Ok(());
|
|
}
|
|
|
|
let detail_json = sandbox_detail_to_json(&sandbox, &config)?;
|
|
if crate::output::print_output_single(output, &detail_json, Clone::clone)? {
|
|
return Ok(());
|
|
}
|
|
|
|
println!("{}", "Sandbox:".cyan().bold());
|
|
println!();
|
|
let id = if sandbox.object_id().is_empty() {
|
|
"unknown"
|
|
} else {
|
|
sandbox.object_id()
|
|
};
|
|
let name = if sandbox.object_name().is_empty() {
|
|
"unknown"
|
|
} else {
|
|
sandbox.object_name()
|
|
};
|
|
println!(" {} {}", "Id:".dimmed(), id);
|
|
println!(" {} {}", "Name:".dimmed(), name);
|
|
println!(" {} {}", "Phase:".dimmed(), phase_name(sandbox.phase()));
|
|
println!(
|
|
" {} {}",
|
|
"Resource version:".dimmed(),
|
|
sandbox.metadata.as_ref().map_or(0, |m| m.resource_version)
|
|
);
|
|
|
|
// Display labels if present
|
|
if let Some(metadata) = &sandbox.metadata
|
|
&& !metadata.labels.is_empty()
|
|
{
|
|
println!(" {} ", "Labels:".dimmed());
|
|
let mut labels: Vec<_> = metadata.labels.iter().collect();
|
|
labels.sort_by_key(|(k, _)| *k);
|
|
for (key, value) in labels {
|
|
println!(" {key}: {value}");
|
|
}
|
|
}
|
|
|
|
if let Some(metadata) = &sandbox.metadata
|
|
&& !metadata.annotations.is_empty()
|
|
{
|
|
println!(" {} ", "Annotations:".dimmed());
|
|
let mut annotations: Vec<_> = metadata.annotations.iter().collect();
|
|
annotations.sort_by_key(|(k, _)| *k);
|
|
for (key, value) in annotations {
|
|
println!(" {key}: {value}");
|
|
}
|
|
}
|
|
|
|
let policy_from_global = config.policy_source == PolicySource::Global as i32;
|
|
println!(
|
|
" {} {}",
|
|
"Policy source:".dimmed(),
|
|
if policy_from_global {
|
|
"global"
|
|
} else {
|
|
"sandbox"
|
|
}
|
|
);
|
|
let revision = if policy_from_global {
|
|
if config.global_policy_version > 0 {
|
|
Some(config.global_policy_version)
|
|
} else if config.version > 0 {
|
|
Some(config.version)
|
|
} else {
|
|
None
|
|
}
|
|
} else if config.version > 0 {
|
|
Some(config.version)
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(rev) = revision {
|
|
println!(" {} {}", "Revision:".dimmed(), rev);
|
|
}
|
|
|
|
if let Some(ref policy) = config.policy {
|
|
println!();
|
|
print_sandbox_policy(policy);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Maximum stdin payload size (4 MiB). Prevents the CLI from reading unbounded
|
|
/// data into memory before the server rejects an oversized message.
|
|
const MAX_STDIN_PAYLOAD: usize = 4 * 1024 * 1024;
|
|
|
|
/// Execute a command in a running sandbox via gRPC, streaming output to the terminal.
|
|
///
|
|
/// Returns the remote command's exit code.
|
|
#[allow(clippy::too_many_arguments, clippy::implicit_hasher)]
|
|
pub async fn sandbox_exec_grpc(
|
|
server: &str,
|
|
name: &str,
|
|
command: &[String],
|
|
workdir: Option<&str>,
|
|
timeout_seconds: u32,
|
|
tty_override: Option<bool>,
|
|
environment: &HashMap<String, String>,
|
|
tls: &TlsOptions,
|
|
workspace: &str,
|
|
) -> Result<i32> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
|
|
// Resolve sandbox name to id.
|
|
let sandbox = client
|
|
.get_sandbox(GetSandboxRequest {
|
|
name: name.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner()
|
|
.sandbox
|
|
.ok_or_else(|| miette::miette!("sandbox not found"))?;
|
|
|
|
// Verify the sandbox is ready before issuing the exec.
|
|
if SandboxPhase::try_from(sandbox.phase()) != Ok(SandboxPhase::Ready) {
|
|
return Err(miette::miette!(
|
|
"sandbox '{}' is not ready (phase: {}); wait for it to reach Ready state",
|
|
name,
|
|
phase_name(sandbox.phase())
|
|
));
|
|
}
|
|
|
|
// Read stdin if piped (not a TTY), using spawn_blocking to avoid blocking
|
|
// the async runtime. Cap the read at MAX_STDIN_PAYLOAD + 1 so we never
|
|
// buffer more than the limit into memory.
|
|
let stdin_payload = if std::io::stdin().is_terminal() {
|
|
Vec::new()
|
|
} else {
|
|
tokio::task::spawn_blocking(|| {
|
|
let limit = (MAX_STDIN_PAYLOAD + 1) as u64;
|
|
let mut buf = Vec::new();
|
|
std::io::stdin()
|
|
.take(limit)
|
|
.read_to_end(&mut buf)
|
|
.into_diagnostic()?;
|
|
if buf.len() > MAX_STDIN_PAYLOAD {
|
|
return Err(miette::miette!(
|
|
"stdin payload exceeds {} byte limit; pipe smaller inputs or use `sandbox upload`",
|
|
MAX_STDIN_PAYLOAD
|
|
));
|
|
}
|
|
Ok(buf)
|
|
})
|
|
.await
|
|
.into_diagnostic()?? // first ? unwraps JoinError, second ? unwraps Result
|
|
};
|
|
|
|
// Resolve TTY mode: explicit --tty / --no-tty wins, otherwise auto-detect.
|
|
let tty = tty_override
|
|
.unwrap_or_else(|| std::io::stdin().is_terminal() && std::io::stdout().is_terminal());
|
|
|
|
if tty_override == Some(true) && std::io::stdin().is_terminal() {
|
|
return sandbox_exec_interactive_grpc(
|
|
client,
|
|
&sandbox,
|
|
command,
|
|
workdir,
|
|
timeout_seconds,
|
|
environment,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// Make the streaming gRPC call.
|
|
let mut stream = client
|
|
.exec_sandbox(ExecSandboxRequest {
|
|
sandbox_id: sandbox.object_id().to_string(),
|
|
command: command.to_vec(),
|
|
workdir: workdir.unwrap_or_default().to_string(),
|
|
environment: environment.clone(),
|
|
timeout_seconds,
|
|
stdin: stdin_payload,
|
|
tty,
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
// Stream output to terminal in real-time.
|
|
let mut exit_code = 0i32;
|
|
let stdout = std::io::stdout();
|
|
let stderr = std::io::stderr();
|
|
|
|
while let Some(event) = stream.next().await {
|
|
let event = event.into_diagnostic()?;
|
|
match event.payload {
|
|
Some(exec_sandbox_event::Payload::Stdout(out)) => {
|
|
let mut handle = stdout.lock();
|
|
handle.write_all(&out.data).into_diagnostic()?;
|
|
handle.flush().into_diagnostic()?;
|
|
}
|
|
Some(exec_sandbox_event::Payload::Stderr(err)) => {
|
|
let mut handle = stderr.lock();
|
|
handle.write_all(&err.data).into_diagnostic()?;
|
|
handle.flush().into_diagnostic()?;
|
|
}
|
|
Some(exec_sandbox_event::Payload::Exit(exit)) => {
|
|
exit_code = exit.exit_code;
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
Ok(exit_code)
|
|
}
|
|
|
|
pub async fn service_forward_tcp(
|
|
server: &str,
|
|
name: &str,
|
|
local: Option<&str>,
|
|
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, workspace).await?;
|
|
|
|
let listener = tokio::net::TcpListener::bind((bind_addr.as_str(), bind_port))
|
|
.await
|
|
.into_diagnostic()
|
|
.wrap_err_with(|| format!("failed to bind local forward on {bind_addr}:{bind_port}"))?;
|
|
let local_addr = listener
|
|
.local_addr()
|
|
.into_diagnostic()
|
|
.wrap_err("failed to read local forward address")?;
|
|
eprintln!(
|
|
"{} Forwarding {} -> {}:{} in sandbox {} via gRPC",
|
|
"✓".green().bold(),
|
|
local_addr,
|
|
target_host,
|
|
target_port,
|
|
name,
|
|
);
|
|
|
|
let sandbox_id = sandbox.object_id().to_string();
|
|
let (fatal_tx, mut fatal_rx) = tokio::sync::mpsc::channel::<String>(1);
|
|
let mut health_check = tokio::time::interval(Duration::from_secs(2));
|
|
health_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
loop {
|
|
tokio::select! {
|
|
Some(reason) = fatal_rx.recv() => {
|
|
return Err(miette::miette!("service forward stopped: {reason}"));
|
|
}
|
|
|
|
_ = health_check.tick() => {
|
|
fetch_ready_sandbox_for_forward(&mut client, name, workspace).await?;
|
|
}
|
|
|
|
accepted = listener.accept() => {
|
|
let (socket, peer) = accepted
|
|
.into_diagnostic()
|
|
.wrap_err("failed to accept local forward connection")?;
|
|
set_tcp_nodelay_best_effort(&socket);
|
|
let mut client = client.clone();
|
|
let sandbox_id = sandbox_id.clone();
|
|
let target_host = target_host.to_string();
|
|
let service_id = format!("service-forward:{name}:{target_host}:{target_port}");
|
|
let fatal_tx = fatal_tx.clone();
|
|
tokio::spawn(async move {
|
|
let token = match create_forward_session_token(&mut client, &sandbox_id).await {
|
|
Ok(token) => token,
|
|
Err(err) => {
|
|
tracing::warn!(peer = %peer, error = %err, "service forward session creation failed");
|
|
if err.fatal {
|
|
let _ = fatal_tx.send(err.message).await;
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
if let Err(err) = forward_one_tcp_connection(
|
|
&mut client,
|
|
socket,
|
|
sandbox_id,
|
|
target_host,
|
|
target_port,
|
|
service_id,
|
|
token.clone(),
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(peer = %peer, error = %err, "service forward connection failed");
|
|
if err.fatal {
|
|
let _ = fatal_tx.send(err.message).await;
|
|
}
|
|
}
|
|
let _ = client
|
|
.revoke_ssh_session(RevokeSshSessionRequest { token })
|
|
.await;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn create_forward_session_token(
|
|
client: &mut crate::tls::GrpcClient,
|
|
sandbox_id: &str,
|
|
) -> std::result::Result<String, ForwardTcpConnectionError> {
|
|
let response = client
|
|
.create_ssh_session(CreateSshSessionRequest {
|
|
sandbox_id: sandbox_id.to_string(),
|
|
})
|
|
.await
|
|
.map_err(ForwardTcpConnectionError::from_status)?;
|
|
Ok(response.into_inner().token)
|
|
}
|
|
|
|
async fn fetch_ready_sandbox_for_forward(
|
|
client: &mut crate::tls::GrpcClient,
|
|
name: &str,
|
|
workspace: &str,
|
|
) -> Result<Sandbox> {
|
|
let response = match client
|
|
.get_sandbox(GetSandboxRequest {
|
|
name: name.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
{
|
|
Ok(response) => response,
|
|
Err(status) if status.code() == Code::NotFound => {
|
|
return Err(miette::miette!(
|
|
"sandbox '{name}' no longer exists; stopping service forward"
|
|
));
|
|
}
|
|
Err(status) => return Err(status).into_diagnostic(),
|
|
};
|
|
|
|
let sandbox = response
|
|
.into_inner()
|
|
.sandbox
|
|
.ok_or_else(|| miette::miette!("sandbox '{name}' not found"))?;
|
|
|
|
if SandboxPhase::try_from(sandbox.phase()) != Ok(SandboxPhase::Ready) {
|
|
return Err(miette::miette!(
|
|
"sandbox '{}' is no longer ready (phase: {}); stopping service forward",
|
|
name,
|
|
phase_name(sandbox.phase())
|
|
));
|
|
}
|
|
|
|
Ok(sandbox)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ForwardTcpConnectionError {
|
|
message: String,
|
|
fatal: bool,
|
|
}
|
|
|
|
impl ForwardTcpConnectionError {
|
|
fn transient(message: impl Into<String>) -> Self {
|
|
Self {
|
|
message: message.into(),
|
|
fatal: false,
|
|
}
|
|
}
|
|
|
|
fn from_status(status: Status) -> Self {
|
|
let fatal = matches!(status.code(), Code::NotFound | Code::FailedPrecondition);
|
|
Self {
|
|
message: status.to_string(),
|
|
fatal,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for ForwardTcpConnectionError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(&self.message)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ForwardTcpConnectionError {}
|
|
|
|
fn parse_tcp_forward_spec(local: Option<&str>, default_port: u16) -> Result<(String, u16)> {
|
|
let Some(spec) = local else {
|
|
return Ok(("127.0.0.1".to_string(), default_port));
|
|
};
|
|
|
|
if let Some(pos) = spec.rfind(':') {
|
|
let addr = &spec[..pos];
|
|
let port_str = &spec[pos + 1..];
|
|
if let Ok(port) = port_str.parse::<u16>() {
|
|
if addr.is_empty() {
|
|
return Err(miette::miette!("bind address is required before ':'"));
|
|
}
|
|
return Ok((addr.to_string(), port));
|
|
}
|
|
}
|
|
|
|
let port: u16 = spec.parse().map_err(|_| {
|
|
miette::miette!("invalid local forward spec '{spec}': expected [bind_address:]port")
|
|
})?;
|
|
Ok(("127.0.0.1".to_string(), port))
|
|
}
|
|
|
|
async fn forward_one_tcp_connection(
|
|
client: &mut crate::tls::GrpcClient,
|
|
socket: tokio::net::TcpStream,
|
|
sandbox_id: String,
|
|
target_host: String,
|
|
target_port: u16,
|
|
service_id: String,
|
|
authorization_token: String,
|
|
) -> std::result::Result<(), ForwardTcpConnectionError> {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio_stream::wrappers::ReceiverStream;
|
|
|
|
let (tx, rx) = tokio::sync::mpsc::channel::<TcpForwardFrame>(16);
|
|
tx.send(TcpForwardFrame {
|
|
payload: Some(openshell_core::proto::tcp_forward_frame::Payload::Init(
|
|
TcpForwardInit {
|
|
sandbox_id,
|
|
service_id,
|
|
target: Some(tcp_forward_init::Target::Tcp(TcpRelayTarget {
|
|
host: target_host,
|
|
port: u32::from(target_port),
|
|
})),
|
|
authorization_token,
|
|
},
|
|
)),
|
|
})
|
|
.await
|
|
.map_err(|_| ForwardTcpConnectionError::transient("failed to initialize forward stream"))?;
|
|
|
|
let mut response = match client.forward_tcp(ReceiverStream::new(rx)).await {
|
|
Ok(response) => response.into_inner(),
|
|
Err(status) => {
|
|
let err = ForwardTcpConnectionError::from_status(status);
|
|
drain_and_shutdown_local_socket(socket).await;
|
|
return Err(err);
|
|
}
|
|
};
|
|
|
|
let (mut local_read, mut local_write) = socket.into_split();
|
|
|
|
let to_gateway = tokio::spawn(async move {
|
|
let mut buf = vec![0u8; 64 * 1024];
|
|
loop {
|
|
let n = local_read.read(&mut buf).await?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
if tx
|
|
.send(TcpForwardFrame {
|
|
payload: Some(openshell_core::proto::tcp_forward_frame::Payload::Data(
|
|
buf[..n].to_vec(),
|
|
)),
|
|
})
|
|
.await
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
Ok::<(), std::io::Error>(())
|
|
});
|
|
|
|
while let Some(frame) = response
|
|
.message()
|
|
.await
|
|
.map_err(ForwardTcpConnectionError::from_status)?
|
|
{
|
|
let Some(openshell_core::proto::tcp_forward_frame::Payload::Data(data)) = frame.payload
|
|
else {
|
|
continue;
|
|
};
|
|
if data.is_empty() {
|
|
continue;
|
|
}
|
|
local_write
|
|
.write_all(&data)
|
|
.await
|
|
.map_err(|err| ForwardTcpConnectionError::transient(err.to_string()))?;
|
|
}
|
|
|
|
let _ = local_write.shutdown().await;
|
|
to_gateway.abort();
|
|
Ok(())
|
|
}
|
|
|
|
async fn drain_and_shutdown_local_socket(mut socket: tokio::net::TcpStream) {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
let mut buf = [0u8; 4096];
|
|
while matches!(
|
|
tokio::time::timeout(Duration::from_millis(25), socket.read(&mut buf)).await,
|
|
Ok(Ok(n)) if n != 0
|
|
) {}
|
|
let _ = socket.shutdown().await;
|
|
}
|
|
|
|
struct RawModeGuard;
|
|
|
|
impl Drop for RawModeGuard {
|
|
fn drop(&mut self) {
|
|
let _ = crossterm::terminal::disable_raw_mode();
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
struct TaskGuard(tokio::task::JoinHandle<()>);
|
|
|
|
#[cfg(unix)]
|
|
impl Drop for TaskGuard {
|
|
fn drop(&mut self) {
|
|
self.0.abort();
|
|
}
|
|
}
|
|
|
|
async fn sandbox_exec_interactive_grpc(
|
|
mut client: crate::tls::GrpcClient,
|
|
sandbox: &Sandbox,
|
|
command: &[String],
|
|
workdir: Option<&str>,
|
|
timeout_seconds: u32,
|
|
environment: &HashMap<String, String>,
|
|
) -> Result<i32> {
|
|
#[cfg(unix)]
|
|
use openshell_core::proto::ExecSandboxWindowResize;
|
|
use openshell_core::proto::{ExecSandboxInput, exec_sandbox_input};
|
|
use tokio_stream::wrappers::ReceiverStream;
|
|
|
|
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
|
|
|
|
let (input_tx, input_rx) = tokio::sync::mpsc::channel::<ExecSandboxInput>(4096);
|
|
|
|
// Send the start message with exec metadata.
|
|
input_tx
|
|
.send(ExecSandboxInput {
|
|
payload: Some(exec_sandbox_input::Payload::Start(ExecSandboxRequest {
|
|
sandbox_id: sandbox.object_id().to_string(),
|
|
command: command.to_vec(),
|
|
workdir: workdir.unwrap_or_default().to_string(),
|
|
environment: environment.clone(),
|
|
timeout_seconds,
|
|
stdin: Vec::new(),
|
|
tty: true,
|
|
cols: u32::from(cols),
|
|
rows: u32::from(rows),
|
|
})),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let mut stream = client
|
|
.exec_sandbox_interactive(ReceiverStream::new(input_rx))
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
// Enable raw mode so keystrokes are forwarded immediately.
|
|
crossterm::terminal::enable_raw_mode().into_diagnostic()?;
|
|
let raw_guard = RawModeGuard;
|
|
|
|
// Stdin reader on a detached OS thread. Using std::thread (not
|
|
// spawn_blocking) so the tokio runtime shutdown doesn't wait for a
|
|
// thread blocked on stdin.read(). The thread exits when the channel
|
|
// closes (blocking_send returns Err) or stdin hits EOF.
|
|
let stdin_tx = input_tx.clone();
|
|
std::thread::spawn(move || {
|
|
let mut stdin = std::io::stdin().lock();
|
|
let mut buf = [0u8; 4096];
|
|
loop {
|
|
match stdin.read(&mut buf) {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(n) => {
|
|
if stdin_tx
|
|
.blocking_send(ExecSandboxInput {
|
|
payload: Some(exec_sandbox_input::Payload::Stdin(buf[..n].to_vec())),
|
|
})
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// SIGWINCH handler: forward terminal resize events.
|
|
#[cfg(unix)]
|
|
let resize_task = {
|
|
let resize_tx = input_tx.clone();
|
|
tokio::spawn(async move {
|
|
let mut sig =
|
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change())
|
|
.expect("failed to register SIGWINCH handler");
|
|
while sig.recv().await.is_some() {
|
|
if let Ok((c, r)) = crossterm::terminal::size() {
|
|
let msg = ExecSandboxInput {
|
|
payload: Some(exec_sandbox_input::Payload::Resize(
|
|
ExecSandboxWindowResize {
|
|
cols: u32::from(c),
|
|
rows: u32::from(r),
|
|
},
|
|
)),
|
|
};
|
|
if resize_tx.send(msg).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
};
|
|
#[cfg(unix)]
|
|
let _resize_guard = TaskGuard(resize_task);
|
|
|
|
let mut exit_code = 0i32;
|
|
let stdout = std::io::stdout();
|
|
let stderr = std::io::stderr();
|
|
|
|
while let Some(event) = stream.next().await {
|
|
let event = event.into_diagnostic()?;
|
|
match event.payload {
|
|
Some(exec_sandbox_event::Payload::Stdout(out)) => {
|
|
let mut handle = stdout.lock();
|
|
handle.write_all(&out.data).into_diagnostic()?;
|
|
handle.flush().into_diagnostic()?;
|
|
}
|
|
Some(exec_sandbox_event::Payload::Stderr(err)) => {
|
|
let mut handle = stderr.lock();
|
|
handle.write_all(&err.data).into_diagnostic()?;
|
|
handle.flush().into_diagnostic()?;
|
|
}
|
|
Some(exec_sandbox_event::Payload::Exit(exit)) => {
|
|
exit_code = exit.exit_code;
|
|
break;
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
drop(input_tx);
|
|
|
|
// Drop the raw mode guard to restore the terminal before returning.
|
|
drop(raw_guard);
|
|
|
|
Ok(exit_code)
|
|
}
|
|
|
|
/// List sandboxes.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn sandbox_list(
|
|
server: &str,
|
|
limit: u32,
|
|
offset: u32,
|
|
ids_only: bool,
|
|
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?;
|
|
|
|
let response = client
|
|
.list_sandboxes(ListSandboxesRequest {
|
|
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()?;
|
|
|
|
let sandboxes = response.into_inner().sandboxes;
|
|
|
|
if crate::output::print_output_collection(output, &sandboxes, sandbox_to_json)? {
|
|
return Ok(());
|
|
}
|
|
|
|
if sandboxes.is_empty() {
|
|
if !ids_only && !names_only {
|
|
println!("No sandboxes found.");
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
if ids_only {
|
|
for sandbox in sandboxes {
|
|
println!("{}", sandbox.object_id());
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
if names_only {
|
|
for sandbox in &sandboxes {
|
|
if all_workspaces {
|
|
println!("{}/{}", sandbox.object_workspace(), sandbox.object_name());
|
|
} else {
|
|
println!("{}", sandbox.object_name());
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Calculate column widths
|
|
let name_width = sandboxes
|
|
.iter()
|
|
.map(|s| s.object_name().len())
|
|
.max()
|
|
.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
|
|
if all_workspaces {
|
|
println!(
|
|
"{:<ws_width$} {:<name_width$} {:<created_width$} {}",
|
|
"WORKSPACE".bold(),
|
|
"NAME".bold(),
|
|
"CREATED".bold(),
|
|
"PHASE".bold(),
|
|
);
|
|
} else {
|
|
println!(
|
|
"{:<name_width$} {:<created_width$} {}",
|
|
"NAME".bold(),
|
|
"CREATED".bold(),
|
|
"PHASE".bold(),
|
|
);
|
|
}
|
|
|
|
// Print rows
|
|
for sandbox in sandboxes {
|
|
let phase = phase_name(sandbox.phase());
|
|
let phase_colored = match SandboxPhase::try_from(sandbox.phase()) {
|
|
Ok(SandboxPhase::Ready) => phase.green().to_string(),
|
|
Ok(SandboxPhase::Error) => phase.red().to_string(),
|
|
Ok(SandboxPhase::Provisioning) => phase.yellow().to_string(),
|
|
Ok(SandboxPhase::Deleting) => phase.dimmed().to_string(),
|
|
_ => phase.to_string(),
|
|
};
|
|
let created = format_epoch_ms(sandbox.metadata.as_ref().map_or(0, |m| m.created_at_ms));
|
|
if all_workspaces {
|
|
println!(
|
|
"{:<ws_width$} {:<name_width$} {:<created_width$} {}",
|
|
sandbox.object_workspace().to_string(),
|
|
sandbox.object_name().to_string(),
|
|
created,
|
|
phase_colored,
|
|
);
|
|
} else {
|
|
println!(
|
|
"{:<name_width$} {:<created_width$} {}",
|
|
sandbox.object_name().to_string(),
|
|
created,
|
|
phase_colored,
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
|
|
let meta = sandbox.metadata.as_ref();
|
|
let labels = meta.map_or_else(|| serde_json::json!({}), |m| serde_json::json!(m.labels));
|
|
let annotations = meta.map_or_else(
|
|
|| serde_json::json!({}),
|
|
|m| serde_json::json!(m.annotations),
|
|
);
|
|
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),
|
|
"created_at": format_epoch_ms(meta.map_or(0, |m| m.created_at_ms)),
|
|
"phase": phase_name(sandbox.phase()),
|
|
"current_policy_version": sandbox.current_policy_version(),
|
|
})
|
|
}
|
|
|
|
fn sandbox_detail_to_json(
|
|
sandbox: &Sandbox,
|
|
config: &GetSandboxConfigResponse,
|
|
) -> Result<serde_json::Value> {
|
|
let mut value = sandbox_to_json(sandbox);
|
|
let obj = value
|
|
.as_object_mut()
|
|
.expect("sandbox_to_json returns object");
|
|
|
|
let policy_source = if config.policy_source == PolicySource::Global as i32 {
|
|
"global"
|
|
} else {
|
|
"sandbox"
|
|
};
|
|
obj.insert("policy_source".into(), serde_json::json!(policy_source));
|
|
|
|
let policy_from_global = config.policy_source == PolicySource::Global as i32;
|
|
let revision = if policy_from_global {
|
|
if config.global_policy_version > 0 {
|
|
Some(config.global_policy_version)
|
|
} else if config.version > 0 {
|
|
Some(config.version)
|
|
} else {
|
|
None
|
|
}
|
|
} else if config.version > 0 {
|
|
Some(config.version)
|
|
} else {
|
|
None
|
|
};
|
|
obj.insert("revision".into(), serde_json::json!(revision));
|
|
|
|
let policy_json = match config.policy.as_ref() {
|
|
Some(p) => openshell_policy::sandbox_policy_to_json_value(p)
|
|
.wrap_err("failed to convert policy to JSON")?,
|
|
None => serde_json::Value::Null,
|
|
};
|
|
obj.insert("policy".into(), policy_json);
|
|
|
|
Ok(value)
|
|
}
|
|
|
|
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()?;
|
|
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,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
|
|
// Fetch current sandbox to get resource_version for CAS
|
|
let sandbox = client
|
|
.get_sandbox(GetSandboxRequest {
|
|
name: name.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner()
|
|
.sandbox
|
|
.ok_or_else(|| miette::miette!("sandbox not found"))?;
|
|
|
|
let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version);
|
|
|
|
let response = match client
|
|
.attach_sandbox_provider(AttachSandboxProviderRequest {
|
|
sandbox_name: name.to_string(),
|
|
provider_name: provider.to_string(),
|
|
expected_resource_version: resource_version,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
{
|
|
Ok(response) => response.into_inner(),
|
|
Err(status) if status.code() == Code::Aborted => {
|
|
return Err(miette::miette!(
|
|
"Failed to attach provider: sandbox was modified by another operation.\n\
|
|
Please retry the command."
|
|
)
|
|
.with_source_code(status.message().to_string()));
|
|
}
|
|
Err(e) => return Err(e).into_diagnostic(),
|
|
};
|
|
|
|
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,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
|
|
// Fetch current sandbox to get resource_version for CAS
|
|
let sandbox = client
|
|
.get_sandbox(GetSandboxRequest {
|
|
name: name.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner()
|
|
.sandbox
|
|
.ok_or_else(|| miette::miette!("sandbox not found"))?;
|
|
|
|
let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version);
|
|
|
|
let response = match client
|
|
.detach_sandbox_provider(DetachSandboxProviderRequest {
|
|
sandbox_name: name.to_string(),
|
|
provider_name: provider.to_string(),
|
|
expected_resource_version: resource_version,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
{
|
|
Ok(response) => response.into_inner(),
|
|
Err(status) if status.code() == Code::Aborted => {
|
|
return Err(miette::miette!(
|
|
"Failed to detach provider: sandbox was modified by another operation.\n\
|
|
Please retry the command."
|
|
)
|
|
.with_source_code(status.message().to_string()));
|
|
}
|
|
Err(e) => return Err(e).into_diagnostic(),
|
|
};
|
|
|
|
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_credential_keys(provider).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,
|
|
names: &[String],
|
|
all: bool,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
gateway: &str,
|
|
) -> Result<()> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
|
|
let names_to_delete: Vec<String> = if all {
|
|
// Fetch all sandboxes (use a large page size).
|
|
let response = client
|
|
.list_sandboxes(ListSandboxesRequest {
|
|
limit: 1000,
|
|
offset: 0,
|
|
label_selector: String::new(),
|
|
workspace: workspace.to_string(),
|
|
all_workspaces: false,
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
let sandboxes = response.into_inner().sandboxes;
|
|
if sandboxes.is_empty() {
|
|
println!("No sandboxes to delete.");
|
|
return Ok(());
|
|
}
|
|
sandboxes
|
|
.into_iter()
|
|
.map(|s| s.object_name().to_string())
|
|
.collect()
|
|
} else {
|
|
names.to_vec()
|
|
};
|
|
|
|
for name in &names_to_delete {
|
|
// Stop any background port forwards for this sandbox before deleting.
|
|
if let Ok(stopped) = stop_forwards_for_sandbox(name) {
|
|
for port in stopped {
|
|
eprintln!(
|
|
"{} Stopped forward of port {port} for sandbox {name}",
|
|
"✓".green().bold(),
|
|
);
|
|
}
|
|
}
|
|
|
|
let response = client
|
|
.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, workspace, name);
|
|
println!("{} Deleted sandbox {name}", "✓".green().bold());
|
|
} else {
|
|
println!("{} Sandbox {name} not found", "!".yellow());
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Return the provider type inferred from the trailing command, if any.
|
|
fn inferred_provider_type(command: &[String]) -> Option<String> {
|
|
detect_provider_from_command(command).map(str::to_string)
|
|
}
|
|
|
|
/// Ensure all required providers exist.
|
|
///
|
|
/// `explicit_names` are provider **names** supplied via `--provider`. They are
|
|
/// passed through directly; the server validates they exist at sandbox creation.
|
|
///
|
|
/// `inferred_types` are provider **types** inferred from the trailing command
|
|
/// (e.g. `claude` -> type `"claude-code"`). These are resolved to provider names via
|
|
/// a type→name lookup, and missing types may be auto-created interactively.
|
|
///
|
|
/// Returns a deduplicated list of provider **names** suitable for
|
|
/// `SandboxSpec.providers`.
|
|
pub async fn ensure_required_providers(
|
|
client: &mut crate::tls::GrpcClient,
|
|
explicit_names: &[String],
|
|
inferred_types: &[String],
|
|
auto_providers_override: Option<bool>,
|
|
workspace: &str,
|
|
) -> Result<Vec<String>> {
|
|
if explicit_names.is_empty() && inferred_types.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut configured_names: Vec<String> = Vec::new();
|
|
let mut seen_names: HashSet<String> = HashSet::new();
|
|
|
|
// ── Fetch all existing providers ─────────────────────────────────────
|
|
// Build both a name set (for explicit --provider lookups) and a
|
|
// type-to-name map (for inferred provider resolution).
|
|
let mut known_names: HashSet<String> = HashSet::new();
|
|
let mut type_to_name: HashMap<String, String> = HashMap::new();
|
|
{
|
|
let mut offset = 0_u32;
|
|
let limit = 100_u32;
|
|
loop {
|
|
let response = client
|
|
.list_providers(ListProvidersRequest {
|
|
limit,
|
|
offset,
|
|
workspace: workspace.to_string(),
|
|
all_workspaces: false,
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
let providers = response.into_inner().providers;
|
|
for provider in &providers {
|
|
known_names.insert(provider.object_name().to_string());
|
|
if !provider.r#type.is_empty() {
|
|
let type_lower = provider.r#type.to_ascii_lowercase();
|
|
type_to_name
|
|
.entry(type_lower)
|
|
.or_insert_with(|| provider.object_name().to_string());
|
|
}
|
|
}
|
|
if providers.len() < limit as usize {
|
|
break;
|
|
}
|
|
offset = offset.saturating_add(limit);
|
|
}
|
|
}
|
|
|
|
// ── Explicit provider names ──────────────────────────────────────────
|
|
// If the name exists on the server, use it directly. Otherwise, if the
|
|
// name matches a known provider type, auto-create a provider of that
|
|
// type with the requested name.
|
|
for name in explicit_names {
|
|
if known_names.contains(name) {
|
|
if seen_names.insert(name.clone()) {
|
|
configured_names.push(name.clone());
|
|
}
|
|
} else if let Some(provider_type) = normalize_provider_type(name) {
|
|
auto_create_provider(
|
|
client,
|
|
provider_type,
|
|
Some(name),
|
|
auto_providers_override,
|
|
&mut seen_names,
|
|
&mut configured_names,
|
|
workspace,
|
|
)
|
|
.await?;
|
|
// Record the type mapping so the inferred-types pass below
|
|
// doesn't attempt to create a duplicate provider.
|
|
type_to_name
|
|
.entry(provider_type.to_ascii_lowercase())
|
|
.or_insert_with(|| name.clone());
|
|
} else {
|
|
return Err(miette::miette!(
|
|
"provider '{name}' not found and '{name}' is not a recognized provider type. \
|
|
Create it first with `openshell provider create --type <type> --name {name}`"
|
|
));
|
|
}
|
|
}
|
|
|
|
// ── Resolve inferred provider types ──────────────────────────────────
|
|
if !inferred_types.is_empty() {
|
|
// Collect resolved names for types that already have a provider.
|
|
for t in inferred_types {
|
|
if let Some(name) = type_to_name.get(&t.to_ascii_lowercase())
|
|
&& seen_names.insert(name.clone())
|
|
{
|
|
configured_names.push(name.clone());
|
|
}
|
|
}
|
|
|
|
let missing = inferred_types
|
|
.iter()
|
|
.filter(|t| !type_to_name.contains_key(&t.to_ascii_lowercase()))
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
|
|
for provider_type in missing {
|
|
auto_create_provider(
|
|
client,
|
|
&provider_type,
|
|
None,
|
|
auto_providers_override,
|
|
&mut seen_names,
|
|
&mut configured_names,
|
|
workspace,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
Ok(configured_names)
|
|
}
|
|
|
|
/// Prompt for (or auto-confirm) creation of a provider from local credentials.
|
|
///
|
|
/// When `preferred_name` is `Some`, the provider is created with that exact
|
|
/// name (used for explicit `--provider <name>` values). When `None`, the name
|
|
/// defaults to the type and retries with suffixes on conflict (used for
|
|
/// inferred provider types).
|
|
async fn auto_create_provider(
|
|
client: &mut crate::tls::GrpcClient,
|
|
provider_type: &str,
|
|
preferred_name: Option<&str>,
|
|
auto_providers_override: Option<bool>,
|
|
seen_names: &mut HashSet<String>,
|
|
configured_names: &mut Vec<String>,
|
|
workspace: &str,
|
|
) -> Result<()> {
|
|
eprintln!("Missing provider: {provider_type}");
|
|
|
|
// --no-auto-providers: skip silently.
|
|
if auto_providers_override == Some(false) {
|
|
eprintln!(
|
|
"{} Skipping provider '{provider_type}' (--no-auto-providers)",
|
|
"!".yellow(),
|
|
);
|
|
eprintln!();
|
|
return Ok(());
|
|
}
|
|
|
|
// No override and non-interactive: error.
|
|
if auto_providers_override.is_none() && !std::io::stdin().is_terminal() {
|
|
return Err(miette::miette!(
|
|
"missing required provider '{provider_type}'. Create it first with \
|
|
`openshell provider create --type {provider_type} --name {provider_type} --from-existing`, \
|
|
pass --auto-providers to auto-create, or set it up manually from inside the sandbox"
|
|
));
|
|
}
|
|
|
|
// --auto-providers: auto-confirm; otherwise prompt.
|
|
let should_create = if auto_providers_override == Some(true) {
|
|
true
|
|
} else {
|
|
Confirm::new()
|
|
.with_prompt("Create from local credentials?")
|
|
.default(true)
|
|
.interact()
|
|
.into_diagnostic()?
|
|
};
|
|
|
|
if !should_create {
|
|
eprintln!("{} Skipping provider '{provider_type}'", "!".yellow());
|
|
eprintln!();
|
|
return Ok(());
|
|
}
|
|
|
|
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 {
|
|
eprintln!(
|
|
"{} No existing local credentials/config found for '{}'. You can configure it from inside the sandbox.",
|
|
"!".yellow(),
|
|
provider_type
|
|
);
|
|
eprintln!();
|
|
return Ok(());
|
|
};
|
|
|
|
if let Some(exact_name) = preferred_name {
|
|
// Explicit name: create with exactly that name, no retries.
|
|
let request = CreateProviderRequest {
|
|
provider: Some(Provider {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: String::new(),
|
|
name: exact_name.to_string(),
|
|
created_at_ms: 0,
|
|
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(),
|
|
credential_handles: HashMap::new(),
|
|
}),
|
|
workspace: workspace.to_string(),
|
|
};
|
|
|
|
let response = client.create_provider(request).await.map_err(|status| {
|
|
miette::miette!("failed to create provider '{exact_name}': {status}")
|
|
})?;
|
|
let provider = response
|
|
.into_inner()
|
|
.provider
|
|
.ok_or_else(|| miette::miette!("provider missing from response"))?;
|
|
eprintln!(
|
|
"{} Created provider {} ({}) from existing local state",
|
|
"✓".green().bold(),
|
|
provider.object_name(),
|
|
provider.r#type
|
|
);
|
|
if seen_names.insert(provider.object_name().to_string()) {
|
|
configured_names.push(provider.object_name().to_string());
|
|
}
|
|
} else {
|
|
// Inferred type: try type as name, then suffixed variants.
|
|
let mut created = false;
|
|
for attempt in 0..5 {
|
|
let name = if attempt == 0 {
|
|
provider_type.to_string()
|
|
} else {
|
|
format!("{provider_type}-{attempt}")
|
|
};
|
|
|
|
let request = CreateProviderRequest {
|
|
provider: Some(Provider {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: String::new(),
|
|
name: name.clone(),
|
|
created_at_ms: 0,
|
|
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(),
|
|
credential_handles: HashMap::new(),
|
|
}),
|
|
workspace: workspace.to_string(),
|
|
};
|
|
|
|
match client.create_provider(request).await {
|
|
Ok(response) => {
|
|
let provider = response
|
|
.into_inner()
|
|
.provider
|
|
.ok_or_else(|| miette::miette!("provider missing from response"))?;
|
|
eprintln!(
|
|
"{} Created provider {} ({}) from existing local state",
|
|
"✓".green().bold(),
|
|
provider.object_name(),
|
|
provider.r#type
|
|
);
|
|
if seen_names.insert(provider.object_name().to_string()) {
|
|
configured_names.push(provider.object_name().to_string());
|
|
}
|
|
created = true;
|
|
break;
|
|
}
|
|
Err(status) if status.code() == Code::AlreadyExists => {}
|
|
Err(status) => {
|
|
return Err(miette::miette!(
|
|
"failed to create provider for type '{provider_type}': {status}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
if !created {
|
|
return Err(miette::miette!(
|
|
"failed to create provider for type '{provider_type}' after name retries"
|
|
));
|
|
}
|
|
}
|
|
|
|
eprintln!();
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn service_expose(
|
|
server: &str,
|
|
sandbox: &str,
|
|
service: &str,
|
|
target_port: u16,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
let response = client
|
|
.expose_service(ExposeServiceRequest {
|
|
sandbox: sandbox.to_string(),
|
|
service: service.to_string(),
|
|
target_port: u32::from(target_port),
|
|
domain: true,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.map_err(service_expose_status_error)?
|
|
.into_inner();
|
|
|
|
if service.is_empty() {
|
|
println!(
|
|
"{} Exposed sandbox {} -> 127.0.0.1:{}",
|
|
"✓".green().bold(),
|
|
sandbox.bold(),
|
|
target_port,
|
|
);
|
|
} else {
|
|
println!(
|
|
"{} Exposed service {} on sandbox {} -> 127.0.0.1:{}",
|
|
"✓".green().bold(),
|
|
service.bold(),
|
|
sandbox.bold(),
|
|
target_port,
|
|
);
|
|
}
|
|
if !response.url.is_empty() {
|
|
let url = service_url_for_gateway(&response.url, server);
|
|
println!(" URL: {}", url.cyan());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn service_expose_status_error(status: Status) -> miette::Report {
|
|
service_status_error("expose service", "sandbox:write", status)
|
|
}
|
|
|
|
pub async fn service_list(
|
|
server: &str,
|
|
sandbox: Option<&str>,
|
|
limit: u32,
|
|
offset: u32,
|
|
workspace: &str,
|
|
all_workspaces: bool,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
let response = client
|
|
.list_services(ListServicesRequest {
|
|
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))?
|
|
.into_inner();
|
|
|
|
if response.services.is_empty() {
|
|
if let Some(sandbox) = sandbox {
|
|
println!("No services exposed for sandbox {sandbox}.");
|
|
} else {
|
|
println!("No services exposed.");
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
print_service_endpoint_table(&response.services, server, all_workspaces);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn service_get(
|
|
server: &str,
|
|
sandbox: &str,
|
|
service: &str,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
let response = client
|
|
.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, false);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn service_delete(
|
|
server: &str,
|
|
sandbox: &str,
|
|
service: &str,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
let response = client
|
|
.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))?
|
|
.into_inner();
|
|
|
|
if !response.deleted {
|
|
return Err(miette!("delete service failed: service endpoint not found"));
|
|
}
|
|
|
|
if service.is_empty() {
|
|
println!(
|
|
"{} Deleted exposed sandbox {}",
|
|
"✓".green().bold(),
|
|
sandbox.bold(),
|
|
);
|
|
} else {
|
|
println!(
|
|
"{} Deleted service {} on sandbox {}",
|
|
"✓".green().bold(),
|
|
service.bold(),
|
|
sandbox.bold(),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn service_status_error(action: &str, required_scope: &str, status: Status) -> miette::Report {
|
|
let message = status.message();
|
|
match status.code() {
|
|
Code::PermissionDenied => {
|
|
miette!("{action} failed: permission denied (requires {required_scope})")
|
|
}
|
|
Code::Unauthenticated => miette!("{action} failed: authentication required"),
|
|
Code::NotFound if message == "sandbox not found" => {
|
|
miette!("{action} failed: sandbox not found")
|
|
}
|
|
Code::NotFound if message == "service endpoint not found" => {
|
|
miette!("{action} failed: service endpoint not found")
|
|
}
|
|
Code::InvalidArgument if !message.is_empty() => {
|
|
miette!("{action} failed: invalid request: {message}")
|
|
}
|
|
_ => miette!("{action} failed: {status}"),
|
|
}
|
|
}
|
|
|
|
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() {
|
|
String::new()
|
|
} else {
|
|
service_url_for_gateway(&response.url, gateway_endpoint)
|
|
};
|
|
Some((
|
|
workspace.to_string(),
|
|
endpoint.sandbox_name.clone(),
|
|
service,
|
|
target,
|
|
url,
|
|
))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
if rows.is_empty() {
|
|
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())
|
|
.max()
|
|
.unwrap_or(7)
|
|
.max(7);
|
|
let service_width = rows
|
|
.iter()
|
|
.map(|(_, _, service, _, _)| service.len())
|
|
.max()
|
|
.unwrap_or(7)
|
|
.max(7);
|
|
let target_width = rows
|
|
.iter()
|
|
.map(|(_, _, _, target, _)| target.len())
|
|
.max()
|
|
.unwrap_or(6)
|
|
.max(6);
|
|
|
|
if all_workspaces {
|
|
println!(
|
|
"{:<ws_width$} {:<sandbox_width$} {:<service_width$} {:<target_width$} {}",
|
|
"WORKSPACE".bold(),
|
|
"SANDBOX".bold(),
|
|
"SERVICE".bold(),
|
|
"TARGET".bold(),
|
|
"URL".bold(),
|
|
);
|
|
} else {
|
|
println!(
|
|
"{:<sandbox_width$} {:<service_width$} {:<target_width$} {}",
|
|
"SANDBOX".bold(),
|
|
"SERVICE".bold(),
|
|
"TARGET".bold(),
|
|
"URL".bold(),
|
|
);
|
|
}
|
|
|
|
for (workspace, sandbox, service, target, url) in rows {
|
|
if all_workspaces {
|
|
println!(
|
|
"{workspace:<ws_width$} {sandbox:<sandbox_width$} {service:<service_width$} {target:<target_width$} {url}"
|
|
);
|
|
} else {
|
|
println!(
|
|
"{sandbox:<sandbox_width$} {service:<service_width$} {target:<target_width$} {url}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn service_display_name(service: &str) -> &str {
|
|
if service.is_empty() { "-" } else { service }
|
|
}
|
|
|
|
/// Read gcloud Application Default Credentials from disk.
|
|
///
|
|
/// Returns `(client_id, client_secret, refresh_token)`.
|
|
///
|
|
/// Checks `GOOGLE_APPLICATION_CREDENTIALS` first; falls back to
|
|
/// `$CLOUDSDK_CONFIG/application_default_credentials.json` when set, then to
|
|
/// `~/.config/gcloud/application_default_credentials.json`.
|
|
fn read_gcloud_adc() -> Result<(String, String, String)> {
|
|
let path = if let Some(env_path) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS")
|
|
.ok()
|
|
.filter(|v| !v.is_empty())
|
|
{
|
|
PathBuf::from(env_path)
|
|
} else if let Some(config_dir) = std::env::var("CLOUDSDK_CONFIG")
|
|
.ok()
|
|
.filter(|v| !v.is_empty())
|
|
{
|
|
PathBuf::from(config_dir).join("application_default_credentials.json")
|
|
} else {
|
|
let home = std::env::var("HOME")
|
|
.map_err(|_| miette::miette!("HOME is not set; cannot locate gcloud ADC file"))?;
|
|
PathBuf::from(home)
|
|
.join(".config")
|
|
.join("gcloud")
|
|
.join("application_default_credentials.json")
|
|
};
|
|
|
|
let content = std::fs::read_to_string(&path).map_err(|err| {
|
|
miette::miette!(
|
|
"failed to read gcloud ADC file at {}: {}. \
|
|
Run: gcloud auth application-default login",
|
|
path.display(),
|
|
err
|
|
)
|
|
})?;
|
|
|
|
let json: serde_json::Value = serde_json::from_str(&content)
|
|
.map_err(|err| miette::miette!("failed to parse gcloud ADC file: {err}"))?;
|
|
|
|
let cred_type = json.get("type").and_then(|v| v.as_str());
|
|
match cred_type {
|
|
Some("service_account") => {
|
|
return Err(miette::miette!(
|
|
"Application Default Credentials are a service account key, not user credentials. \
|
|
To use a service account, create the provider with the service account JSON key \
|
|
and configure gateway-managed refresh for 'GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN'. \
|
|
See: openshell provider create --help"
|
|
));
|
|
}
|
|
Some("authorized_user") => {}
|
|
Some(other) => {
|
|
return Err(miette::miette!(
|
|
"Application Default Credentials have unsupported type '{other}' \
|
|
(expected 'authorized_user'). \
|
|
Run: gcloud auth application-default login"
|
|
));
|
|
}
|
|
None => {
|
|
return Err(miette::miette!(
|
|
"gcloud ADC file is missing the 'type' field. \
|
|
The file may be malformed. \
|
|
Run: gcloud auth application-default login"
|
|
));
|
|
}
|
|
}
|
|
|
|
let client_id = json
|
|
.get("client_id")
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
.ok_or_else(|| miette::miette!("gcloud ADC file is missing 'client_id'"))?
|
|
.to_string();
|
|
|
|
let client_secret = json
|
|
.get("client_secret")
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
.ok_or_else(|| miette::miette!("gcloud ADC file is missing 'client_secret'"))?
|
|
.to_string();
|
|
|
|
let refresh_token = json
|
|
.get("refresh_token")
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
.ok_or_else(|| miette::miette!("gcloud ADC file is missing 'refresh_token'"))?
|
|
.to_string();
|
|
|
|
Ok((client_id, client_secret, refresh_token))
|
|
}
|
|
|
|
async fn rollback_provider_create_after_gcloud_adc_failure(
|
|
client: &mut crate::tls::GrpcClient,
|
|
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
|
|
{
|
|
Ok(_) => Err(miette!(
|
|
"failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \
|
|
The provider was rolled back successfully."
|
|
)),
|
|
Err(cleanup_err) => {
|
|
eprintln!(
|
|
"{} Failed to clean up provider '{}' after {} failed: {}. \
|
|
Run 'openshell provider delete {}' to remove it manually.",
|
|
"⚠".yellow(),
|
|
provider_name,
|
|
stage,
|
|
cleanup_err,
|
|
provider_name
|
|
);
|
|
Err(miette!(
|
|
"failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \
|
|
Cleanup also failed, so the provider may still exist. \
|
|
Run 'openshell provider delete {provider_name}' to remove it manually."
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String {
|
|
let (Ok(mut service_url), Ok(gateway_endpoint)) = (
|
|
url::Url::parse(service_url),
|
|
url::Url::parse(gateway_endpoint),
|
|
) else {
|
|
return service_url.to_string();
|
|
};
|
|
|
|
if service_url
|
|
.set_port(gateway_endpoint.port_or_known_default())
|
|
.is_err()
|
|
{
|
|
return service_url.to_string();
|
|
}
|
|
|
|
service_url.to_string()
|
|
}
|
|
|
|
async fn gateway_providers_v2_enabled(client: &mut crate::tls::GrpcClient) -> Result<bool> {
|
|
let response = client
|
|
.get_gateway_config(GetGatewayConfigRequest {})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
let Some(setting) = response.settings.get(settings::PROVIDERS_V2_ENABLED_KEY) else {
|
|
return Ok(false);
|
|
};
|
|
match setting.value.as_ref() {
|
|
Some(setting_value::Value::BoolValue(enabled)) => Ok(*enabled),
|
|
None => Ok(false),
|
|
Some(_) => Err(miette::miette!(
|
|
"gateway setting '{}' has invalid value type; expected bool",
|
|
settings::PROVIDERS_V2_ENABLED_KEY
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn fetch_provider_profile(
|
|
client: &mut crate::tls::GrpcClient,
|
|
provider_type: &str,
|
|
workspace: &str,
|
|
) -> Result<ProviderProfile> {
|
|
let response = client
|
|
.get_provider_profile(GetProviderProfileRequest {
|
|
id: provider_type.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.map_err(|status| {
|
|
if status.code() == Code::NotFound {
|
|
miette::miette!(
|
|
"provider profile '{provider_type}' not found; providers v2 discovery requires a provider profile"
|
|
)
|
|
} else {
|
|
miette::miette!(status.to_string())
|
|
}
|
|
})?;
|
|
|
|
response
|
|
.into_inner()
|
|
.profile
|
|
.ok_or_else(|| miette::miette!("provider profile '{provider_type}' missing from response"))
|
|
}
|
|
|
|
async fn discover_existing_provider_data(
|
|
client: &mut crate::tls::GrpcClient,
|
|
provider_type: &str,
|
|
workspace: &str,
|
|
) -> Result<Option<openshell_providers::DiscoveredProvider>> {
|
|
if gateway_providers_v2_enabled(client).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| {
|
|
miette::miette!("failed to discover existing provider data from profile: {err}")
|
|
})?;
|
|
|
|
// Vertex AI config keys (project ID, region, base URL, publisher) are not
|
|
// declared in the profile's discovery.credentials list, so discover_from_profile
|
|
// does not scan them. Scan them directly here so --from-existing captures them.
|
|
if provider_type == VERTEX_AI_PROVIDER_TYPE {
|
|
let discovered = discovered.get_or_insert_with(Default::default);
|
|
for key in openshell_core::inference::VERTEX_AI_CONFIG_KEY_NAMES {
|
|
if let Ok(val) = std::env::var(key) {
|
|
let val = val.trim().to_string();
|
|
if !val.is_empty() {
|
|
discovered.config.entry(key.to_string()).or_insert(val);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(discovered)
|
|
} else {
|
|
let registry = ProviderRegistry::new();
|
|
registry
|
|
.discover_existing(provider_type)
|
|
.map_err(|err| miette::miette!("failed to discover existing provider data: {err}"))
|
|
}
|
|
}
|
|
|
|
/// Canonical provider type string for Google Vertex AI.
|
|
const VERTEX_AI_PROVIDER_TYPE: &str = "google-vertex-ai";
|
|
|
|
/// Canonical provider type string for Google Cloud (GCP APIs).
|
|
const GOOGLE_CLOUD_PROVIDER_TYPE: &str = "google-cloud";
|
|
|
|
fn missing_credentials_error(provider_type: &str) -> miette::Report {
|
|
if provider_type == VERTEX_AI_PROVIDER_TYPE {
|
|
return miette::miette!(
|
|
"no credentials resolved for provider type '{provider_type}'. \
|
|
Set GOOGLE_VERTEX_AI_TOKEN, VERTEX_AI_TOKEN, \
|
|
GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN, or VERTEX_AI_SERVICE_ACCOUNT_TOKEN; \
|
|
or use --from-gcloud-adc or --from-existing with those env vars set."
|
|
);
|
|
}
|
|
|
|
if provider_type == GOOGLE_CLOUD_PROVIDER_TYPE {
|
|
return miette::miette!(
|
|
"no credentials resolved for provider type '{provider_type}'. \
|
|
Set GCP_ADC_ACCESS_TOKEN or GCP_SA_ACCESS_TOKEN; \
|
|
or use --from-gcloud-adc / --from-existing with those env vars set."
|
|
);
|
|
}
|
|
|
|
miette::miette!(
|
|
"no credentials resolved for provider type '{provider_type}'. \
|
|
Use --credential KEY[=VALUE], --runtime-credentials for runtime-resolved profile credentials, or --from-existing \
|
|
with the appropriate env vars set."
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn provider_create(
|
|
server: &str,
|
|
name: &str,
|
|
provider_type: &str,
|
|
from_existing: bool,
|
|
credentials: &[String],
|
|
from_gcloud_adc: bool,
|
|
config: &[String],
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
provider_create_with_options(
|
|
server,
|
|
name,
|
|
provider_type,
|
|
from_existing,
|
|
credentials,
|
|
from_gcloud_adc,
|
|
false,
|
|
config,
|
|
workspace,
|
|
workspace,
|
|
tls,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn provider_create_with_options(
|
|
server: &str,
|
|
name: &str,
|
|
provider_type: &str,
|
|
from_existing: bool,
|
|
credentials: &[String],
|
|
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) {
|
|
return Err(miette::miette!(
|
|
"--from-gcloud-adc cannot be combined with --from-existing, --credential, or --runtime-credentials"
|
|
));
|
|
}
|
|
if from_existing && (!credentials.is_empty() || runtime_credentials) {
|
|
return Err(miette::miette!(
|
|
"--from-existing cannot be combined with --credential or --runtime-credentials"
|
|
));
|
|
}
|
|
if runtime_credentials && !credentials.is_empty() {
|
|
return Err(miette::miette!(
|
|
"--runtime-credentials cannot be combined with --credential"
|
|
));
|
|
}
|
|
|
|
let mut client = grpc_client(server, tls).await?;
|
|
|
|
let provider_type = if let Some(provider_type) = normalize_provider_type(provider_type) {
|
|
provider_type.to_string()
|
|
} else {
|
|
let profile_id = provider_type.trim();
|
|
if profile_id.is_empty() {
|
|
return Err(miette::miette!("provider type is required"));
|
|
}
|
|
let response = client
|
|
.get_provider_profile(GetProviderProfileRequest {
|
|
id: profile_id.to_string(),
|
|
workspace: profile_workspace.to_string(),
|
|
})
|
|
.await;
|
|
match response {
|
|
Ok(response) => response
|
|
.into_inner()
|
|
.profile
|
|
.map(|profile| profile.id)
|
|
.filter(|id| !id.trim().is_empty())
|
|
.unwrap_or_else(|| profile_id.to_string()),
|
|
Err(status) if status.code() == Code::NotFound => {
|
|
return Err(miette::miette!(
|
|
"unsupported provider type or profile: {provider_type}"
|
|
));
|
|
}
|
|
Err(status) => return Err(status).into_diagnostic(),
|
|
}
|
|
};
|
|
|
|
let adc_credential_key = if from_gcloud_adc {
|
|
let profile = fetch_provider_profile(&mut client, &provider_type, profile_workspace)
|
|
.await
|
|
.map_err(|err| {
|
|
miette::miette!(
|
|
"--from-gcloud-adc is not supported for '{provider_type}' providers ({err})"
|
|
)
|
|
})?;
|
|
let profile = ProviderTypeProfile::from_proto(&profile);
|
|
let adc_cred = profile.adc_credential().ok_or_else(|| {
|
|
miette::miette!(
|
|
"--from-gcloud-adc is not supported for '{provider_type}' providers \
|
|
(no ADC-compatible credential in the provider profile)"
|
|
)
|
|
})?;
|
|
Some(
|
|
adc_cred
|
|
.env_vars
|
|
.first()
|
|
.ok_or_else(|| {
|
|
miette::miette!(
|
|
"ADC credential in '{provider_type}' profile has no env_vars declared"
|
|
)
|
|
})?
|
|
.clone(),
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mut credential_map = parse_credential_pairs(credentials)?;
|
|
let mut config_map = parse_key_value_pairs(config, "--config")?;
|
|
|
|
if from_existing {
|
|
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}'"
|
|
));
|
|
};
|
|
|
|
for (key, value) in discovered.credentials {
|
|
credential_map.entry(key).or_insert(value);
|
|
}
|
|
for (key, value) in discovered.config {
|
|
config_map.entry(key).or_insert(value);
|
|
}
|
|
}
|
|
|
|
if credential_map.is_empty() {
|
|
if from_existing {
|
|
return Err(missing_credentials_error(&provider_type));
|
|
}
|
|
if !from_gcloud_adc && !runtime_credentials {
|
|
return Err(missing_credentials_error(&provider_type));
|
|
}
|
|
let allows_empty_credentials = if runtime_credentials {
|
|
provider_profile_allows_empty_credentials(
|
|
&fetch_provider_profile(&mut client, &provider_type, profile_workspace).await?,
|
|
)
|
|
} else {
|
|
fetch_provider_profile(&mut client, &provider_type, profile_workspace)
|
|
.await
|
|
.ok()
|
|
.is_some_and(|profile| provider_profile_allows_empty_credentials(&profile))
|
|
};
|
|
if !allows_empty_credentials {
|
|
if runtime_credentials {
|
|
return Err(miette::miette!(
|
|
"--runtime-credentials is only valid for provider profiles whose required credentials are resolved at runtime"
|
|
));
|
|
}
|
|
return Err(missing_credentials_error(&provider_type));
|
|
}
|
|
}
|
|
|
|
// Validate and read the ADC file BEFORE creating the provider so that
|
|
// a bad/missing ADC does not leave an orphan provider behind. Bundle the
|
|
// credential key with the material so they stay coupled.
|
|
let gcloud_adc_bootstrap = if from_gcloud_adc {
|
|
let (client_id, client_secret, refresh_token) = read_gcloud_adc()?;
|
|
let key = adc_credential_key.expect("set when from_gcloud_adc is true");
|
|
Some((key, client_id, client_secret, refresh_token))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let response = client
|
|
.create_provider(CreateProviderRequest {
|
|
provider: Some(Provider {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: String::new(),
|
|
name: name.to_string(),
|
|
created_at_ms: 0,
|
|
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(),
|
|
credential_handles: HashMap::new(),
|
|
}),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let provider = response
|
|
.into_inner()
|
|
.provider
|
|
.ok_or_else(|| miette::miette!("provider missing from response"))?;
|
|
let provider_name = provider.object_name().to_string();
|
|
|
|
if let Some((adc_credential_key, client_id, client_secret, refresh_token)) =
|
|
gcloud_adc_bootstrap
|
|
{
|
|
let mut material = HashMap::new();
|
|
material.insert("client_id".to_string(), client_id);
|
|
material.insert("client_secret".to_string(), client_secret);
|
|
material.insert("refresh_token".to_string(), refresh_token);
|
|
|
|
if let Err(configure_err) = client
|
|
.configure_provider_refresh(ConfigureProviderRefreshRequest {
|
|
provider: provider_name.clone(),
|
|
credential_key: adc_credential_key.clone(),
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32,
|
|
material,
|
|
secret_material_keys: vec![
|
|
"client_secret".to_string(),
|
|
"refresh_token".to_string(),
|
|
],
|
|
expires_at_ms: None,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
{
|
|
return rollback_provider_create_after_gcloud_adc_failure(
|
|
&mut client,
|
|
&provider_name,
|
|
"configure",
|
|
&configure_err,
|
|
workspace,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
if let Err(rotate_err) = client
|
|
.rotate_provider_credential(RotateProviderCredentialRequest {
|
|
provider: provider_name.clone(),
|
|
credential_key: adc_credential_key,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
{
|
|
return rollback_provider_create_after_gcloud_adc_failure(
|
|
&mut client,
|
|
&provider_name,
|
|
"mint the initial access token for",
|
|
&rotate_err,
|
|
workspace,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
println!("{} Created provider {}", "✓".green().bold(), provider_name);
|
|
println!("Configured GCP credentials from gcloud ADC and minted the initial access token");
|
|
return Ok(());
|
|
}
|
|
|
|
println!("{} Created provider {}", "✓".green().bold(), provider_name);
|
|
Ok(())
|
|
}
|
|
|
|
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,
|
|
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()?;
|
|
|
|
let provider = response
|
|
.into_inner()
|
|
.provider
|
|
.ok_or_else(|| miette::miette!("provider missing from response"))?;
|
|
|
|
let credential_keys = provider_credential_keys(&provider);
|
|
let config_keys = provider.config.keys().cloned().collect::<Vec<_>>();
|
|
|
|
println!("{}", "Provider:".cyan().bold());
|
|
println!();
|
|
println!(" {} {}", "Id:".dimmed(), provider.object_id());
|
|
println!(" {} {}", "Name:".dimmed(), provider.object_name());
|
|
println!(" {} {}", "Type:".dimmed(), provider.r#type);
|
|
println!(
|
|
" {} {}",
|
|
"Resource version:".dimmed(),
|
|
provider.metadata.as_ref().map_or(0, |m| m.resource_version)
|
|
);
|
|
println!(
|
|
" {} {}",
|
|
"Credential keys:".dimmed(),
|
|
if credential_keys.is_empty() {
|
|
"<none>".to_string()
|
|
} else {
|
|
credential_keys.join(", ")
|
|
}
|
|
);
|
|
println!(
|
|
" {} {}",
|
|
"Config keys:".dimmed(),
|
|
if config_keys.is_empty() {
|
|
"<none>".to_string()
|
|
} else {
|
|
config_keys.join(", ")
|
|
}
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn provider_to_json(provider: &Provider) -> serde_json::Value {
|
|
let mut obj = serde_json::Map::new();
|
|
|
|
// Core fields
|
|
obj.insert("id".to_string(), serde_json::json!(provider.object_id()));
|
|
obj.insert(
|
|
"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)
|
|
let credential_keys = provider_credential_keys(provider);
|
|
obj.insert(
|
|
"credential_keys".to_string(),
|
|
serde_json::json!(credential_keys),
|
|
);
|
|
|
|
// Config keys (keys only, not values)
|
|
if !provider.config.is_empty() {
|
|
let config_keys: Vec<String> = provider.config.keys().cloned().collect();
|
|
obj.insert("config_keys".to_string(), serde_json::json!(config_keys));
|
|
}
|
|
|
|
// Metadata fields (only if metadata exists)
|
|
if let Some(meta) = &provider.metadata {
|
|
if !meta.labels.is_empty() {
|
|
obj.insert("labels".to_string(), serde_json::json!(meta.labels));
|
|
}
|
|
if meta.resource_version != 0 {
|
|
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)),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Credential expiration times (only if present)
|
|
if !provider.credential_expires_at_ms.is_empty() {
|
|
obj.insert(
|
|
"credential_expires_at_ms".to_string(),
|
|
serde_json::json!(provider.credential_expires_at_ms),
|
|
);
|
|
}
|
|
|
|
serde_json::Value::Object(obj)
|
|
}
|
|
|
|
fn provider_credential_keys(provider: &Provider) -> Vec<String> {
|
|
let mut keys: Vec<String> = provider
|
|
.credentials
|
|
.keys()
|
|
.chain(provider.credential_handles.keys())
|
|
.cloned()
|
|
.collect();
|
|
keys.sort();
|
|
keys.dedup();
|
|
keys
|
|
}
|
|
|
|
#[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,
|
|
workspace: if all_workspaces {
|
|
String::new()
|
|
} else {
|
|
workspace.to_string()
|
|
},
|
|
all_workspaces,
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
let providers = response.into_inner().providers;
|
|
|
|
// Handle structured output formats (json, yaml)
|
|
if crate::output::print_output_collection(output, &providers, provider_to_json)? {
|
|
return Ok(());
|
|
}
|
|
|
|
if providers.is_empty() {
|
|
if !names_only {
|
|
println!("No providers found.");
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
if names_only {
|
|
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())
|
|
.max()
|
|
.unwrap_or(4)
|
|
.max(4);
|
|
let type_width = providers
|
|
.iter()
|
|
.map(|provider| provider.r#type.len())
|
|
.max()
|
|
.unwrap_or(4)
|
|
.max(4);
|
|
|
|
if all_workspaces {
|
|
println!(
|
|
"{:<ws_width$} {:<name_width$} {:<type_width$} {:<16} {}",
|
|
"WORKSPACE".bold(),
|
|
"NAME".bold(),
|
|
"TYPE".bold(),
|
|
"CREDENTIAL_KEYS".bold(),
|
|
"CONFIG_KEYS".bold(),
|
|
);
|
|
} else {
|
|
println!(
|
|
"{:<name_width$} {:<type_width$} {:<16} {}",
|
|
"NAME".bold(),
|
|
"TYPE".bold(),
|
|
"CREDENTIAL_KEYS".bold(),
|
|
"CONFIG_KEYS".bold(),
|
|
);
|
|
}
|
|
|
|
for provider in providers {
|
|
if all_workspaces {
|
|
println!(
|
|
"{:<ws_width$} {:<name_width$} {:<type_width$} {:<16} {}",
|
|
provider.object_workspace(),
|
|
provider.object_name().to_string(),
|
|
provider.r#type,
|
|
provider.credentials.len(),
|
|
provider.config.len(),
|
|
);
|
|
} else {
|
|
println!(
|
|
"{:<name_width$} {:<type_width$} {:<16} {}",
|
|
provider.object_name().to_string(),
|
|
provider.r#type,
|
|
provider.credentials.len(),
|
|
provider.config.len(),
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
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()?;
|
|
let mut profiles = response.into_inner().profiles;
|
|
profiles.sort_by(|left, right| {
|
|
left.category
|
|
.cmp(&right.category)
|
|
.then_with(|| left.id.cmp(&right.id))
|
|
});
|
|
let dto_profiles = profiles
|
|
.iter()
|
|
.map(ProviderTypeProfile::from_proto)
|
|
.collect::<Vec<_>>();
|
|
|
|
if crate::output::print_output_direct(
|
|
output,
|
|
|| profiles_to_json(&dto_profiles).into_diagnostic(),
|
|
|| profiles_to_yaml(&dto_profiles).into_diagnostic(),
|
|
)? {
|
|
return Ok(());
|
|
}
|
|
|
|
if profiles.is_empty() {
|
|
println!("No provider profiles found.");
|
|
return Ok(());
|
|
}
|
|
|
|
println!("{}", "Available Provider Profiles:".cyan().bold());
|
|
let id_width = provider_profile_id_width(&profiles);
|
|
let display_width = provider_profile_display_width(&profiles);
|
|
let source_width = provider_profile_source_width(&profiles);
|
|
let scope_width = provider_profile_scope_width(&profiles);
|
|
let mut current_category = i32::MIN;
|
|
for profile in &profiles {
|
|
if profile.category != current_category {
|
|
current_category = profile.category;
|
|
println!();
|
|
println!(" {}", display_provider_category(current_category).bold());
|
|
print_provider_type_header(id_width, scope_width, source_width, display_width);
|
|
}
|
|
print_provider_type_row(profile, id_width, scope_width, source_width, display_width);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
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, workspace, tls).await?;
|
|
if output == "json" {
|
|
println!("{rendered}");
|
|
} else {
|
|
print!("{rendered}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn provider_profile_export_text(
|
|
server: &str,
|
|
id: &str,
|
|
output: &str,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<String> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
let response = client
|
|
.get_provider_profile(GetProviderProfileRequest {
|
|
id: id.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
let profile = response
|
|
.into_inner()
|
|
.profile
|
|
.ok_or_else(|| miette!("provider profile '{id}' not found"))?;
|
|
let profile = ProviderTypeProfile::from_proto(&profile);
|
|
|
|
match output {
|
|
"json" => profile_to_json(&profile).into_diagnostic(),
|
|
"yaml" => profile_to_yaml(&profile).into_diagnostic(),
|
|
"table" => Err(miette!(
|
|
"profile export supports '-o yaml' and '-o json'; table output is not supported"
|
|
)),
|
|
_ => Err(miette!("unsupported output format: {output}")),
|
|
}
|
|
}
|
|
|
|
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)?;
|
|
if items.is_empty() && diagnostics.is_empty() {
|
|
return Err(miette!("no provider profile files found"));
|
|
}
|
|
if profile_diagnostics_have_errors(&diagnostics) {
|
|
print_profile_diagnostics(&diagnostics);
|
|
return Err(miette!("provider profile import failed"));
|
|
}
|
|
|
|
let mut client = grpc_client(server, tls).await?;
|
|
if !items.is_empty() {
|
|
let response = client
|
|
.import_provider_profiles(ImportProviderProfilesRequest {
|
|
profiles: items,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
diagnostics.extend(response.diagnostics);
|
|
if response.imported {
|
|
println!(
|
|
"Imported {} provider profile{}.",
|
|
response.profiles.len(),
|
|
if response.profiles.len() == 1 {
|
|
""
|
|
} else {
|
|
"s"
|
|
}
|
|
);
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
print_profile_diagnostics(&diagnostics);
|
|
Err(miette!("provider profile import failed"))
|
|
}
|
|
|
|
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)?;
|
|
if items.is_empty() && diagnostics.is_empty() {
|
|
return Err(miette!("no provider profile files found"));
|
|
}
|
|
if profile_diagnostics_have_errors(&diagnostics) {
|
|
print_profile_diagnostics(&diagnostics);
|
|
return Err(miette!("provider profile update failed"));
|
|
}
|
|
|
|
let mut client = grpc_client(server, tls).await?;
|
|
if let Some(item) = items.pop() {
|
|
let expected_resource_version = item
|
|
.profile
|
|
.as_ref()
|
|
.map_or(0, |profile| profile.resource_version);
|
|
let response = client
|
|
.update_provider_profiles(UpdateProviderProfilesRequest {
|
|
profile: Some(item),
|
|
expected_resource_version,
|
|
id: id.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
diagnostics.extend(response.diagnostics);
|
|
if response.updated {
|
|
println!("Updated provider profile.");
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
print_profile_diagnostics(&diagnostics);
|
|
Err(miette!("provider profile update failed"))
|
|
}
|
|
|
|
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)?;
|
|
if items.is_empty() && diagnostics.is_empty() {
|
|
return Err(miette!("no provider profile files found"));
|
|
}
|
|
|
|
if !items.is_empty() {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
let response = client
|
|
.lint_provider_profiles(LintProviderProfilesRequest {
|
|
profiles: items,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
diagnostics.extend(response.diagnostics);
|
|
}
|
|
|
|
if profile_diagnostics_have_errors(&diagnostics) {
|
|
print_profile_diagnostics(&diagnostics);
|
|
return Err(miette!("provider profile lint failed"));
|
|
}
|
|
|
|
println!("Provider profile lint passed.");
|
|
Ok(())
|
|
}
|
|
|
|
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(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
if response.deleted {
|
|
println!("Deleted provider profile '{id}'.");
|
|
} else {
|
|
println!("Provider profile '{id}' was not deleted.");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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?;
|
|
let response = client
|
|
.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()?
|
|
.into_inner();
|
|
|
|
if response.credentials.is_empty() {
|
|
if let Some(credential_key) = credential_key {
|
|
println!(
|
|
"No refresh configuration found for provider '{name}' credential '{credential_key}'."
|
|
);
|
|
} else {
|
|
println!("No refresh configurations found for provider '{name}'.");
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
println!("{}", refresh_status_header());
|
|
for status in response.credentials {
|
|
print_refresh_status_row(&status);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn refresh_status_header() -> String {
|
|
format!(
|
|
"{:<24} {:<28} {:<28} {:<18} {:<20} {:<20} {:<20} {}",
|
|
"PROVIDER".bold(),
|
|
"CREDENTIAL_KEY".bold(),
|
|
"STRATEGY".bold(),
|
|
"STATUS".bold(),
|
|
"EXPIRES_AT".bold(),
|
|
"NEXT_REFRESH".bold(),
|
|
"LAST_REFRESH".bold(),
|
|
"LAST_ERROR".bold(),
|
|
)
|
|
}
|
|
|
|
pub struct ProviderRefreshConfigInput<'a> {
|
|
pub name: &'a str,
|
|
pub credential_key: &'a str,
|
|
pub strategy: &'a str,
|
|
pub material: &'a [String],
|
|
pub secret_material_env: &'a [String],
|
|
pub secret_material_keys: &'a [String],
|
|
pub credential_expires_at_ms: Option<i64>,
|
|
}
|
|
|
|
pub async fn provider_refresh_config(
|
|
server: &str,
|
|
input: ProviderRefreshConfigInput<'_>,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let strategy = provider_refresh_strategy(input.strategy)?;
|
|
let mut material = parse_key_value_pairs(input.material, "--material")?;
|
|
let mut secret_material_keys = input.secret_material_keys.to_vec();
|
|
// Env-resolved secrets are auto-marked secret; duplicate keys are an
|
|
// error rather than a precedence order.
|
|
for (key, value) in parse_secret_material_env_pairs(input.secret_material_env)? {
|
|
if material.contains_key(&key) {
|
|
return Err(miette!(
|
|
"duplicate material key '{key}': supplied via both --material and --secret-material-env"
|
|
));
|
|
}
|
|
if !secret_material_keys.contains(&key) {
|
|
secret_material_keys.push(key.clone());
|
|
}
|
|
material.insert(key, value);
|
|
}
|
|
let mut client = grpc_client(server, tls).await?;
|
|
let status = client
|
|
.configure_provider_refresh(ConfigureProviderRefreshRequest {
|
|
provider: input.name.to_string(),
|
|
credential_key: input.credential_key.to_string(),
|
|
strategy: strategy as i32,
|
|
material,
|
|
secret_material_keys,
|
|
expires_at_ms: input.credential_expires_at_ms,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner()
|
|
.status
|
|
.ok_or_else(|| miette!("provider refresh status missing from response"))?;
|
|
|
|
println!(
|
|
"{} Configured refresh for {} {}",
|
|
"✓".green().bold(),
|
|
status.provider_name,
|
|
status.credential_key
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
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?;
|
|
let status = client
|
|
.rotate_provider_credential(RotateProviderCredentialRequest {
|
|
provider: name.to_string(),
|
|
credential_key: credential_key.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner()
|
|
.status
|
|
.ok_or_else(|| miette!("provider refresh status missing from response"))?;
|
|
|
|
if status.last_error.is_empty() {
|
|
println!(
|
|
"{} Rotation requested for {} {} ({})",
|
|
"✓".green().bold(),
|
|
status.provider_name,
|
|
status.credential_key,
|
|
status.status
|
|
);
|
|
} else {
|
|
println!(
|
|
"Rotation request recorded for {} {} ({}): {}",
|
|
status.provider_name, status.credential_key, status.status, status.last_error
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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?;
|
|
let response = client
|
|
.delete_provider_refresh(DeleteProviderRefreshRequest {
|
|
provider: name.to_string(),
|
|
credential_key: credential_key.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
if response.deleted {
|
|
println!(
|
|
"{} Deleted refresh config for {} {}",
|
|
"✓".green().bold(),
|
|
name,
|
|
credential_key
|
|
);
|
|
} else {
|
|
println!("No refresh config found for provider '{name}' credential '{credential_key}'.");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn provider_refresh_strategy(strategy: &str) -> Result<ProviderCredentialRefreshStrategy> {
|
|
match strategy {
|
|
"oauth2_refresh_token" => Ok(ProviderCredentialRefreshStrategy::Oauth2RefreshToken),
|
|
"oauth2_client_credentials" => {
|
|
Ok(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials)
|
|
}
|
|
"google_service_account_jwt" => {
|
|
Ok(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt)
|
|
}
|
|
"aws_sts_assume_role" => Ok(ProviderCredentialRefreshStrategy::AwsStsAssumeRole),
|
|
_ => Err(miette!("unsupported provider refresh strategy: {strategy}")),
|
|
}
|
|
}
|
|
|
|
fn print_refresh_status_row(status: &ProviderCredentialRefreshStatus) {
|
|
println!("{}", refresh_status_row(status));
|
|
}
|
|
|
|
fn refresh_status_row(status: &ProviderCredentialRefreshStatus) -> String {
|
|
let strategy = ProviderCredentialRefreshStrategy::try_from(status.strategy)
|
|
.unwrap_or(ProviderCredentialRefreshStrategy::Unspecified);
|
|
format!(
|
|
"{:<24} {:<28} {:<28} {:<18} {:<20} {:<20} {:<20} {}",
|
|
status.provider_name,
|
|
status.credential_key,
|
|
provider_refresh_strategy_name(strategy),
|
|
status.status,
|
|
format_optional_epoch_ms(status.expires_at_ms),
|
|
format_optional_epoch_ms(status.next_refresh_at_ms),
|
|
format_optional_epoch_ms(status.last_refresh_at_ms),
|
|
truncate_status_field(&status.last_error, 72),
|
|
)
|
|
}
|
|
|
|
fn provider_refresh_strategy_name(strategy: ProviderCredentialRefreshStrategy) -> &'static str {
|
|
match strategy {
|
|
ProviderCredentialRefreshStrategy::Static => "static",
|
|
ProviderCredentialRefreshStrategy::External => "external",
|
|
ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token",
|
|
ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials",
|
|
ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt",
|
|
ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role",
|
|
ProviderCredentialRefreshStrategy::Unspecified => "unspecified",
|
|
}
|
|
}
|
|
|
|
fn load_profile_import_items(
|
|
file: Option<&Path>,
|
|
from: Option<&Path>,
|
|
) -> Result<(
|
|
Vec<ProviderProfileImportItem>,
|
|
Vec<ProviderProfileDiagnostic>,
|
|
)> {
|
|
let paths = profile_source_paths(file, from)?;
|
|
let mut items = Vec::new();
|
|
let mut diagnostics = Vec::new();
|
|
for path in paths {
|
|
match load_profile_import_item(&path) {
|
|
Ok(item) => items.push(item),
|
|
Err(diagnostic) => diagnostics.push(diagnostic),
|
|
}
|
|
}
|
|
Ok((items, diagnostics))
|
|
}
|
|
|
|
fn profile_source_paths(file: Option<&Path>, from: Option<&Path>) -> Result<Vec<PathBuf>> {
|
|
if let Some(file) = file {
|
|
return Ok(vec![file.to_path_buf()]);
|
|
}
|
|
let Some(from) = from else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let mut paths = Vec::new();
|
|
for entry in std::fs::read_dir(from)
|
|
.into_diagnostic()
|
|
.wrap_err_with(|| format!("failed to read profile directory {}", from.display()))?
|
|
{
|
|
let entry = entry.into_diagnostic()?;
|
|
let path = entry.path();
|
|
if path.is_file() && profile_extension_supported(&path) {
|
|
paths.push(path);
|
|
}
|
|
}
|
|
paths.sort();
|
|
Ok(paths)
|
|
}
|
|
|
|
fn profile_extension_supported(path: &Path) -> bool {
|
|
matches!(
|
|
path.extension().and_then(|ext| ext.to_str()),
|
|
Some("yaml" | "yml" | "json")
|
|
)
|
|
}
|
|
|
|
fn load_profile_import_item(
|
|
path: &Path,
|
|
) -> Result<ProviderProfileImportItem, ProviderProfileDiagnostic> {
|
|
let source = path.display().to_string();
|
|
let input = std::fs::read_to_string(path).map_err(|err| {
|
|
profile_file_diagnostic(
|
|
&source,
|
|
format!("failed to read provider profile file: {err}"),
|
|
)
|
|
})?;
|
|
let profile = match path.extension().and_then(|ext| ext.to_str()) {
|
|
Some("yaml" | "yml") => parse_profile_yaml(&input),
|
|
Some("json") => parse_profile_json(&input),
|
|
_ => {
|
|
return Err(profile_file_diagnostic(
|
|
&source,
|
|
"unsupported provider profile file format".to_string(),
|
|
));
|
|
}
|
|
}
|
|
.map_err(|err| profile_file_diagnostic(&source, err.to_string()))?;
|
|
|
|
let pre_lower = profile.validate_before_lowering(&source);
|
|
if let Some(diag) = pre_lower.into_iter().find(|d| d.severity == "error") {
|
|
return Err(ProviderProfileDiagnostic {
|
|
source: diag.source,
|
|
profile_id: diag.profile_id,
|
|
field: diag.field,
|
|
message: diag.message,
|
|
severity: diag.severity,
|
|
});
|
|
}
|
|
|
|
Ok(ProviderProfileImportItem {
|
|
profile: Some(profile.to_proto()),
|
|
source,
|
|
})
|
|
}
|
|
|
|
fn profile_file_diagnostic(source: &str, message: String) -> ProviderProfileDiagnostic {
|
|
ProviderProfileDiagnostic {
|
|
source: source.to_string(),
|
|
profile_id: String::new(),
|
|
field: "file".to_string(),
|
|
message,
|
|
severity: "error".to_string(),
|
|
}
|
|
}
|
|
|
|
fn print_profile_diagnostics(diagnostics: &[ProviderProfileDiagnostic]) {
|
|
if diagnostics.is_empty() {
|
|
return;
|
|
}
|
|
eprintln!("{}", "Provider profile diagnostics:".red().bold());
|
|
for diagnostic in diagnostics {
|
|
let source = if diagnostic.source.is_empty() {
|
|
"<input>"
|
|
} else {
|
|
&diagnostic.source
|
|
};
|
|
let profile = if diagnostic.profile_id.is_empty() {
|
|
"-".to_string()
|
|
} else {
|
|
diagnostic.profile_id.clone()
|
|
};
|
|
eprintln!(
|
|
" {} {} profile={} field={} {}",
|
|
diagnostic.severity.as_str().red(),
|
|
source,
|
|
profile,
|
|
diagnostic.field,
|
|
diagnostic.message
|
|
);
|
|
}
|
|
}
|
|
|
|
fn profile_diagnostics_have_errors(diagnostics: &[ProviderProfileDiagnostic]) -> bool {
|
|
diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic.severity == "error")
|
|
}
|
|
|
|
fn display_provider_category(category: i32) -> &'static str {
|
|
match ProviderProfileCategory::try_from(category).unwrap_or(ProviderProfileCategory::Other) {
|
|
ProviderProfileCategory::Inference => "INFERENCE",
|
|
ProviderProfileCategory::Agent => "AGENT",
|
|
ProviderProfileCategory::SourceControl => "SOURCE CONTROL",
|
|
ProviderProfileCategory::Messaging => "MESSAGING",
|
|
ProviderProfileCategory::Data => "DATA",
|
|
ProviderProfileCategory::Knowledge => "KNOWLEDGE",
|
|
ProviderProfileCategory::Other | ProviderProfileCategory::Unspecified => "OTHER",
|
|
}
|
|
}
|
|
|
|
const PROVIDER_PROFILE_ID_MAX_WIDTH: usize = 32;
|
|
const PROVIDER_PROFILE_DISPLAY_MAX_WIDTH: usize = 40;
|
|
const PROVIDER_PROFILE_SOURCE_MAX_WIDTH: usize = 24;
|
|
|
|
fn provider_profile_id_width(profiles: &[ProviderProfile]) -> usize {
|
|
profiles
|
|
.iter()
|
|
.map(|profile| {
|
|
profile
|
|
.id
|
|
.chars()
|
|
.count()
|
|
.min(PROVIDER_PROFILE_ID_MAX_WIDTH)
|
|
})
|
|
.max()
|
|
.unwrap_or(2)
|
|
.max(2)
|
|
}
|
|
|
|
fn provider_profile_display_width(profiles: &[ProviderProfile]) -> usize {
|
|
profiles
|
|
.iter()
|
|
.map(|profile| {
|
|
profile
|
|
.display_name
|
|
.chars()
|
|
.count()
|
|
.min(PROVIDER_PROFILE_DISPLAY_MAX_WIDTH)
|
|
})
|
|
.max()
|
|
.unwrap_or(4)
|
|
.max(4)
|
|
}
|
|
|
|
fn provider_profile_scope_width(profiles: &[ProviderProfile]) -> usize {
|
|
profiles
|
|
.iter()
|
|
.map(|profile| profile.scope.chars().count())
|
|
.max()
|
|
.unwrap_or(5)
|
|
.max(5)
|
|
}
|
|
|
|
fn provider_profile_source_width(profiles: &[ProviderProfile]) -> usize {
|
|
profiles
|
|
.iter()
|
|
.map(|profile| {
|
|
profile
|
|
.source
|
|
.chars()
|
|
.count()
|
|
.min(PROVIDER_PROFILE_SOURCE_MAX_WIDTH)
|
|
})
|
|
.max()
|
|
.unwrap_or(6)
|
|
.max(6)
|
|
}
|
|
|
|
fn print_provider_type_header(
|
|
id_width: usize,
|
|
scope_width: usize,
|
|
source_width: usize,
|
|
display_width: usize,
|
|
) {
|
|
let endpoints = "ENDPOINTS";
|
|
println!(
|
|
" {:<id_width$} {:<scope_width$} {:<source_width$} {:<display_width$} {endpoints}",
|
|
"ID", "SCOPE", "SOURCE", "NAME"
|
|
);
|
|
}
|
|
|
|
fn print_provider_type_row(
|
|
profile: &ProviderProfile,
|
|
id_width: usize,
|
|
scope_width: usize,
|
|
source_width: usize,
|
|
display_width: usize,
|
|
) {
|
|
let inference = if profile.inference_capable {
|
|
" inference"
|
|
} else {
|
|
""
|
|
};
|
|
let id = truncate_display(&profile.id, PROVIDER_PROFILE_ID_MAX_WIDTH);
|
|
let scope = &profile.scope;
|
|
let source = truncate_display(&profile.source, PROVIDER_PROFILE_SOURCE_MAX_WIDTH);
|
|
let display_name = truncate_display(&profile.display_name, PROVIDER_PROFILE_DISPLAY_MAX_WIDTH);
|
|
println!(
|
|
" {id:<id_width$} {scope:<scope_width$} {source:<source_width$} {display_name:<display_width$} {:<2}{}",
|
|
profile.endpoints.len(),
|
|
inference
|
|
);
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn provider_update(
|
|
server: &str,
|
|
name: &str,
|
|
from_existing: bool,
|
|
credentials: &[String],
|
|
config: &[String],
|
|
credential_expires_at: &[String],
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
if from_existing && !credentials.is_empty() {
|
|
return Err(miette::miette!(
|
|
"--from-existing cannot be combined with --credential"
|
|
));
|
|
}
|
|
|
|
let mut client = grpc_client(server, tls).await?;
|
|
|
|
let mut credential_map = parse_credential_pairs(credentials)?;
|
|
let mut config_map = parse_key_value_pairs(config, "--config")?;
|
|
let credential_expires_at_ms = parse_credential_expiry_pairs(credential_expires_at)?;
|
|
|
|
if from_existing {
|
|
// Fetch the existing provider to discover its type for credential lookup.
|
|
let existing = client
|
|
.get_provider(GetProviderRequest {
|
|
name: name.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner()
|
|
.provider
|
|
.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, workspace).await?;
|
|
let Some(discovered) = discovered else {
|
|
return Err(miette::miette!(
|
|
"no existing local credentials/config found for provider type '{provider_type}'"
|
|
));
|
|
};
|
|
|
|
for (key, value) in discovered.credentials {
|
|
credential_map.entry(key).or_insert(value);
|
|
}
|
|
for (key, value) in discovered.config {
|
|
config_map.entry(key).or_insert(value);
|
|
}
|
|
}
|
|
|
|
let response = client
|
|
.update_provider(UpdateProviderRequest {
|
|
provider: Some(Provider {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: String::new(),
|
|
name: name.to_string(),
|
|
created_at_ms: 0,
|
|
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_handles: HashMap::new(),
|
|
}),
|
|
credential_expires_at_ms,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let provider = response
|
|
.into_inner()
|
|
.provider
|
|
.ok_or_else(|| miette::miette!("provider missing from response"))?;
|
|
|
|
println!(
|
|
"{} Updated provider {}",
|
|
"✓".green().bold(),
|
|
provider.object_name()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
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(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
if response.into_inner().deleted {
|
|
println!("{} Deleted provider {name}", "✓".green().bold());
|
|
} else {
|
|
println!("{} Provider {name} not found", "!".yellow());
|
|
}
|
|
}
|
|
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::<HashMap<String, String>>();
|
|
|
|
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::<Vec<_>>()
|
|
.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!(
|
|
"{:<name_width$} {:<12} {:<20} {}",
|
|
"NAME".bold(),
|
|
"STATUS".bold(),
|
|
"CREATED".bold(),
|
|
"LABELS".bold(),
|
|
);
|
|
|
|
for workspace in &workspaces {
|
|
let status = workspace_phase_display(workspace);
|
|
let created = workspace
|
|
.metadata
|
|
.as_ref()
|
|
.map_or_else(String::new, |m| format_epoch_ms(m.created_at_ms));
|
|
let labels = workspace.metadata.as_ref().map_or_else(String::new, |m| {
|
|
m.labels
|
|
.iter()
|
|
.map(|(k, v)| format!("{k}={v}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
});
|
|
println!(
|
|
"{:<name_width$} {:<12} {:<20} {}",
|
|
workspace.object_name(),
|
|
status,
|
|
created,
|
|
labels,
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn workspace_delete(server: &str, names: &[String], tls: &TlsOptions) -> 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!("{:<subject_width$} {}", "SUBJECT".bold(), "ROLE".bold());
|
|
|
|
for member in &members {
|
|
let role_str = match WorkspaceRole::try_from(member.role) {
|
|
Ok(WorkspaceRole::Admin) => "admin",
|
|
Ok(WorkspaceRole::User) => "user",
|
|
_ => "unknown",
|
|
};
|
|
println!("{:<subject_width$} {}", member.principal_subject, role_str);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn workspace_phase_str(workspace: &openshell_core::proto::Workspace) -> &'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,
|
|
model_id: &str,
|
|
route_name: &str,
|
|
no_verify: bool,
|
|
timeout_secs: u64,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let progress = if std::io::stdout().is_terminal() {
|
|
let spinner = ProgressBar::new_spinner();
|
|
spinner.set_style(
|
|
ProgressStyle::with_template("{spinner:.cyan} {msg} ({elapsed})")
|
|
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
|
|
);
|
|
spinner.set_message("Configuring inference...");
|
|
spinner.enable_steady_tick(Duration::from_millis(120));
|
|
Some(spinner)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mut client = grpc_inference_client(server, tls).await?;
|
|
let response = client
|
|
.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;
|
|
|
|
if let Some(progress) = &progress {
|
|
progress.finish_and_clear();
|
|
}
|
|
|
|
let response = response.map_err(format_inference_status)?;
|
|
|
|
let configured = response.into_inner();
|
|
let label = if configured.route_name == "sandbox-system" {
|
|
"System inference configured:"
|
|
} else {
|
|
"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);
|
|
println!(" {} {}", "Version:".dimmed(), configured.version);
|
|
print_timeout(configured.timeout_secs);
|
|
if configured.validation_performed {
|
|
println!(" {}", "Validated Endpoints:".dimmed());
|
|
for endpoint in configured.validated_endpoints {
|
|
println!(" - {} ({})", endpoint.url, endpoint.protocol);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn gateway_inference_update(
|
|
server: &str,
|
|
provider_name: Option<&str>,
|
|
model_id: Option<&str>,
|
|
route_name: &str,
|
|
no_verify: bool,
|
|
timeout_secs: Option<u64>,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
if provider_name.is_none() && model_id.is_none() && timeout_secs.is_none() {
|
|
return Err(miette::miette!(
|
|
"at least one of --provider, --model, or --timeout must be specified"
|
|
));
|
|
}
|
|
|
|
let mut client = grpc_inference_client(server, tls).await?;
|
|
|
|
// Fetch current config to use as base for the partial update.
|
|
let current = client
|
|
.get_inference_route(GetInferenceRouteRequest {
|
|
route_name: route_name.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
let provider = provider_name.unwrap_or(¤t.provider_name);
|
|
let model = model_id.unwrap_or(¤t.model_id);
|
|
let timeout = timeout_secs.unwrap_or(current.timeout_secs);
|
|
|
|
let progress = if std::io::stdout().is_terminal() {
|
|
let spinner = ProgressBar::new_spinner();
|
|
spinner.set_style(
|
|
ProgressStyle::with_template("{spinner:.cyan} {msg} ({elapsed})")
|
|
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
|
|
);
|
|
spinner.set_message("Configuring inference...");
|
|
spinner.enable_steady_tick(Duration::from_millis(120));
|
|
Some(spinner)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let response = client
|
|
.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;
|
|
|
|
if let Some(progress) = &progress {
|
|
progress.finish_and_clear();
|
|
}
|
|
|
|
let response = response.map_err(format_inference_status)?;
|
|
|
|
let configured = response.into_inner();
|
|
let label = if configured.route_name == "sandbox-system" {
|
|
"System inference updated:"
|
|
} else {
|
|
"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);
|
|
println!(" {} {}", "Version:".dimmed(), configured.version);
|
|
print_timeout(configured.timeout_secs);
|
|
if configured.validation_performed {
|
|
println!(" {}", "Validated Endpoints:".dimmed());
|
|
for endpoint in configured.validated_endpoints {
|
|
println!(" - {} ({})", endpoint.url, endpoint.protocol);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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?;
|
|
|
|
if let Some(name) = route_name {
|
|
// Show a single route (--system was specified).
|
|
let response = client
|
|
.get_inference_route(GetInferenceRouteRequest {
|
|
route_name: name.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let configured = response.into_inner();
|
|
let label = if name == "sandbox-system" {
|
|
"System inference:"
|
|
} else {
|
|
"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, "Inference", "", workspace).await;
|
|
println!();
|
|
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(())
|
|
}
|
|
|
|
async fn print_inference_route(
|
|
client: &mut crate::tls::GrpcInferenceClient,
|
|
label: &str,
|
|
route_name: &str,
|
|
workspace: &str,
|
|
) {
|
|
match client
|
|
.get_inference_route(GetInferenceRouteRequest {
|
|
route_name: route_name.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
{
|
|
Ok(response) => {
|
|
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);
|
|
print_timeout(configured.timeout_secs);
|
|
}
|
|
Err(e) if e.code() == Code::NotFound => {
|
|
println!("{}", format!("{label}:").cyan().bold());
|
|
println!();
|
|
println!(" {}", "Not configured".dimmed());
|
|
}
|
|
Err(e) => {
|
|
println!("{}", format!("{label}:").cyan().bold());
|
|
println!();
|
|
println!(" {} {}", "Error:".red(), e.message());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn print_timeout(timeout_secs: u64) {
|
|
if timeout_secs == 0 {
|
|
println!(" {} {}s (default)", "Timeout:".dimmed(), 60);
|
|
} else {
|
|
println!(" {} {}s", "Timeout:".dimmed(), timeout_secs);
|
|
}
|
|
}
|
|
|
|
fn format_inference_status(status: Status) -> miette::Report {
|
|
let message = status.message().trim();
|
|
|
|
if message.is_empty() {
|
|
return miette::miette!("inference configuration failed ({})", status.code());
|
|
}
|
|
|
|
miette::miette!("{message}")
|
|
}
|
|
|
|
pub fn git_repo_root(local_path: &Path) -> Result<PathBuf> {
|
|
let git_dir = if local_path.is_dir() {
|
|
local_path
|
|
} else {
|
|
local_path
|
|
.parent()
|
|
.ok_or_else(|| miette::miette!("path has no parent: {}", local_path.display()))?
|
|
};
|
|
let mut command = Command::new("git");
|
|
scrub_git_env(&mut command);
|
|
let output = command
|
|
.args(["rev-parse", "--show-toplevel"])
|
|
.current_dir(git_dir)
|
|
.output()
|
|
.into_diagnostic()
|
|
.wrap_err("failed to run git rev-parse")?;
|
|
|
|
if !output.status.success() {
|
|
return Err(miette::miette!(
|
|
"git rev-parse --show-toplevel failed with status {}",
|
|
output.status
|
|
));
|
|
}
|
|
|
|
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if root.is_empty() {
|
|
return Err(miette::miette!(
|
|
"git rev-parse returned empty repository root"
|
|
));
|
|
}
|
|
|
|
Ok(PathBuf::from(root))
|
|
}
|
|
|
|
pub fn git_sync_files(local_path: &Path) -> Result<(PathBuf, Vec<String>)> {
|
|
let repo_root = std::fs::canonicalize(git_repo_root(local_path)?)
|
|
.into_diagnostic()
|
|
.wrap_err("failed to canonicalize git repository root")?;
|
|
let local_path = if local_path.is_absolute() {
|
|
local_path.to_path_buf()
|
|
} else {
|
|
std::env::current_dir()
|
|
.into_diagnostic()
|
|
.wrap_err("failed to resolve current directory")?
|
|
.join(local_path)
|
|
};
|
|
let local_path = std::fs::canonicalize(local_path)
|
|
.into_diagnostic()
|
|
.wrap_err("failed to canonicalize local upload path")?;
|
|
let relative_path = local_path
|
|
.strip_prefix(&repo_root)
|
|
.into_diagnostic()
|
|
.wrap_err_with(|| {
|
|
format!(
|
|
"local path '{}' is not inside git repository '{}'",
|
|
local_path.display(),
|
|
repo_root.display()
|
|
)
|
|
})?;
|
|
|
|
let is_file = local_path.is_file();
|
|
let base_dir = if is_file {
|
|
local_path
|
|
.parent()
|
|
.map(Path::to_path_buf)
|
|
.ok_or_else(|| miette::miette!("path has no parent: {}", local_path.display()))?
|
|
} else {
|
|
local_path.clone()
|
|
};
|
|
let pathspec = if relative_path.as_os_str().is_empty() {
|
|
None
|
|
} else {
|
|
Some(relative_path.to_string_lossy().into_owned())
|
|
};
|
|
|
|
let mut command = Command::new("git");
|
|
scrub_git_env(&mut command);
|
|
let output = command
|
|
.args(["ls-files", "-co", "--exclude-standard", "-z"])
|
|
.args(pathspec.as_deref())
|
|
.current_dir(&repo_root)
|
|
.output()
|
|
.into_diagnostic()
|
|
.wrap_err("failed to run git ls-files")?;
|
|
|
|
if !output.status.success() {
|
|
return Err(miette::miette!(
|
|
"git ls-files failed with status {}",
|
|
output.status
|
|
));
|
|
}
|
|
|
|
let mut files = Vec::new();
|
|
for entry in output.stdout.split(|byte| *byte == 0) {
|
|
if entry.is_empty() {
|
|
continue;
|
|
}
|
|
let repo_relative = Path::new(std::str::from_utf8(entry).into_diagnostic()?);
|
|
let path = if is_file {
|
|
repo_relative
|
|
.file_name()
|
|
.map(PathBuf::from)
|
|
.ok_or_else(|| {
|
|
miette::miette!("path has no file name: {}", repo_relative.display())
|
|
})?
|
|
} else if relative_path.as_os_str().is_empty() {
|
|
repo_relative.to_path_buf()
|
|
} else {
|
|
repo_relative
|
|
.strip_prefix(relative_path)
|
|
.into_diagnostic()?
|
|
.to_path_buf()
|
|
};
|
|
if path.as_os_str().is_empty() {
|
|
continue;
|
|
}
|
|
files.push(path.to_string_lossy().into_owned());
|
|
}
|
|
|
|
Ok((base_dir, files))
|
|
}
|
|
|
|
fn sandbox_upload_plan(local_path: &Path, git_ignore: bool) -> Result<SandboxUploadPlan> {
|
|
let metadata = std::fs::symlink_metadata(local_path).map_err(|err| {
|
|
if err.kind() == ErrorKind::NotFound {
|
|
miette::miette!("local path does not exist: {}", local_path.display())
|
|
} else {
|
|
miette::miette!(
|
|
"failed to inspect local upload path: {}",
|
|
local_path.display()
|
|
)
|
|
}
|
|
})?;
|
|
|
|
if git_ignore
|
|
&& !metadata.file_type().is_symlink()
|
|
&& let Ok((base_dir, files)) = git_sync_files(local_path)
|
|
{
|
|
if files.is_empty() {
|
|
return Ok(SandboxUploadPlan::GitFilteredEmpty);
|
|
}
|
|
return Ok(SandboxUploadPlan::GitAware { base_dir, files });
|
|
}
|
|
|
|
Ok(SandboxUploadPlan::Regular)
|
|
}
|
|
|
|
/// Upload a local path to a sandbox.
|
|
///
|
|
/// Symlink sources, including dangling links, bypass Git-aware filtering so
|
|
/// the tar upload preserves the link instead of dereferencing its target.
|
|
pub async fn sandbox_upload(
|
|
server: &str,
|
|
name: &str,
|
|
local_path: &Path,
|
|
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("~");
|
|
eprintln!(
|
|
"Uploading {} -> sandbox:{}",
|
|
local_path.display(),
|
|
dest_display
|
|
);
|
|
|
|
match upload_plan {
|
|
SandboxUploadPlan::GitAware { base_dir, files } => {
|
|
sandbox_sync_up_files(
|
|
server,
|
|
name,
|
|
&base_dir,
|
|
&files,
|
|
local_path,
|
|
sandbox_path,
|
|
tls,
|
|
workspace,
|
|
)
|
|
.await?;
|
|
}
|
|
SandboxUploadPlan::GitFilteredEmpty => {
|
|
eprintln!(
|
|
"{} .gitignore filtering excluded all files in {}; uploading unfiltered",
|
|
"⚠".yellow().bold(),
|
|
local_path.display(),
|
|
);
|
|
sandbox_sync_up(server, name, local_path, sandbox_path, tls, workspace).await?;
|
|
}
|
|
SandboxUploadPlan::Regular => {
|
|
sandbox_sync_up(server, name, local_path, sandbox_path, tls, workspace).await?;
|
|
}
|
|
}
|
|
|
|
eprintln!("{} Upload complete", "✓".green().bold());
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sandbox policy commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub async fn sandbox_policy_set_global(
|
|
server: &str,
|
|
policy_path: &str,
|
|
yes: bool,
|
|
wait: bool,
|
|
_timeout_secs: u64,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
if wait {
|
|
return Err(miette::miette!(
|
|
"--wait is only supported for sandbox-scoped policy updates"
|
|
));
|
|
}
|
|
|
|
confirm_global_setting_takeover("policy", yes)?;
|
|
|
|
let policy = load_sandbox_policy(Some(policy_path))?
|
|
.ok_or_else(|| miette::miette!("No policy loaded from {policy_path}"))?;
|
|
|
|
let mut client = grpc_client(server, tls).await?;
|
|
let response = client
|
|
.update_config(UpdateConfigRequest {
|
|
name: String::new(),
|
|
policy: Some(policy),
|
|
global: true,
|
|
workspace: workspace.to_string(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
eprintln!(
|
|
"{} Global policy configured (hash: {}, settings revision: {})",
|
|
"✓".green().bold(),
|
|
if response.policy_hash.len() >= 12 {
|
|
&response.policy_hash[..12]
|
|
} else {
|
|
&response.policy_hash
|
|
},
|
|
response.settings_revision,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
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()?
|
|
.into_inner()
|
|
.sandbox
|
|
.ok_or_else(|| miette::miette!("sandbox not found"))?;
|
|
|
|
let response = client
|
|
.get_sandbox_config(GetSandboxConfigRequest {
|
|
sandbox_id: sandbox.object_id().to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
if json {
|
|
let obj = settings_to_json_sandbox(name, workspace, &response);
|
|
println!("{}", serde_json::to_string_pretty(&obj).into_diagnostic()?);
|
|
return Ok(());
|
|
}
|
|
|
|
let policy_source = if response.policy_source == PolicySource::Global as i32 {
|
|
"global"
|
|
} else {
|
|
"sandbox"
|
|
};
|
|
|
|
println!("Sandbox: {name}");
|
|
println!("Config Rev: {}", response.config_revision);
|
|
println!("Policy Source: {policy_source}");
|
|
println!("Policy Hash: {}", response.policy_hash);
|
|
|
|
if response.settings.is_empty() {
|
|
println!("Settings: No settings available.");
|
|
return Ok(());
|
|
}
|
|
|
|
println!("Settings:");
|
|
let mut keys: Vec<_> = response.settings.keys().cloned().collect();
|
|
keys.sort();
|
|
for key in keys {
|
|
if let Some(setting) = response.settings.get(&key) {
|
|
let scope = match SettingScope::try_from(setting.scope) {
|
|
Ok(SettingScope::Global) => "global",
|
|
Ok(SettingScope::Sandbox) => "sandbox",
|
|
_ => "unset",
|
|
};
|
|
println!(
|
|
" {} = {} ({})",
|
|
key,
|
|
format_setting_value(setting.value.as_ref()),
|
|
scope
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn gateway_settings_get(server: &str, json: bool, tls: &TlsOptions) -> Result<()> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
let response = client
|
|
.get_gateway_config(GetGatewayConfigRequest {})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
if json {
|
|
let obj = settings_to_json_global(&response);
|
|
println!("{}", serde_json::to_string_pretty(&obj).into_diagnostic()?);
|
|
return Ok(());
|
|
}
|
|
|
|
println!("Scope: global");
|
|
println!("Settings Rev: {}", response.settings_revision);
|
|
|
|
if response.settings.is_empty() {
|
|
println!("Settings: No settings available.");
|
|
return Ok(());
|
|
}
|
|
|
|
println!("Settings:");
|
|
let mut keys: Vec<_> = response.settings.keys().cloned().collect();
|
|
keys.sort();
|
|
for key in keys {
|
|
if let Some(setting) = response.settings.get(&key) {
|
|
println!(" {} = {}", key, format_setting_value(Some(setting)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn settings_to_json_sandbox(
|
|
name: &str,
|
|
workspace: &str,
|
|
response: &GetSandboxConfigResponse,
|
|
) -> serde_json::Value {
|
|
let policy_source = if response.policy_source == PolicySource::Global as i32 {
|
|
"global"
|
|
} else {
|
|
"sandbox"
|
|
};
|
|
|
|
let mut settings = serde_json::Map::new();
|
|
let mut keys: Vec<_> = response.settings.keys().cloned().collect();
|
|
keys.sort();
|
|
for key in keys {
|
|
if let Some(setting) = response.settings.get(&key) {
|
|
let scope = match SettingScope::try_from(setting.scope) {
|
|
Ok(SettingScope::Global) => "global",
|
|
Ok(SettingScope::Sandbox) => "sandbox",
|
|
_ => "unset",
|
|
};
|
|
settings.insert(
|
|
key,
|
|
serde_json::json!({
|
|
"value": format_setting_value(setting.value.as_ref()),
|
|
"scope": scope,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
serde_json::json!({
|
|
"sandbox": name,
|
|
"workspace": workspace,
|
|
"config_revision": response.config_revision,
|
|
"policy_source": policy_source,
|
|
"policy_hash": response.policy_hash,
|
|
"settings": settings,
|
|
})
|
|
}
|
|
|
|
fn settings_to_json_global(
|
|
response: &openshell_core::proto::GetGatewayConfigResponse,
|
|
) -> serde_json::Value {
|
|
let mut settings = serde_json::Map::new();
|
|
let mut keys: Vec<_> = response.settings.keys().cloned().collect();
|
|
keys.sort();
|
|
for key in keys {
|
|
if let Some(setting) = response.settings.get(&key) {
|
|
settings.insert(key, serde_json::json!(format_setting_value(Some(setting))));
|
|
}
|
|
}
|
|
|
|
serde_json::json!({
|
|
"scope": "global",
|
|
"settings_revision": response.settings_revision,
|
|
"settings": settings,
|
|
})
|
|
}
|
|
|
|
pub async fn gateway_setting_set(
|
|
server: &str,
|
|
key: &str,
|
|
value: &str,
|
|
yes: bool,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let setting_value = parse_cli_setting_value(key, value)?;
|
|
confirm_global_setting_takeover(key, yes)?;
|
|
|
|
let mut client = grpc_client(server, tls).await?;
|
|
let response = client
|
|
.update_config(UpdateConfigRequest {
|
|
name: String::new(),
|
|
setting_key: key.to_string(),
|
|
setting_value: Some(setting_value),
|
|
global: true,
|
|
workspace: workspace.to_string(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
println!(
|
|
"{} Set global setting {}={} (revision {})",
|
|
"✓".green().bold(),
|
|
key,
|
|
value,
|
|
response.settings_revision
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn sandbox_setting_set(
|
|
server: &str,
|
|
name: &str,
|
|
key: &str,
|
|
value: &str,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let setting_value = parse_cli_setting_value(key, value)?;
|
|
|
|
let mut client = grpc_client(server, tls).await?;
|
|
let response = client
|
|
.update_config(UpdateConfigRequest {
|
|
name: name.to_string(),
|
|
setting_key: key.to_string(),
|
|
setting_value: Some(setting_value),
|
|
workspace: workspace.to_string(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
println!(
|
|
"{} Set sandbox setting {}={} for {} (revision {})",
|
|
"✓".green().bold(),
|
|
key,
|
|
value,
|
|
name,
|
|
response.settings_revision
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn gateway_setting_delete(
|
|
server: &str,
|
|
key: &str,
|
|
yes: bool,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
confirm_global_setting_delete(key, yes)?;
|
|
|
|
let mut client = grpc_client(server, tls).await?;
|
|
let response = client
|
|
.update_config(UpdateConfigRequest {
|
|
name: String::new(),
|
|
setting_key: key.to_string(),
|
|
delete_setting: true,
|
|
global: true,
|
|
workspace: workspace.to_string(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
if response.deleted {
|
|
println!(
|
|
"{} Deleted global setting {} (revision {})",
|
|
"✓".green().bold(),
|
|
key,
|
|
response.settings_revision
|
|
);
|
|
} else {
|
|
println!("{} Global setting {} not found", "!".yellow(), key);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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?;
|
|
let response = client
|
|
.update_config(UpdateConfigRequest {
|
|
name: name.to_string(),
|
|
setting_key: key.to_string(),
|
|
delete_setting: true,
|
|
workspace: workspace.to_string(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
if response.deleted {
|
|
println!(
|
|
"{} Deleted sandbox setting {} for {} (revision {})",
|
|
"✓".green().bold(),
|
|
key,
|
|
name,
|
|
response.settings_revision
|
|
);
|
|
} else {
|
|
println!(
|
|
"{} Sandbox setting {} not found for {}",
|
|
"!".yellow(),
|
|
key,
|
|
name,
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn sandbox_policy_set(
|
|
server: &str,
|
|
name: &str,
|
|
policy_path: &str,
|
|
wait: bool,
|
|
timeout_secs: u64,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let policy = load_sandbox_policy(Some(policy_path))?
|
|
.ok_or_else(|| miette::miette!("No policy loaded from {policy_path}"))?;
|
|
|
|
let mut client = grpc_client(server, tls).await?;
|
|
|
|
// Get current version so we can detect no-ops.
|
|
let current_version = client
|
|
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
|
|
name: name.to_string(),
|
|
version: 0,
|
|
global: false,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.ok()
|
|
.and_then(|r| r.into_inner().revision)
|
|
.map_or(0, |r| r.version);
|
|
|
|
let response = client
|
|
.update_config(UpdateConfigRequest {
|
|
name: name.to_string(),
|
|
policy: Some(policy),
|
|
workspace: workspace.to_string(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let resp = response.into_inner();
|
|
|
|
if resp.version == current_version {
|
|
eprintln!(
|
|
"{} Policy unchanged (version {}, hash: {})",
|
|
"·".dimmed(),
|
|
resp.version,
|
|
&resp.policy_hash[..12]
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
eprintln!(
|
|
"{} Policy version {} submitted (hash: {})",
|
|
"✓".green().bold(),
|
|
resp.version,
|
|
&resp.policy_hash[..12]
|
|
);
|
|
|
|
if !wait {
|
|
return Ok(());
|
|
}
|
|
|
|
// Poll for status until loaded, failed, or timeout.
|
|
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
|
loop {
|
|
if Instant::now() > deadline {
|
|
eprintln!(
|
|
"{} Timeout waiting for policy version {} to load",
|
|
"✗".red().bold(),
|
|
resp.version
|
|
);
|
|
std::process::exit(124);
|
|
}
|
|
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
|
|
let status_resp = client
|
|
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
|
|
name: name.to_string(),
|
|
version: resp.version,
|
|
global: false,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let inner = status_resp.into_inner();
|
|
if let Some(rev) = &inner.revision {
|
|
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
|
|
match status {
|
|
PolicyStatus::Loaded => {
|
|
eprintln!(
|
|
"{} Policy version {} loaded (active version: {})",
|
|
"✓".green().bold(),
|
|
rev.version,
|
|
inner.active_version
|
|
);
|
|
return Ok(());
|
|
}
|
|
PolicyStatus::Failed => {
|
|
eprintln!(
|
|
"{} Policy version {} failed to load: {}",
|
|
"✗".red().bold(),
|
|
rev.version,
|
|
rev.load_error
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
PolicyStatus::Superseded => {
|
|
eprintln!(
|
|
"{} Policy version {} was superseded (active version: {})",
|
|
"⚠".yellow().bold(),
|
|
rev.version,
|
|
inner.active_version
|
|
);
|
|
return Ok(());
|
|
}
|
|
_ => {} // still pending, keep polling
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn sandbox_policy_update(
|
|
server: &str,
|
|
name: &str,
|
|
add_endpoints: &[String],
|
|
remove_endpoints: &[String],
|
|
add_deny: &[String],
|
|
add_allow: &[String],
|
|
remove_rules: &[String],
|
|
binaries: &[String],
|
|
rule_name: Option<&str>,
|
|
dry_run: bool,
|
|
wait: bool,
|
|
timeout_secs: u64,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
if dry_run && wait {
|
|
return Err(miette!("--wait cannot be combined with --dry-run"));
|
|
}
|
|
|
|
let plan = build_policy_update_plan(
|
|
add_endpoints,
|
|
remove_endpoints,
|
|
add_deny,
|
|
add_allow,
|
|
remove_rules,
|
|
binaries,
|
|
rule_name,
|
|
)?;
|
|
|
|
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()?
|
|
.into_inner()
|
|
.sandbox
|
|
.ok_or_else(|| miette!("sandbox not found"))?;
|
|
|
|
let sandbox_id = if sandbox.object_id().is_empty() {
|
|
return Err(miette!("sandbox missing metadata"));
|
|
} else {
|
|
sandbox.object_id().to_string()
|
|
};
|
|
|
|
let current = client
|
|
.get_sandbox_config(GetSandboxConfigRequest { sandbox_id })
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
if current.policy_source == PolicySource::Global as i32 {
|
|
return Err(miette!(
|
|
"policy is managed globally; delete the global policy before using `openshell policy update`"
|
|
));
|
|
}
|
|
|
|
let merged = openshell_policy::merge_policy(
|
|
current.policy.clone().unwrap_or_default(),
|
|
&plan.preview_operations,
|
|
)
|
|
.map_err(|error| miette!("{error}"))?;
|
|
|
|
if dry_run {
|
|
eprintln!(
|
|
"{} Dry run preview for {} incremental policy operation(s)",
|
|
"✓".green().bold(),
|
|
plan.preview_operations.len()
|
|
);
|
|
print_policy_merge_warnings(&merged.warnings);
|
|
print_sandbox_policy(&merged.policy);
|
|
return Ok(());
|
|
}
|
|
|
|
let current_version = current.version;
|
|
let current_hash = current.policy_hash.clone();
|
|
let response = client
|
|
.update_config(UpdateConfigRequest {
|
|
name: name.to_string(),
|
|
merge_operations: plan.merge_operations,
|
|
workspace: workspace.to_string(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
print_policy_merge_warnings(&merged.warnings);
|
|
|
|
if response.version == current_version && response.policy_hash == current_hash {
|
|
eprintln!(
|
|
"{} Policy unchanged (version {}, hash: {})",
|
|
"·".dimmed(),
|
|
response.version,
|
|
short_hash(&response.policy_hash)
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
eprintln!(
|
|
"{} Policy version {} submitted (hash: {})",
|
|
"✓".green().bold(),
|
|
response.version,
|
|
short_hash(&response.policy_hash)
|
|
);
|
|
|
|
if !wait {
|
|
return Ok(());
|
|
}
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
|
loop {
|
|
if Instant::now() > deadline {
|
|
eprintln!(
|
|
"{} Timeout waiting for policy version {} to load",
|
|
"✗".red().bold(),
|
|
response.version
|
|
);
|
|
std::process::exit(124);
|
|
}
|
|
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
|
|
let status_resp = client
|
|
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
|
|
name: name.to_string(),
|
|
version: response.version,
|
|
global: false,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let inner = status_resp.into_inner();
|
|
if let Some(rev) = &inner.revision {
|
|
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
|
|
match status {
|
|
PolicyStatus::Loaded => {
|
|
eprintln!(
|
|
"{} Policy version {} loaded (active version: {})",
|
|
"✓".green().bold(),
|
|
rev.version,
|
|
inner.active_version
|
|
);
|
|
return Ok(());
|
|
}
|
|
PolicyStatus::Failed => {
|
|
eprintln!(
|
|
"{} Policy version {} failed to load: {}",
|
|
"✗".red().bold(),
|
|
rev.version,
|
|
rev.load_error
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
PolicyStatus::Superseded => {
|
|
eprintln!(
|
|
"{} Policy version {} was superseded (active version: {})",
|
|
"⚠".yellow().bold(),
|
|
rev.version,
|
|
inner.active_version
|
|
);
|
|
return Ok(());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn sandbox_policy_get(
|
|
server: &str,
|
|
name: &str,
|
|
version: u32,
|
|
view: PolicyGetView,
|
|
output: &str,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
sandbox_policy_get_to_writer(
|
|
server,
|
|
name,
|
|
version,
|
|
view,
|
|
output,
|
|
workspace,
|
|
tls,
|
|
(&mut stdout, &mut stderr),
|
|
)
|
|
.await?;
|
|
|
|
{
|
|
let mut terminal_stdout = std::io::stdout().lock();
|
|
terminal_stdout.write_all(&stdout).into_diagnostic()?;
|
|
}
|
|
{
|
|
let mut terminal_stderr = std::io::stderr().lock();
|
|
terminal_stderr.write_all(&stderr).into_diagnostic()?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn sandbox_policy_get_to_writer<W, E>(
|
|
server: &str,
|
|
name: &str,
|
|
version: u32,
|
|
view: PolicyGetView,
|
|
output: &str,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
writers: (&mut W, &mut E),
|
|
) -> Result<()>
|
|
where
|
|
W: Write + Send,
|
|
E: Write + Send,
|
|
{
|
|
if version == 0 {
|
|
return sandbox_policy_get_effective_to_writer(
|
|
server, name, view, output, workspace, tls, writers,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let (stdout, stderr) = writers;
|
|
let mut client = grpc_client(server, tls).await?;
|
|
|
|
let status_resp = client
|
|
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
|
|
name: name.to_string(),
|
|
version,
|
|
global: false,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let inner = status_resp.into_inner();
|
|
if let Some(rev) = inner.revision {
|
|
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
|
|
match output {
|
|
"json" => {
|
|
let obj = policy_revision_to_json(
|
|
"sandbox",
|
|
Some(name),
|
|
Some(inner.active_version),
|
|
&rev,
|
|
status,
|
|
view,
|
|
)?;
|
|
writeln!(
|
|
stdout,
|
|
"{}",
|
|
serde_json::to_string_pretty(&obj).into_diagnostic()?
|
|
)
|
|
.into_diagnostic()?;
|
|
return Ok(());
|
|
}
|
|
"table" => {}
|
|
_ => return Err(miette!("unsupported output format: {output}")),
|
|
}
|
|
|
|
writeln!(stdout, "Version: {}", rev.version).into_diagnostic()?;
|
|
writeln!(stdout, "Hash: {}", rev.policy_hash).into_diagnostic()?;
|
|
writeln!(stdout, "Status: {status:?}").into_diagnostic()?;
|
|
writeln!(stdout, "Active: {}", inner.active_version).into_diagnostic()?;
|
|
if rev.created_at_ms > 0 {
|
|
writeln!(stdout, "Created: {} ms", rev.created_at_ms).into_diagnostic()?;
|
|
}
|
|
if rev.loaded_at_ms > 0 {
|
|
writeln!(stdout, "Loaded: {} ms", rev.loaded_at_ms).into_diagnostic()?;
|
|
}
|
|
if !rev.load_error.is_empty() {
|
|
writeln!(stdout, "Error: {}", rev.load_error).into_diagnostic()?;
|
|
}
|
|
|
|
if view.includes_policy() {
|
|
if let Some(ref policy) = rev.policy {
|
|
writeln!(stdout, "---").into_diagnostic()?;
|
|
let policy = policy_for_view(policy, view);
|
|
let yaml_str = openshell_policy::serialize_sandbox_policy(policy.as_ref())
|
|
.wrap_err("failed to serialize policy to YAML")?;
|
|
write!(stdout, "{yaml_str}").into_diagnostic()?;
|
|
} else {
|
|
writeln!(stderr, "Policy payload not available for this version")
|
|
.into_diagnostic()?;
|
|
}
|
|
}
|
|
} else {
|
|
writeln!(stderr, "No policy history found for sandbox '{name}'").into_diagnostic()?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn sandbox_policy_get_effective_to_writer<W, E>(
|
|
server: &str,
|
|
name: &str,
|
|
view: PolicyGetView,
|
|
output: &str,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
writers: (&mut W, &mut E),
|
|
) -> Result<()>
|
|
where
|
|
W: Write + Send,
|
|
E: Write + Send,
|
|
{
|
|
let (stdout, _stderr) = writers;
|
|
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()?
|
|
.into_inner()
|
|
.sandbox
|
|
.ok_or_else(|| miette!("sandbox missing from response"))?;
|
|
let sandbox_id = sandbox.object_id();
|
|
if sandbox_id.is_empty() {
|
|
return Err(miette!("sandbox missing metadata"));
|
|
}
|
|
|
|
let config = client
|
|
.get_sandbox_config(GetSandboxConfigRequest {
|
|
sandbox_id: sandbox_id.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
let policy = config
|
|
.policy
|
|
.as_ref()
|
|
.ok_or_else(|| miette!("no active policy configured for sandbox '{name}'"))?;
|
|
let policy_source =
|
|
PolicySource::try_from(config.policy_source).unwrap_or(PolicySource::Sandbox);
|
|
let policy_source_label = match policy_source {
|
|
PolicySource::Global => "global",
|
|
PolicySource::Sandbox => "sandbox",
|
|
PolicySource::Unspecified => "unspecified",
|
|
};
|
|
let version = if policy_source == PolicySource::Global && config.global_policy_version > 0 {
|
|
config.global_policy_version
|
|
} else {
|
|
config.version
|
|
};
|
|
|
|
match output {
|
|
"json" => {
|
|
let mut obj = serde_json::Map::new();
|
|
obj.insert("scope".to_string(), serde_json::json!("sandbox"));
|
|
obj.insert("sandbox".to_string(), serde_json::json!(name));
|
|
obj.insert("version".to_string(), serde_json::json!(version));
|
|
obj.insert("active_version".to_string(), serde_json::json!(version));
|
|
obj.insert("hash".to_string(), serde_json::json!(config.policy_hash));
|
|
obj.insert("status".to_string(), serde_json::json!("effective"));
|
|
obj.insert(
|
|
"config_revision".to_string(),
|
|
serde_json::json!(config.config_revision),
|
|
);
|
|
obj.insert(
|
|
"policy_source".to_string(),
|
|
serde_json::json!(policy_source_label),
|
|
);
|
|
if config.global_policy_version > 0 {
|
|
obj.insert(
|
|
"global_policy_version".to_string(),
|
|
serde_json::json!(config.global_policy_version),
|
|
);
|
|
}
|
|
if view.includes_policy() {
|
|
let policy = policy_for_view(policy, view);
|
|
obj.insert(
|
|
"policy".to_string(),
|
|
openshell_policy::sandbox_policy_to_json_value(policy.as_ref())?,
|
|
);
|
|
}
|
|
writeln!(
|
|
stdout,
|
|
"{}",
|
|
serde_json::to_string_pretty(&serde_json::Value::Object(obj)).into_diagnostic()?
|
|
)
|
|
.into_diagnostic()?;
|
|
}
|
|
"table" => {
|
|
writeln!(stdout, "Version: {version}").into_diagnostic()?;
|
|
writeln!(stdout, "Hash: {}", config.policy_hash).into_diagnostic()?;
|
|
writeln!(stdout, "Status: Effective").into_diagnostic()?;
|
|
writeln!(stdout, "Source: {policy_source_label}").into_diagnostic()?;
|
|
writeln!(stdout, "Config rev: {}", config.config_revision).into_diagnostic()?;
|
|
if config.global_policy_version > 0 {
|
|
writeln!(stdout, "Global: {}", config.global_policy_version)
|
|
.into_diagnostic()?;
|
|
}
|
|
if view.includes_policy() {
|
|
writeln!(stdout, "---").into_diagnostic()?;
|
|
let policy = policy_for_view(policy, view);
|
|
let yaml_str = openshell_policy::serialize_sandbox_policy(policy.as_ref())
|
|
.wrap_err("failed to serialize policy to YAML")?;
|
|
write!(stdout, "{yaml_str}").into_diagnostic()?;
|
|
}
|
|
}
|
|
_ => return Err(miette!("unsupported output format: {output}")),
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn sandbox_policy_get_global(
|
|
server: &str,
|
|
version: u32,
|
|
view: PolicyGetView,
|
|
output: &str,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
|
|
let status_resp = client
|
|
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
|
|
name: String::new(),
|
|
version,
|
|
global: true,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let inner = status_resp.into_inner();
|
|
if let Some(rev) = inner.revision {
|
|
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
|
|
match output {
|
|
"json" => {
|
|
let obj = policy_revision_to_json("global", None, None, &rev, status, view)?;
|
|
println!("{}", serde_json::to_string_pretty(&obj).into_diagnostic()?);
|
|
return Ok(());
|
|
}
|
|
"table" => {}
|
|
_ => return Err(miette!("unsupported output format: {output}")),
|
|
}
|
|
|
|
println!("Scope: global");
|
|
println!("Version: {}", rev.version);
|
|
println!("Hash: {}", rev.policy_hash);
|
|
println!("Status: {status:?}");
|
|
if rev.created_at_ms > 0 {
|
|
println!("Created: {} ms", rev.created_at_ms);
|
|
}
|
|
if rev.loaded_at_ms > 0 {
|
|
println!("Loaded: {} ms", rev.loaded_at_ms);
|
|
}
|
|
|
|
if view.includes_policy() {
|
|
if let Some(ref policy) = rev.policy {
|
|
println!("---");
|
|
let policy = policy_for_view(policy, view);
|
|
let yaml_str = openshell_policy::serialize_sandbox_policy(policy.as_ref())
|
|
.wrap_err("failed to serialize policy to YAML")?;
|
|
print!("{yaml_str}");
|
|
} else {
|
|
eprintln!("Policy payload not available for this version");
|
|
}
|
|
}
|
|
} else {
|
|
eprintln!("No global policy history found");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn policy_status_json_name(status: PolicyStatus) -> &'static str {
|
|
match status {
|
|
PolicyStatus::Unspecified => "unspecified",
|
|
PolicyStatus::Pending => "pending",
|
|
PolicyStatus::Loaded => "loaded",
|
|
PolicyStatus::Failed => "failed",
|
|
PolicyStatus::Superseded => "superseded",
|
|
}
|
|
}
|
|
|
|
fn policy_revision_to_json(
|
|
scope: &str,
|
|
sandbox: Option<&str>,
|
|
active_version: Option<u32>,
|
|
rev: &openshell_core::proto::SandboxPolicyRevision,
|
|
status: PolicyStatus,
|
|
view: PolicyGetView,
|
|
) -> Result<serde_json::Value> {
|
|
let mut obj = serde_json::Map::new();
|
|
obj.insert("scope".to_string(), serde_json::json!(scope));
|
|
if let Some(sandbox) = sandbox {
|
|
obj.insert("sandbox".to_string(), serde_json::json!(sandbox));
|
|
}
|
|
obj.insert("version".to_string(), serde_json::json!(rev.version));
|
|
obj.insert("hash".to_string(), serde_json::json!(rev.policy_hash));
|
|
obj.insert(
|
|
"status".to_string(),
|
|
serde_json::json!(policy_status_json_name(status)),
|
|
);
|
|
if let Some(active_version) = active_version {
|
|
obj.insert(
|
|
"active_version".to_string(),
|
|
serde_json::json!(active_version),
|
|
);
|
|
}
|
|
if rev.created_at_ms > 0 {
|
|
obj.insert(
|
|
"created_at_ms".to_string(),
|
|
serde_json::json!(rev.created_at_ms),
|
|
);
|
|
}
|
|
if rev.loaded_at_ms > 0 {
|
|
obj.insert(
|
|
"loaded_at_ms".to_string(),
|
|
serde_json::json!(rev.loaded_at_ms),
|
|
);
|
|
}
|
|
if !rev.load_error.is_empty() {
|
|
obj.insert("load_error".to_string(), serde_json::json!(rev.load_error));
|
|
}
|
|
if !rev.provenance.is_empty() {
|
|
obj.insert("provenance".to_string(), serde_json::json!(rev.provenance));
|
|
}
|
|
if view.includes_policy() {
|
|
let policy = match rev.policy.as_ref() {
|
|
Some(policy) => {
|
|
let policy = policy_for_view(policy, view);
|
|
openshell_policy::sandbox_policy_to_json_value(policy.as_ref())?
|
|
}
|
|
None => serde_json::Value::Null,
|
|
};
|
|
obj.insert("policy".to_string(), policy);
|
|
}
|
|
Ok(serde_json::Value::Object(obj))
|
|
}
|
|
|
|
fn policy_for_view(policy: &SandboxPolicy, view: PolicyGetView) -> Cow<'_, SandboxPolicy> {
|
|
if view != PolicyGetView::Base {
|
|
return Cow::Borrowed(policy);
|
|
}
|
|
|
|
let mut base_policy = policy.clone();
|
|
base_policy
|
|
.network_policies
|
|
.retain(|name, _| !openshell_policy::is_provider_rule_name(name));
|
|
Cow::Owned(base_policy)
|
|
}
|
|
|
|
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?;
|
|
|
|
let resp = client
|
|
.list_sandbox_policies(ListSandboxPoliciesRequest {
|
|
name: name.to_string(),
|
|
limit,
|
|
offset: 0,
|
|
global: false,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let revisions = resp.into_inner().revisions;
|
|
if revisions.is_empty() {
|
|
eprintln!("No policy history found for sandbox '{name}'");
|
|
return Ok(());
|
|
}
|
|
|
|
print_policy_revision_table(&revisions);
|
|
Ok(())
|
|
}
|
|
|
|
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
|
|
.list_sandbox_policies(ListSandboxPoliciesRequest {
|
|
name: String::new(),
|
|
limit,
|
|
offset: 0,
|
|
global: true,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let revisions = resp.into_inner().revisions;
|
|
if revisions.is_empty() {
|
|
eprintln!("No global policy history found");
|
|
return Ok(());
|
|
}
|
|
|
|
print_policy_revision_table(&revisions);
|
|
Ok(())
|
|
}
|
|
|
|
fn print_policy_revision_table(revisions: &[openshell_core::proto::SandboxPolicyRevision]) {
|
|
println!(
|
|
"{:<8} {:<14} {:<12} {:<24} ERROR",
|
|
"VERSION", "HASH", "STATUS", "CREATED"
|
|
);
|
|
for rev in revisions {
|
|
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
|
|
let hash_short = if rev.policy_hash.len() >= 12 {
|
|
&rev.policy_hash[..12]
|
|
} else {
|
|
&rev.policy_hash
|
|
};
|
|
let error_short = if rev.load_error.len() > 40 {
|
|
format!("{}...", &rev.load_error[..40])
|
|
} else {
|
|
rev.load_error.clone()
|
|
};
|
|
println!(
|
|
"{:<8} {:<14} {:<12} {:<24} {}",
|
|
rev.version,
|
|
hash_short,
|
|
format!("{status:?}"),
|
|
rev.created_at_ms,
|
|
error_short,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sandbox logs command
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[allow(clippy::too_many_arguments)] // user-facing CLI command
|
|
pub async fn sandbox_logs(
|
|
server: &str,
|
|
name: &str,
|
|
lines: u32,
|
|
tail: bool,
|
|
since: Option<&str>,
|
|
sources: &[String],
|
|
level: &str,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
|
|
// Resolve sandbox name to id.
|
|
let sandbox = client
|
|
.get_sandbox(GetSandboxRequest {
|
|
name: name.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner()
|
|
.sandbox
|
|
.ok_or_else(|| miette::miette!("sandbox not found"))?;
|
|
|
|
// Normalize "all" to empty list (server treats empty as "no filter").
|
|
let source_filter: Vec<String> = sources
|
|
.iter()
|
|
.filter(|s| s.as_str() != "all")
|
|
.cloned()
|
|
.collect();
|
|
|
|
let since_ms = if let Some(s) = since {
|
|
let dur_ms = parse_duration_to_ms(s)?;
|
|
let now_ms = i64::try_from(
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.into_diagnostic()?
|
|
.as_millis(),
|
|
)
|
|
.into_diagnostic()?;
|
|
now_ms - dur_ms
|
|
} else {
|
|
0
|
|
};
|
|
|
|
if tail {
|
|
// Streaming mode: use WatchSandbox.
|
|
let mut stream = client
|
|
.watch_sandbox(WatchSandboxRequest {
|
|
id: sandbox.object_id().to_string(),
|
|
follow_status: false,
|
|
follow_logs: true,
|
|
follow_events: false,
|
|
log_tail_lines: lines,
|
|
event_tail: 0,
|
|
stop_on_terminal: false,
|
|
log_since_ms: since_ms,
|
|
log_sources: source_filter,
|
|
log_min_level: level.to_uppercase(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?
|
|
.into_inner();
|
|
|
|
while let Some(event) = stream.next().await {
|
|
let event = event.into_diagnostic()?;
|
|
if let Some(openshell_core::proto::sandbox_stream_event::Payload::Log(log)) =
|
|
event.payload
|
|
{
|
|
print_log_line(&log);
|
|
}
|
|
}
|
|
} else {
|
|
// One-shot mode: use GetSandboxLogs.
|
|
let resp = client
|
|
.get_sandbox_logs(GetSandboxLogsRequest {
|
|
sandbox_id: sandbox.object_id().to_string(),
|
|
lines,
|
|
since_ms,
|
|
sources: source_filter,
|
|
min_level: level.to_uppercase(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let inner = resp.into_inner();
|
|
|
|
if since_ms > 0 && inner.buffer_total > 0 {
|
|
eprintln!(
|
|
"Warning: log buffer contains only the last {} lines; --since results may be incomplete.",
|
|
inner.buffer_total
|
|
);
|
|
}
|
|
|
|
for log in &inner.logs {
|
|
print_log_line(log);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn print_log_line(log: &openshell_core::proto::SandboxLogLine) {
|
|
let source = if log.source.is_empty() {
|
|
"gateway"
|
|
} else {
|
|
&log.source
|
|
};
|
|
let secs = log.timestamp_ms / 1000;
|
|
let millis = log.timestamp_ms % 1000;
|
|
if log.fields.is_empty() {
|
|
println!(
|
|
"[{secs}.{millis:03}] [{source:<7}] [{:<5}] [{}] {}",
|
|
log.level, log.target, log.message
|
|
);
|
|
} else {
|
|
let mut fields_str = String::new();
|
|
let mut entries: Vec<_> = log.fields.iter().collect();
|
|
entries.sort_by_key(|(k, _)| k.as_str());
|
|
for (k, v) in entries {
|
|
if !fields_str.is_empty() {
|
|
fields_str.push(' ');
|
|
}
|
|
fields_str.push_str(k);
|
|
fields_str.push('=');
|
|
fields_str.push_str(v);
|
|
}
|
|
println!(
|
|
"[{secs}.{millis:03}] [{source:<7}] [{:<5}] [{}] {} {}",
|
|
log.level, log.target, log.message, fields_str
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Network rule commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Show network rules for a sandbox.
|
|
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?;
|
|
|
|
let response = client
|
|
.get_draft_policy(GetDraftPolicyRequest {
|
|
name: name.to_string(),
|
|
status_filter: status_filter.unwrap_or("").to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let inner = response.into_inner();
|
|
|
|
if inner.chunks.is_empty() {
|
|
println!("No network rules for sandbox '{name}'");
|
|
return Ok(());
|
|
}
|
|
|
|
println!(
|
|
"{} (version {}, {} chunk{})",
|
|
"Network Rules:".cyan().bold(),
|
|
inner.draft_version,
|
|
inner.chunks.len(),
|
|
if inner.chunks.len() == 1 { "" } else { "s" }
|
|
);
|
|
println!();
|
|
|
|
for chunk in &inner.chunks {
|
|
let status_colored = match chunk.status.as_str() {
|
|
"pending" => chunk.status.yellow().to_string(),
|
|
"approved" => chunk.status.green().to_string(),
|
|
"rejected" => chunk.status.red().to_string(),
|
|
_ => chunk.status.clone(),
|
|
};
|
|
|
|
println!(" {} {}", "Chunk:".dimmed(), chunk.id);
|
|
println!(" {} {}", "Status:".dimmed(), status_colored);
|
|
println!(" {} {}", "Rule:".dimmed(), chunk.rule_name);
|
|
if !chunk.binary.is_empty() {
|
|
println!(" {} {}", "Binary:".dimmed(), chunk.binary);
|
|
}
|
|
println!(
|
|
" {} {:.0}%",
|
|
"Confidence:".dimmed(),
|
|
chunk.confidence * 100.0
|
|
);
|
|
println!(" {} {}", "Rationale:".dimmed(), chunk.rationale);
|
|
|
|
if !chunk.security_notes.is_empty() {
|
|
println!(
|
|
" {} {}",
|
|
"Security:".dimmed(),
|
|
chunk.security_notes.yellow()
|
|
);
|
|
}
|
|
if !chunk.validation_result.is_empty() {
|
|
println!(
|
|
" {} {}",
|
|
"Validation:".dimmed(),
|
|
chunk.validation_result.cyan()
|
|
);
|
|
}
|
|
|
|
if let Some(ref rule) = chunk.proposed_rule {
|
|
println!(" {} {}", "Endpoints:".dimmed(), format_endpoints(rule));
|
|
if !rule.binaries.is_empty() {
|
|
let bins: Vec<&str> = rule.binaries.iter().map(|b| b.path.as_str()).collect();
|
|
println!(" {} {}", "Binaries:".dimmed(), bins.join(", "));
|
|
}
|
|
}
|
|
|
|
if chunk.hit_count > 1 {
|
|
println!(
|
|
" {} {} (first seen {}, last seen {})",
|
|
"Hits:".dimmed(),
|
|
chunk.hit_count,
|
|
format_epoch_ms(chunk.first_seen_ms),
|
|
format_epoch_ms(chunk.last_seen_ms),
|
|
);
|
|
}
|
|
println!();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Approve a network rule.
|
|
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?;
|
|
|
|
let response = client
|
|
.approve_draft_chunk(ApproveDraftChunkRequest {
|
|
name: name.to_string(),
|
|
chunk_id: chunk_id.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let inner = response.into_inner();
|
|
println!(
|
|
"{} Chunk approved. Policy version: {}, hash: {}",
|
|
"OK".green().bold(),
|
|
inner.policy_version,
|
|
&inner.policy_hash[..12.min(inner.policy_hash.len())]
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Reject a network rule.
|
|
pub async fn sandbox_draft_reject(
|
|
server: &str,
|
|
name: &str,
|
|
chunk_id: &str,
|
|
reason: &str,
|
|
workspace: &str,
|
|
tls: &TlsOptions,
|
|
) -> Result<()> {
|
|
let mut client = grpc_client(server, tls).await?;
|
|
|
|
client
|
|
.reject_draft_chunk(RejectDraftChunkRequest {
|
|
name: name.to_string(),
|
|
chunk_id: chunk_id.to_string(),
|
|
reason: reason.to_string(),
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
println!("{} Chunk rejected.", "OK".green().bold());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Approve all pending network rules.
|
|
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?;
|
|
|
|
let response = client
|
|
.approve_all_draft_chunks(ApproveAllDraftChunksRequest {
|
|
name: name.to_string(),
|
|
include_security_flagged,
|
|
workspace: workspace.to_string(),
|
|
})
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
let inner = response.into_inner();
|
|
println!(
|
|
"{} {} chunk(s) approved, {} skipped. Policy version: {}",
|
|
"OK".green().bold(),
|
|
inner.chunks_approved,
|
|
inner.chunks_skipped,
|
|
inner.policy_version,
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Clear all pending network rules.
|
|
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()?;
|
|
|
|
let inner = response.into_inner();
|
|
println!(
|
|
"{} {} pending chunk(s) cleared.",
|
|
"OK".green().bold(),
|
|
inner.chunks_cleared,
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Show network rule history.
|
|
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()?;
|
|
|
|
let inner = response.into_inner();
|
|
|
|
if inner.entries.is_empty() {
|
|
println!("No rule history for sandbox '{name}'");
|
|
return Ok(());
|
|
}
|
|
|
|
println!("{}", "Rule History:".cyan().bold());
|
|
println!();
|
|
|
|
for entry in &inner.entries {
|
|
let event_colored = match entry.event_type.as_str() {
|
|
"proposed" => entry.event_type.yellow().to_string(),
|
|
"approved" => entry.event_type.green().to_string(),
|
|
"rejected" => entry.event_type.red().to_string(),
|
|
_ => entry.event_type.clone(),
|
|
};
|
|
|
|
println!(
|
|
" {} {} [{}] {}",
|
|
format_timestamp_ms(entry.timestamp_ms).dimmed(),
|
|
event_colored,
|
|
entry.chunk_id.get(..8).unwrap_or(&entry.chunk_id),
|
|
entry.description,
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Format a `NetworkPolicyRule`'s endpoints as a compact string.
|
|
fn format_endpoints(rule: &openshell_core::proto::NetworkPolicyRule) -> String {
|
|
rule.endpoints
|
|
.iter()
|
|
.map(format_endpoint)
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
/// Render an endpoint as `host:port [layer, …allows…, …denies…]` so a reader
|
|
/// can tell L4-only access apart from a method/path-scoped L7 grant. The L7
|
|
/// fields (`protocol: rest`, `rules`, `access`) materially change what gets
|
|
/// allowed; surfacing them in the default text output is what makes
|
|
/// `openshell rule get` useful for approval review.
|
|
fn format_endpoint(endpoint: &openshell_core::proto::NetworkEndpoint) -> String {
|
|
let host_port = if endpoint.port > 0 {
|
|
format!("{}:{}", endpoint.host, endpoint.port)
|
|
} else {
|
|
endpoint.host.clone()
|
|
};
|
|
|
|
let mut tags: Vec<String> = Vec::new();
|
|
let layer_tag = if endpoint.protocol.eq_ignore_ascii_case("rest") {
|
|
"L7 rest"
|
|
} else if endpoint.protocol.is_empty() {
|
|
"L4"
|
|
} else {
|
|
endpoint.protocol.as_str()
|
|
};
|
|
tags.push(layer_tag.to_string());
|
|
|
|
if !endpoint.access.is_empty() {
|
|
tags.push(format!("access={}", endpoint.access));
|
|
}
|
|
|
|
for r in &endpoint.rules {
|
|
if let Some(allow) = &r.allow {
|
|
let method = non_empty_or(&allow.method, "*");
|
|
let path = non_empty_or(&allow.path, "*");
|
|
tags.push(format!("allow {method} {path}"));
|
|
}
|
|
}
|
|
for r in &endpoint.deny_rules {
|
|
let method = non_empty_or(&r.method, "*");
|
|
let path = non_empty_or(&r.path, "*");
|
|
tags.push(format!("deny {method} {path}"));
|
|
}
|
|
|
|
format!("{host_port} [{}]", tags.join(", "))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
PolicyGetView, ProvisioningStep, build_sandbox_resource_limits,
|
|
dockerfile_sources_supported_for_gateway, format_endpoint,
|
|
format_provider_attachment_table, git_sync_files, inferred_provider_type,
|
|
parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs,
|
|
parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs,
|
|
policy_revision_to_json, provider_profile_allows_empty_credentials,
|
|
provisioning_timeout_message, ready_false_condition_message, refresh_status_header,
|
|
refresh_status_row, resolve_from, sandbox_should_persist, sandbox_upload_plan,
|
|
service_expose_status_error, service_url_for_gateway,
|
|
};
|
|
#[cfg(unix)]
|
|
use super::{local_upload_path_exists, local_upload_path_is_symlink};
|
|
use crate::TEST_ENV_LOCK;
|
|
use crate::commands::common::progress_step_from_metadata;
|
|
use crate::test_utils::EnvVarGuard;
|
|
use std::fs;
|
|
use std::io::Write;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
use tonic::Status;
|
|
|
|
use openshell_bootstrap::GatewayMetadata;
|
|
use openshell_core::progress::{
|
|
PROGRESS_STEP_PULLING_IMAGE, PROGRESS_STEP_REQUESTING_SANDBOX,
|
|
PROGRESS_STEP_STARTING_SANDBOX,
|
|
};
|
|
use openshell_core::proto::{
|
|
GetSandboxConfigResponse, GpuResourceRequirements, PolicySource, PolicyStatus, Provider,
|
|
ProviderCredentialRefresh, ProviderCredentialRefreshStatus,
|
|
ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, ProviderProfile,
|
|
ProviderProfileCredential, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase,
|
|
SandboxPolicyRevision, SandboxStatus, datamodel::v1::ObjectMeta,
|
|
};
|
|
|
|
#[test]
|
|
fn policy_revision_json_includes_revision_provenance() {
|
|
let revision = SandboxPolicyRevision {
|
|
version: 2,
|
|
policy_hash: "hash".to_string(),
|
|
provenance: std::collections::HashMap::from([(
|
|
"openshell.nvidia.com/policy-signature".to_string(),
|
|
"signed".to_string(),
|
|
)]),
|
|
..Default::default()
|
|
};
|
|
|
|
let json = policy_revision_to_json(
|
|
"sandbox",
|
|
Some("example"),
|
|
Some(2),
|
|
&revision,
|
|
PolicyStatus::Pending,
|
|
PolicyGetView::Metadata,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
json["provenance"]["openshell.nvidia.com/policy-signature"],
|
|
"signed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_credential_pairs_accepts_key_value_form() {
|
|
let parsed = parse_credential_pairs(&["API_KEY=abc123".to_string()]).expect("parse");
|
|
assert_eq!(parsed.get("API_KEY"), Some(&"abc123".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_credential_pairs_reads_value_from_environment_for_key_only_form() {
|
|
let _guard = EnvVarGuard::set("NAV_PARSE_CREDENTIAL_TEST_KEY", "from-env");
|
|
|
|
let parsed =
|
|
parse_credential_pairs(&["NAV_PARSE_CREDENTIAL_TEST_KEY".to_string()]).expect("parse");
|
|
assert_eq!(
|
|
parsed.get("NAV_PARSE_CREDENTIAL_TEST_KEY"),
|
|
Some(&"from-env".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_credential_pairs_rejects_missing_environment_for_key_only_form() {
|
|
let _guard = EnvVarGuard::unset("NAV_PARSE_CREDENTIAL_MISSING");
|
|
|
|
let err = parse_credential_pairs(&["NAV_PARSE_CREDENTIAL_MISSING".to_string()])
|
|
.expect_err("missing env should error");
|
|
assert!(err.to_string().contains(
|
|
"requires local env var 'NAV_PARSE_CREDENTIAL_MISSING' to be set to a non-empty value"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_credential_pairs_rejects_empty_environment_for_key_only_form() {
|
|
let _guard = EnvVarGuard::set("NAV_PARSE_CREDENTIAL_EMPTY", "");
|
|
|
|
let err = parse_credential_pairs(&["NAV_PARSE_CREDENTIAL_EMPTY".to_string()])
|
|
.expect_err("empty env should error");
|
|
assert!(err.to_string().contains(
|
|
"requires local env var 'NAV_PARSE_CREDENTIAL_EMPTY' to be set to a non-empty value"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_secret_material_env_pairs_reads_value_from_named_environment_variable() {
|
|
let _guard = EnvVarGuard::set("NAV_PARSE_SME_NAMED", "pem-material");
|
|
|
|
let parsed =
|
|
parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_NAMED".to_string()])
|
|
.expect("parse");
|
|
assert_eq!(parsed.get("private_key"), Some(&"pem-material".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_secret_material_env_pairs_defaults_env_name_to_key() {
|
|
let _guard = EnvVarGuard::set("NAV_PARSE_SME_KEY_ONLY", "key-only-material");
|
|
|
|
let parsed = parse_secret_material_env_pairs(&["NAV_PARSE_SME_KEY_ONLY".to_string()])
|
|
.expect("parse");
|
|
assert_eq!(
|
|
parsed.get("NAV_PARSE_SME_KEY_ONLY"),
|
|
Some(&"key-only-material".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_secret_material_env_pairs_rejects_missing_environment() {
|
|
let _guard = EnvVarGuard::unset("NAV_PARSE_SME_MISSING");
|
|
|
|
let err =
|
|
parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_MISSING".to_string()])
|
|
.expect_err("missing env should error");
|
|
assert!(err.to_string().contains(
|
|
"requires local env var 'NAV_PARSE_SME_MISSING' to be set to a non-empty value"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_secret_material_env_pairs_rejects_empty_environment_value() {
|
|
let _guard = EnvVarGuard::set("NAV_PARSE_SME_EMPTY", " ");
|
|
|
|
let err = parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_EMPTY".to_string()])
|
|
.expect_err("blank env should error");
|
|
assert!(err.to_string().contains(
|
|
"requires local env var 'NAV_PARSE_SME_EMPTY' to be set to a non-empty value"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_secret_material_env_pairs_rejects_empty_key() {
|
|
let err = parse_secret_material_env_pairs(&["=NAV_PARSE_SME_NO_KEY".to_string()])
|
|
.expect_err("empty key should error");
|
|
assert!(err.to_string().contains("key cannot be empty"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_secret_material_env_pairs_rejects_duplicate_keys() {
|
|
let _guard = EnvVarGuard::set("NAV_PARSE_SME_DUP", "value");
|
|
|
|
let err = parse_secret_material_env_pairs(&[
|
|
"private_key=NAV_PARSE_SME_DUP".to_string(),
|
|
"private_key=NAV_PARSE_SME_DUP".to_string(),
|
|
])
|
|
.expect_err("duplicate key should error");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("key 'private_key' supplied more than once")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_credential_expiry_pairs_accepts_epoch_millis_and_rfc3339() {
|
|
let parsed = parse_credential_expiry_pairs(&[
|
|
"API_TOKEN=1767225600000".to_string(),
|
|
"MS_GRAPH_ACCESS_TOKEN=2026-01-01T00:00:00Z".to_string(),
|
|
])
|
|
.expect("parse");
|
|
|
|
assert_eq!(parsed.get("API_TOKEN"), Some(&1_767_225_600_000));
|
|
assert_eq!(
|
|
parsed.get("MS_GRAPH_ACCESS_TOKEN"),
|
|
Some(&1_767_225_600_000)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_credential_expiry_pairs_accepts_zero_to_clear_expiry() {
|
|
let parsed =
|
|
parse_credential_expiry_pairs(&["API_TOKEN=0".to_string()]).expect("parse zero");
|
|
|
|
assert_eq!(parsed.get("API_TOKEN"), Some(&0));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_credential_expiry_rejects_invalid_timestamp() {
|
|
let err = parse_credential_expiry_pairs(&["API_TOKEN=next-week".to_string()])
|
|
.expect_err("invalid timestamp should error");
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("must be a Unix epoch millisecond timestamp or RFC3339 timestamp")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_credential_expiry_cli_value_accepts_rfc3339_offsets() {
|
|
let parsed = parse_credential_expiry_cli_value("2026-01-01T01:00:00+01:00")
|
|
.expect("parse RFC3339 with offset");
|
|
|
|
assert_eq!(parsed, 1_767_225_600_000);
|
|
}
|
|
|
|
#[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(),
|
|
credential_expires_at_ms: std::collections::HashMap::new(),
|
|
profile_workspace: String::new(),
|
|
credential_handles: std::collections::HashMap::new(),
|
|
}],
|
|
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'));
|
|
}
|
|
|
|
#[test]
|
|
fn progress_step_metadata_values_map_to_cli_steps() {
|
|
assert_eq!(
|
|
progress_step_from_metadata(PROGRESS_STEP_REQUESTING_SANDBOX),
|
|
Some(ProvisioningStep::RequestingSandbox)
|
|
);
|
|
assert_eq!(
|
|
progress_step_from_metadata(PROGRESS_STEP_PULLING_IMAGE),
|
|
Some(ProvisioningStep::PullingSandboxImage)
|
|
);
|
|
assert_eq!(
|
|
progress_step_from_metadata(PROGRESS_STEP_STARTING_SANDBOX),
|
|
Some(ProvisioningStep::StartingSandbox)
|
|
);
|
|
assert_eq!(progress_step_from_metadata("driver-private-step"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_status_table_includes_operational_fields() {
|
|
let header = refresh_status_header();
|
|
assert!(header.contains("NEXT_REFRESH"));
|
|
assert!(header.contains("LAST_REFRESH"));
|
|
assert!(header.contains("LAST_ERROR"));
|
|
|
|
let row = refresh_status_row(&ProviderCredentialRefreshStatus {
|
|
provider_name: "my-graph".to_string(),
|
|
provider_id: "provider-id".to_string(),
|
|
credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(),
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32,
|
|
status: "error".to_string(),
|
|
expires_at_ms: 1_767_225_600_000,
|
|
next_refresh_at_ms: 1_767_225_660_000,
|
|
last_refresh_at_ms: 1_767_225_000_000,
|
|
last_error: "token endpoint returned a very long error message that should be truncated for table readability"
|
|
.to_string(),
|
|
});
|
|
|
|
assert!(row.contains("my-graph"));
|
|
assert!(row.contains("MS_GRAPH_ACCESS_TOKEN"));
|
|
assert!(row.contains("oauth2_client_credentials"));
|
|
assert!(row.contains("error"));
|
|
assert!(row.contains("2026-01-01 00:00:00"));
|
|
assert!(row.contains("..."));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_provider_credentials_require_all_required_credentials_to_be_runtime_resolvable() {
|
|
let refresh_token_profile = ProviderProfile {
|
|
credentials: vec![ProviderProfileCredential {
|
|
name: "MS_GRAPH_ACCESS_TOKEN".to_string(),
|
|
required: true,
|
|
refresh: Some(ProviderCredentialRefresh {
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
assert!(provider_profile_allows_empty_credentials(
|
|
&refresh_token_profile
|
|
));
|
|
|
|
let token_grant_profile = ProviderProfile {
|
|
credentials: vec![ProviderProfileCredential {
|
|
name: "ACCESS_TOKEN".to_string(),
|
|
required: true,
|
|
token_grant: Some(ProviderCredentialTokenGrant {
|
|
token_endpoint: "https://auth.example.com/token".to_string(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
assert!(provider_profile_allows_empty_credentials(
|
|
&token_grant_profile
|
|
));
|
|
|
|
let mixed_static_profile = ProviderProfile {
|
|
credentials: vec![
|
|
ProviderProfileCredential {
|
|
name: "ACCESS_TOKEN".to_string(),
|
|
required: true,
|
|
refresh: Some(ProviderCredentialRefresh {
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
ProviderProfileCredential {
|
|
name: "STATIC_API_KEY".to_string(),
|
|
required: true,
|
|
refresh: None,
|
|
..Default::default()
|
|
},
|
|
],
|
|
..Default::default()
|
|
};
|
|
assert!(!provider_profile_allows_empty_credentials(
|
|
&mixed_static_profile
|
|
));
|
|
|
|
let optional_refresh_profile = ProviderProfile {
|
|
credentials: vec![ProviderProfileCredential {
|
|
name: "OPTIONAL_TOKEN".to_string(),
|
|
required: false,
|
|
refresh: Some(ProviderCredentialRefresh {
|
|
strategy: ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt as i32,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
assert!(provider_profile_allows_empty_credentials(
|
|
&optional_refresh_profile
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_setting_value_parses_bool_aliases() {
|
|
let yes_value = parse_cli_setting_value("ocsf_json_enabled", "yes").expect("parse yes");
|
|
assert_eq!(
|
|
yes_value.value,
|
|
Some(openshell_core::proto::setting_value::Value::BoolValue(true))
|
|
);
|
|
|
|
let zero_value = parse_cli_setting_value("ocsf_json_enabled", "0").expect("parse 0");
|
|
assert_eq!(
|
|
zero_value.value,
|
|
Some(openshell_core::proto::setting_value::Value::BoolValue(
|
|
false
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_setting_value_rejects_invalid_bool() {
|
|
let err = parse_cli_setting_value("ocsf_json_enabled", "maybe")
|
|
.expect_err("invalid bool should fail");
|
|
assert!(err.to_string().contains("invalid bool value"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_setting_value_rejects_unknown_key() {
|
|
let err =
|
|
parse_cli_setting_value("unknown_key", "value").expect_err("unknown key should fail");
|
|
assert!(err.to_string().contains("unknown setting key"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_sandbox_resource_limits_sets_limits_only() {
|
|
let resources = build_sandbox_resource_limits(Some("500m"), Some("2Gi"))
|
|
.expect("resource limits should parse")
|
|
.expect("resource limits should be present");
|
|
|
|
let limits = resources
|
|
.fields
|
|
.get("limits")
|
|
.and_then(|value| value.kind.as_ref())
|
|
.and_then(|kind| match kind {
|
|
prost_types::value::Kind::StructValue(inner) => Some(inner),
|
|
_ => None,
|
|
})
|
|
.expect("limits should be a struct");
|
|
|
|
assert_eq!(
|
|
limits
|
|
.fields
|
|
.get("cpu")
|
|
.and_then(|value| value.kind.as_ref())
|
|
.and_then(|kind| match kind {
|
|
prost_types::value::Kind::StringValue(value) => Some(value.as_str()),
|
|
_ => None,
|
|
}),
|
|
Some("500m")
|
|
);
|
|
assert_eq!(
|
|
limits
|
|
.fields
|
|
.get("memory")
|
|
.and_then(|value| value.kind.as_ref())
|
|
.and_then(|kind| match kind {
|
|
prost_types::value::Kind::StringValue(value) => Some(value.as_str()),
|
|
_ => None,
|
|
}),
|
|
Some("2Gi")
|
|
);
|
|
assert!(!resources.fields.contains_key("requests"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_sandbox_resource_limits_rejects_invalid_quantities() {
|
|
assert!(build_sandbox_resource_limits(Some("0"), None).is_err());
|
|
assert!(build_sandbox_resource_limits(Some("half"), None).is_err());
|
|
assert!(build_sandbox_resource_limits(None, Some("0Gi")).is_err());
|
|
assert!(build_sandbox_resource_limits(None, Some("1.5Gi")).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_driver_config_json_accepts_driver_keyed_object() {
|
|
let config =
|
|
parse_driver_config_json(r#"{"kubernetes":{"pod":{"node_selector":{"pool":"gpu"}}}}"#)
|
|
.expect("driver config should parse");
|
|
|
|
let kubernetes = config
|
|
.fields
|
|
.get("kubernetes")
|
|
.and_then(|value| value.kind.as_ref())
|
|
.and_then(|kind| match kind {
|
|
prost_types::value::Kind::StructValue(inner) => Some(inner),
|
|
_ => None,
|
|
})
|
|
.expect("kubernetes block should be a struct");
|
|
let pod = kubernetes
|
|
.fields
|
|
.get("pod")
|
|
.and_then(|value| value.kind.as_ref())
|
|
.and_then(|kind| match kind {
|
|
prost_types::value::Kind::StructValue(inner) => Some(inner),
|
|
_ => None,
|
|
})
|
|
.expect("pod block should be a struct");
|
|
|
|
assert!(pod.fields.contains_key("node_selector"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_driver_config_json_rejects_non_object() {
|
|
let err = parse_driver_config_json(r#"["kubernetes"]"#)
|
|
.expect_err("top-level array should be rejected");
|
|
|
|
assert!(
|
|
err.to_string().contains("keyed by driver name"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_driver_config_json_rejects_invalid_json() {
|
|
let err = parse_driver_config_json(r#"{"kubernetes":"#)
|
|
.expect_err("invalid JSON should be rejected");
|
|
|
|
assert!(
|
|
err.to_string().contains("must be valid JSON"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn inferred_provider_type_returns_type_for_known_command() {
|
|
let result = inferred_provider_type(&["claude".to_string(), "--help".to_string()]);
|
|
assert_eq!(result, Some("claude-code".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn inferred_provider_type_returns_none_for_unknown_command() {
|
|
let result = inferred_provider_type(&["bash".to_string()]);
|
|
assert_eq!(result, None);
|
|
}
|
|
|
|
#[test]
|
|
fn inferred_provider_type_returns_none_for_empty_command() {
|
|
let result = inferred_provider_type(&[]);
|
|
assert_eq!(result, None);
|
|
}
|
|
|
|
#[test]
|
|
fn inferred_provider_type_normalizes_aliases() {
|
|
// `glab` should resolve to `gitlab`
|
|
let result = inferred_provider_type(&["glab".to_string()]);
|
|
assert_eq!(result, Some("gitlab".to_string()));
|
|
|
|
// `gh` should resolve to `github`
|
|
let result = inferred_provider_type(&["gh".to_string()]);
|
|
assert_eq!(result, Some("github".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn inferred_provider_type_handles_full_path() {
|
|
let result = inferred_provider_type(&["/usr/local/bin/claude".to_string()]);
|
|
assert_eq!(result, Some("claude-code".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_should_persist_defaults_to_persistent() {
|
|
assert!(sandbox_should_persist(true, None));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_should_not_persist_when_no_keep_is_set() {
|
|
assert!(!sandbox_should_persist(false, None));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_should_persist_when_forward_is_requested() {
|
|
let spec = openshell_core::forward::ForwardSpec::new(8080);
|
|
assert!(sandbox_should_persist(false, Some(&spec)));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_from_classifies_existing_dockerfile_path() {
|
|
let temp = tempfile::tempdir().expect("failed to create tempdir");
|
|
let dockerfile = temp.path().join("Dockerfile");
|
|
fs::write(&dockerfile, "FROM scratch\n").expect("failed to write Dockerfile");
|
|
|
|
match resolve_from(dockerfile.to_str().expect("temp path is not UTF-8"))
|
|
.expect("expected Dockerfile source")
|
|
{
|
|
super::ResolvedSource::Dockerfile {
|
|
dockerfile: resolved,
|
|
context,
|
|
} => {
|
|
assert_eq!(
|
|
resolved,
|
|
dockerfile
|
|
.canonicalize()
|
|
.expect("failed to canonicalize Dockerfile")
|
|
);
|
|
assert_eq!(
|
|
context,
|
|
temp.path()
|
|
.canonicalize()
|
|
.expect("failed to canonicalize context")
|
|
);
|
|
}
|
|
super::ResolvedSource::Image(image) => {
|
|
panic!("expected Dockerfile source, got image {image}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_from_rejects_missing_explicit_dockerfile_path() {
|
|
let temp = tempfile::tempdir().expect("failed to create tempdir");
|
|
let missing = temp.path().join("Dockerfile");
|
|
|
|
let err = resolve_from(missing.to_str().expect("temp path is not UTF-8"))
|
|
.expect_err("expected missing Dockerfile path to be rejected");
|
|
|
|
assert!(
|
|
err.to_string().contains("local --from path does not exist"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_from_keeps_dockerfile_named_image_refs_as_images() {
|
|
let image_ref = "ghcr.io/acme/dockerfile-runner:latest";
|
|
|
|
match resolve_from(image_ref).expect("expected image source") {
|
|
super::ResolvedSource::Image(image) => assert_eq!(image, image_ref),
|
|
super::ResolvedSource::Dockerfile { .. } => {
|
|
panic!("expected image ref, got Dockerfile source");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dockerfile_sources_are_rejected_for_remote_gateways() {
|
|
let metadata = GatewayMetadata {
|
|
name: "remote".to_string(),
|
|
gateway_endpoint: "https://gateway.example.com".to_string(),
|
|
is_remote: true,
|
|
gateway_port: 443,
|
|
remote_host: Some("user@gateway.example.com".to_string()),
|
|
resolved_host: Some("gateway.example.com".to_string()),
|
|
auth_mode: None,
|
|
edge_team_domain: None,
|
|
edge_auth_url: None,
|
|
vm_driver_state_dir: None,
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(!dockerfile_sources_supported_for_gateway(Some(&metadata)));
|
|
}
|
|
|
|
#[test]
|
|
fn dockerfile_sources_are_allowed_for_local_gateways() {
|
|
let metadata = GatewayMetadata {
|
|
name: "local".to_string(),
|
|
gateway_endpoint: "http://127.0.0.1:8080".to_string(),
|
|
is_remote: false,
|
|
gateway_port: 8080,
|
|
remote_host: None,
|
|
resolved_host: None,
|
|
auth_mode: None,
|
|
edge_team_domain: None,
|
|
edge_auth_url: None,
|
|
vm_driver_state_dir: None,
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(dockerfile_sources_supported_for_gateway(Some(&metadata)));
|
|
assert!(dockerfile_sources_supported_for_gateway(None));
|
|
}
|
|
|
|
#[test]
|
|
fn service_url_for_gateway_uses_external_gateway_port() {
|
|
assert_eq!(
|
|
service_url_for_gateway(
|
|
"https://quiet-flamingo--notebook.navigator.openshell.localhost:8080/",
|
|
"https://127.0.0.1:31886"
|
|
),
|
|
"https://quiet-flamingo--notebook.navigator.openshell.localhost:31886/"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn service_url_for_gateway_omits_default_external_port() {
|
|
assert_eq!(
|
|
service_url_for_gateway(
|
|
"https://quiet-flamingo--notebook.navigator.openshell.localhost:8080/",
|
|
"https://gateway.example.com"
|
|
),
|
|
"https://quiet-flamingo--notebook.navigator.openshell.localhost/"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn service_url_for_gateway_preserves_service_scheme() {
|
|
assert_eq!(
|
|
service_url_for_gateway(
|
|
"http://quiet-flamingo--notebook.navigator.openshell.localhost:8080/",
|
|
"https://127.0.0.1:31886"
|
|
),
|
|
"http://quiet-flamingo--notebook.navigator.openshell.localhost:31886/"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn service_url_for_gateway_uses_gateway_default_port() {
|
|
assert_eq!(
|
|
service_url_for_gateway(
|
|
"http://quiet-flamingo--notebook.navigator.openshell.localhost:8080/",
|
|
"https://gateway.example.com"
|
|
),
|
|
"http://quiet-flamingo--notebook.navigator.openshell.localhost:443/"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn service_expose_status_error_mentions_required_scope() {
|
|
let report = service_expose_status_error(Status::permission_denied(
|
|
"scope 'sandbox:write' required",
|
|
));
|
|
|
|
assert_eq!(
|
|
report.to_string(),
|
|
"expose service failed: permission denied (requires sandbox:write)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ready_false_condition_message_prefers_reason_and_message() {
|
|
let status = SandboxStatus {
|
|
sandbox_name: "gpu".to_string(),
|
|
agent_pod: "gpu-pod".to_string(),
|
|
conditions: vec![SandboxCondition {
|
|
r#type: "Ready".to_string(),
|
|
status: "False".to_string(),
|
|
reason: "Unschedulable".to_string(),
|
|
message: "Another GPU sandbox may already be using the available GPU.".to_string(),
|
|
last_transition_time: String::new(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
assert_eq!(
|
|
ready_false_condition_message(Some(&status)).as_deref(),
|
|
Some("Unschedulable: Another GPU sandbox may already be using the available GPU.")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ready_false_condition_message_ignores_non_ready_conditions() {
|
|
let status = SandboxStatus {
|
|
sandbox_name: "gpu".to_string(),
|
|
agent_pod: "gpu-pod".to_string(),
|
|
conditions: vec![SandboxCondition {
|
|
r#type: "Scheduled".to_string(),
|
|
status: "True".to_string(),
|
|
reason: "Scheduled".to_string(),
|
|
message: "Sandbox scheduled".to_string(),
|
|
last_transition_time: String::new(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(ready_false_condition_message(Some(&status)).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn provisioning_timeout_message_includes_condition_and_gpu_hint() {
|
|
let resource_requirements = ResourceRequirements {
|
|
gpu: Some(GpuResourceRequirements { count: None }),
|
|
};
|
|
let message = provisioning_timeout_message(
|
|
120,
|
|
Some(&resource_requirements),
|
|
Some("DependenciesNotReady: Pod exists with phase: Pending; Service Exists"),
|
|
);
|
|
|
|
assert!(message.contains("sandbox provisioning timed out after 120s"));
|
|
assert!(message.contains("Last reported status: DependenciesNotReady: Pod exists with phase: Pending; Service Exists"));
|
|
assert!(message.contains("available GPU is already in use by another sandbox"));
|
|
}
|
|
|
|
#[test]
|
|
fn provisioning_timeout_message_omits_gpu_hint_for_non_gpu_requests() {
|
|
let message = provisioning_timeout_message(120, None, None);
|
|
|
|
assert_eq!(message, "sandbox provisioning timed out after 120s");
|
|
}
|
|
|
|
#[test]
|
|
fn provisioning_timeout_message_omits_gpu_hint_without_gpu_requirements() {
|
|
let resource_requirements = ResourceRequirements { gpu: None };
|
|
let message = provisioning_timeout_message(120, Some(&resource_requirements), None);
|
|
|
|
assert_eq!(message, "sandbox provisioning timed out after 120s");
|
|
}
|
|
|
|
fn init_git_repo(path: &Path) {
|
|
let mut command = Command::new("git");
|
|
super::scrub_git_env(&mut command);
|
|
let status = command
|
|
.args(["init"])
|
|
.current_dir(path)
|
|
.status()
|
|
.expect("git init");
|
|
assert!(status.success(), "git init should succeed");
|
|
}
|
|
|
|
#[test]
|
|
fn git_sync_files_scopes_single_file_to_requested_path() {
|
|
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
|
let repo = tmpdir.path().join("repo");
|
|
fs::create_dir_all(repo.join("nested")).expect("create repo");
|
|
init_git_repo(&repo);
|
|
|
|
fs::write(repo.join("tracked.txt"), "tracked").expect("write tracked.txt");
|
|
fs::write(repo.join("nested/other.txt"), "other").expect("write other.txt");
|
|
|
|
let result = git_sync_files(&repo.join("tracked.txt"));
|
|
let (base_dir, files) = result.expect("git_sync_files should succeed");
|
|
assert_eq!(
|
|
base_dir,
|
|
fs::canonicalize(&repo).expect("canonicalize repo path")
|
|
);
|
|
assert_eq!(files, vec!["tracked.txt"]);
|
|
}
|
|
|
|
#[test]
|
|
fn git_sync_files_scopes_directory_to_requested_subtree() {
|
|
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
|
let repo = tmpdir.path().join("repo");
|
|
fs::create_dir_all(repo.join("nested/inner")).expect("create repo");
|
|
init_git_repo(&repo);
|
|
|
|
fs::write(repo.join("nested/file.txt"), "file").expect("write file.txt");
|
|
fs::write(repo.join("nested/inner/child.txt"), "child").expect("write child.txt");
|
|
fs::write(repo.join("top.txt"), "top").expect("write top.txt");
|
|
|
|
let result = git_sync_files(&repo.join("nested"));
|
|
let (base_dir, mut files) = result.expect("git_sync_files should succeed");
|
|
files.sort();
|
|
|
|
assert_eq!(
|
|
base_dir,
|
|
fs::canonicalize(repo.join("nested")).expect("canonicalize nested path")
|
|
);
|
|
assert_eq!(files, vec!["file.txt", "inner/child.txt"]);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_upload_plan_errors_for_missing_local_path() {
|
|
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
|
let missing = tmpdir.path().join("missing");
|
|
|
|
let err = sandbox_upload_plan(&missing, false).expect_err("missing path should error");
|
|
|
|
assert!(
|
|
err.to_string().contains("local path does not exist"),
|
|
"expected missing-path error, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_upload_plan_errors_for_missing_local_path_with_git_ignore() {
|
|
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
|
let repo = tmpdir.path().join("repo");
|
|
fs::create_dir_all(&repo).expect("create repo");
|
|
init_git_repo(&repo);
|
|
let missing = repo.join("missing");
|
|
|
|
let err = sandbox_upload_plan(&missing, true).expect_err("missing path should error");
|
|
|
|
assert!(
|
|
err.to_string().contains("local path does not exist"),
|
|
"expected missing-path error, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn sandbox_upload_plan_uses_regular_upload_for_symlinks() {
|
|
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
|
let repo = tmpdir.path().join("repo");
|
|
fs::create_dir_all(repo.join("real-dir")).expect("create repo");
|
|
init_git_repo(&repo);
|
|
fs::write(repo.join("real-dir/file.txt"), "file").expect("write file.txt");
|
|
std::os::unix::fs::symlink("real-dir", repo.join("link-dir")).expect("create symlink");
|
|
|
|
let plan = sandbox_upload_plan(&repo.join("link-dir"), true)
|
|
.expect("symlink upload should be planned");
|
|
|
|
assert_eq!(plan, super::SandboxUploadPlan::Regular);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn sandbox_upload_plan_accepts_dangling_symlinks() {
|
|
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
|
let link = tmpdir.path().join("dangling-link");
|
|
std::os::unix::fs::symlink("missing-target", &link).expect("create symlink");
|
|
|
|
let plan =
|
|
sandbox_upload_plan(&link, true).expect("dangling symlink upload should be planned");
|
|
|
|
assert_eq!(plan, super::SandboxUploadPlan::Regular);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_upload_plan_falls_back_when_all_files_gitignored() {
|
|
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
|
let repo = tmpdir.path().join("repo");
|
|
fs::create_dir_all(repo.join("runs")).expect("create repo");
|
|
init_git_repo(&repo);
|
|
fs::write(repo.join(".gitignore"), "runs/\n").expect("write .gitignore");
|
|
fs::write(repo.join("runs/test.json"), r#"{"key":"value"}"#).expect("write test.json");
|
|
|
|
let plan =
|
|
sandbox_upload_plan(&repo.join("runs"), true).expect("upload plan should succeed");
|
|
|
|
assert_eq!(
|
|
plan,
|
|
super::SandboxUploadPlan::GitFilteredEmpty,
|
|
"gitignored directory should fall back with GitFilteredEmpty"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_sync_files_ignores_inherited_git_env() {
|
|
let _lock = TEST_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
|
let repo = tmpdir.path().join("repo");
|
|
fs::create_dir_all(repo.join("nested")).expect("create repo");
|
|
init_git_repo(&repo);
|
|
|
|
fs::write(repo.join("nested/file.txt"), "file").expect("write file.txt");
|
|
fs::write(repo.join("top.txt"), "top").expect("write top.txt");
|
|
|
|
let _git_dir = EnvVarGuard::set("GIT_DIR", "/tmp/not-the-test-repo/.git");
|
|
let _git_work_tree = EnvVarGuard::set("GIT_WORK_TREE", "/tmp/not-the-test-repo");
|
|
|
|
let result = git_sync_files(&repo.join("nested"));
|
|
let (base_dir, files) = result.expect("git_sync_files should succeed");
|
|
|
|
assert_eq!(
|
|
base_dir,
|
|
fs::canonicalize(repo.join("nested")).expect("canonicalize nested path")
|
|
);
|
|
assert_eq!(files, vec!["file.txt"]);
|
|
}
|
|
|
|
#[test]
|
|
fn format_endpoint_distinguishes_l4_from_l7_rest() {
|
|
use openshell_core::proto::{L7Allow, L7DenyRule, L7Rule, NetworkEndpoint};
|
|
|
|
let l4 = NetworkEndpoint {
|
|
host: "host.example.test".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
};
|
|
assert_eq!(format_endpoint(&l4), "host.example.test:443 [L4]");
|
|
|
|
let l7_readonly = NetworkEndpoint {
|
|
host: "host.example.test".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
access: "read-only".to_string(),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
format_endpoint(&l7_readonly),
|
|
"host.example.test:443 [L7 rest, access=read-only]"
|
|
);
|
|
|
|
let l7_scoped = NetworkEndpoint {
|
|
host: "host.example.test".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
rules: vec![L7Rule {
|
|
allow: Some(L7Allow {
|
|
method: "PUT".to_string(),
|
|
path: "/v1/example/resource".to_string(),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
deny_rules: vec![L7DenyRule {
|
|
method: "DELETE".to_string(),
|
|
path: "/v1/example/resource".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
format_endpoint(&l7_scoped),
|
|
"host.example.test:443 [L7 rest, allow PUT /v1/example/resource, deny DELETE /v1/example/resource]"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn read_gcloud_adc_missing_file_errors() {
|
|
let _lock = TEST_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let _guard = EnvVarGuard::set(
|
|
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
"/nonexistent/path/to/adc.json",
|
|
);
|
|
let err = super::read_gcloud_adc().expect_err("missing file should error");
|
|
assert!(
|
|
err.to_string().contains("failed to read gcloud ADC file"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn read_gcloud_adc_wrong_type_errors() {
|
|
let _lock = TEST_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
|
|
let json = serde_json::json!({
|
|
"type": "service_account",
|
|
"project_id": "my-project",
|
|
"private_key_id": "key123"
|
|
});
|
|
Write::write_all(&mut tmp.as_file(), json.to_string().as_bytes()).expect("write tempfile");
|
|
let _guard = EnvVarGuard::set(
|
|
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
tmp.path().to_str().expect("tempfile path"),
|
|
);
|
|
let err = super::read_gcloud_adc().expect_err("wrong type should error");
|
|
// The service_account type gets a targeted message directing the user
|
|
// to the real Vertex service-account credential flow instead of the
|
|
// generic authorized_user hint.
|
|
assert!(
|
|
err.to_string()
|
|
.contains("GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN"),
|
|
"error should mention the service-account token key, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn read_gcloud_adc_parses_user_creds() {
|
|
let _lock = TEST_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
|
|
let json = serde_json::json!({
|
|
"type": "authorized_user",
|
|
"client_id": "test-client-id.apps.googleusercontent.com",
|
|
"client_secret": "test-client-secret",
|
|
"refresh_token": "test-refresh-token"
|
|
});
|
|
Write::write_all(&mut tmp.as_file(), json.to_string().as_bytes()).expect("write tempfile");
|
|
let _guard = EnvVarGuard::set(
|
|
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
tmp.path().to_str().expect("tempfile path"),
|
|
);
|
|
let (client_id, client_secret, refresh_token) =
|
|
super::read_gcloud_adc().expect("valid ADC should parse");
|
|
assert_eq!(client_id, "test-client-id.apps.googleusercontent.com");
|
|
assert_eq!(client_secret, "test-client-secret");
|
|
assert_eq!(refresh_token, "test-refresh-token");
|
|
}
|
|
|
|
#[test]
|
|
fn read_gcloud_adc_uses_cloudsdk_config_fallback() {
|
|
let _lock = TEST_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let adc_path = dir.path().join("application_default_credentials.json");
|
|
let json = serde_json::json!({
|
|
"type": "authorized_user",
|
|
"client_id": "cloudsdk-client-id.apps.googleusercontent.com",
|
|
"client_secret": "cloudsdk-client-secret",
|
|
"refresh_token": "cloudsdk-refresh-token"
|
|
});
|
|
fs::write(&adc_path, json.to_string()).expect("write adc file");
|
|
let _adc_guard = EnvVarGuard::unset("GOOGLE_APPLICATION_CREDENTIALS");
|
|
let _cloudsdk_guard =
|
|
EnvVarGuard::set("CLOUDSDK_CONFIG", dir.path().to_str().expect("config path"));
|
|
|
|
let (client_id, client_secret, refresh_token) =
|
|
super::read_gcloud_adc().expect("valid CLOUDSDK_CONFIG ADC should parse");
|
|
assert_eq!(client_id, "cloudsdk-client-id.apps.googleusercontent.com");
|
|
assert_eq!(client_secret, "cloudsdk-client-secret");
|
|
assert_eq!(refresh_token, "cloudsdk-refresh-token");
|
|
}
|
|
|
|
#[test]
|
|
fn read_gcloud_adc_malformed_json_errors() {
|
|
let _lock = TEST_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
|
|
Write::write_all(&mut tmp.as_file(), b"not valid json at all {{{{")
|
|
.expect("write tempfile");
|
|
let _guard = EnvVarGuard::set(
|
|
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
tmp.path().to_str().expect("tempfile path"),
|
|
);
|
|
let result = super::read_gcloud_adc();
|
|
assert!(
|
|
result.is_err(),
|
|
"malformed JSON should produce an error, got: {result:?}"
|
|
);
|
|
let err = result.unwrap_err();
|
|
let msg = format!("{err}");
|
|
assert!(
|
|
msg.contains("parse")
|
|
|| msg.contains("JSON")
|
|
|| msg.contains("json")
|
|
|| msg.contains("invalid")
|
|
|| msg.contains("failed"),
|
|
"error message should mention parse/JSON failure, got: {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_provider_credentials_allow_oauth2_refresh_token() {
|
|
use openshell_core::proto::{
|
|
ProviderCredentialRefresh, ProviderCredentialRefreshStrategy, ProviderProfile,
|
|
ProviderProfileCredential,
|
|
};
|
|
|
|
let strategy = ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32;
|
|
let profile = ProviderProfile {
|
|
credentials: vec![ProviderProfileCredential {
|
|
required: true,
|
|
refresh: Some(ProviderCredentialRefresh {
|
|
strategy,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
assert!(
|
|
provider_profile_allows_empty_credentials(&profile),
|
|
"Oauth2RefreshToken should be allowed for refresh bootstrap"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_to_json_includes_core_fields() {
|
|
let metadata = ObjectMeta {
|
|
id: "prov-123".to_string(),
|
|
name: "test-provider".to_string(),
|
|
..Default::default()
|
|
};
|
|
|
|
let provider = Provider {
|
|
metadata: Some(metadata),
|
|
r#type: "anthropic".to_string(),
|
|
credentials: std::collections::HashMap::new(),
|
|
config: std::collections::HashMap::new(),
|
|
credential_expires_at_ms: std::collections::HashMap::new(),
|
|
profile_workspace: String::new(),
|
|
credential_handles: std::collections::HashMap::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");
|
|
}
|
|
|
|
#[test]
|
|
fn provider_to_json_exposes_credential_keys_not_values() {
|
|
let mut credentials = std::collections::HashMap::new();
|
|
credentials.insert("ANTHROPIC_API_KEY".to_string(), "secret-value".to_string());
|
|
credentials.insert("OTHER_KEY".to_string(), "other-secret".to_string());
|
|
|
|
let provider = Provider {
|
|
metadata: Some(ObjectMeta::default()),
|
|
r#type: "anthropic".to_string(),
|
|
credentials,
|
|
config: std::collections::HashMap::new(),
|
|
credential_expires_at_ms: std::collections::HashMap::new(),
|
|
profile_workspace: String::new(),
|
|
credential_handles: std::collections::HashMap::new(),
|
|
};
|
|
|
|
let json = super::provider_to_json(&provider);
|
|
let json_str = json.to_string();
|
|
|
|
// Assert credential keys are present
|
|
let keys = json["credential_keys"].as_array().unwrap();
|
|
assert_eq!(keys.len(), 2);
|
|
assert!(keys.iter().any(|k| k.as_str() == Some("ANTHROPIC_API_KEY")));
|
|
assert!(keys.iter().any(|k| k.as_str() == Some("OTHER_KEY")));
|
|
|
|
// Assert credential values are NOT in the output (SECURITY)
|
|
assert!(
|
|
!json_str.contains("secret-value"),
|
|
"credential values must not be exposed"
|
|
);
|
|
assert!(
|
|
!json_str.contains("other-secret"),
|
|
"credential values must not be exposed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_to_json_exposes_config_keys_not_values() {
|
|
let mut config = std::collections::HashMap::new();
|
|
config.insert("region".to_string(), "us-west".to_string());
|
|
config.insert(
|
|
"endpoint".to_string(),
|
|
"https://api.example.com".to_string(),
|
|
);
|
|
|
|
let provider = Provider {
|
|
metadata: Some(ObjectMeta::default()),
|
|
r#type: "custom".to_string(),
|
|
credentials: std::collections::HashMap::new(),
|
|
config,
|
|
credential_expires_at_ms: std::collections::HashMap::new(),
|
|
profile_workspace: String::new(),
|
|
credential_handles: std::collections::HashMap::new(),
|
|
};
|
|
|
|
let json = super::provider_to_json(&provider);
|
|
let json_str = json.to_string();
|
|
|
|
// Assert config keys are present
|
|
let keys = json["config_keys"].as_array().unwrap();
|
|
assert_eq!(keys.len(), 2);
|
|
assert!(keys.iter().any(|k| k.as_str() == Some("region")));
|
|
assert!(keys.iter().any(|k| k.as_str() == Some("endpoint")));
|
|
|
|
// Assert config values are NOT in the output (SECURITY)
|
|
assert!(
|
|
!json_str.contains("us-west"),
|
|
"config values must not be exposed"
|
|
);
|
|
assert!(
|
|
!json_str.contains("https://api.example.com"),
|
|
"config values must not be exposed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_to_json_omits_empty_config() {
|
|
let provider = Provider {
|
|
metadata: Some(ObjectMeta::default()),
|
|
r#type: "anthropic".to_string(),
|
|
credentials: std::collections::HashMap::new(),
|
|
config: std::collections::HashMap::new(), // Empty config
|
|
credential_expires_at_ms: std::collections::HashMap::new(),
|
|
profile_workspace: String::new(),
|
|
credential_handles: std::collections::HashMap::new(),
|
|
};
|
|
|
|
let json = super::provider_to_json(&provider);
|
|
|
|
assert!(
|
|
json.get("config_keys").is_none(),
|
|
"empty config_keys should be omitted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_to_json_includes_metadata_fields_when_present() {
|
|
let mut labels = std::collections::HashMap::new();
|
|
labels.insert("env".to_string(), "prod".to_string());
|
|
|
|
let metadata = ObjectMeta {
|
|
id: "prov-123".to_string(),
|
|
name: "test-provider".to_string(),
|
|
resource_version: 42,
|
|
created_at_ms: 1_234_567_890_000,
|
|
labels,
|
|
annotations: std::collections::HashMap::new(),
|
|
workspace: String::new(),
|
|
deletion_timestamp_ms: 0,
|
|
};
|
|
|
|
let provider = Provider {
|
|
metadata: Some(metadata),
|
|
r#type: "anthropic".to_string(),
|
|
credentials: std::collections::HashMap::new(),
|
|
config: std::collections::HashMap::new(),
|
|
credential_expires_at_ms: std::collections::HashMap::new(),
|
|
profile_workspace: String::new(),
|
|
credential_handles: std::collections::HashMap::new(),
|
|
};
|
|
|
|
let json = super::provider_to_json(&provider);
|
|
|
|
assert_eq!(json["resource_version"], 42);
|
|
assert_eq!(json["created_at"], "2009-02-13 23:31:30");
|
|
assert_eq!(json["labels"]["env"], "prod");
|
|
}
|
|
|
|
#[test]
|
|
fn provider_to_json_omits_zero_metadata_fields() {
|
|
let metadata = ObjectMeta {
|
|
id: "prov-123".to_string(),
|
|
name: "test-provider".to_string(),
|
|
// resource_version and created_at_ms are 0
|
|
// labels is empty
|
|
..Default::default()
|
|
};
|
|
|
|
let provider = Provider {
|
|
metadata: Some(metadata),
|
|
r#type: "anthropic".to_string(),
|
|
credentials: std::collections::HashMap::new(),
|
|
config: std::collections::HashMap::new(),
|
|
credential_expires_at_ms: std::collections::HashMap::new(),
|
|
profile_workspace: String::new(),
|
|
credential_handles: std::collections::HashMap::new(),
|
|
};
|
|
|
|
let json = super::provider_to_json(&provider);
|
|
|
|
assert!(
|
|
json.get("resource_version").is_none(),
|
|
"zero resource_version should be omitted"
|
|
);
|
|
assert!(
|
|
json.get("created_at").is_none(),
|
|
"zero created_at should be omitted"
|
|
);
|
|
assert!(
|
|
json.get("labels").is_none(),
|
|
"empty labels should be omitted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_to_json_includes_credential_expiration() {
|
|
let mut credential_expires_at_ms = std::collections::HashMap::new();
|
|
credential_expires_at_ms.insert("ACCESS_TOKEN".to_string(), 1_234_567_890);
|
|
|
|
let provider = Provider {
|
|
metadata: Some(ObjectMeta::default()),
|
|
r#type: "oauth".to_string(),
|
|
credentials: std::collections::HashMap::new(),
|
|
config: std::collections::HashMap::new(),
|
|
credential_expires_at_ms,
|
|
profile_workspace: String::new(),
|
|
credential_handles: std::collections::HashMap::new(),
|
|
};
|
|
|
|
let json = super::provider_to_json(&provider);
|
|
|
|
assert_eq!(
|
|
json["credential_expires_at_ms"]["ACCESS_TOKEN"],
|
|
1_234_567_890
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_to_json_formats_created_at_as_human_readable() {
|
|
let metadata = ObjectMeta {
|
|
id: "prov-123".to_string(),
|
|
name: "test-provider".to_string(),
|
|
created_at_ms: 1_609_459_200_000, // 2021-01-01 00:00:00
|
|
..Default::default()
|
|
};
|
|
|
|
let provider = Provider {
|
|
metadata: Some(metadata),
|
|
r#type: "anthropic".to_string(),
|
|
credentials: std::collections::HashMap::new(),
|
|
config: std::collections::HashMap::new(),
|
|
credential_expires_at_ms: std::collections::HashMap::new(),
|
|
profile_workspace: String::new(),
|
|
credential_handles: std::collections::HashMap::new(),
|
|
};
|
|
|
|
let json = super::provider_to_json(&provider);
|
|
|
|
// Should format as human-readable datetime, not raw milliseconds
|
|
assert_eq!(json["created_at"], "2021-01-01 00:00:00");
|
|
assert!(
|
|
json.get("created_at_ms").is_none(),
|
|
"raw milliseconds field should not exist"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_detail_to_json_includes_policy_fields() {
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(ObjectMeta {
|
|
id: "sb-123".to_string(),
|
|
name: "test-sb".to_string(),
|
|
resource_version: 5,
|
|
created_at_ms: 1_609_459_200_000,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
sandbox.set_current_policy_version(2);
|
|
|
|
let config = GetSandboxConfigResponse {
|
|
policy_source: PolicySource::Global as i32,
|
|
global_policy_version: 3,
|
|
..Default::default()
|
|
};
|
|
|
|
let json = super::sandbox_detail_to_json(&sandbox, &config).unwrap();
|
|
|
|
assert_eq!(json["id"], "sb-123");
|
|
assert_eq!(json["name"], "test-sb");
|
|
assert_eq!(json["phase"], "Ready");
|
|
assert_eq!(json["policy_source"], "global");
|
|
assert_eq!(json["revision"], 3);
|
|
assert!(json["policy"].is_null());
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_detail_to_json_sandbox_source_without_policy() {
|
|
let sandbox = Sandbox {
|
|
metadata: Some(ObjectMeta {
|
|
id: "sb-456".to_string(),
|
|
name: "no-policy-sb".to_string(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let config = GetSandboxConfigResponse {
|
|
policy_source: PolicySource::Sandbox as i32,
|
|
version: 0,
|
|
..Default::default()
|
|
};
|
|
|
|
let json = super::sandbox_detail_to_json(&sandbox, &config).unwrap();
|
|
|
|
assert_eq!(json["policy_source"], "sandbox");
|
|
assert!(json["revision"].is_null());
|
|
assert!(json["policy"].is_null());
|
|
}
|
|
}
|