esk 0.8.0

Encrypted Secrets Keeper with multi-target deploy
Documentation
//! Heroku target — deploys config vars via the `heroku` CLI.
//!
//! Heroku is a cloud PaaS that runs applications in managed containers (dynos).
//! Config vars are exposed as environment variables to the running application
//! and persist across deploys.
//!
//! CLI: `heroku` (Heroku's official CLI).
//! Commands: `heroku config:set KEY=value -a <app>` / `heroku config:unset KEY -a <app>`.
//!
//! The Heroku CLI does **not** support stdin for secret values, so they are
//! passed as command-line arguments (visible in `ps` output). Requires an app
//! name (mapped from esk's app config).

use anyhow::{Context, Result};

use crate::config::{Config, HerokuTargetConfig, ResolvedTarget};
use crate::targets::{
    check_command, resolve_env_flags, CommandOpts, CommandRunner, DeployMode, DeployTarget,
};

pub struct HerokuTarget<'a> {
    pub config: &'a Config,
    pub target_config: &'a HerokuTargetConfig,
    pub runner: &'a dyn CommandRunner,
}

impl HerokuTarget<'_> {
    fn resolve_app(&self, target: &ResolvedTarget) -> Result<&str> {
        let app = target
            .app
            .as_deref()
            .context("heroku target requires an app")?;
        self.target_config
            .app_names
            .get(app)
            .map(std::string::String::as_str)
            .with_context(|| format!("no heroku app_names mapping for '{app}'"))
    }
}

