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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/openshell-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,4 @@ serde_json = { workspace = true }
temp-env = "0.3"
tokio-stream = { workspace = true }
url = { workspace = true }
wiremock = "0.6"
294 changes: 294 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,13 @@ enum Commands {
command: Option<ProfileCommands>,
},

/// Manage delegated identity credential records.
#[command(help_template = SUBCOMMAND_HELP_TEMPLATE)]
DelegatedCredential {
#[command(subcommand)]
command: Option<DelegatedCredentialCommands>,
},

/// Manage workspaces.
#[command(alias = "ws", after_help = WORKSPACE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
Workspace {
Expand Down Expand Up @@ -1288,6 +1295,62 @@ impl ProfileCommands {
}
}

#[derive(Subcommand, Debug)]
enum DelegatedCredentialCommands {
/// List delegated identity credentials.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
List {
/// Maximum number of credentials to return in this page.
#[arg(long, default_value_t = 100)]
page_size: i32,

/// Opaque continuation token from a previous page.
#[arg(long, default_value = "")]
page_token: String,

/// Output only credential IDs.
#[arg(long, conflicts_with = "output")]
ids: bool,

/// Output format.
#[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with = "ids")]
output: OutputFormat,
},

/// Show delegated identity credential status.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Status {
/// Delegated identity credential ID.
id: String,

/// Output format.
#[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)]
output: OutputFormat,
},

/// Revoke a delegated identity credential.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Revoke {
/// Delegated identity credential ID.
id: String,

/// Expected resource version for compare-and-swap updates.
#[arg(long = "resource-version", default_value_t = 0)]
resource_version: u64,
},

/// Delete a delegated identity credential record.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Delete {
/// Delegated identity credential ID.
id: String,

/// Expected resource version for compare-and-swap deletes.
#[arg(long = "resource-version", default_value_t = 0)]
resource_version: u64,
},
}

// -----------------------------------------------------------------------
// Gateway commands (replaces the old `cluster` / `cluster admin` groups)
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -1528,6 +1591,12 @@ enum SandboxCommands {
#[arg(long = "provider")]
providers: Vec<String>,

/// Allow the sandbox to use a separately authorized copy of your identity for a bounded duration.
/// Opens a browser authorization flow when no usable gateway-owned grant exists. Accepts
/// positive durations with m, h, or d suffixes, for example 30m, 8h, or 7d.
#[arg(long = "use-my-identity-for", value_name = "DURATION")]
delegate_identity_for: Option<String>,

/// Path to a custom sandbox policy YAML file.
/// Overrides the built-in default and the `OPENSHELL_SANDBOX_POLICY` env var.
#[arg(long, value_hint = ValueHint::FilePath)]
Expand Down Expand Up @@ -1845,6 +1914,41 @@ enum SandboxCommands {
/// Manage reusable sandbox workload templates.
#[command(subcommand)]
Template(SandboxTemplateCommands),

/// Manage sandbox delegated identity.
#[command(subcommand, name = "delegated-identity")]
DelegatedIdentity(SandboxDelegatedIdentityCommands),
}

#[derive(Subcommand, Debug)]
enum SandboxDelegatedIdentityCommands {
/// Show delegated identity status for a sandbox.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Status {
/// Sandbox name.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: String,
},

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

/// Set delegated identity expiry to now plus the requested duration.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Extend {
/// Sandbox name.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: String,

/// New authorization window, for example 24h.
#[arg(long = "for", value_name = "DURATION")]
duration: String,
},
}

