Skip to content

Commit 39a8f2d

Browse files
author
Luca Valentini
committed
Add ParameterStore client with tests
1 parent 9b9096d commit 39a8f2d

File tree

3 files changed

+221
-0
lines changed

3 files changed

+221
-0
lines changed

python_aws_ssm/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__ = '0.1.0'

python_aws_ssm/parameters.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from typing import Dict, List, Optional, Union, Any
2+
3+
import boto3
4+
5+
6+
class ParameterStore:
7+
def __init__(self, client: Optional[boto3.client] = None):
8+
self.client = client or boto3.client("ssm")
9+
10+
def get_parameters(self, ssm_key_names: List[str]) -> Dict[str, Optional[str]]:
11+
"""
12+
Retrieve keys from SSM.
13+
The keys are mapped to a dictionary for easy querying:
14+
* Keys that exist in SSM should have a matching key in the result dict
15+
and a matching value.
16+
* Keys that do not exist in SSM should also have a matching key, but
17+
have a matching value of None.
18+
If SSM somehow returns keys that are not requested, these keys are not
19+
returned in the result dict.
20+
"""
21+
22+
parameters = self.client.get_parameters(
23+
Names=ssm_key_names, WithDecryption=True
24+
).get("Parameters")
25+
return {
26+
parameter.get("Name"): parameter.get("Value")
27+
for parameter in parameters
28+
if parameter.get("Name") in ssm_key_names
29+
}
30+
31+
def get_parameters_by_path(
32+
self,
33+
ssm_base_path: str,
34+
with_decryption: bool = True,
35+
recursive: bool = False,
36+
nested: bool = False,
37+
) -> Dict[str, Optional[str]]:
38+
"""
39+
Retrieve all the keys under a certain path on SSM.
40+
* Wnen recursive is set to False, SSM doesn't parameters under a nested path.
41+
e.g.: /{ssm_base_path}/foo/bar will not return 'bar' nor '/foo/bar'.
42+
* When recursive and nested are set to True, a nested dictionary is returned.
43+
e.g.: /{ssm_base_path}/foo/bar will return {"foo": {"bar": "value"}}
44+
* When nested is set to False, the full subpath is returned as key.
45+
e.g.: /{ssm_base_path}/foo/bar will return {"foo/bar": "value"}}}
46+
"""
47+
48+
parameters = self.client.get_parameters_by_path(
49+
Path=ssm_base_path, Recursive=recursive, WithDecryption=with_decryption
50+
).get("Parameters")
51+
52+
parameters = {
53+
parameter.get("Name").replace(ssm_base_path, ""): parameter.get("Value")
54+
for parameter in parameters
55+
}
56+
57+
return (
58+
self._parse_parameters(parameters) if recursive and nested else parameters
59+
)
60+
61+
@staticmethod
62+
def _parse_parameters(
63+
parameters: Dict[str, Optional[str]]
64+
) -> Dict[Union[Dict, str], Optional[Union[Dict, str]]]:
65+
parsed_dict: Dict[Union[Dict, str], Optional[Union[Dict, str]]] = {}
66+
for key, value in parameters.items():
67+
nested_dict = ParameterStore._tree_dict(key.split("/"), value)
68+
parsed_dict = ParameterStore._deep_merge(parsed_dict, nested_dict)
69+
return parsed_dict
70+
71+
@staticmethod
72+
def _tree_dict(key_list: List[Any], value: Optional[Any]) -> Dict[Any, Any]:
73+
tree_dict: Dict[Any, Any] = {key_list[-1]: value}
74+
for key in reversed(key_list[:-1]):
75+
tree_dict = {key: tree_dict}
76+
return tree_dict
77+
78+
@staticmethod
79+
def _deep_merge(a, b):
80+
# NOTE: Thanks to: https://stackoverflow.com/a/56177639/9563578
81+
if not isinstance(a, dict) or not isinstance(b, dict):
82+
return a if b is None else b
83+
else:
84+
keys = set(a.keys()) | set(b.keys())
85+
return {
86+
key: ParameterStore._deep_merge(a.get(key), b.get(key)) for key in keys
87+
}
88+

