Skip to content

Commit 8968375

Browse files
committed
Significantly improves storage engines including (1) converting command line flags to ENV variables (fixes #15), (2) a way to generate YAML files for branches of the SSM tree (closes #11), and (3) the ability to ignore SecureString keys if they are not necessary (closes #13), and (4) the introduction of metadata in the YAML files to permit compatibility checking (more general fix for #15 with support for new features)
1 parent e33935d commit 8968375

File tree

5 files changed

+445
-37
lines changed

5 files changed

+445
-37
lines changed

ssm-diff

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ from states import *
1010
def configure_endpoints(args):
1111
# configure() returns a DiffBase class (whose constructor may be wrapped in `partial` to pre-configure it)
1212
diff_class = DiffBase.get_plugin(args.engine).configure(args)
13-
return storage.ParameterStore(args.profile, diff_class, paths=args.path), storage.YAMLFile(args.filename, paths=args.path)
13+
return storage.ParameterStore(args.profile, diff_class, paths=args.paths, no_secure=args.no_secure), \
14+
storage.YAMLFile(args.filename, paths=args.paths, no_secure=args.no_secure, root_path=args.yaml_root)
1415

1516

1617
def init(args):
@@ -49,8 +50,7 @@ def plan(args):
4950

5051
if __name__ == "__main__":
5152
parser = argparse.ArgumentParser()
52-
parser.add_argument('-f', help='local state yml file', action='store', dest='filename', default='parameters.yml')
53-
parser.add_argument('--path', '-p', action='append', help='filter SSM path')
53+
parser.add_argument('-f', help='local state yml file', action='store', dest='filename')
5454
parser.add_argument('--engine', '-e', help='diff engine to use when interacting with SSM', action='store', dest='engine', default='DiffResolver')
5555
parser.add_argument('--profile', help='AWS profile name', action='store', dest='profile')
5656
subparsers = parser.add_subparsers(dest='func', help='commands')
@@ -70,12 +70,29 @@ if __name__ == "__main__":
7070
parser_apply.set_defaults(func=apply)
7171

7272
args = parser.parse_args()
73-
args.path = args.path if args.path else ['/']
74-
75-
if args.filename == 'parameters.yml':
76-
if not args.profile:
77-
if 'AWS_PROFILE' in os.environ:
78-
args.filename = os.environ['AWS_PROFILE'] + '.yml'
79-
else:
80-
args.filename = args.profile + '.yml'
73+
74+
args.no_secure = os.environ.get('SSM_NO_SECURE', 'false').lower() in ['true', '1']
75+
args.yaml_root = os.environ.get('SSM_YAML_ROOT', '/')
76+
args.paths = os.environ.get('SSM_PATHS', None)
77+
if args.paths is not None:
78+
args.paths = args.paths.split(';:')
79+
else:
80+
# this defaults to '/'
81+
args.paths = args.yaml_root
82+
83+
# root filename
84+
if args.filename is not None:
85+
filename = args.filename
86+
elif args.profile:
87+
filename = args.profile
88+
elif 'AWS_PROFILE' in os.environ:
89+
filename = os.environ['AWS_PROFILE']
90+
else:
91+
filename = 'parameters'
92+
93+
# remove extension (will be restored by storage classes)
94+
if filename[-4:] == '.yml':
95+
filename = filename[:-4]
96+
args.filename = filename
97+
8198
args.func(args)

states/engine.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,15 @@ def describe_diff(cls, plan):
6666
description = ""
6767
for k, v in plan['add'].items():
6868
# { key: new_value }
69-
description += colored("+", 'green'), "{} = {}".format(k, v) + '\n'
69+
description += colored("+", 'green') + "{} = {}".format(k, v) + '\n'
7070

7171
for k in plan['delete']:
7272
# { key: old_value }
73-
description += colored("-", 'red'), k + '\n'
73+
description += colored("-", 'red') + k + '\n'
7474

7575
for k, v in plan['change'].items():
7676
# { key: {'old': value, 'new': value} }
77-
description += colored("~", 'yellow'), "{}:\n\t< {}\n\t> {}".format(k, v['old'], v['new']) + '\n'
77+
description += colored("~", 'yellow') + "{}:\n\t< {}\n\t> {}".format(k, v['old'], v['new']) + '\n'
7878

7979
return description
8080

states/helpers.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,31 @@ def add(obj, path, value, sep='/'):
55
"""Add value to the `obj` dict at the specified path"""
66
parts = path.strip(sep).split(sep)
77
last = len(parts) - 1
8+
current = obj
89
for index, part in enumerate(parts):
910
if index == last:
10-
obj[part] = value
11+
current[part] = value
1112
else:
12-
obj = obj.setdefault(part, {})
13+
current = current.setdefault(part, {})
14+
# convenience return, object is mutated
15+
return obj
1316

1417

1518
def search(state, path):
16-
result = state
19+
"""Get value in `state` at the specified path, returning {} if the key is absent"""
20+
if path.strip("/") == '':
21+
return state
1722
for p in path.strip("/").split("/"):
18-
if result.clone(p):
19-
result = result[p]
20-
else:
21-
result = {}
22-
break
23-
output = {}
24-
add(output, path, result)
25-
return output
23+
if p not in state:
24+
return {}
25+
state = state[p]
26+
return state
27+
28+
29+
def filter(state, path):
30+
if path.strip("/") == '':
31+
return state
32+
return add({}, path, search(state, path))
2633

2734

2835
def merge(a, b):

states/storage.py

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import yaml
88
from botocore.exceptions import ClientError, NoCredentialsError
99

10-
from .helpers import merge, add, search
10+
from .helpers import merge, add, filter, search
1111

1212

1313
def str_presenter(dumper, data):
@@ -62,18 +62,34 @@ def to_yaml(cls, dumper, data):
6262

6363
class YAMLFile(object):
6464
"""Encodes/decodes a dictionary to/from a YAML file"""
65-
def __init__(self, filename, paths=('/',)):
66-
self.filename = filename
65+
METADATA_CONFIG = 'ssm-diff:config'
66+
METADATA_PATHS = 'ssm-diff:paths'
67+
METADATA_ROOT = 'ssm:root'
68+
METADATA_NO_SECURE = 'ssm:no-secure'
69+
70+
def __init__(self, filename, paths=('/',), root_path='/', no_secure=False):
71+
self.filename = '{}.yml'.format(filename)
72+
self.root_path = root_path
6773
self.paths = paths
74+
self.validate_paths()
75+
self.no_secure = no_secure
76+
77+
def validate_paths(self):
78+
length = len(self.root_path)
79+
for path in self.paths:
80+
if path[:length] != self.root_path:
81+
raise ValueError('Root path {} does not contain path {}'.format(self.root_path, path))
6882

6983
def get(self):
7084
try:
7185
output = {}
7286
with open(self.filename, 'rb') as f:
7387
local = yaml.safe_load(f.read())
88+
self.validate_config(local)
89+
local = self.nest_root(local)
7490
for path in self.paths:
7591
if path.strip('/'):
76-
output = merge(output, search(local, path))
92+
output = merge(output, filter(local, path))
7793
else:
7894
return local
7995
return output
@@ -87,7 +103,55 @@ def get(self):
87103
return dict()
88104
raise
89105

106+
def validate_config(self, local):
107+
"""YAML files may contain a special ssm:config tag that stores information about the file when it was generated.
108+
This information can be used to ensure the file is compatible with future calls. For example, a file created
109+
with a particular subpath (e.g. /my/deep/path) should not be used to overwrite the root path since this would
110+
delete any keys not in the original scope. This method does that validation (with permissive defaults for
111+
backwards compatibility)."""
112+
config = local.pop(self.METADATA_CONFIG, {})
113+
114+
# strict requirement that the no_secure setting is equal
115+
config_no_secure = config.get(self.METADATA_NO_SECURE, False)
116+
if config_no_secure != self.no_secure:
117+
raise ValueError("YAML file generated with no_secure={} but current class set to no_secure={}".format(
118+
config_no_secure, self.no_secure,
119+
))
120+
# strict requirement that root_path is equal
121+
config_root = config.get(self.METADATA_ROOT, '/')
122+
if config_root != self.root_path:
123+
raise ValueError("YAML file generated with root_path={} but current class set to root_path={}".format(
124+
config_root, self.root_path,
125+
))
126+
# make sure all paths are subsets of file paths
127+
config_paths = config.get(self.METADATA_PATHS, ['/'])
128+
for path in self.paths:
129+
for config_path in config_paths:
130+
# if path is not found in a config path, it could look like we've deleted values
131+
if path[:len(config_path)] == config_path:
132+
break
133+
else:
134+
raise ValueError("Path {} was not included in this file when it was created.".format(path))
135+
136+
def unnest_root(self, state):
137+
if self.root_path == '/':
138+
return state
139+
return search(state, self.root_path)
140+
141+
def nest_root(self, state):
142+
if self.root_path == '/':
143+
return state
144+
return add({}, self.root_path, state)
145+
90146
def save(self, state):
147+
state = self.unnest_root(state)
148+
# inject state information so we can validate the file on load
149+
# colon is not allowed in SSM keys so this namespace cannot collide with keys at any depth
150+
state[self.METADATA_CONFIG] = {
151+
self.METADATA_PATHS: self.paths,
152+
self.METADATA_ROOT: self.root_path,
153+
self.METADATA_NO_SECURE: self.no_secure
154+
}
91155
try:
92156
with open(self.filename, 'wb') as f:
93157
content = yaml.safe_dump(state, default_flow_style=False)
@@ -99,12 +163,21 @@ def save(self, state):
99163

100164
class ParameterStore(object):
101165
"""Encodes/decodes a dict to/from the SSM Parameter Store"""
102-
def __init__(self, profile, diff_class, paths=('/',)):
166+
def __init__(self, profile, diff_class, paths=('/',), no_secure=False):
103167
if profile:
104168
boto3.setup_default_session(profile_name=profile)
105169
self.ssm = boto3.client('ssm')
106170
self.diff_class = diff_class
107171
self.paths = paths
172+
self.parameter_filters = []
173+
if no_secure:
174+
self.parameter_filters.append({
175+
'Key': 'Type',
176+
'Option': 'Equals',
177+
'Values': [
178+
'String', 'StringList',
179+
]
180+
})
108181

109182
def clone(self):
110183
p = self.ssm.get_paginator('get_parameters_by_path')
@@ -114,7 +187,9 @@ def clone(self):
114187
for page in p.paginate(
115188
Path=path,
116189
Recursive=True,
117-
WithDecryption=True):
190+
WithDecryption=True,
191+
ParameterFilters=self.parameter_filters,
192+
):
118193
for param in page['Parameters']:
119194
add(obj=output,
120195
path=param['Name'],
@@ -136,10 +211,10 @@ def pull(self, local):
136211
return diff.merge()
137212

138213
def dry_run(self, local):
139-
return self.diff_class(self.clone(), local).plan
214+
return self.diff_class(self.clone(), local)
140215

141216
def push(self, local):
142-
plan = self.dry_run(local)
217+
plan = self.dry_run(local).plan
143218

144219
# plan
145220
for k, v in plan['add'].items():

0 commit comments

Comments
 (0)