/// Common observation flags; the deadline starts after the mutation is saved.
Expand Down Expand Up @@ -3367,6 +3471,7 @@ async fn run_async() -> Result<()> {
memory,
driver_config_json,
providers,
delegate_identity_for,
policy,
forward,
expose,
Expand Down Expand Up @@ -3463,6 +3568,7 @@ async fn run_async() -> Result<()> {
driver_config_json: driver_config_json.as_deref(),
editor,
providers: &providers,
delegate_identity_for: delegate_identity_for.as_deref(),
policy: policy.as_deref(),
forward,
expose,
Expand Down Expand Up @@ -3813,6 +3919,37 @@ async fn run_async() -> Result<()> {
.await?;
}
},
SandboxCommands::DelegatedIdentity(command) => match command {
SandboxDelegatedIdentityCommands::Status { name } => {
run::sandbox_delegated_identity_status(
endpoint,
&name,
&cli.workspace,
&tls,
)
.await?;
}
SandboxDelegatedIdentityCommands::Withdraw { name } => {
run::sandbox_delegated_identity_withdraw(
endpoint,
&name,
&cli.workspace,
&tls,
)
.await?;
}
SandboxDelegatedIdentityCommands::Extend { name, duration } => {
run::sandbox_delegated_identity_extend(
endpoint,
&ctx.name,
&name,
&duration,
&cli.workspace,
&tls,
)
.await?;
}
},
}
}
}
Expand Down Expand Up @@ -4075,6 +4212,61 @@ async fn run_async() -> Result<()> {
apply_auth(&mut tls, &ctx.name)?;
command.run(endpoint, &cli.workspace, &tls).await?;
}
Some(Commands::DelegatedCredential {
command: Some(command),
}) => {
let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
let endpoint = &ctx.endpoint;
let mut tls = tls.with_gateway_name(&ctx.name);
apply_auth(&mut tls, &ctx.name)?;

match command {
DelegatedCredentialCommands::List {
page_size,
page_token,
ids,
output,
} => {
run::delegated_identity_credential_list(
endpoint,
page_size,
&page_token,
ids,
output.as_str(),
&tls,
)
.await?;
}
DelegatedCredentialCommands::Status { id, output } => {
run::delegated_identity_credential_status(endpoint, &id, output.as_str(), &tls)
.await?;
}
DelegatedCredentialCommands::Revoke {
id,
resource_version,
} => {
run::delegated_identity_credential_revoke(
endpoint,
&id,
resource_version,
&tls,
)
.await?;
}
DelegatedCredentialCommands::Delete {
id,
resource_version,
} => {
run::delegated_identity_credential_delete(
endpoint,
&id,
resource_version,
&tls,
)
.await?;
}
}
}
Some(Commands::Term { theme }) => {
let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
let mut tls = tls.with_gateway_name(&ctx.name);
Expand Down Expand Up @@ -4214,6 +4406,13 @@ async fn run_async() -> Result<()> {
.print_help()
.expect("Failed to print help");
}
Some(Commands::DelegatedCredential { command: None }) => {
Cli::command()
.find_subcommand_mut("delegated-credential")
.expect("delegated-credential subcommand exists")
.print_help()
.expect("Failed to print help");
}
Some(Commands::Gateway { command: None }) => {
Cli::command()
.find_subcommand_mut("gateway")
Expand Down Expand Up @@ -5170,6 +5369,76 @@ mod tests {
}) if ids == ["custom-api", "custom-alt"]));
}

#[test]
fn delegated_credential_commands_parse() {
let list = Cli::try_parse_from([
"openshell",
"delegated-credential",
"list",
"--page-size",
"25",
"--page-token",
"next-credential-page",
"--ids",
])
.expect("delegated credential list should parse");
match list.command {
Some(Commands::DelegatedCredential {
command:
Some(DelegatedCredentialCommands::List {
page_size,
page_token,
ids,
output,
}),
}) => {
assert_eq!(page_size, 25);
assert_eq!(page_token, "next-credential-page");
assert!(ids);
assert_eq!(output, OutputFormat::Table);
}
command => panic!("unexpected delegated credential list command: {command:?}"),
}

let status = Cli::try_parse_from([
"openshell",
"delegated-credential",
"status",
"delegated-identity-123",
"-o",
"json",
])
.expect("delegated credential status should parse");
assert!(matches!(
status.command,
Some(Commands::DelegatedCredential {
command: Some(DelegatedCredentialCommands::Status {
id,
output: OutputFormat::Json,
})
}) if id == "delegated-identity-123"
));

let revoke = Cli::try_parse_from([
"openshell",
"delegated-credential",
"revoke",
"delegated-identity-123",
"--resource-version",
"7",
])
.expect("delegated credential revoke should parse");
assert!(matches!(
revoke.command,
Some(Commands::DelegatedCredential {
command: Some(DelegatedCredentialCommands::Revoke {
id,
resource_version: 7,
})
}) if id == "delegated-identity-123"
));
}

#[test]
fn profile_import_and_lint_require_exactly_one_source() {
for verb in ["import", "lint"] {
Expand Down Expand Up @@ -6363,6 +6632,31 @@ mod tests {
}
}

#[test]
fn sandbox_create_use_my_identity_for_parses() {
let cli = Cli::try_parse_from([
"openshell",
"sandbox",
"create",
"--use-my-identity-for",
"8h",
])
.expect("sandbox create --use-my-identity-for should parse");
match cli.command {
Some(Commands::Sandbox {
command:
Some(SandboxCommands::Create {
delegate_identity_for,
..
}),
..
}) => {
assert_eq!(delegate_identity_for.as_deref(), Some("8h"));
}
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
}
}

/// `--approval-mode <bogus>` is rejected by clap's value parser, so the
/// CLI can't smuggle through a future-mode value that the gateway
/// doesn't yet know about.
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-cli/src/oidc_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ pub async fn oidc_browser_auth_flow(
() = tokio::time::sleep(AUTH_TIMEOUT) => {
return Err(miette::miette!(
"OIDC authentication timed out after {} seconds.\n\
Try again with: openshell gateway login",
Please retry the command.",
AUTH_TIMEOUT.as_secs()
));
}
Expand Down
Loading
Loading