From 3c91a839425b18a3e2d5f9756cc9e3e324596f7d Mon Sep 17 00:00:00 2001 From: Ralf Schmitz Bongiolo Date: Thu, 10 Oct 2024 21:41:09 -0400 Subject: [PATCH] feat(secrets): add Doppler adapter --- lib/kamal/secrets/adapters/doppler.rb | 28 ++++++++ test/secrets/doppler_adapter_test.rb | 100 ++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 lib/kamal/secrets/adapters/doppler.rb create mode 100644 test/secrets/doppler_adapter_test.rb diff --git a/lib/kamal/secrets/adapters/doppler.rb b/lib/kamal/secrets/adapters/doppler.rb new file mode 100644 index 00000000..caa1833e --- /dev/null +++ b/lib/kamal/secrets/adapters/doppler.rb @@ -0,0 +1,28 @@ +class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base + private + def login(account) + unless loggedin?(account) + `doppler login -y` + raise RuntimeError, "Failed to login to Doppler" unless $?.success? + end + end + + def loggedin?(account) + `doppler me --json 2> /dev/null` + $?.success? + end + + def fetch_secrets(secrets, account:, session:) + project, config = account.split("/") + + raise RuntimeError, "Missing project or config from --acount=project/config option" unless project && config + raise RuntimeError, "Using --from option or FOLDER/SECRET is not supported by Doppler" if secrets.any?(/\//) + + items = `doppler secrets get #{secrets.map(&:shellescape).join(" ")} --json -p #{project} -c #{config}` + raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success? + + items = JSON.parse(items) + + items.transform_values { |value| value["computed"] } + end +end diff --git a/test/secrets/doppler_adapter_test.rb b/test/secrets/doppler_adapter_test.rb new file mode 100644 index 00000000..c7cda494 --- /dev/null +++ b/test/secrets/doppler_adapter_test.rb @@ -0,0 +1,100 @@ +require "test_helper" + +class DopplerAdapterTest < SecretAdapterTestCase + setup do + `true` # Ensure $? is 0 + end + + test "fetch" do + stub_ticks.with("doppler me --json 2> /dev/null") + + stub_ticks + .with("doppler secrets get SECRET1 FSECRET1 FSECRET2 --json -p my-project -c prd") + .returns(<<~JSON) + { + "SECRET1": { + "computed":"secret1", + "computedVisibility":"unmasked", + "note":"" + }, + "FSECRET1": { + "computed":"fsecret1", + "computedVisibility":"unmasked", + "note":"" + }, + "FSECRET2": { + "computed":"fsecret2", + "computedVisibility":"unmasked", + "note":"" + } + } + JSON + + json = JSON.parse(shellunescape(run_command("fetch", "SECRET1", "FSECRET1", "FSECRET2"))) + + expected_json = { + "SECRET1"=>"secret1", + "FSECRET1"=>"fsecret1", + "FSECRET2"=>"fsecret2" + } + + assert_equal expected_json, json + end + + test "fetch with from" do + stub_ticks.with("doppler me --json 2> /dev/null") + + error = assert_raises RuntimeError do + run_command("fetch", "--from", "FOLDER1", "FSECRET1", "FSECRET2") + end + + assert_match(/Using --from option or FOLDER\/SECRET is not supported by Doppler/, error.message) + end + + test "fetch with folder in secret" do + stub_ticks.with("doppler me --json 2> /dev/null") + + error = assert_raises RuntimeError do + run_command("fetch", "FOLDER1/FSECRET1", "SECRET2") + end + + assert_match(/Using --from option or FOLDER\/SECRET is not supported by Doppler/, error.message) + end + + test "fetch with signin" do + stub_ticks_with("doppler me --json 2> /dev/null", succeed: false) + stub_ticks_with("doppler login -y", succeed: true).returns("") + stub_ticks.with("doppler secrets get SECRET1 --json -p my-project -c prd").returns(single_item_json) + + json = JSON.parse(shellunescape(run_command("fetch", "SECRET1"))) + + expected_json = { + "SECRET1"=>"secret1" + } + + assert_equal expected_json, json + end + + private + def run_command(*command) + stdouted do + Kamal::Cli::Secrets.start \ + [ *command, + "-c", "test/fixtures/deploy_with_accessories.yml", + "--adapter", "doppler", + "--account", "my-project/prd" ] + end + end + + def single_item_json + <<~JSON + { + "SECRET1": { + "computed":"secret1", + "computedVisibility":"unmasked", + "note":"" + } + } + JSON + end +end