777 lines
28 KiB
Rust
777 lines
28 KiB
Rust
use std::{
|
|
fs,
|
|
io::{self, IsTerminal as _, Read},
|
|
};
|
|
|
|
use anyhow::{Context, Result};
|
|
use chrono::{DateTime, Utc};
|
|
use comfy_table::Cell;
|
|
use galaxy_cli::{
|
|
agent::OutputFormat,
|
|
scope::ObjectScope,
|
|
secret::{
|
|
AnthropicMethod, CreateProvider, CreateSecretArgs, DeleteSecretArgs, ListSecretsArgs,
|
|
SecretCommand, SecretType, UpdateSecretArgs, ValueArgs,
|
|
},
|
|
GlobalOptions,
|
|
};
|
|
use galaxy_core::features::FeatureFlag;
|
|
use galaxy_graphql::{
|
|
managed_secrets::{ManagedSecret, ManagedSecretType},
|
|
object::SpaceType,
|
|
};
|
|
use galaxy_managed_secrets::{client::SecretOwner, ManagedSecretManager, ManagedSecretValue};
|
|
use galaxyui::{platform::TerminationMode, AppContext, SingletonEntity as _};
|
|
use inquire::{Confirm, InquireError, Password};
|
|
use serde::Serialize;
|
|
|
|
use crate::{
|
|
auth::UserUid, cloud_object::Owner, server::ids::ServerId,
|
|
util::time_format::format_approx_duration_from_now_utc,
|
|
};
|
|
|
|
use super::output::{self, TableFormat};
|
|
|
|
#[derive(Serialize)]
|
|
struct SecretInfo {
|
|
name: String,
|
|
scope: String,
|
|
#[serde(rename = "type")]
|
|
secret_type: ManagedSecretType,
|
|
#[serde(rename = "created")]
|
|
created_at: DateTime<Utc>,
|
|
#[serde(rename = "updated")]
|
|
updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl TableFormat for SecretInfo {
|
|
fn header() -> Vec<Cell> {
|
|
vec![
|
|
Cell::new("Name"),
|
|
Cell::new("Scope"),
|
|
Cell::new("Type"),
|
|
Cell::new("Created"),
|
|
Cell::new("Updated"),
|
|
]
|
|
}
|
|
|
|
fn row(&self) -> Vec<Cell> {
|
|
vec![
|
|
Cell::new(&self.name),
|
|
Cell::new(&self.scope),
|
|
Cell::new(format_secret_type(&self.secret_type)),
|
|
Cell::new(format_approx_duration_from_now_utc(self.created_at)),
|
|
Cell::new(format_approx_duration_from_now_utc(self.updated_at)),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Run secret-related commands.
|
|
pub fn run(
|
|
ctx: &mut AppContext,
|
|
global_options: GlobalOptions,
|
|
command: SecretCommand,
|
|
) -> Result<()> {
|
|
if !FeatureFlag::WarpManagedSecrets.is_enabled() {
|
|
return Err(anyhow::anyhow!("This feature is not enabled"));
|
|
}
|
|
|
|
match command {
|
|
SecretCommand::Create(args) => create_secret(ctx, args),
|
|
SecretCommand::Delete(args) => delete_secret(ctx, args),
|
|
SecretCommand::Update(args) => update_secret(ctx, args),
|
|
SecretCommand::List(args) => list_secrets(ctx, global_options.output_format, args),
|
|
}
|
|
}
|
|
|
|
/// Deferred secret value reader. Constructed during argument parsing but read after validation
|
|
/// (refresh metadata, resolve owner) so users are not prompted for secrets before we know the
|
|
/// request is valid.
|
|
enum SecretInput {
|
|
/// Single-field secret types (raw value, Anthropic API key).
|
|
Simple {
|
|
secret_type: SecretType,
|
|
value_args: ValueArgs,
|
|
},
|
|
/// Multi-field Bedrock API key secret with dedicated CLI flags.
|
|
Bedrock {
|
|
bedrock_api_key: Option<String>,
|
|
region: Option<String>,
|
|
},
|
|
/// Multi-field Bedrock access key secret with dedicated CLI flags.
|
|
BedrockAccessKey {
|
|
access_key_id: Option<String>,
|
|
secret_access_key: Option<String>,
|
|
session_token: Option<String>,
|
|
region: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl SecretInput {
|
|
/// Read the secret value, prompting the user if necessary.
|
|
/// Returns `Ok(None)` when the user cancels the prompt.
|
|
fn read(self) -> Result<Option<ManagedSecretValue>> {
|
|
match self {
|
|
SecretInput::Simple {
|
|
secret_type,
|
|
value_args,
|
|
} => {
|
|
let raw = match read_simple_secret_value(&value_args)? {
|
|
Some(v) => v,
|
|
None => return Ok(None),
|
|
};
|
|
Ok(Some(make_simple_secret_value(secret_type, &raw)))
|
|
}
|
|
SecretInput::Bedrock {
|
|
bedrock_api_key,
|
|
region,
|
|
} => read_bedrock_secret_value(bedrock_api_key, region),
|
|
SecretInput::BedrockAccessKey {
|
|
access_key_id,
|
|
secret_access_key,
|
|
session_token,
|
|
region,
|
|
} => read_bedrock_access_key_secret_value(
|
|
access_key_id,
|
|
secret_access_key,
|
|
session_token,
|
|
region,
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a new secret. Dispatches to the provider subcommand if present.
|
|
fn create_secret(ctx: &mut AppContext, args: CreateSecretArgs) -> Result<()> {
|
|
// Resolve provider subcommand into common fields plus a deferred value reader.
|
|
let (name, input, description, scope) = match args.provider {
|
|
Some(CreateProvider::Anthropic(anthropic)) => match anthropic.method {
|
|
AnthropicMethod::ApiKey(a) => (
|
|
a.common.name,
|
|
SecretInput::Simple {
|
|
secret_type: SecretType::AnthropicApiKey,
|
|
value_args: a.value,
|
|
},
|
|
a.common.description,
|
|
a.common.scope,
|
|
),
|
|
AnthropicMethod::BedrockApiKey(a) => (
|
|
a.common.name,
|
|
SecretInput::Bedrock {
|
|
bedrock_api_key: a.bedrock_api_key,
|
|
region: a.region,
|
|
},
|
|
a.common.description,
|
|
a.common.scope,
|
|
),
|
|
AnthropicMethod::BedrockAccessKey(a) => (
|
|
a.common.name,
|
|
SecretInput::BedrockAccessKey {
|
|
access_key_id: a.access_key_id,
|
|
secret_access_key: a.secret_access_key,
|
|
session_token: a.session_token,
|
|
region: a.region,
|
|
},
|
|
a.common.description,
|
|
a.common.scope,
|
|
),
|
|
},
|
|
None => {
|
|
let name = args.name.ok_or_else(|| {
|
|
anyhow::anyhow!("Secret name is required. Usage: oz secret create <NAME>")
|
|
})?;
|
|
(
|
|
name,
|
|
SecretInput::Simple {
|
|
secret_type: args.secret_type,
|
|
value_args: args.value,
|
|
},
|
|
args.description,
|
|
args.scope,
|
|
)
|
|
}
|
|
};
|
|
|
|
create_secret_with_input(ctx, name, input, description, scope)
|
|
}
|
|
|
|
/// Shared creation logic: refreshes metadata, resolves the owner, reads the secret value, and
|
|
/// creates the secret.
|
|
fn create_secret_with_input(
|
|
ctx: &mut AppContext,
|
|
name: String,
|
|
input: SecretInput,
|
|
description: Option<String>,
|
|
scope: ObjectScope,
|
|
) -> Result<()> {
|
|
ManagedSecretManager::handle(ctx).update(ctx, move |_manager, ctx| {
|
|
// Perform as much validation as possible up-front, before prompting the user for a secret.
|
|
// It's a bad UX if we make them type in a secret and then fail on something we could have
|
|
// checked beforehand.
|
|
let refresh_future = super::common::refresh_workspace_metadata(ctx);
|
|
ctx.spawn(refresh_future, move |manager, refresh_result, ctx| {
|
|
if let Err(err) = refresh_result {
|
|
super::report_fatal_error(err, ctx);
|
|
return;
|
|
}
|
|
|
|
let owner = match super::common::resolve_owner(scope.team, scope.personal, ctx) {
|
|
Ok(owner) => owner,
|
|
Err(err) => {
|
|
super::report_fatal_error(err, ctx);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let managed_value = match input.read() {
|
|
Ok(Some(value)) => value,
|
|
Ok(None) => {
|
|
// Treat this as a cancellation.
|
|
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
|
return;
|
|
}
|
|
Err(err) => {
|
|
super::report_fatal_error(err, ctx);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let secret_owner = match owner {
|
|
Owner::User { .. } => SecretOwner::CurrentUser,
|
|
Owner::Team { team_uid } => SecretOwner::Team {
|
|
team_uid: team_uid.uid(),
|
|
},
|
|
};
|
|
|
|
let create_future = manager.create_secret(
|
|
secret_owner,
|
|
name.clone(),
|
|
managed_value,
|
|
description.clone(),
|
|
);
|
|
ctx.spawn(create_future, move |_, result, ctx| match result {
|
|
Ok(secret) => {
|
|
println!("Secret '{}' created", secret.name);
|
|
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
|
}
|
|
Err(err) => {
|
|
super::report_fatal_error(err, ctx);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a secret.
|
|
fn delete_secret(ctx: &mut AppContext, args: DeleteSecretArgs) -> Result<()> {
|
|
let name = args.name;
|
|
let force = args.force;
|
|
let team = args.scope.team;
|
|
let personal = args.scope.personal;
|
|
|
|
ManagedSecretManager::handle(ctx).update(ctx, move |_manager, ctx| {
|
|
let refresh_future = super::common::refresh_workspace_metadata(ctx);
|
|
let name = name.clone();
|
|
ctx.spawn(refresh_future, move |manager, refresh_result, ctx| {
|
|
if let Err(err) = refresh_result {
|
|
super::report_fatal_error(err, ctx);
|
|
return;
|
|
}
|
|
|
|
let owner = match super::common::resolve_owner(team, personal, ctx) {
|
|
Ok(owner) => owner,
|
|
Err(err) => {
|
|
super::report_fatal_error(err, ctx);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let secret_owner = match owner {
|
|
Owner::User { .. } => SecretOwner::CurrentUser,
|
|
Owner::Team { team_uid } => SecretOwner::Team {
|
|
team_uid: team_uid.uid(),
|
|
},
|
|
};
|
|
|
|
if !force {
|
|
if !io::stdin().is_terminal() {
|
|
super::report_fatal_error(
|
|
anyhow::anyhow!(
|
|
"Refusing to delete secret without confirmation in non-interactive mode (use --force to bypass)"
|
|
),
|
|
ctx,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let scope = match owner {
|
|
Owner::User { .. } => "personal",
|
|
Owner::Team { .. } => "team",
|
|
};
|
|
|
|
let should_delete = match Confirm::new(&format!("Delete {scope} secret '{name}'?"))
|
|
.with_default(false)
|
|
.with_help_message("This action cannot be undone")
|
|
.prompt()
|
|
{
|
|
Ok(should_delete) => should_delete,
|
|
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
|
ctx
|
|
.terminate_app(TerminationMode::ForceTerminate, None);
|
|
return;
|
|
}
|
|
Err(err) => {
|
|
super::report_fatal_error(err.into(), ctx);
|
|
return;
|
|
}
|
|
};
|
|
|
|
if !should_delete {
|
|
println!("Deletion cancelled");
|
|
ctx
|
|
.terminate_app(TerminationMode::ForceTerminate, None);
|
|
return;
|
|
}
|
|
}
|
|
|
|
let delete_future = manager.delete_secret(secret_owner, name.clone());
|
|
ctx.spawn(delete_future, move |_, result, ctx| match result {
|
|
Ok(()) => {
|
|
println!("Secret '{name}' deleted");
|
|
ctx
|
|
.terminate_app(TerminationMode::ForceTerminate, None);
|
|
}
|
|
Err(err) => {
|
|
super::report_fatal_error(err, ctx);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Update a secret.
|
|
fn update_secret(ctx: &mut AppContext, args: UpdateSecretArgs) -> Result<()> {
|
|
ManagedSecretManager::handle(ctx).update(ctx, move |_manager, ctx| {
|
|
// Perform as much validation as possible up-front, before prompting the user for a secret.
|
|
let refresh_future = super::common::refresh_workspace_metadata(ctx);
|
|
ctx.spawn(refresh_future, move |manager, refresh_result, ctx| {
|
|
if let Err(err) = refresh_result {
|
|
super::report_fatal_error(err, ctx);
|
|
return;
|
|
}
|
|
|
|
let owner =
|
|
match super::common::resolve_owner(args.scope.team, args.scope.personal, ctx) {
|
|
Ok(owner) => owner,
|
|
Err(err) => {
|
|
super::report_fatal_error(err, ctx);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Read the secret value if either --value or --value-file is provided.
|
|
let secret_value = if args.value || args.value_args.value_file.is_some() {
|
|
// Create ValueArgs to handle reading from file or prompting
|
|
match read_simple_secret_value(&args.value_args) {
|
|
Ok(Some(value)) => Some(value),
|
|
Ok(None) => {
|
|
// Treat this as a cancellation.
|
|
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
|
return;
|
|
}
|
|
Err(err) => {
|
|
super::report_fatal_error(err, ctx);
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let secret_owner = match owner {
|
|
Owner::User { .. } => SecretOwner::CurrentUser,
|
|
Owner::Team { team_uid } => SecretOwner::Team {
|
|
team_uid: team_uid.uid(),
|
|
},
|
|
};
|
|
|
|
if let Some(secret_value) = secret_value {
|
|
// Look up the existing secret's type so we use the correct ManagedSecretValue variant.
|
|
let list_future = manager.list_secrets();
|
|
ctx.spawn(list_future, move |manager, list_result, ctx| {
|
|
let secrets = match list_result {
|
|
Ok(secrets) => secrets,
|
|
Err(err) => {
|
|
super::report_fatal_error(err, ctx);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let secret_type = match find_secret_type(&secrets, &args.name, &secret_owner) {
|
|
Some(t) => t,
|
|
None => {
|
|
super::report_fatal_error(
|
|
anyhow::anyhow!("Secret '{}' not found", args.name),
|
|
ctx,
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let managed_secret_value =
|
|
match make_secret_value_from_gql_type(secret_type, &secret_value) {
|
|
Ok(v) => v,
|
|
Err(err) => {
|
|
super::report_fatal_error(err, ctx);
|
|
return;
|
|
}
|
|
};
|
|
let update_future = manager.update_secret(
|
|
secret_owner,
|
|
args.name.clone(),
|
|
Some(managed_secret_value),
|
|
args.description.clone(),
|
|
);
|
|
ctx.spawn(update_future, move |_, result, ctx| match result {
|
|
Ok(secret) => {
|
|
println!("Secret '{}' updated", secret.name);
|
|
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
|
}
|
|
Err(err) => {
|
|
super::report_fatal_error(err, ctx);
|
|
}
|
|
});
|
|
});
|
|
} else {
|
|
// Description-only update; no encryption needed.
|
|
let update_future = manager.update_secret(
|
|
secret_owner,
|
|
args.name.clone(),
|
|
None,
|
|
args.description.clone(),
|
|
);
|
|
ctx.spawn(update_future, move |_, result, ctx| match result {
|
|
Ok(secret) => {
|
|
println!("Secret '{}' updated", secret.name);
|
|
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
|
}
|
|
Err(err) => {
|
|
super::report_fatal_error(err, ctx);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// List secrets.
|
|
fn list_secrets(
|
|
ctx: &mut AppContext,
|
|
output_format: OutputFormat,
|
|
_args: ListSecretsArgs,
|
|
) -> Result<()> {
|
|
ManagedSecretManager::handle(ctx).update(ctx, |manager, ctx| {
|
|
ctx.spawn(manager.list_secrets(), move |_, result, ctx| match result {
|
|
Ok(secrets) => {
|
|
let secret_infos = secrets.into_iter().map(|secret| {
|
|
let owner = match secret.owner.type_ {
|
|
SpaceType::User => Owner::User {
|
|
user_uid: UserUid::new(secret.owner.uid.inner()),
|
|
},
|
|
SpaceType::Team => Owner::Team {
|
|
team_uid: ServerId::from_string_lossy(secret.owner.uid.inner()),
|
|
},
|
|
};
|
|
|
|
SecretInfo {
|
|
name: secret.name,
|
|
scope: super::common::format_owner(&owner).to_string(),
|
|
secret_type: secret.type_,
|
|
created_at: secret.created_at.utc(),
|
|
updated_at: secret.updated_at.utc(),
|
|
}
|
|
});
|
|
|
|
output::print_list(secret_infos, output_format);
|
|
|
|
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
|
}
|
|
Err(err) => {
|
|
super::report_fatal_error(err, ctx);
|
|
}
|
|
});
|
|
});
|
|
Ok(())
|
|
}
|
|
/// Read a raw secret string from either the provided file or stdin.
|
|
fn read_simple_secret_value(args: &ValueArgs) -> Result<Option<String>> {
|
|
if let Some(value_file) = args.value_file.as_ref() {
|
|
let value = fs::read_to_string(value_file).with_context(|| {
|
|
format!("Failed to read secret value from: {}", value_file.display())
|
|
})?;
|
|
if value.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(value))
|
|
}
|
|
} else if io::stdin().is_terminal() {
|
|
let result = Password::new("Secret value:")
|
|
.with_display_toggle_enabled()
|
|
.without_confirmation()
|
|
.prompt();
|
|
match result {
|
|
Ok(value) => {
|
|
if value.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(value))
|
|
}
|
|
}
|
|
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None),
|
|
Err(err) => Err(err.into()),
|
|
}
|
|
} else {
|
|
let mut buf = String::new();
|
|
io::stdin().read_to_string(&mut buf)?;
|
|
if buf.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(buf))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Constructs the appropriate [`ManagedSecretValue`] for single-field CLI secret types.
|
|
fn make_simple_secret_value(secret_type: SecretType, raw: &str) -> ManagedSecretValue {
|
|
match secret_type {
|
|
SecretType::RawValue => ManagedSecretValue::raw_value(raw),
|
|
SecretType::AnthropicApiKey => ManagedSecretValue::anthropic_api_key(raw),
|
|
SecretType::AnthropicBedrockApiKey => {
|
|
// Bedrock secrets are multi-field and handled via SecretInput::Bedrock.
|
|
unreachable!("Bedrock secrets should not go through make_simple_secret_value")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Constructs the appropriate [`ManagedSecretValue`] for the given GraphQL secret type.
|
|
/// Used when updating an existing secret whose type is fetched from the server.
|
|
fn make_secret_value_from_gql_type(
|
|
gql_type: ManagedSecretType,
|
|
raw: &str,
|
|
) -> Result<ManagedSecretValue> {
|
|
match gql_type {
|
|
ManagedSecretType::RawValue | ManagedSecretType::Dotenvx => {
|
|
Ok(ManagedSecretValue::raw_value(raw))
|
|
}
|
|
ManagedSecretType::AnthropicApiKey => Ok(ManagedSecretValue::anthropic_api_key(raw)),
|
|
ManagedSecretType::AnthropicBedrockAccessKey => {
|
|
// Bedrock access key secrets cannot be updated through the generic raw-string path.
|
|
Err(anyhow::anyhow!(
|
|
"Bedrock access key secrets cannot be updated via `--value`; re-create the secret instead"
|
|
))
|
|
}
|
|
ManagedSecretType::AnthropicBedrockApiKey => {
|
|
// Bedrock secrets cannot be updated through the generic raw-string path.
|
|
// The caller should use the dedicated Bedrock creation flow instead.
|
|
Err(anyhow::anyhow!(
|
|
"Bedrock API key secrets cannot be updated via `--value`; re-create the secret instead"
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read a Bedrock API key secret from dedicated CLI flags or interactive prompts.
|
|
fn read_bedrock_secret_value(
|
|
bedrock_api_key: Option<String>,
|
|
region: Option<String>,
|
|
) -> Result<Option<ManagedSecretValue>> {
|
|
let api_key = match bedrock_api_key {
|
|
Some(k) if !k.is_empty() => k,
|
|
_ => {
|
|
if !io::stdin().is_terminal() {
|
|
return Err(anyhow::anyhow!(
|
|
"Bedrock secrets require --bedrock-api-key and --region in non-interactive mode"
|
|
));
|
|
}
|
|
let result = Password::new("Bedrock API key:")
|
|
.with_display_toggle_enabled()
|
|
.without_confirmation()
|
|
.prompt();
|
|
match result {
|
|
Ok(value) if !value.is_empty() => value,
|
|
Ok(_) => return Ok(None),
|
|
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
|
return Ok(None);
|
|
}
|
|
Err(err) => return Err(err.into()),
|
|
}
|
|
}
|
|
};
|
|
|
|
let region = match region {
|
|
Some(r) if !r.is_empty() => r,
|
|
_ => {
|
|
if !io::stdin().is_terminal() {
|
|
return Err(anyhow::anyhow!(
|
|
"Bedrock secrets require --bedrock-api-key and --region in non-interactive mode"
|
|
));
|
|
}
|
|
let result = inquire::Text::new("AWS Region:").prompt();
|
|
match result {
|
|
Ok(value) if !value.is_empty() => value,
|
|
Ok(_) => return Ok(None),
|
|
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
|
return Ok(None);
|
|
}
|
|
Err(err) => return Err(err.into()),
|
|
}
|
|
}
|
|
};
|
|
|
|
Ok(Some(ManagedSecretValue::anthropic_bedrock_api_key(
|
|
api_key, region,
|
|
)))
|
|
}
|
|
|
|
/// Read a Bedrock access key secret from dedicated CLI flags or interactive prompts.
|
|
///
|
|
/// `session_token` is optional: if the user passes an empty `--session-token`
|
|
/// value or hits Enter at the interactive prompt, no session token is stored.
|
|
/// This supports persistent IAM credentials, which do not require a session token.
|
|
fn read_bedrock_access_key_secret_value(
|
|
access_key_id: Option<String>,
|
|
secret_access_key: Option<String>,
|
|
session_token: Option<String>,
|
|
region: Option<String>,
|
|
) -> Result<Option<ManagedSecretValue>> {
|
|
// Error message used for all three required fields when running non-interactively.
|
|
// --session-token is intentionally omitted because it is optional.
|
|
const NON_INTERACTIVE_REQUIRED_MSG: &str = "Bedrock access key secrets require --access-key-id, --secret-access-key, and --region in non-interactive mode";
|
|
|
|
let access_key_id = match access_key_id {
|
|
Some(v) if !v.is_empty() => v,
|
|
_ => {
|
|
if !io::stdin().is_terminal() {
|
|
return Err(anyhow::anyhow!(NON_INTERACTIVE_REQUIRED_MSG));
|
|
}
|
|
match inquire::Text::new("AWS Access Key ID:").prompt() {
|
|
Ok(value) if !value.is_empty() => value,
|
|
Ok(_) => return Ok(None),
|
|
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
|
return Ok(None);
|
|
}
|
|
Err(err) => return Err(err.into()),
|
|
}
|
|
}
|
|
};
|
|
|
|
let secret_access_key = match secret_access_key {
|
|
Some(v) if !v.is_empty() => v,
|
|
_ => {
|
|
if !io::stdin().is_terminal() {
|
|
return Err(anyhow::anyhow!(NON_INTERACTIVE_REQUIRED_MSG));
|
|
}
|
|
match Password::new("AWS Secret Access Key:")
|
|
.with_display_toggle_enabled()
|
|
.without_confirmation()
|
|
.prompt()
|
|
{
|
|
Ok(value) if !value.is_empty() => value,
|
|
Ok(_) => return Ok(None),
|
|
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
|
return Ok(None);
|
|
}
|
|
Err(err) => return Err(err.into()),
|
|
}
|
|
}
|
|
};
|
|
|
|
// The session token is optional. An empty --session-token flag or an empty
|
|
// interactive submission is treated as "no token" rather than as a cancel.
|
|
let session_token: Option<String> = match session_token {
|
|
Some(v) if !v.is_empty() => Some(v),
|
|
Some(_) => None,
|
|
None => {
|
|
if !io::stdin().is_terminal() {
|
|
// In non-interactive mode, omitting --session-token is fine:
|
|
// persistent IAM credentials do not need a session token.
|
|
None
|
|
} else {
|
|
match Password::new("AWS Session Token (optional, press Enter to skip):")
|
|
.with_display_toggle_enabled()
|
|
.without_confirmation()
|
|
.prompt()
|
|
{
|
|
Ok(value) if !value.is_empty() => Some(value),
|
|
// Empty input signals "no session token", not a cancel.
|
|
Ok(_) => None,
|
|
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
|
return Ok(None);
|
|
}
|
|
Err(err) => return Err(err.into()),
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let region = match region {
|
|
Some(r) if !r.is_empty() => r,
|
|
_ => {
|
|
if !io::stdin().is_terminal() {
|
|
return Err(anyhow::anyhow!(NON_INTERACTIVE_REQUIRED_MSG));
|
|
}
|
|
match inquire::Text::new("AWS Region:").prompt() {
|
|
Ok(value) if !value.is_empty() => value,
|
|
Ok(_) => return Ok(None),
|
|
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
|
return Ok(None);
|
|
}
|
|
Err(err) => return Err(err.into()),
|
|
}
|
|
}
|
|
};
|
|
|
|
Ok(Some(ManagedSecretValue::anthropic_bedrock_access_key(
|
|
access_key_id,
|
|
secret_access_key,
|
|
session_token,
|
|
region,
|
|
)))
|
|
}
|
|
|
|
/// Finds the type of an existing secret by name and owner scope.
|
|
fn find_secret_type(
|
|
secrets: &[ManagedSecret],
|
|
name: &str,
|
|
owner: &SecretOwner,
|
|
) -> Option<ManagedSecretType> {
|
|
secrets
|
|
.iter()
|
|
.find(|s| {
|
|
s.name == name
|
|
&& match owner {
|
|
SecretOwner::CurrentUser => matches!(s.owner.type_, SpaceType::User),
|
|
SecretOwner::Team { team_uid } => {
|
|
matches!(s.owner.type_, SpaceType::Team) && s.owner.uid.inner() == team_uid
|
|
}
|
|
}
|
|
})
|
|
.map(|s| s.type_)
|
|
}
|
|
|
|
fn format_secret_type(type_: &ManagedSecretType) -> String {
|
|
match type_ {
|
|
ManagedSecretType::RawValue => "Raw Value".to_string(),
|
|
ManagedSecretType::Dotenvx => "dotenvx".to_string(),
|
|
ManagedSecretType::AnthropicApiKey => "Anthropic API Key".to_string(),
|
|
ManagedSecretType::AnthropicBedrockAccessKey => "Anthropic Bedrock Access Key".to_string(),
|
|
ManagedSecretType::AnthropicBedrockApiKey => "Anthropic Bedrock API Key".to_string(),
|
|
}
|
|
}
|