diff --git a/src/commands/compose_project.rs b/src/commands/compose_project.rs index 031c05b2..985c646f 100644 --- a/src/commands/compose_project.rs +++ b/src/commands/compose_project.rs @@ -19,8 +19,14 @@ pub(crate) use crate::commands::container_name::LONGEST_CONTAINER_NAME_SUFFIX; use crate::commands::dotenv::read_dotenv; use crate::i18n::Messages; -/// The executable every Docker invocation runs. -const DOCKER_BIN: &str = "docker"; +/// The executable every Docker invocation runs when the caller names +/// none. +/// +/// The only spelling of the literal the executable seam uses: the +/// spawn helpers default to it, and the context values production +/// builds start out holding it. A spawn site never reads it — it +/// spawns whatever its caller supplied. +pub(crate) const DOCKER_BIN: &str = "docker"; /// The `docker` subcommand every Compose invocation starts with. /// @@ -317,7 +323,22 @@ impl ComposeInvocation { /// rename this invocation's containers out from under the recorded /// identity. The order is the enforcement; there is no other guard. pub(crate) fn command(&self, extra_env: &[(&str, &str)]) -> ProcessCommand { - let mut command = ProcessCommand::new(DOCKER_BIN); + self.command_with_exec(extra_env, Path::new(DOCKER_BIN)) + } + + /// Builds the command for this invocation, spawning the executable + /// `docker` names rather than whatever `PATH` resolves. + /// + /// The executable is the only thing that varies: `extra_env`, the + /// argument vector and the instance pin behave exactly as they do + /// in [`ComposeInvocation::command`], which is this function with + /// the default supplied. + pub(crate) fn command_with_exec( + &self, + extra_env: &[(&str, &str)], + docker: &Path, + ) -> ProcessCommand { + let mut command = ProcessCommand::new(docker); command.args(&self.args); for (key, value) in extra_env { command.env(key, value); @@ -372,6 +393,27 @@ mod tests { .expect("write .env"); } + /// The compose spawn site runs whatever the caller named, and + /// [`ComposeInvocation::command`] is that call with the default + /// supplied — so the program the two build differs only by what was + /// asked for, and nothing else about the invocation moves. + #[test] + fn compose_command_runs_the_supplied_executable() { + let identity = ComposeIdentity::for_instance(DEFAULT_INSTANCE_NAME); + let invocation = identity.compose(&["docker-compose.yml"], None, &["up", "-d"]); + + let default = invocation.command(&[]); + assert_eq!(default.get_program(), "docker"); + + let supplied = invocation.command_with_exec(&[], Path::new("/tmp/fake-docker")); + assert_eq!(supplied.get_program(), "/tmp/fake-docker"); + assert_eq!( + supplied.get_args().collect::>(), + default.get_args().collect::>(), + "only the executable may differ from the default path" + ); + } + #[test] fn instance_name_accepts_the_documented_character_set() { let messages = test_messages(); @@ -788,11 +830,15 @@ mod tests { } /// The guard that makes the property above unbypassable: the - /// invocation hands out no argument vector, so `command()` — the one - /// method that sets the environment — is the only way to spawn one. - /// A new accessor returning the args would let a call site build a - /// compose vector and spawn it bare, which is exactly what this - /// module exists to prevent. + /// invocation hands out no argument vector, so the command builders + /// — the only methods that set the environment — are the only way to + /// spawn one. A new accessor returning the args would let a call + /// site build a compose vector and spawn it bare, which is exactly + /// what this module exists to prevent. + /// + /// `command_with_exec` is on the list because it is the same builder + /// with the executable named; `command` delegates to it, so the + /// instance pin is applied once, in one place, on both paths. #[test] fn compose_invocation_exposes_only_the_command_builder() { let source = std::fs::read_to_string( @@ -823,9 +869,10 @@ mod tests { .collect(); assert_eq!( methods, - vec!["command"], - "`ComposeInvocation` must expose nothing but `command`, which is \ - what pins `{INSTANCE_NAME_ENV_KEY}`; found {methods:?}" + vec!["command", "command_with_exec"], + "`ComposeInvocation` must expose nothing but its command \ + builders, which are what pin `{INSTANCE_NAME_ENV_KEY}`; \ + found {methods:?}" ); } diff --git a/src/commands/infra.rs b/src/commands/infra.rs index 97bfb7ab..b94c9b36 100644 --- a/src/commands/infra.rs +++ b/src/commands/infra.rs @@ -16,8 +16,8 @@ use bootroot::openbao::OpenBaoClient; use crate::cli::args::{InfraInstallArgs, InfraUpArgs}; use crate::commands::compose_project::{ - ComposeIdentity, ComposeInvocation, INSTANCE_NAME_ENV_KEY, resolve_recorded_instance_name, - validate_instance_name, + ComposeIdentity, ComposeInvocation, DOCKER_BIN, INSTANCE_NAME_ENV_KEY, + resolve_recorded_instance_name, validate_instance_name, }; use crate::commands::constants::RESPONDER_SERVICE_NAME; use crate::commands::dns_alias::replay_dns_aliases; @@ -2067,7 +2067,24 @@ pub(crate) fn run_docker>( context: &str, messages: &Messages, ) -> Result<()> { - let mut cmd = ProcessCommand::new("docker"); + run_docker_with_exec(args, context, Path::new(DOCKER_BIN), messages) +} + +/// Runs the executable `docker` names with a plain (non-Compose) +/// argument vector. +/// +/// [`run_docker`] is this function with the default executable +/// supplied; a caller that has to name the program — a test pointing at +/// a fake, above all — reaches it through here instead. A bare program +/// name carrying no path separator is still resolved against `PATH` by +/// `Command` itself, so the default loses nothing. +pub(crate) fn run_docker_with_exec>( + args: &[S], + context: &str, + docker: &Path, + messages: &Messages, +) -> Result<()> { + let mut cmd = ProcessCommand::new(docker); cmd.args(args.iter().map(AsRef::as_ref)); run_to_completion(&mut cmd, context, messages) } @@ -2078,7 +2095,19 @@ pub(crate) fn run_compose( context: &str, messages: &Messages, ) -> Result<()> { - run_compose_with_env(invocation, &[], context, messages) + run_compose_with_exec(invocation, context, Path::new(DOCKER_BIN), messages) +} + +/// Runs a `docker compose` invocation with `docker` as the executable. +/// +/// [`run_compose`] is this function with the default supplied. +pub(crate) fn run_compose_with_exec( + invocation: &ComposeInvocation, + context: &str, + docker: &Path, + messages: &Messages, +) -> Result<()> { + run_compose_with_env_and_exec(invocation, &[], context, docker, messages) } /// Runs a `docker compose` invocation with additional child-environment @@ -2095,7 +2124,27 @@ pub(crate) fn run_compose_with_env( context: &str, messages: &Messages, ) -> Result<()> { - run_to_completion(&mut invocation.command(env), context, messages) + run_compose_with_env_and_exec(invocation, env, context, Path::new(DOCKER_BIN), messages) +} + +/// Runs a `docker compose` invocation with additional child-environment +/// entries and `docker` as the executable. +/// +/// [`run_compose_with_env`] is this function with the default supplied. +/// The instance pin is applied by [`ComposeInvocation::command_with_exec`] +/// after `env`, exactly as it is on the default path. +pub(crate) fn run_compose_with_env_and_exec( + invocation: &ComposeInvocation, + env: &[(&str, &str)], + context: &str, + docker: &Path, + messages: &Messages, +) -> Result<()> { + run_to_completion( + &mut invocation.command_with_exec(env, docker), + context, + messages, + ) } /// Spawns `command`, waits for it, and turns a non-zero exit into an diff --git a/src/commands/init/steps.rs b/src/commands/init/steps.rs index bd8eb219..1a5330ff 100644 --- a/src/commands/init/steps.rs +++ b/src/commands/init/steps.rs @@ -10,7 +10,7 @@ mod responder_setup; mod secrets; pub(crate) mod stepca_setup; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use bootroot::openbao::{InitResponse, OpenBaoClient}; @@ -24,7 +24,9 @@ pub(crate) use orchestrator::run_init; pub(crate) use prompts::prompt_yes_no; use super::types::EabCredentials; -use crate::commands::compose_project::{ComposeIdentity, ComposeInvocation, DEFAULT_INSTANCE_NAME}; +use crate::commands::compose_project::{ + ComposeIdentity, ComposeInvocation, DEFAULT_INSTANCE_NAME, DOCKER_BIN, +}; use crate::commands::container_name::BootrootContainer; use crate::i18n::Messages; @@ -99,15 +101,35 @@ pub(super) struct InitRollback { /// restores the pre-TLS `state.json` so it does not keep pointing at /// an HTTPS URL / TLS certs after `OpenBao` is recreated on plaintext. pub(super) state_backup: Option, + /// The `docker` executable every spawn in the `init` flow runs. + /// + /// `None` *is* `docker`: this value comes straight from the derived + /// `Default`, with no constructor to fill a bare `PathBuf` in, and + /// an empty path would spawn nothing at all. [`InitRollback::docker`] + /// is the one place that resolution happens. + pub(super) docker: Option, } impl InitRollback { + /// Returns the executable every `docker` spawn in this run uses. + /// + /// The single resolution point for the `init` tree's default: a run + /// that named no executable gets `docker`. + pub(super) fn docker(&self) -> &Path { + self.docker.as_deref().unwrap_or(Path::new(DOCKER_BIN)) + } + + // A linear teardown: each step undoes one forward-path artefact and + // reports its own failure without aborting the rest, so splitting it + // would only scatter that sequence across helpers. + #[allow(clippy::too_many_lines)] pub(super) async fn rollback( &self, client: &OpenBaoClient, kv_mount: &str, messages: &Messages, ) { + let docker = self.docker(); // Restore the HCL and remove TLS artifacts before OpenBao API // calls so that a container restart switches OpenBao back to // HTTP, letting the original HTTP client reach it for cleanup. @@ -142,9 +164,10 @@ impl InitRollback { &rollback_identity(compose_file, messages), compose_file, ); - if let Err(err) = crate::commands::infra::run_compose( + if let Err(err) = crate::commands::infra::run_compose_with_exec( &invocation, "docker compose up -d openbao (rollback)", + docker, messages, ) { eprintln!("Rollback: failed to recreate OpenBao: {err}"); @@ -165,9 +188,10 @@ impl InitRollback { compose_file, override_path, ); - if let Err(err) = crate::commands::infra::run_compose( + if let Err(err) = crate::commands::infra::run_compose_with_exec( &invocation, "docker compose rm infra agents (rollback)", + docker, messages, ) { eprintln!("Rollback: failed to remove infra OpenBao agents: {err}"); @@ -212,9 +236,10 @@ impl InitRollback { compose_file, config_override, ); - if let Err(err) = crate::commands::infra::run_compose( + if let Err(err) = crate::commands::infra::run_compose_with_exec( &invocation, "docker compose up -d responder (rollback)", + docker, messages, ) { eprintln!("Rollback: failed to recreate responder: {err}"); @@ -244,7 +269,7 @@ impl InitRollback { { eprintln!("Rollback: failed to restore {}: {err}", file.path.display()); } - self.restore_stepca_ca_json(messages); + self.restore_stepca_ca_json(docker, messages); } /// Restores `ca.json` together with the Agent template it is @@ -258,7 +283,7 @@ impl InitRollback { /// triggered by the restart already produces the pre-`init` name set, /// and `ca.json` goes back after it so the file left on disk is the /// pre-`init` document either way. - fn restore_stepca_ca_json(&self, messages: &Messages) { + fn restore_stepca_ca_json(&self, docker: &Path, messages: &Messages) { if let Some(file) = &self.stepca_ca_json_template_backup && let Err(err) = rollback_file(file, messages) { @@ -281,6 +306,7 @@ impl InitRollback { let identity = rollback_identity(compose_file, messages); stepca_setup::restart_stepca_openbao_agent( &identity.container(BootrootContainer::OpenBaoAgentStepCa), + docker, ); } } @@ -386,6 +412,8 @@ fn rollback_file(file: &RollbackFile, messages: &Messages) -> Result<()> { #[cfg(test)] mod rollback_tests { + use std::path::{Path, PathBuf}; + use bootroot::openbao::OpenBaoClient; use super::{ @@ -610,6 +638,87 @@ mod rollback_tests { ); } + /// The derived `Default` is how production builds this value, so a + /// run that names no executable still has to spawn `docker`. A bare + /// `PathBuf` field would default to the empty path and spawn nothing + /// at all, which no other assertion in this file would catch. + #[test] + fn rollback_defaults_to_the_docker_executable() { + let rollback = InitRollback { + compose_file: None, + ..Default::default() + }; + + assert_eq!(rollback.docker(), Path::new("docker")); + } + + /// The seam: a caller that names an executable is the one the + /// rollback resolves, and the default is not consulted. + #[test] + fn rollback_resolves_the_executable_it_was_given() { + let rollback = InitRollback { + docker: Some(PathBuf::from("/tmp/fake-docker")), + ..Default::default() + }; + + assert_eq!(rollback.docker(), Path::new("/tmp/fake-docker")); + } + + /// Resolving the field is only half of it: the resolved program has + /// to reach the child. A rollback that names an executable runs + /// *that* one for both spawn shapes it drives — the compose recreate + /// through `run_compose_with_exec`, and the sidecar restart through + /// `restart_stepca_openbao_agent`'s own `Command`. + /// + /// Nothing process-global moves here: the fake carries its argv-log + /// path in its own script text, which is the property that lets the + /// conversion issue delete this file's remaining `PATH` fake. + #[tokio::test] + async fn rollback_runs_the_executable_it_was_given() { + use std::fs; + + use super::test_support::write_self_contained_fake_docker; + + let dir = tempfile::tempdir().expect("tempdir"); + let fake = dir.path().join("fake-docker"); + let args_log = dir.path().join("docker_args.log"); + write_self_contained_fake_docker(&fake, &args_log); + + let rollback = InitRollback { + docker: Some(fake), + compose_file: Some(dir.path().join("docker-compose.yml")), + openbao_recreated: true, + stepca_agent_restarted: true, + ..Default::default() + }; + + let client = OpenBaoClient::new("http://127.0.0.1:1").expect("client"); + rollback + .rollback(&client, "secret", &crate::i18n::test_messages()) + .await; + + let log = fs::read_to_string(&args_log) + .expect("the supplied executable must have run, not `docker` from `PATH`"); + let invocations: Vec<&str> = log.lines().collect(); + assert_eq!( + invocations.len(), + 2, + "the supplied executable must have run the recreate and the sidecar restart, got: {log}" + ); + assert!( + invocations + .first() + .is_some_and(|recreate| recreate.contains("up -d openbao")), + "the first invocation must be the compose recreate, got: {log}" + ); + assert!( + invocations + .get(1) + .is_some_and(|restart| restart.contains("restart ")), + "the second invocation must be the sidecar restart, got: {log}" + ); + } + /// Regression: rollback must recreate the `OpenBao` container with /// `up -d` (not `restart`) so that Docker Compose applies the base /// compose config without the non-loopback override. `restart` @@ -859,13 +968,39 @@ mod rollback_tests { #[cfg(test)] pub(super) mod test_support { - use std::path::PathBuf; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; use super::super::constants::openbao_constants::SECRET_ID_TTL; use super::super::constants::{DEFAULT_CERT_DURATION, DEFAULT_STEPCA_PROVISIONER}; use crate::cli::args::InitArgs; pub(in crate::commands::init::steps) use crate::i18n::test_messages; + /// Writes a fake `docker` that appends one line per invocation to + /// `args_log` and reads nothing from its environment. + /// + /// The log path is baked into the script text at the moment it is + /// written, so a test that hands this executable through the docker + /// seam gets its argv back without setting a single variable on this + /// process — which is the property the seam exists to make possible. + pub(in crate::commands::init::steps) fn write_self_contained_fake_docker( + path: &Path, + args_log: &Path, + ) { + let log = args_log.display().to_string(); + assert!( + !log.contains('\''), + "the log path is interpolated into a single-quoted shell word" + ); + let script = format!( + "#!/bin/sh\nset -eu\n{{ printf '%s ' \"$@\"; printf '\\n'; }} >> '{log}'\nexit 0\n" + ); + fs::write(path, script).expect("fake docker script should be written"); + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .expect("fake docker script should be executable"); + } + pub(in crate::commands::init::steps) fn default_init_args() -> InitArgs { InitArgs { openbao: crate::cli::args::OpenBaoArgs { diff --git a/src/commands/init/steps/http01_admin_tls.rs b/src/commands/init/steps/http01_admin_tls.rs index 77348b42..09464529 100644 --- a/src/commands/init/steps/http01_admin_tls.rs +++ b/src/commands/init/steps/http01_admin_tls.rs @@ -6,7 +6,7 @@ use anyhow::{Context, Result}; use super::super::constants::{ RESPONDER_CONFIG_DIR, RESPONDER_CONFIG_NAME, RESPONDER_TEMPLATE_DIR, RESPONDER_TEMPLATE_NAME, }; -use crate::commands::infra::run_docker; +use crate::commands::infra::run_docker_with_exec; use crate::commands::init::{ CA_CERTS_DIR, CA_INTERMEDIATE_CERT_FILENAME, HTTP01_ADMIN_INFRA_CERT_KEY, HTTP01_ADMIN_TLS_CERT_REL_PATH, HTTP01_ADMIN_TLS_DEFAULT_NOT_AFTER, @@ -24,6 +24,7 @@ use crate::state::{InfraCertEntry, ReloadStrategy, StateFile}; pub(in crate::commands::init) fn issue_http01_admin_tls_cert( secrets_dir: &Path, sans: &[&str], + docker: &Path, messages: &Messages, ) -> Result<()> { let cert_path = secrets_dir.join(HTTP01_ADMIN_TLS_CERT_REL_PATH); @@ -81,9 +82,10 @@ pub(in crate::commands::init) fn issue_http01_admin_tls_cert( } args.extend(["--not-after", HTTP01_ADMIN_TLS_DEFAULT_NOT_AFTER, "--force"]); - run_docker( + run_docker_with_exec( &args, "docker step certificate create (http01 admin tls)", + docker, messages, ) .with_context(|| messages.error_http01_admin_tls_provision_failed())?; @@ -203,6 +205,7 @@ pub(crate) fn reissue_http01_admin_tls_cert( secrets_dir: &Path, entry: &InfraCertEntry, responder_container: &str, + docker: &Path, messages: &Messages, ) -> Result<()> { let san_refs: Vec<&str> = entry.sans.iter().map(String::as_str).collect(); @@ -211,7 +214,7 @@ pub(crate) fn reissue_http01_admin_tls_cert( } else { san_refs }; - issue_http01_admin_tls_cert(secrets_dir, &sans, messages) + issue_http01_admin_tls_cert(secrets_dir, &sans, docker, messages) } /// Strips `tls_cert_path` and `tls_key_path` lines from the responder @@ -333,8 +336,14 @@ mod tests { &args_log, ); - reissue_http01_admin_tls_cert(&secrets_dir, &entry, "insight-http01", &messages) - .expect("re-issuance must succeed against the fake docker"); + reissue_http01_admin_tls_cert( + &secrets_dir, + &entry, + "insight-http01", + Path::new("docker"), + &messages, + ) + .expect("re-issuance must succeed against the fake docker"); let log = std::fs::read_to_string(&args_log).unwrap_or_default(); assert!( diff --git a/src/commands/init/steps/openbao_tls.rs b/src/commands/init/steps/openbao_tls.rs index c14312a8..0d11ee30 100644 --- a/src/commands/init/steps/openbao_tls.rs +++ b/src/commands/init/steps/openbao_tls.rs @@ -3,7 +3,7 @@ use std::path::Path; use anyhow::{Context, Result}; -use crate::commands::infra::run_docker; +use crate::commands::infra::run_docker_with_exec; use crate::commands::init::{ CA_CERTS_DIR, CA_INTERMEDIATE_CERT_FILENAME, OPENBAO_HCL_PATH, OPENBAO_INFRA_CERT_KEY, OPENBAO_TLS_CERT_PATH, OPENBAO_TLS_CONTAINER_CERT_PATH, OPENBAO_TLS_CONTAINER_KEY_PATH, @@ -31,6 +31,7 @@ pub(in crate::commands::init) fn issue_openbao_tls_cert( compose_dir: &Path, secrets_dir: &Path, sans: &[&str], + docker: &Path, messages: &Messages, ) -> Result<()> { let cert_path = compose_dir.join(OPENBAO_TLS_CERT_PATH); @@ -52,7 +53,7 @@ pub(in crate::commands::init) fn issue_openbao_tls_cert( .with_context(|| messages.error_resolve_path_failed(&secrets_dir.display().to_string()))?; let user_arg = format!("{}:{}", meta.uid(), meta.gid()); - chown_tls_output_dir(&tls_mount, &user_arg, messages)?; + chown_tls_output_dir(&tls_mount, &user_arg, docker, messages)?; let intermediate_cert = format!("/home/step/{CA_CERTS_DIR}/{CA_INTERMEDIATE_CERT_FILENAME}"); let output_cert = format!("{OPENBAO_TLS_OUTPUT_MOUNT}/server.crt"); @@ -94,9 +95,10 @@ pub(in crate::commands::init) fn issue_openbao_tls_cert( } args.extend(["--not-after", OPENBAO_TLS_DEFAULT_NOT_AFTER, "--force"]); - run_docker( + run_docker_with_exec( &args, "docker step certificate create (openbao tls)", + docker, messages, ) .with_context(|| messages.error_openbao_tls_provision_failed())?; @@ -195,12 +197,17 @@ fn build_tls_output_chown_args<'a>( /// Reuses the image the `step certificate create` container runs in the /// very next statement, so no extra image or pull is introduced. The /// chown is a no-op when ownership is already correct. -fn chown_tls_output_dir(tls_mount: &str, user_arg: &str, messages: &Messages) -> Result<()> { +fn chown_tls_output_dir( + tls_mount: &str, + user_arg: &str, + docker: &Path, + messages: &Messages, +) -> Result<()> { let args = build_tls_output_chown_args(tls_mount, user_arg, STEP_CA_HELPER_IMAGE); // Same context as the `step certificate create` call this precedes: // the chown is part of the issuance, so a failure here has to name // the step that failed and not just the docker command. - run_docker(&args, "docker openbao tls output chown", messages) + run_docker_with_exec(&args, "docker openbao tls output chown", docker, messages) .with_context(|| messages.error_openbao_tls_provision_failed()) } @@ -451,6 +458,7 @@ pub(crate) fn reissue_openbao_tls_cert( secrets_dir: &Path, entry: &InfraCertEntry, openbao_container: &str, + docker: &Path, messages: &Messages, ) -> Result<()> { let san_refs: Vec<&str> = entry.sans.iter().map(String::as_str).collect(); @@ -459,7 +467,7 @@ pub(crate) fn reissue_openbao_tls_cert( } else { san_refs }; - issue_openbao_tls_cert(compose_dir, secrets_dir, &sans, messages) + issue_openbao_tls_cert(compose_dir, secrets_dir, &sans, docker, messages) } #[cfg(test)] @@ -467,6 +475,7 @@ mod tests { use std::collections::BTreeMap; use std::fs; + use super::super::test_support::write_self_contained_fake_docker; use super::*; use crate::commands::rotate::test_support::{ ScopedEnvVar, TEST_DOCKER_ARGS_ENV, env_lock, path_with_prepend, @@ -559,6 +568,7 @@ exit {exit_code} &secrets_dir, &entry, "insight-openbao", + Path::new("docker"), &messages, ) .expect("re-issuance must succeed against the fake docker"); @@ -794,6 +804,55 @@ exit {exit_code} assert_eq!(args.last(), Some(&OPENBAO_TLS_OUTPUT_MOUNT)); } + /// The same wiring, reached through the executable seam instead of + /// `PATH`: `issue_openbao_tls_cert` runs whichever program its + /// caller named, all the way down through `run_docker`. + /// + /// This test mutates nothing process-global — no `PATH` edit, no + /// variable set, no lock — because the fake it writes carries its + /// own argv-log path in its script text. It is therefore also proof + /// that the seam needs no environment mechanism to be usable. + #[test] + fn issue_openbao_tls_cert_runs_the_supplied_executable() { + let dir = tempfile::tempdir().expect("tempdir"); + let fake = dir.path().join("fake-docker"); + let args_log = dir.path().join("docker_args.log"); + write_self_contained_fake_docker(&fake, &args_log); + + let compose_dir = dir.path().join("compose"); + let secrets_dir = dir.path().join("secrets"); + fs::create_dir_all(&secrets_dir).expect("create secrets dir"); + // The fake no-ops `step certificate create`, so the files the + // host-side chmod expects have to exist up front. + let tls_dir = compose_dir.join("openbao").join("tls"); + fs::create_dir_all(&tls_dir).expect("create tls dir"); + fs::write(tls_dir.join("server.crt"), "cert").expect("write cert"); + fs::write(tls_dir.join("server.key"), "key").expect("write key"); + + issue_openbao_tls_cert( + &compose_dir, + &secrets_dir, + &["openbao.internal"], + &fake, + &crate::i18n::test_messages(), + ) + .expect("issuing the certificate must succeed against the supplied executable"); + + let log = fs::read_to_string(&args_log).expect("read docker args"); + let invocations: Vec<&str> = log.lines().collect(); + assert_eq!( + invocations.len(), + 2, + "the supplied executable must have run the chown and the certificate write, got: {log}" + ); + assert!( + invocations + .get(1) + .is_some_and(|create| create.contains("certificate create")), + "the second invocation must be the certificate write, got: {log}" + ); + } + /// The argv builder is only worth anything if the certificate write /// actually runs it, and runs it *first*: a chown that landed after /// `step certificate create` would repair the ownership of files the @@ -822,8 +881,14 @@ exit {exit_code} let _path = ScopedEnvVar::set("PATH", path_with_prepend(&bin_dir)); let _log = ScopedEnvVar::set(TEST_DOCKER_ARGS_ENV, &args_log); - issue_openbao_tls_cert(&compose_dir, &secrets_dir, &["openbao.internal"], &messages) - .expect("issuing the certificate must succeed against the fake docker"); + issue_openbao_tls_cert( + &compose_dir, + &secrets_dir, + &["openbao.internal"], + Path::new("docker"), + &messages, + ) + .expect("issuing the certificate must succeed against the fake docker"); let log = fs::read_to_string(&args_log).unwrap_or_default(); let invocations: Vec<&str> = log.lines().collect(); @@ -886,9 +951,14 @@ exit {exit_code} let _path = ScopedEnvVar::set("PATH", path_with_prepend(&bin_dir)); let _log = ScopedEnvVar::set(TEST_DOCKER_ARGS_ENV, &args_log); - let error = - issue_openbao_tls_cert(&compose_dir, &secrets_dir, &["openbao.internal"], &messages) - .expect_err("a failing chown must fail the issuance"); + let error = issue_openbao_tls_cert( + &compose_dir, + &secrets_dir, + &["openbao.internal"], + Path::new("docker"), + &messages, + ) + .expect_err("a failing chown must fail the issuance"); let chain = format!("{error:#}"); assert!( chain.contains(messages.error_openbao_tls_provision_failed()), @@ -940,9 +1010,14 @@ exit {exit_code} let _path = ScopedEnvVar::set("PATH", path_with_prepend(&bin_dir)); let _log = ScopedEnvVar::set(TEST_DOCKER_ARGS_ENV, &args_log); - let error = - issue_openbao_tls_cert(&compose_dir, &secrets_dir, &["openbao.internal"], &messages) - .expect_err("a symlinked output directory must fail the issuance"); + let error = issue_openbao_tls_cert( + &compose_dir, + &secrets_dir, + &["openbao.internal"], + Path::new("docker"), + &messages, + ) + .expect_err("a symlinked output directory must fail the issuance"); let chain = format!("{error:#}"); assert!( chain.contains(&tls_dir.display().to_string()), diff --git a/src/commands/init/steps/openbao_transition.rs b/src/commands/init/steps/openbao_transition.rs index 106579f9..6c756a94 100644 --- a/src/commands/init/steps/openbao_transition.rs +++ b/src/commands/init/steps/openbao_transition.rs @@ -24,9 +24,9 @@ use anyhow::{Context, Result}; use bootroot::openbao::OpenBaoClient; use super::prompts::prompt_unseal_keys; -use crate::commands::compose_project::{ComposeIdentity, ComposeInvocation}; +use crate::commands::compose_project::{ComposeIdentity, ComposeInvocation, DOCKER_BIN}; use crate::commands::infra::{ - build_openbao_client, run_compose, wait_for_openbao_api_reachable_within, + build_openbao_client, run_compose_with_exec, wait_for_openbao_api_reachable_within, }; use crate::commands::openbao_unseal::read_unseal_keys_from_file; use crate::i18n::Messages; @@ -197,6 +197,12 @@ pub(super) struct OpenBaoTlsTransition<'a> { /// address, which local commands must not depend on. https_url: &'a str, secrets_dir: &'a Path, + /// The `docker` executable the recreate runs. + /// + /// Set by [`OpenBaoTlsTransition::new`] and only read afterwards, so + /// a test can name a fake in the struct-update form its siblings + /// already use for `probe_attempts`. + docker: &'a Path, probe_attempts: u32, probe_delay: Duration, } @@ -213,6 +219,7 @@ impl<'a> OpenBaoTlsTransition<'a> { override_path, https_url, secrets_dir, + docker: Path::new(DOCKER_BIN), probe_attempts: TLS_PROBE_ATTEMPTS, probe_delay: TLS_PROBE_DELAY, } @@ -246,7 +253,12 @@ impl<'a> OpenBaoTlsTransition<'a> { let identity = ComposeIdentity::resolve(self.compose_file, None, messages)?; let invocation = openbao_recreate_invocation(&identity, self.compose_file, self.override_path); - run_compose(&invocation, "docker compose up -d openbao (tls)", messages)?; + run_compose_with_exec( + &invocation, + "docker compose up -d openbao (tls)", + self.docker, + messages, + )?; let client = self.probe_tls(messages).await?; ensure_unsealed(&client, prepared, messages).await @@ -346,6 +358,28 @@ mod tests { const PROBE_ATTEMPTS: u32 = 1; const PROBE_DELAY: Duration = Duration::from_millis(1); + + /// `new` is this value's only constructor, so it is the one place + /// the default can be got wrong — and the struct-update form is how + /// a test names a fake, exactly as its siblings already do for + /// `probe_attempts`. + #[test] + fn the_transition_defaults_to_docker_and_honours_a_named_executable() { + let dir = tempdir().expect("temp dir"); + let compose = dir.path().join("docker-compose.yml"); + let override_path = dir.path().join("docker-compose.openbao-exposed.yml"); + + let default = + OpenBaoTlsTransition::new(&compose, &override_path, UNREACHABLE_HTTPS_URL, dir.path()); + assert_eq!(default.docker, Path::new("docker")); + + let fake = dir.path().join("fake-docker"); + let named = OpenBaoTlsTransition { + docker: &fake, + ..OpenBaoTlsTransition::new(&compose, &override_path, UNREACHABLE_HTTPS_URL, dir.path()) + }; + assert_eq!(named.docker, fake); + } /// A port nothing listens on, so the TLS probe fails fast instead of /// handshaking with a real server. const UNREACHABLE_HTTPS_URL: &str = "https://127.0.0.1:1"; diff --git a/src/commands/init/steps/orchestrator.rs b/src/commands/init/steps/orchestrator.rs index 2655a9be..5e2788cf 100644 --- a/src/commands/init/steps/orchestrator.rs +++ b/src/commands/init/steps/orchestrator.rs @@ -621,7 +621,10 @@ async fn run_init_inner( // triggered by a later failure restarts the sidecar back onto the // template it restores, even if the restart itself half-succeeded. rollback.stepca_agent_restarted = true; - restart_stepca_openbao_agent(&identity.container(BootrootContainer::OpenBaoAgentStepCa)); + restart_stepca_openbao_agent( + &identity.container(BootrootContainer::OpenBaoAgentStepCa), + rollback.docker(), + ); reconcile_ca_json_dns_names(&secrets_dir, &stepca_dns_names, messages).await?; } @@ -739,7 +742,7 @@ async fn run_init_inner( &identity.container(BootrootContainer::Http01), ); let san_refs: Vec<&str> = sans.iter().map(String::as_str).collect(); - issue_http01_admin_tls_cert(&secrets_dir, &san_refs, messages)?; + issue_http01_admin_tls_cert(&secrets_dir, &san_refs, rollback.docker(), messages)?; // Track TLS artifacts for rollback cleanup. rollback .tls_artifacts @@ -883,6 +886,7 @@ async fn run_init_inner( compose_dir, &args.secrets_dir.secrets_dir, &san_refs, + rollback.docker(), messages, )?; diff --git a/src/commands/init/steps/stepca_setup.rs b/src/commands/init/steps/stepca_setup.rs index 7c46827b..3a928c57 100644 --- a/src/commands/init/steps/stepca_setup.rs +++ b/src/commands/init/steps/stepca_setup.rs @@ -476,8 +476,8 @@ pub(super) async fn reconcile_ca_json_dns_names( /// Returns `false` when the container is absent — a fresh install has no /// sidecar yet, and that is not a failure. Output is discarded so the /// absent-container case adds no noise to the `init` transcript. -pub(super) fn restart_stepca_openbao_agent(container: &str) -> bool { - std::process::Command::new("docker") +pub(super) fn restart_stepca_openbao_agent(container: &str, docker: &Path) -> bool { + std::process::Command::new(docker) .args(["restart", container]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -557,7 +557,7 @@ mod tests { use tempfile::tempdir; - use super::super::test_support::test_messages; + use super::super::test_support::{test_messages, write_self_contained_fake_docker}; use super::*; /// The step-ca container name a default install renders. @@ -593,7 +593,10 @@ mod tests { let _path = ScopedEnvVar::set("PATH", path_with_prepend(&bin_dir)); let _args = ScopedEnvVar::set(TEST_DOCKER_ARGS_ENV, &args_log); - assert!(restart_stepca_openbao_agent("insight-openbao-agent-stepca")); + assert!(restart_stepca_openbao_agent( + "insight-openbao-agent-stepca", + Path::new("docker") + )); let logged = fs::read_to_string(&args_log).expect("read docker args"); assert_eq!( @@ -602,6 +605,31 @@ mod tests { ); } + /// The executable seam: the caller names the program, and the child + /// that runs is the one it named. + /// + /// The fake carries its own argv-log path in its script text, so + /// nothing here sets a variable on this process or edits `PATH` — + /// which is the whole point of taking the executable as a value. + #[test] + fn restarting_the_stepca_sidecar_runs_the_supplied_executable() { + let dir = tempdir().expect("tempdir"); + let fake = dir.path().join("fake-docker"); + let args_log = dir.path().join("docker_args.log"); + write_self_contained_fake_docker(&fake, &args_log); + + assert!(restart_stepca_openbao_agent( + "insight-openbao-agent-stepca", + &fake + )); + + let logged = fs::read_to_string(&args_log).expect("read docker args"); + assert_eq!( + logged.lines().collect::>(), + vec!["restart insight-openbao-agent-stepca "] + ); + } + #[tokio::test] async fn test_write_stepca_templates_writes_templates() { let temp_dir = tempdir().unwrap(); diff --git a/src/commands/rotate.rs b/src/commands/rotate.rs index d5b599d2..132a43ac 100644 --- a/src/commands/rotate.rs +++ b/src/commands/rotate.rs @@ -15,6 +15,7 @@ use anyhow::{Context, Result}; use bootroot::openbao::OpenBaoClient; use crate::cli::args::{RotateArgs, RotateCommand}; +use crate::commands::compose_project::DOCKER_BIN; use crate::commands::init::{CA_CERTS_DIR, CA_INTERMEDIATE_CERT_FILENAME, CA_ROOT_CERT_FILENAME}; use crate::commands::openbao_auth::{authenticate_openbao_client, resolve_runtime_auth}; use crate::i18n::Messages; @@ -125,6 +126,12 @@ pub(super) struct RotateContext { pub(super) paths: StatePaths, pub(super) state_dir: PathBuf, pub(super) state_file: PathBuf, + /// The `docker` executable every spawn in this rotation runs. + /// + /// Set once in [`run_rotate`] and only read afterwards, so a test + /// that builds a context can point the whole tree at a fake without + /// touching `PATH`. + pub(super) docker: PathBuf, } #[allow(clippy::too_many_lines)] @@ -166,6 +173,7 @@ pub(crate) async fn run_rotate(args: &RotateArgs, messages: &Messages) -> Result paths, state_dir, state_file: state_path, + docker: PathBuf::from(DOCKER_BIN), }; // InfraCert operates on local files and Docker only — it must not diff --git a/src/commands/rotate/approle.rs b/src/commands/rotate/approle.rs index fafea3ed..bfb0b63f 100644 --- a/src/commands/rotate/approle.rs +++ b/src/commands/rotate/approle.rs @@ -455,7 +455,7 @@ async fn rotate_infra_approle_secret_id( infra_agent_container_kind(target), messages, )?; - restart_container(&container, messages)?; + restart_container(&container, &ctx.docker, messages)?; // The infra roles carry no CIDR binding, so the post-rotation login // verification is unconditional (unlike the service flow). client @@ -794,6 +794,7 @@ mod tests { write_fake_docker_script, }; use super::*; + use crate::commands::compose_project::DOCKER_BIN; use crate::state::{ServiceRoleEntry, StateFile}; const RUNTIME_ROTATE_ROLE: &str = "bootroot-runtime-rotate-role"; @@ -826,6 +827,7 @@ mod tests { paths: super::super::StatePaths::new(dir.join("secrets")), state_dir: dir.to_path_buf(), state_file: dir.join("state.json"), + docker: PathBuf::from(DOCKER_BIN), } } diff --git a/src/commands/rotate/ca.rs b/src/commands/rotate/ca.rs index 6a1764f6..afcf3961 100644 --- a/src/commands/rotate/ca.rs +++ b/src/commands/rotate/ca.rs @@ -1095,11 +1095,13 @@ async fn wait_for_local_completion( #[cfg(test)] mod tests { use std::fs; + use std::path::PathBuf; use tempfile::tempdir; use super::super::test_support::test_messages; use super::*; + use crate::commands::compose_project::DOCKER_BIN; /// Builds a rotate context whose compose directory records a /// non-default identity. @@ -1113,6 +1115,7 @@ mod tests { paths: super::super::StatePaths::new(dir.join("secrets")), state_dir: dir.to_path_buf(), state_file: dir.join("state.json"), + docker: PathBuf::from(DOCKER_BIN), } } diff --git a/src/commands/rotate/db.rs b/src/commands/rotate/db.rs index f09e81e0..5a54dd5c 100644 --- a/src/commands/rotate/db.rs +++ b/src/commands/rotate/db.rs @@ -127,6 +127,7 @@ pub(super) async fn rotate_db( restart_openbao_agent( &ctx.compose_file, BootrootContainer::OpenBaoAgentStepCa, + &ctx.docker, messages, )?; wait_for_rendered_file(&ca_json_path, &new_dsn, RENDERED_FILE_TIMEOUT, messages).await?; diff --git a/src/commands/rotate/helpers.rs b/src/commands/rotate/helpers.rs index edca754a..4239be49 100644 --- a/src/commands/rotate/helpers.rs +++ b/src/commands/rotate/helpers.rs @@ -8,7 +8,7 @@ use super::RENDERED_FILE_POLL_INTERVAL; use crate::cli::prompt::Prompt; use crate::commands::compose_project::ComposeIdentity; use crate::commands::container_name::{BootrootContainer, resolve_container_name}; -use crate::commands::infra::{run_compose, run_docker}; +use crate::commands::infra::{run_compose, run_docker_with_exec}; use crate::i18n::Messages; use crate::state::ServiceEntry; @@ -86,9 +86,14 @@ pub(super) async fn write_secret_id_atomic( Ok(()) } -pub(super) fn restart_container(container: &str, messages: &Messages) -> Result<()> { +pub(super) fn restart_container(container: &str, docker: &Path, messages: &Messages) -> Result<()> { let args = ["restart", container]; - run_docker(&args, &format!("docker restart {container}"), messages) + run_docker_with_exec( + &args, + &format!("docker restart {container}"), + docker, + messages, + ) } /// Restarts one of the `OpenBao` Agent sidecars so it re-renders its @@ -102,10 +107,11 @@ pub(super) fn restart_container(container: &str, messages: &Messages) -> Result< pub(super) fn restart_openbao_agent( compose_file: &Path, container: BootrootContainer, + docker: &Path, messages: &Messages, ) -> Result<()> { let name = resolve_container_name(compose_file, container, messages)?; - restart_container(&name, messages) + restart_container(&name, docker, messages) } pub(super) fn restart_compose_service( @@ -224,6 +230,7 @@ mod tests { restart_openbao_agent( &dir.path().join("docker-compose.yml"), container, + Path::new("docker"), &test_messages(), ) .expect("restarting the sidecar must succeed"); @@ -275,6 +282,7 @@ mod tests { restart_openbao_agent( &dir.path().join("docker-compose.yml"), BootrootContainer::OpenBaoAgentStepCa, + Path::new("docker"), &test_messages(), ) .expect("restarting the sidecar must succeed"); diff --git a/src/commands/rotate/infra_cert.rs b/src/commands/rotate/infra_cert.rs index e73c704e..5e4cc70b 100644 --- a/src/commands/rotate/infra_cert.rs +++ b/src/commands/rotate/infra_cert.rs @@ -81,6 +81,7 @@ pub(super) async fn rotate_infra_certs( openbao: &openbao_container, responder: &responder_container, }, + &ctx.docker, messages, ) .with_context(|| messages.error_infra_tls_renew_failed(name))?; @@ -105,7 +106,7 @@ pub(super) async fn rotate_infra_certs( println!("{}", messages.info_infra_tls_renewed(name)); println!("{}", messages.info_infra_tls_reload(&strategy.to_string())); - execute_reload_strategy(&strategy)?; + execute_reload_strategy(&strategy, &ctx.docker)?; // A delivered-but-ignored signal leaves no trace, so confirm the // renewed leaf is actually served before the run reports success. @@ -169,6 +170,7 @@ fn dispatch_reissue( secrets_dir: &Path, entry: &InfraCertEntry, containers: &ContainerNames<'_>, + docker: &Path, messages: &Messages, ) -> Result<()> { match name { @@ -177,17 +179,27 @@ fn dispatch_reissue( secrets_dir, entry, containers.openbao, + docker, + messages, + ), + HTTP01_ADMIN_INFRA_CERT_KEY => reissue_http01_admin_tls_cert( + secrets_dir, + entry, + containers.responder, + docker, messages, ), - HTTP01_ADMIN_INFRA_CERT_KEY => { - reissue_http01_admin_tls_cert(secrets_dir, entry, containers.responder, messages) - } _ => bail!("Unknown infra cert key: {name}"), } } /// Executes a reload strategy after certificate renewal. -fn execute_reload_strategy(strategy: &ReloadStrategy) -> Result<()> { +/// +/// `docker` reaches the signal arm only. The restart arm goes through +/// [`try_restart_container`], which no test drives and which is shared +/// with a `&dyn Fn` seam in `rotate::ca`; giving it an executable would +/// change that callback type for no caller. +fn execute_reload_strategy(strategy: &ReloadStrategy, docker: &Path) -> Result<()> { match strategy { ReloadStrategy::ContainerRestart { container_name } => { try_restart_container(container_name) @@ -197,7 +209,7 @@ fn execute_reload_strategy(strategy: &ReloadStrategy) -> Result<()> { container_name, signal, } => { - try_signal_container(container_name, signal) + try_signal_container(container_name, signal, docker) .with_context(|| format!("Failed to signal container {container_name}"))?; } } @@ -205,8 +217,8 @@ fn execute_reload_strategy(strategy: &ReloadStrategy) -> Result<()> { } /// Sends a signal to a Docker container via `docker kill -s`. -fn try_signal_container(container: &str, signal: &str) -> Result<()> { - let status = std::process::Command::new("docker") +fn try_signal_container(container: &str, signal: &str, docker: &Path) -> Result<()> { + let status = std::process::Command::new(docker) .args(["kill", "-s", signal, container]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -449,6 +461,35 @@ mod tests { /// The `OpenBao` container name a default install renders. const DEFAULT_OPENBAO_CONTAINER: &str = "bootroot-openbao"; + /// Writes a fake `docker` that appends one line per invocation to + /// `args_log` and reads nothing from its environment. + /// + /// The log path is baked into the script text as it is written, so a + /// test handing this executable through the docker seam gets its + /// argv back without setting a single variable on this process. + /// `init::steps::test_support` carries a twin for the `init` tree. + /// The one module both trees can reach, `rotate::test_support`, + /// holds the `PATH`-based fake every unconverted test still depends + /// on and stays untouched until those conversions land; the `init` + /// twin is scoped to its own tree and is not visible here. The + /// duplication is what that costs, and it goes away when the + /// conversions are free to rework the shared harness. + fn write_self_contained_fake_docker(path: &Path, args_log: &Path) { + use std::os::unix::fs::PermissionsExt; + + let log = args_log.display().to_string(); + assert!( + !log.contains('\''), + "the log path is interpolated into a single-quoted shell word" + ); + let script = format!( + "#!/bin/sh\nset -eu\n{{ printf '%s ' \"$@\"; printf '\\n'; }} >> '{log}'\nexit 0\n" + ); + fs::write(path, script).expect("fake docker script should be written"); + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .expect("fake docker script should be executable"); + } + fn make_openbao_infra_entry(compose_dir: &std::path::Path) -> InfraCertEntry { InfraCertEntry { cert_path: compose_dir.join(OPENBAO_TLS_CERT_PATH), @@ -968,10 +1009,13 @@ mod tests { let _path = ScopedEnvVar::set("PATH", path_with_prepend(&bin_dir)); let _log = ScopedEnvVar::set(TEST_DOCKER_ARGS_ENV, &args_log); - execute_reload_strategy(&ReloadStrategy::ContainerSignal { - container_name: "insight-http01".to_string(), - signal: "SIGHUP".to_string(), - }) + execute_reload_strategy( + &ReloadStrategy::ContainerSignal { + container_name: "insight-http01".to_string(), + signal: "SIGHUP".to_string(), + }, + Path::new("docker"), + ) .expect("signalling must succeed against the fake docker"); let logged = fs::read_to_string(&args_log).expect("read docker args"); @@ -979,6 +1023,39 @@ mod tests { assert_eq!(logged_args, vec!["kill", "-s", "SIGHUP", "insight-http01"]); } + /// The seam at the signal spawn: `execute_reload_strategy` kills the + /// container with whichever program its caller named, which in the + /// flow is `ctx.docker`. + /// + /// The test mutates nothing process-global — no `PATH` edit, no + /// variable set, no lock — because the fake carries its own + /// argv-log path in its script text. That is the property the seam + /// exists to make possible, and the model the conversion of this + /// file's remaining `PATH` fakes follows. + #[test] + fn container_signal_runs_the_supplied_executable() { + let dir = tempfile::tempdir().expect("tempdir"); + let fake = dir.path().join("fake-docker"); + let args_log = dir.path().join("docker_args.log"); + write_self_contained_fake_docker(&fake, &args_log); + + execute_reload_strategy( + &ReloadStrategy::ContainerSignal { + container_name: "insight-http01".to_string(), + signal: "SIGHUP".to_string(), + }, + &fake, + ) + .expect("signalling must succeed against the supplied executable"); + + let logged = fs::read_to_string(&args_log).expect("read docker args"); + assert_eq!( + logged.lines().collect::>(), + vec!["kill -s SIGHUP insight-http01 "], + "the supplied executable must have received the unchanged argv" + ); + } + /// An empty `infra_certs` map is a no-op: the command prints the /// no-entries message and exits 0 without prompting or invoking /// Docker. diff --git a/src/commands/rotate/responder_hmac.rs b/src/commands/rotate/responder_hmac.rs index c0f96026..50ff2389 100644 --- a/src/commands/rotate/responder_hmac.rs +++ b/src/commands/rotate/responder_hmac.rs @@ -47,6 +47,7 @@ pub(super) async fn rotate_responder_hmac( restart_openbao_agent( &ctx.compose_file, BootrootContainer::OpenBaoAgentResponder, + &ctx.docker, messages, )?; wait_for_rendered_file(&responder_path, &hmac, RENDERED_FILE_TIMEOUT, messages).await?; diff --git a/src/commands/rotate/stepca_password.rs b/src/commands/rotate/stepca_password.rs index f2334c1f..b9711fc7 100644 --- a/src/commands/rotate/stepca_password.rs +++ b/src/commands/rotate/stepca_password.rs @@ -13,7 +13,7 @@ use super::helpers::{ use super::{RENDERED_FILE_TIMEOUT, RotateContext, STEP_CA_HELPER_IMAGE}; use crate::cli::args::RotateStepcaPasswordArgs; use crate::commands::container_name::BootrootContainer; -use crate::commands::infra::run_docker; +use crate::commands::infra::run_docker_with_exec; use crate::commands::init::{PATH_STEPCA_PASSWORD, SECRET_BYTES, to_container_path}; use crate::i18n::Messages; @@ -72,6 +72,7 @@ pub(super) async fn rotate_stepca_password( &password_path, &new_password_path, &root_key, + &ctx.docker, messages, )?; change_stepca_passphrase( @@ -79,6 +80,7 @@ pub(super) async fn rotate_stepca_password( &password_path, &new_password_path, &intermediate_key, + &ctx.docker, messages, )?; @@ -93,6 +95,7 @@ pub(super) async fn rotate_stepca_password( restart_openbao_agent( &ctx.compose_file, BootrootContainer::OpenBaoAgentStepCa, + &ctx.docker, messages, )?; wait_for_rendered_file( @@ -121,6 +124,7 @@ pub(super) fn change_stepca_passphrase( current_password: &Path, new_password: &Path, key_path: &Path, + docker: &Path, messages: &Messages, ) -> Result<()> { let mount_root = fs::canonicalize(secrets_dir) @@ -154,7 +158,7 @@ pub(super) fn change_stepca_passphrase( &*new_pwd_container, "-f", ]; - run_docker(&args, "docker step-ca change-pass", messages)?; + run_docker_with_exec(&args, "docker step-ca change-pass", docker, messages)?; Ok(()) } @@ -195,6 +199,7 @@ mod tests { ¤t_password, &new_password, &key_path, + Path::new("docker"), &test_messages(), ) .expect("change passphrase should succeed"); @@ -245,6 +250,7 @@ mod tests { ¤t_password, &new_password, &external_key, + Path::new("docker"), &test_messages(), ) .expect_err("key outside secrets dir must fail"); @@ -279,6 +285,7 @@ mod tests { ¤t_password, &new_password, &key_path, + Path::new("docker"), &test_messages(), ) .expect_err("docker failure should bubble up");