diff --git a/lib/kamal/secrets/adapters/doppler.rb b/lib/kamal/secrets/adapters/doppler.rb new file mode 100644 index 00000000..1da7acb0 --- /dev/null +++ b/lib/kamal/secrets/adapters/doppler.rb @@ -0,0 +1,59 @@ +class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base + private + def login(account) + unless loggedin? + raise RuntimeError, "Doppler CLI not logged in and no DOPPLER_TOKEN found in environment" + end + end + + def loggedin? + `doppler me 2> /dev/null` + $?.success? + end + + def fetch_secrets(secrets, account:, session:) + if secrets.empty? + raise RuntimeError, "No secrets were fetched. Please specify which secrets to fetch or use 'all' to fetch all secrets." + end + + project_and_config_flags = "" + unless service_token_set? + project, config, _ = secrets.first.split("/") + + unless project && config + raise RuntimeError, "You must pass the Doppler project and config in using --from PROJECT/CONFIG" + end + + project_and_config_flags = " -p #{project.shellescape} -c #{config.shellescape}" + end + + secret_names = secrets.collect{|s| s.split("/").last} + + if secret_names.first.downcase == "all" + raw_secrets_json = `doppler secrets download --no-file --json#{project_and_config_flags}` + else + raw_secrets_json = `doppler secrets get --json#{project_and_config_flags} #{secret_names.map(&:shellescape).join(" ")}` + end + raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success? + + secrets_json = JSON.parse(raw_secrets_json) + {}.tap do |results| + secrets_json.each do |k, v| + results[k] = v["computed"] || v + end + end + end + + def service_token_set? + ENV["DOPPLER_TOKEN"] && ENV["DOPPLER_TOKEN"][0,5] == "dp.st" + end + + def check_dependencies! + raise RuntimeError, "Doppler CLI is not installed" unless cli_installed? + end + + def cli_installed? + `doppler --version 2> /dev/null` + $?.success? + end +end diff --git a/test/secrets/doppler_adapter_test.rb b/test/secrets/doppler_adapter_test.rb new file mode 100644 index 00000000..97b1c39b --- /dev/null +++ b/test/secrets/doppler_adapter_test.rb @@ -0,0 +1,165 @@ +require "test_helper" + +class DopplerAdapterTest < SecretAdapterTestCase + setup do + ENV.delete("DOPPLER_TOKEN") + `true` # Ensure $? is 0 + end + + test "fetch" do + ENV["DOPPLER_TOKEN"] = "dp.st.xxxxxxxxxxxxxxxxxxxxxx" + stub_ticks.with("doppler --version 2> /dev/null") + stub_ticks.with("doppler me 2> /dev/null") + + stub_ticks + .with("doppler secrets get --json HOST PORT") + .returns(secrets_get_json) + + json = JSON.parse(shellunescape(run_command("fetch", "HOST", "PORT"))) + + expected_json = { + "HOST"=>"0.0.0.0", + "PORT"=>"8080" + } + + assert_equal expected_json, json + ENV.delete("DOPPLER_TOKEN") + end + + test "fetch with from" do + stub_ticks.with("doppler --version 2> /dev/null") + stub_ticks.with("doppler me 2> /dev/null") + + stub_ticks + .with("doppler secrets get --json -p example -c dev HOST PORT") + .returns(secrets_get_json) + + json = JSON.parse(shellunescape(run_command("fetch", "--from", "example/dev", "HOST", "PORT"))) + + expected_json = { + "HOST"=>"0.0.0.0", + "PORT"=>"8080" + } + + assert_equal expected_json, json + end + + test "fetch all" do + ENV["DOPPLER_TOKEN"] = "dp.st.xxxxxxxxxxxxxxxxxxxxxx" + stub_ticks.with("doppler --version 2> /dev/null") + stub_ticks.with("doppler me 2> /dev/null") + + stub_ticks + .with("doppler secrets download --no-file --json") + .returns(secrets_download_json) + + json = JSON.parse(shellunescape(run_command("fetch", "all"))) + + expected_json = { + "DOPPLER_PROJECT"=>"example", + "DOPPLER_ENVIRONMENT"=>"dev", + "DOPPLER_CONFIG"=>"dev", + "HOST"=>"0.0.0.0", + "PORT"=>"8080" + } + + assert_equal expected_json, json + ENV.delete("DOPPLER_TOKEN") + end + + test "fetch all with from" do + stub_ticks.with("doppler --version 2> /dev/null") + stub_ticks.with("doppler me 2> /dev/null") + + stub_ticks + .with("doppler secrets download --no-file --json -p example -c dev") + .returns(secrets_download_json) + + json = JSON.parse(shellunescape(run_command("fetch", "--from", "example/dev", "all"))) + + expected_json = { + "DOPPLER_PROJECT"=>"example", + "DOPPLER_ENVIRONMENT"=>"dev", + "DOPPLER_CONFIG"=>"dev", + "HOST"=>"0.0.0.0", + "PORT"=>"8080" + } + + assert_equal expected_json, json + end + + test "fetch without CLI installed" do + stub_ticks_with("doppler --version 2> /dev/null", succeed: false) + + error = assert_raises RuntimeError do + JSON.parse(shellunescape(run_command("fetch", "HOST", "PORT"))) + end + assert_equal "Doppler CLI is not installed", error.message + end + + test "fetch without being logged in and without DOPPLER_TOKEN" do + stub_ticks_with("doppler --version 2> /dev/null") + stub_ticks_with("doppler me 2> /dev/null", succeed: false) + + error = assert_raises RuntimeError do + JSON.parse(shellunescape(run_command("fetch", "HOST", "PORT"))) + end + assert_equal "Doppler CLI not logged in and no DOPPLER_TOKEN found in environment", error.message + end + + test "fetch with from and no secrets or 'all' specified" do + stub_ticks.with("doppler --version 2> /dev/null") + stub_ticks.with("doppler me 2> /dev/null") + + error = assert_raises RuntimeError do + JSON.parse(shellunescape(run_command("fetch", "--from", "example/dev"))) + end + assert_equal "No secrets were fetched. Please specify which secrets to fetch or use 'all' to fetch all secrets.", error.message + end + + private + def run_command(*command) + stdouted do + Kamal::Cli::Secrets.start \ + [ *command, + "-c", "test/fixtures/deploy_with_accessories.yml", + "--adapter", "doppler", + "--account", "-" ] + end + end + + def secrets_get_json + <<~JSON + { + "HOST": { + "computed": "0.0.0.0", + "computedValueType": { + "type": "string" + }, + "computedVisibility": "masked", + "note": "" + }, + "PORT": { + "computed": "8080", + "computedValueType": { + "type": "string" + }, + "computedVisibility": "masked", + "note": "" + } + } + JSON + end + + def secrets_download_json + <<~JSON + { + "DOPPLER_CONFIG": "dev", + "DOPPLER_ENVIRONMENT": "dev", + "DOPPLER_PROJECT": "example", + "HOST": "0.0.0.0", + "PORT": "8080" + } + JSON + end +end