diff --git a/lib/kamal/secrets/adapters/aws_ssm_parameter_store.rb b/lib/kamal/secrets/adapters/aws_ssm_parameter_store.rb new file mode 100644 index 000000000..4889d10eb --- /dev/null +++ b/lib/kamal/secrets/adapters/aws_ssm_parameter_store.rb @@ -0,0 +1,50 @@ +class Kamal::Secrets::Adapters::AwsSsmParameterStore < Kamal::Secrets::Adapters::Base + MAX_PARAMETERS_PER_REQUEST = 10 + + def requires_account? + false + end + + private + def login(_account) + nil + end + + def fetch_secrets(secrets, from:, account: nil, session:) + {}.tap do |results| + prefixed_secrets(secrets, from: from).each_slice(MAX_PARAMETERS_PER_REQUEST) do |batch| + get_from_parameter_store(batch, account: account).each do |secret| + results[secret["Name"]] = secret["Value"] + end + end + end + end + + def get_from_parameter_store(secrets, account: nil) + args = [ "aws", "ssm", "get-parameters", "--names" ] + secrets.map(&:shellescape) + # We have to pass --with-decryption. Otherwise, we would get the raw encrypted value for secrets with type SecureString (AWS KMS encrypted secrets). + args += [ "--with-decryption" ] + args += [ "--profile", account.shellescape ] if account + args += [ "--output", "json" ] + cmd = args.join(" ") + + `#{cmd}`.tap do |response| + raise RuntimeError, "Could not read from AWS SSM Parameter Store" unless $?.success? + + response = JSON.parse(response) + + return response["Parameters"] unless response["InvalidParameters"].present? + + raise RuntimeError, response["InvalidParameters"].map { |name| "#{name}: SSM Parameter Store can't find the specified secret." }.join(" ") + end + end + + def check_dependencies! + raise RuntimeError, "AWS CLI is not installed" unless cli_installed? + end + + def cli_installed? + `aws --version 2> /dev/null` + $?.success? + end +end diff --git a/test/secrets/aws_ssm_parameter_store_adapter_test.rb b/test/secrets/aws_ssm_parameter_store_adapter_test.rb new file mode 100644 index 000000000..9b723b2bd --- /dev/null +++ b/test/secrets/aws_ssm_parameter_store_adapter_test.rb @@ -0,0 +1,194 @@ +require "test_helper" + +class AwsSsmParameterStoreAdapterTest < SecretAdapterTestCase + test "fails when errors are present" do + stub_ticks.with("aws --version 2> /dev/null") + stub_ticks + .with("aws ssm get-parameters --names unknown1 unknown2 --with-decryption --profile default --output json") + .returns(<<~JSON) + { + "Parameters": [], + "InvalidParameters": [ + "unknown1", + "unknown2" + ] + } + JSON + + error = assert_raises RuntimeError do + JSON.parse(run_command("fetch", "unknown1", "unknown2")) + end + + assert_equal [ + "unknown1: SSM Parameter Store can't find the specified secret.", + "unknown2: SSM Parameter Store can't find the specified secret." + ].join(" "), error.message + end + + test "fetch" do + stub_ticks.with("aws --version 2> /dev/null") + stub_ticks + .with("aws ssm get-parameters --names secret/KEY1 secret/KEY2 secret2/KEY3 --with-decryption --profile default --output json") + .returns(<<~JSON) + { + "Parameters": [ + { + "Name": "secret/KEY1", + "Value": "VALUE1" + }, + { + "Name": "secret/KEY2", + "Value": "VALUE2" + }, + { + "Name": "secret2/KEY3", + "Value": "VALUE3" + } + ], + "InvalidParameters": [] + } + JSON + + json = JSON.parse(run_command("fetch", "secret/KEY1", "secret/KEY2", "secret2/KEY3")) + + expected_json = { + "secret/KEY1"=>"VALUE1", + "secret/KEY2"=>"VALUE2", + "secret2/KEY3"=>"VALUE3" + } + + assert_equal expected_json, json + end + + test "fetch batches requests to stay within the API limit" do + stub_ticks.with("aws --version 2> /dev/null") + stub_ticks + .with("aws ssm get-parameters --names secret1 secret2 secret3 secret4 secret5 secret6 secret7 secret8 secret9 secret10 --with-decryption --profile default --output json") + .returns(JSON.generate({ + "Parameters" => (1..10).map { |i| { "Name" => "secret#{i}", "Value" => "VALUE#{i}" } }, + "InvalidParameters" => [] + })) + stub_ticks + .with("aws ssm get-parameters --names secret11 --with-decryption --profile default --output json") + .returns(JSON.generate({ + "Parameters" => [ { "Name" => "secret11", "Value" => "VALUE11" } ], + "InvalidParameters" => [] + })) + + json = JSON.parse(run_command("fetch", *(1..11).map { |i| "secret#{i}" })) + + expected_json = (1..11).map { |i| [ "secret#{i}", "VALUE#{i}" ] }.to_h + + assert_equal expected_json, json + end + + test "fetch with string value" do + stub_ticks.with("aws --version 2> /dev/null") + stub_ticks + .with("aws ssm get-parameters --names secret secret2/KEY1 --with-decryption --profile default --output json") + .returns(<<~JSON) + { + "Parameters": [ + { + "Name": "secret", + "Value": "a-string-secret" + }, + { + "Name": "secret2/KEY1", + "Value": "{\\"KEY2\\":\\"VALUE2\\"}" + } + ], + "InvalidParameters": [] + } + JSON + + json = JSON.parse(run_command("fetch", "secret", "secret2/KEY1")) + + expected_json = { + "secret"=>"a-string-secret", + "secret2/KEY1"=>"{\"KEY2\":\"VALUE2\"}" + } + + assert_equal expected_json, json + end + + test "fetch with secret names" do + stub_ticks.with("aws --version 2> /dev/null") + stub_ticks + .with("aws ssm get-parameters --names secret/KEY1 secret/KEY2 --with-decryption --profile default --output json") + .returns(<<~JSON) + { + "Parameters": [ + { + "Name": "secret/KEY1", + "Value": "VALUE1" + }, + { + "Name": "secret/KEY2", + "Value": "VALUE2" + } + ], + "InvalidParameters": [] + } + JSON + + json = JSON.parse(run_command("fetch", "--from", "secret", "KEY1", "KEY2")) + + expected_json = { + "secret/KEY1"=>"VALUE1", + "secret/KEY2"=>"VALUE2" + } + + assert_equal expected_json, json + end + + test "fetch without CLI installed" do + stub_ticks_with("aws --version 2> /dev/null", succeed: false) + + error = assert_raises RuntimeError do + JSON.parse(run_command("fetch", "SECRET1")) + end + assert_equal "AWS CLI is not installed", error.message + end + + test "fetch without account option omits --profile" do + stub_ticks.with("aws --version 2> /dev/null") + stub_ticks + .with("aws ssm get-parameters --names secret/KEY1 secret/KEY2 --with-decryption --output json") + .returns(<<~JSON) + { + "Parameters": [ + { + "Name": "secret/KEY1", + "Value": "VALUE1" + }, + { + "Name": "secret/KEY2", + "Value": "VALUE2" + } + ], + "InvalidParameters": [] + } + JSON + + json = JSON.parse(run_command("fetch", "--from", "secret", "KEY1", "KEY2", account: nil)) + + expected_json = { + "secret/KEY1"=>"VALUE1", + "secret/KEY2"=>"VALUE2" + } + + assert_equal expected_json, json + end + + private + def run_command(*command, account: "default") + stdouted do + args = [ *command, + "-c", "test/fixtures/deploy_with_accessories.yml", + "--adapter", "aws_ssm_parameter_store" ] + args += [ "--account", account ] if account + Kamal::Cli::Secrets.start(args) + end + end +end