impl DeployTarget for HerokuTarget<'_> {
    fn name(&self) -> &'static str {
        "heroku"
    }

    fn passes_value_as_cli_arg(&self) -> bool {
        true
    }

    fn deploy_mode(&self) -> DeployMode {
        DeployMode::Individual
    }

    fn preflight(&self) -> Result<()> {
        check_command(self.runner, "heroku").map_err(|_| {
            anyhow::anyhow!(
                "heroku is not installed or not in PATH. Install it from: https://devcenter.heroku.com/articles/heroku-cli"
            )
        })?;
        let output = self
            .runner
            .run("heroku", &["auth:whoami"], CommandOpts::default())
            .context("failed to run heroku auth:whoami")?;
        if !output.success {
            anyhow::bail!("heroku is not authenticated. Run: heroku login");
        }
        Ok(())
    }

    // SECURITY: heroku CLI has no stdin/file support for config:set. Secret values are exposed
    // in process arguments (visible via `ps aux`). Feature requested upstream since 2016, never
    // implemented. No workaround available.
    fn deploy_secret(&self, key: &str, value: &str, target: &ResolvedTarget) -> Result<()> {
        let heroku_app = self.resolve_app(target)?;
        let kv = format!("{key}={value}");

        let flag_parts = resolve_env_flags(&self.target_config.env_flags, &target.environment);
        let mut args: Vec<&str> = vec!["config:set", &kv, "-a", heroku_app];
        args.extend(flag_parts.iter().map(String::as_str));

        self.runner
            .run("heroku", &args, CommandOpts::default())
            .with_context(|| format!("failed to run heroku config:set for {key}"))?
            .check("heroku config:set", key)
    }

    fn delete_secret(&self, key: &str, target: &ResolvedTarget) -> Result<()> {
        let heroku_app = self.resolve_app(target)?;

        let flag_parts = resolve_env_flags(&self.target_config.env_flags, &target.environment);
        let mut args: Vec<&str> = vec!["config:unset", key, "-a", heroku_app];
        args.extend(flag_parts.iter().map(String::as_str));

        self.runner
            .run("heroku", &args, CommandOpts::default())
            .with_context(|| format!("failed to run heroku config:unset for {key}"))?
            .check("heroku config:unset", key)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::targets::CommandOutput;
    use crate::test_support::{ConfigFixture, ErrorCommandRunner, MockCommandRunner};

    fn make_config() -> ConfigFixture {
        let yaml = r#"
project: x
environments: [dev, prod]
apps:
  web:
    path: apps/web
targets:
  heroku:
    app_names:
      web: my-heroku-app
    env_flags:
      prod: "--remote staging"
"#;
        ConfigFixture::new(yaml).expect("fixture")
    }

    fn make_target(app: Option<&str>, env: &str) -> ResolvedTarget {
        ResolvedTarget {
            service: "heroku".to_string(),
            app: app.map(String::from),
            environment: env.to_string(),
        }
    }

    #[test]
    fn heroku_preflight_success() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.heroku.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![
            CommandOutput {
                success: true,
                stdout: b"1.0.0".to_vec(),
                stderr: vec![],
            },
            CommandOutput {
                success: true,
                stdout: b"user@test".to_vec(),
                stderr: vec![],
            },
        ]);
        let target = HerokuTarget {
            config,
            target_config,
            runner: &runner,
        };
        assert!(target.preflight().is_ok());
        let calls = runner.take_calls();
        assert_eq!(calls[1].args, vec!["auth:whoami"]);
    }

    #[test]
    fn heroku_preflight_auth_failure() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.heroku.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![
            CommandOutput {
                success: true,
                stdout: b"1.0.0".to_vec(),
                stderr: vec![],
            },
            CommandOutput {
                success: false,
                stdout: vec![],
                stderr: b"not logged in".to_vec(),
            },
        ]);
        let target = HerokuTarget {
            config,
            target_config,
            runner: &runner,
        };
        let err = target.preflight().unwrap_err();
        assert!(err.to_string().contains("heroku is not authenticated"));
    }

    #[test]
    fn heroku_preflight_missing_cli() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.heroku.as_ref().unwrap();
        let runner = ErrorCommandRunner::missing_command();
        let target = HerokuTarget {
            config,
            target_config,
            runner: &runner,
        };
        let err = target.preflight().unwrap_err();
        assert!(err.to_string().contains("heroku is not installed"));
    }

    #[test]
    fn heroku_deploy_correct_args() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.heroku.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
            success: true,
            stdout: vec![],
            stderr: vec![],
        }]);
        let target = HerokuTarget {
            config,
            target_config,
            runner: &runner,
        };
        target
            .deploy_secret("MY_KEY", "secret_val", &make_target(Some("web"), "dev"))
            .unwrap();
        let calls = runner.take_calls();
        assert_eq!(calls[0].program, "heroku");
        assert_eq!(
            calls[0].args,
            vec!["config:set", "MY_KEY=secret_val", "-a", "my-heroku-app"]
        );
    }

    #[test]
    fn heroku_deploy_with_env_flags() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.heroku.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
            success: true,
            stdout: vec![],
            stderr: vec![],
        }]);
        let target = HerokuTarget {
            config,
            target_config,
            runner: &runner,
        };
        target
            .deploy_secret("KEY", "val", &make_target(Some("web"), "prod"))
            .unwrap();
        let calls = runner.take_calls();
        assert_eq!(
            calls[0].args,
            vec![
                "config:set",
                "KEY=val",
                "-a",
                "my-heroku-app",
                "--remote",
                "staging"
            ]
        );
    }

    #[test]
    fn heroku_requires_app() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.heroku.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![]);
        let target = HerokuTarget {
            config,
            target_config,
            runner: &runner,
        };
        let err = target
            .deploy_secret("KEY", "val", &make_target(None, "dev"))
            .unwrap_err();
        assert!(err.to_string().contains("requires an app"));
    }

    #[test]
    fn heroku_unknown_app_mapping() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.heroku.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![]);
        let target = HerokuTarget {
            config,
            target_config,
            runner: &runner,
        };
        let err = target
            .deploy_secret("KEY", "val", &make_target(Some("api"), "dev"))
            .unwrap_err();
        assert!(err.to_string().contains("no heroku app_names mapping"));
    }

    #[test]
    fn heroku_delete_correct_args() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.heroku.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
            success: true,
            stdout: vec![],
            stderr: vec![],
        }]);
        let target = HerokuTarget {
            config,
            target_config,
            runner: &runner,
        };
        target
            .delete_secret("MY_KEY", &make_target(Some("web"), "dev"))
            .unwrap();
        let calls = runner.take_calls();
        assert_eq!(
            calls[0].args,
            vec!["config:unset", "MY_KEY", "-a", "my-heroku-app"]
        );
    }

    #[test]
    fn heroku_delete_failure() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.heroku.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
            success: false,
            stdout: vec![],
            stderr: b"not found".to_vec(),
        }]);
        let target = HerokuTarget {
            config,
            target_config,
            runner: &runner,
        };
        let err = target
            .delete_secret("KEY", &make_target(Some("web"), "dev"))
            .unwrap_err();
        assert!(err.to_string().contains("not found"));
    }

    #[test]
    fn heroku_nonzero_exit() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.heroku.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
            success: false,
            stdout: vec![],
            stderr: b"auth error".to_vec(),
        }]);
        let target = HerokuTarget {
            config,
            target_config,
            runner: &runner,
        };
        let err = target
            .deploy_secret("KEY", "val", &make_target(Some("web"), "dev"))
            .unwrap_err();
        assert!(err.to_string().contains("auth error"));
    }
}