tests/test_python_aws_ssm.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
from unittest import TestCase, mock
2+
from unittest.mock import MagicMock
3+
4+
from python_aws_ssm import __version__
5+
from python_aws_ssm.parameters import ParameterStore
6+
7+
8+
def test_version():
9+
assert __version__ == "0.1.0"
10+
11+
12+
class TestGetParameters(TestCase):
13+
def setUp(self):
14+
self.parameter_store = ParameterStore(client=MagicMock())
15+
16+
def tearDown(self):
17+
pass
18+
19+
def test_get_parameters_keys_are_mapped(self):
20+
self.parameter_store.client.get_parameters.return_value = {
21+
"Parameters": [
22+
{"Name": "foo_ssm_key_1", "Value": "foo_ssm_value_1"},
23+
# Note: foo_ssm_key_2 does not exist so is not returned from SSM.
24+
{"Name": "foo_ssm_key_3", "Value": "foo_ssm_value_3"},
25+
]
26+
}
27+
28+
secrets = self.parameter_store.get_parameters(
29+
["foo_ssm_key_1", "foo_ssm_key_2", "foo_ssm_key_3"]
30+
)
31+
32+
self.assertEqual(
33+
{"foo_ssm_key_1": "foo_ssm_value_1", "foo_ssm_key_3": "foo_ssm_value_3"},
34+
secrets,
35+
)
36+
37+
self.parameter_store.client.get_parameters.assert_called_once_with(
38+
Names=["foo_ssm_key_1", "foo_ssm_key_2", "foo_ssm_key_3"],
39+
WithDecryption=True,
40+
)
41+
42+
def test_get_parameters_unknown_keys_are_ignored(self):
43+
self.parameter_store.client.get_parameters.return_value = {
44+
"Parameters": [
45+
{"Name": "foo_ssm_key_1", "Value": "foo_ssm_value_1"},
46+
{"Name": "some_other_key", "Value": "value"},
47+
]
48+
}
49+
50+
secrets = self.parameter_store.get_parameters(["foo_ssm_key_1"])
51+
52+
self.assertEqual({"foo_ssm_key_1": "foo_ssm_value_1"}, secrets)
53+
54+
def test_get_parameters_aws_errors_are_not_caught(self):
55+
expected_error = Exception("Unexpected AWS error!")
56+
self.parameter_store.client.get_parameters.side_effect = expected_error
57+
58+
with self.assertRaises(Exception, msg="Unexpected AWS error!"):
59+
self.parameter_store.get_parameters(["/key"])
60+
61+
def test_get_parameters_by_path_keys_are_mapped(self):
62+
self.parameter_store.client.get_parameters_by_path.return_value = {
63+
"Parameters": [
64+
{"Name": "/bar/env/foo_ssm_key_1", "Value": "foo_ssm_value_1"},
65+
{"Name": "/bar/env/foo_ssm_key_2", "Value": "foo_ssm_value_2"},
66+
]
67+
}
68+
secrets = self.parameter_store.get_parameters_by_path("/bar/env/")
69+
70+
self.assertEqual(
71+
{"foo_ssm_key_1": "foo_ssm_value_1", "foo_ssm_key_2": "foo_ssm_value_2"},
72+
secrets,
73+
)
74+
75+
self.parameter_store.client.get_parameters_by_path.assert_called_once_with(
76+
Path="/bar/env/", Recursive=False, WithDecryption=True
77+
)
78+
79+
def test_get_parameters_by_path_recursive_not_nested(self):
80+
self.parameter_store.client.get_parameters_by_path.return_value = {
81+
"Parameters": [
82+
{"Name": "/bar/env/foo_ssm_key_1", "Value": "foo_ssm_value_1"},
83+
{"Name": "/bar/env/foo_ssm_key_2", "Value": "foo_ssm_value_2"},
84+
]
85+
}
86+
secrets = self.parameter_store.get_parameters_by_path(
87+
"/bar/", recursive=True, nested=False
88+
)
89+
90+
self.assertEqual(
91+
{
92+
"env/foo_ssm_key_1": "foo_ssm_value_1",
93+
"env/foo_ssm_key_2": "foo_ssm_value_2",
94+
},
95+
secrets,
96+
)
97+
98+
self.parameter_store.client.get_parameters_by_path.assert_called_once_with(
99+
Path="/bar/", Recursive=True, WithDecryption=True
100+
)
101+
102+
def test_get_parameters_by_path_recursive_nested(self):
103+
self.parameter_store.client.get_parameters_by_path.return_value = {
104+
"Parameters": [
105+
{"Name": "/bar/env/foo_ssm_key_1", "Value": "foo_ssm_value_1"},
106+
{"Name": "/bar/env/foo_ssm_key_2", "Value": "foo_ssm_value_2"},
107+
]
108+
}
109+
secrets = self.parameter_store.get_parameters_by_path(
110+
"/bar/", recursive=True, nested=True
111+
)
112+
113+
self.assertEqual(
114+
{
115+
"env": {
116+
"foo_ssm_key_1": "foo_ssm_value_1",
117+
"foo_ssm_key_2": "foo_ssm_value_2",
118+
}
119+
},
120+
secrets,
121+
)
122+
123+
self.parameter_store.client.get_parameters_by_path.assert_called_once_with(
124+
Path="/bar/", Recursive=True, WithDecryption=True
125+
)
126+
127+
def test_get_parameter_by_path_aws_errors_are_not_caught(self):
128+
expected_error = Exception("Unexpected AWS error!")
129+
self.parameter_store.client.get_parameters_by_path.side_effect = expected_error
130+
131+
with self.assertRaises(Exception, msg="Unexpected AWS error!"):
132+
self.parameter_store.get_parameters_by_path(["/key"])

0 commit comments

Comments
 (0)