Skip to content
Merged
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
90 changes: 73 additions & 17 deletions src/handlers/run/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1010,7 +1010,11 @@ pub async fn handle_load_env_run(args: HandleRunArgs) -> anyhow::Result<()> {
});

let json_str = get_formatted_json_string(&message, false).unwrap();
eprintln!("{}", json_str);
if let Some(ref mut spinner) = spinner {
spinner.stop_with_message(&json_str);
} else {
eprintln!("{}", json_str);
}
} else if let Some(ref mut spinner) = spinner {
spinner.stop_with_message(&format!(
"{}\n Message: {}\n Details:\n Missing secrets: {}",
Expand Down Expand Up @@ -1110,7 +1114,7 @@ pub async fn handle_load_env_run(args: HandleRunArgs) -> anyhow::Result<()> {
})
.unwrap_or_else(|| only.clone());

if remote_only.is_empty() {
if !needs_remote_fetch(&only, &remote_only, proxy_policy.is_some()) {
let mut secrets = local_overrides.unwrap_or_default();
for secret in &mut secrets {
secret.value = format_env_variable_value(secret.value.to_string());
Expand Down Expand Up @@ -1203,7 +1207,11 @@ pub async fn handle_load_env_run(args: HandleRunArgs) -> anyhow::Result<()> {
});

let json_str = get_formatted_json_string(&message, false).unwrap();
eprintln!("{}", json_str);
if let Some(ref mut spinner) = spinner {
spinner.stop_with_message(&json_str);
} else {
eprintln!("{}", json_str);
}
} else {
let msg = format!(
"{}\n Message: {}\n Details:\n Missing secrets: {}",
Expand Down Expand Up @@ -1447,19 +1455,9 @@ async fn handle_run(

if !silent {
let mut success_msg = format!(
"{} {} ({} {})",
"{} {}",
"✓".green_if_tty_stderr(),
if secrets.is_empty() {
"Egress-only profile"
} else {
"Environment loaded"
},
secrets.len(),
if secrets.len() == 1 {
"secret"
} else {
"secrets"
}
loaded_message(secrets.len(), proxy_policy.is_some())
);

if print_secrets.is_some() && !is_from_file {
Expand Down Expand Up @@ -1830,6 +1828,36 @@ fn apply_secret_bindings(

/// Combines the remote fallback with local overrides. Names outside the
/// profile's requested source set are discarded before a child can receive them.
/// The line `run` prints once the secrets are loaded. "Egress-only profile"
/// describes an agent profile, so plain `stashbase run` never says it.
fn loaded_message(secret_count: usize, agent_run: bool) -> String {
let label = if agent_run && secret_count == 0 {
"Egress-only profile"
} else {
"Environment loaded"
};
let noun = if secret_count == 1 {
"secret"
} else {
"secrets"
};
format!("{label} ({secret_count} {noun})")
}

/// Whether `run` has to fetch secrets from Stashbase. `remote_only` is
/// `only` minus the secrets a local profile file already provides.
///
/// An empty `only` means "every secret" for `stashbase run`, so it always
/// fetches. An agent run (`agent_run`) only ever receives the secrets its
/// profile binds, so an empty `only` there means none.
fn needs_remote_fetch(only: &[String], remote_only: &[String], agent_run: bool) -> bool {
if only.is_empty() {
!agent_run
} else {
!remote_only.is_empty()
}
}

fn merge_remote_and_local_secrets(
remote: Vec<SecretWithoutComment>,
local: Vec<SecretWithoutComment>,
Expand Down Expand Up @@ -1870,8 +1898,9 @@ fn missing_secret_labels(
#[cfg(test)]
mod tests {
use super::{
apply_secret_bindings, load_run_secrets_from_file, merge_remote_and_local_secrets,
missing_secret_labels, prepare_local_run_secrets,
apply_secret_bindings, load_run_secrets_from_file, loaded_message,
merge_remote_and_local_secrets, missing_secret_labels, needs_remote_fetch,
prepare_local_run_secrets,
};
use crate::models::secrets::SecretWithoutComment;
use std::{
Expand Down Expand Up @@ -2131,6 +2160,33 @@ mod tests {
assert_eq!(missing, ["GH_TOKEN (from GITHUB_TOKEN)"]);
}

#[test]
fn only_an_agent_run_without_secrets_is_called_egress_only() {
assert_eq!(loaded_message(0, true), "Egress-only profile (0 secrets)");
assert_eq!(loaded_message(1, true), "Environment loaded (1 secret)");
assert_eq!(loaded_message(0, false), "Environment loaded (0 secrets)");
assert_eq!(loaded_message(10, false), "Environment loaded (10 secrets)");
}

#[test]
fn run_without_only_fetches_every_secret() {
assert!(needs_remote_fetch(&[], &[], false));
}

#[test]
fn an_agent_run_never_fetches_secrets_it_does_not_bind() {
assert!(!needs_remote_fetch(&[], &[], true));
}

#[test]
fn requested_secrets_are_fetched_unless_a_local_file_provides_them_all() {
let only = ["GITHUB_TOKEN".to_owned()];
for agent_run in [false, true] {
assert!(needs_remote_fetch(&only, &only, agent_run));
assert!(!needs_remote_fetch(&only, &[], agent_run));
}
}

#[test]
fn local_overrides_replace_remote_values_without_adding_unrequested_secrets() {
let merged = merge_remote_and_local_secrets(
Expand Down
56 changes: 56 additions & 0 deletions tests/exit_status_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,62 @@ fn an_api_request_that_fails_exits_1() {
);
}

/// Without `--only`, `run` fetches every secret of the environment. Against
/// an API nothing listens on, that fetch fails and the command never starts.
#[test]
fn run_without_only_fetches_secrets_before_starting_the_command() {
let project = Project::new();

let out = project.run(&[
"run",
"-p",
"myproj",
"-e",
"dev",
"--api-key",
"k",
"--",
"sh",
"-c",
"echo child-ran",
]);

check(
"run",
&out,
1,
"Could not connect to the API",
Reported::NotAsserted,
);
assert!(!text(&out.stdout).contains("child-ran"));
}

/// A `--json` error replaces the loading spinner instead of being appended to
/// its line, so the error is the only thing left to parse.
#[test]
fn run_json_error_clears_the_loading_spinner() {
let project = Project::new();
std::fs::write(project.cwd.join("secrets.env"), "OTHER=1\n").unwrap();

let out = project.run(&[
"run",
"--file",
"secrets.env",
"--only",
"MISSING",
"--api-key",
"k",
"--json",
"--",
"true",
]);

let stderr = text(&out.stderr);
assert_eq!(out.status.code(), Some(1), "{stderr}");
assert!(stderr.contains("no secrets found"), "{stderr}");
assert!(!stderr.contains("Loading environment...{"), "{stderr}");
}

#[test]
fn run_without_any_secrets_does_not_start_the_command_and_exits_1() {
let project = Project::new();
Expand Down
Loading