Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions lib/kamal/secrets/adapters/aws_ssm_parameter_store.rb
Original file line number Diff line number Diff line change
@@ -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(" ")
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly, get-parameters doesn't return a proper Error field, just an InvalidParameters: ["secret1", "secret2"]. So we can't only provide a generic error message here.

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
194 changes: 194 additions & 0 deletions test/secrets/aws_ssm_parameter_store_adapter_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading