Skip to content

Commit b234761

Browse files
committed
Add secrets adapter for AWS SSM Parameter Store
1 parent 9c6252d commit b234761

File tree

2 files changed

+243
-0
lines changed

2 files changed

+243
-0
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
class Kamal::Secrets::Adapters::AwsSsmParameterStore < Kamal::Secrets::Adapters::Base
2+
MAX_PARAMETERS_PER_REQUEST = 10
3+
4+
def requires_account?
5+
false
6+
end
7+
8+
private
9+
def login(_account)
10+
nil
11+
end
12+
13+
def fetch_secrets(secrets, from:, account: nil, session:)
14+
{}.tap do |results|
15+
prefixed_secrets(secrets, from: from).each_slice(MAX_PARAMETERS_PER_REQUEST) do |batch|
16+
get_from_parameter_store(batch, account: account).each do |secret|
17+
results[secret["Name"]] = secret["Value"]
18+
end
19+
end
20+
end
21+
end
22+
23+
def get_from_parameter_store(secrets, account: nil)
24+
args = [ "aws", "ssm", "get-parameters", "--names" ] + secrets.map(&:shellescape)
25+
args += [ "--with-decryption" ]
26+
args += [ "--profile", account.shellescape ] if account
27+
args += [ "--output", "json" ]
28+
cmd = args.join(" ")
29+
30+
`#{cmd}`.tap do |response|
31+
raise RuntimeError, "Could not read from AWS SSM Parameter Store" unless $?.success?
32+
33+
response = JSON.parse(response)
34+
35+
return response["Parameters"] unless response["InvalidParameters"].present?
36+
37+
raise RuntimeError, response["InvalidParameters"].map { |name| "#{name}: SSM Parameter Store can't find the specified secret." }.join(" ")
38+
end
39+
end
40+
41+
def check_dependencies!
42+
raise RuntimeError, "AWS CLI is not installed" unless cli_installed?
43+
end
44+
45+
def cli_installed?
46+
`aws --version 2> /dev/null`
47+
$?.success?
48+
end
49+
end
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
require "test_helper"
2+
3+
class AwsSsmParameterStoreAdapterTest < SecretAdapterTestCase
4+
test "fails when errors are present" do
5+
stub_ticks.with("aws --version 2> /dev/null")
6+
stub_ticks
7+
.with("aws ssm get-parameters --names unknown1 unknown2 --with-decryption --profile default --output json")
8+
.returns(<<~JSON)
9+
{
10+
"Parameters": [],
11+
"InvalidParameters": [
12+
"unknown1",
13+
"unknown2"
14+
]
15+
}
16+
JSON
17+
18+
error = assert_raises RuntimeError do
19+
JSON.parse(run_command("fetch", "unknown1", "unknown2"))
20+
end
21+
22+
assert_equal [
23+
"unknown1: SSM Parameter Store can't find the specified secret.",
24+
"unknown2: SSM Parameter Store can't find the specified secret."
25+
].join(" "), error.message
26+
end
27+
28+
test "fetch" do
29+
stub_ticks.with("aws --version 2> /dev/null")
30+
stub_ticks
31+
.with("aws ssm get-parameters --names secret/KEY1 secret/KEY2 secret2/KEY3 --with-decryption --profile default --output json")
32+
.returns(<<~JSON)
33+
{
34+
"Parameters": [
35+
{
36+
"Name": "secret/KEY1",
37+
"Value": "VALUE1"
38+
},
39+
{
40+
"Name": "secret/KEY2",
41+
"Value": "VALUE2"
42+
},
43+
{
44+
"Name": "secret2/KEY3",
45+
"Value": "VALUE3"
46+
}
47+
],
48+
"InvalidParameters": []
49+
}
50+
JSON
51+
52+
json = JSON.parse(run_command("fetch", "secret/KEY1", "secret/KEY2", "secret2/KEY3"))
53+
54+
expected_json = {
55+
"secret/KEY1"=>"VALUE1",
56+
"secret/KEY2"=>"VALUE2",
57+
"secret2/KEY3"=>"VALUE3"
58+
}
59+
60+
assert_equal expected_json, json
61+
end
62+
63+
test "fetch batches requests to stay within the API limit" do
64+
stub_ticks.with("aws --version 2> /dev/null")
65+
stub_ticks
66+
.with("aws ssm get-parameters --names secret1 secret2 secret3 secret4 secret5 secret6 secret7 secret8 secret9 secret10 --with-decryption --profile default --output json")
67+
.returns(JSON.generate({
68+
"Parameters" => (1..10).map { |i| { "Name" => "secret#{i}", "Value" => "VALUE#{i}" } },
69+
"InvalidParameters" => []
70+
}))
71+
stub_ticks
72+
.with("aws ssm get-parameters --names secret11 --with-decryption --profile default --output json")
73+
.returns(JSON.generate({
74+
"Parameters" => [ { "Name" => "secret11", "Value" => "VALUE11" } ],
75+
"InvalidParameters" => []
76+
}))
77+
78+
json = JSON.parse(run_command("fetch", *(1..11).map { |i| "secret#{i}" }))
79+
80+
expected_json = (1..11).map { |i| [ "secret#{i}", "VALUE#{i}" ] }.to_h
81+
82+
assert_equal expected_json, json
83+
end
84+
85+
test "fetch with string value" do
86+
stub_ticks.with("aws --version 2> /dev/null")
87+
stub_ticks
88+
.with("aws ssm get-parameters --names secret secret2/KEY1 --with-decryption --profile default --output json")
89+
.returns(<<~JSON)
90+
{
91+
"Parameters": [
92+
{
93+
"Name": "secret",
94+
"Value": "a-string-secret"
95+
},
96+
{
97+
"Name": "secret2/KEY1",
98+
"Value": "{\\"KEY2\\":\\"VALUE2\\"}"
99+
}
100+
],
101+
"InvalidParameters": []
102+
}
103+
JSON
104+
105+
json = JSON.parse(run_command("fetch", "secret", "secret2/KEY1"))
106+
107+
expected_json = {
108+
"secret"=>"a-string-secret",
109+
"secret2/KEY1"=>"{\"KEY2\":\"VALUE2\"}"
110+
}
111+
112+
assert_equal expected_json, json
113+
end
114+
115+
test "fetch with secret names" do
116+
stub_ticks.with("aws --version 2> /dev/null")
117+
stub_ticks
118+
.with("aws ssm get-parameters --names secret/KEY1 secret/KEY2 --with-decryption --profile default --output json")
119+
.returns(<<~JSON)
120+
{
121+
"Parameters": [
122+
{
123+
"Name": "secret/KEY1",
124+
"Value": "VALUE1"
125+
},
126+
{
127+
"Name": "secret/KEY2",
128+
"Value": "VALUE2"
129+
}
130+
],
131+
"InvalidParameters": []
132+
}
133+
JSON
134+
135+
json = JSON.parse(run_command("fetch", "--from", "secret", "KEY1", "KEY2"))
136+
137+
expected_json = {
138+
"secret/KEY1"=>"VALUE1",
139+
"secret/KEY2"=>"VALUE2"
140+
}
141+
142+
assert_equal expected_json, json
143+
end
144+
145+
test "fetch without CLI installed" do
146+
stub_ticks_with("aws --version 2> /dev/null", succeed: false)
147+
148+
error = assert_raises RuntimeError do
149+
JSON.parse(run_command("fetch", "SECRET1"))
150+
end
151+
assert_equal "AWS CLI is not installed", error.message
152+
end
153+
154+
test "fetch without account option omits --profile" do
155+
stub_ticks.with("aws --version 2> /dev/null")
156+
stub_ticks
157+
.with("aws ssm get-parameters --names secret/KEY1 secret/KEY2 --with-decryption --output json")
158+
.returns(<<~JSON)
159+
{
160+
"Parameters": [
161+
{
162+
"Name": "secret/KEY1",
163+
"Value": "VALUE1"
164+
},
165+
{
166+
"Name": "secret/KEY2",
167+
"Value": "VALUE2"
168+
}
169+
],
170+
"InvalidParameters": []
171+
}
172+
JSON
173+
174+
json = JSON.parse(run_command("fetch", "--from", "secret", "KEY1", "KEY2", account: nil))
175+
176+
expected_json = {
177+
"secret/KEY1"=>"VALUE1",
178+
"secret/KEY2"=>"VALUE2"
179+
}
180+
181+
assert_equal expected_json, json
182+
end
183+
184+
private
185+
def run_command(*command, account: "default")
186+
stdouted do
187+
args = [ *command,
188+
"-c", "test/fixtures/deploy_with_accessories.yml",
189+
"--adapter", "aws_ssm_parameter_store" ]
190+
args += [ "--account", account ] if account
191+
Kamal::Cli::Secrets.start(args)
192+
end
193+
end
194+
end

0 commit comments

Comments
 (0)