Skip to content

Commit 7614275

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), (3) the ability to ignore SecureString keys if they are not necessary (closes #13), (4) support for the SSM StringList type and more timely type coercion so e.g. YAML integers and SSM strings match, and (5) 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 7614275

File tree

5 files changed

+508
-59
lines changed

5 files changed

+508
-59
lines changed

ssm-diff

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,27 @@
22
from __future__ import print_function
33

44
import argparse
5+
import logging
56
import os
7+
import sys
68

79
from states import *
810

11+
root = logging.getLogger()
12+
root.setLevel(logging.INFO)
13+
14+
handler = logging.StreamHandler(sys.stdout)
15+
handler.setLevel(logging.INFO)
16+
formatter = logging.Formatter('%(name)s - %(message)s')
17+
handler.setFormatter(formatter)
18+
root.addHandler(handler)
19+
920

1021
def configure_endpoints(args):
1122
# configure() returns a DiffBase class (whose constructor may be wrapped in `partial` to pre-configure it)
1223
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)
24+
return storage.ParameterStore(args.profile, diff_class, paths=args.paths, no_secure=args.no_secure), \
25+
storage.YAMLFile(args.filename, paths=args.paths, no_secure=args.no_secure, root_path=args.yaml_root)
1426

1527

1628
def init(args):
@@ -49,8 +61,7 @@ def plan(args):
4961

5062
if __name__ == "__main__":
5163
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')
64+
parser.add_argument('-f', help='local state yml file', action='store', dest='filename')
5465
parser.add_argument('--engine', '-e', help='diff engine to use when interacting with SSM', action='store', dest='engine', default='DiffResolver')
5566
parser.add_argument('--profile', help='AWS profile name', action='store', dest='profile')
5667
subparsers = parser.add_subparsers(dest='func', help='commands')
@@ -70,12 +81,29 @@ if __name__ == "__main__":
7081
parser_apply.set_defaults(func=apply)
7182

7283
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'
84+
85+
args.no_secure = os.environ.get('SSM_NO_SECURE', 'false').lower() in ['true', '1']
86+
args.yaml_root = os.environ.get('SSM_YAML_ROOT', '/')
87+
args.paths = os.environ.get('SSM_PATHS', None)
88+
if args.paths is not None:
89+
args.paths = args.paths.split(';:')
90+
else:
91+
# this defaults to '/'
92+
args.paths = args.yaml_root
93+
94+
# root filename
95+
if args.filename is not None:
96+
filename = args.filename
97+
elif args.profile:
98+
filename = args.profile
99+
elif 'AWS_PROFILE' in os.environ:
100+
filename = os.environ['AWS_PROFILE']
101+
else:
102+
filename = 'parameters'
103+
104+
# remove extension (will be restored by storage classes)
105+
if filename[-4:] == '.yml':
106+
filename = filename[:-4]
107+
args.filename = filename
108+
81109
args.func(args)

states/engine.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import collections
22
import logging
3+
import re
34
from functools import partial
45

56
from termcolor import colored
@@ -19,6 +20,8 @@ def __init__(cls, *args, **kwargs):
1920

2021
class DiffBase(metaclass=DiffMount):
2122
"""Superclass for diff plugins"""
23+
invalid_characters = r'[^a-zA-Z0-9\-_\.]'
24+
2225
def __init__(self, remote, local):
2326
self.logger = logging.getLogger(self.__module__)
2427
self.remote_flat, self.local_flat = self._flatten(remote), self._flatten(local)
@@ -38,24 +41,36 @@ def configure(cls, args):
3841
@classmethod
3942
def _flatten(cls, d, current_path='', sep='/'):
4043
"""Convert a nested dict structure into a "flattened" dict i.e. {"full/path": "value", ...}"""
41-
items = []
42-
for k in d:
44+
items = {}
45+
for k, v in d.items():
46+
if re.search(cls.invalid_characters, k) is not None:
47+
raise ValueError("Invalid key at {}: {}".format(current_path, k))
4348
new = current_path + sep + k if current_path else k
44-
if isinstance(d[k], collections.MutableMapping):
45-
items.extend(cls._flatten(d[k], new, sep=sep).items())
49+
if isinstance(v, collections.MutableMapping):
50+
items.update(cls._flatten(v, new, sep=sep).items())
4651
else:
47-
items.append((sep + new, d[k]))
48-
return dict(items)
52+
items[sep + new] = cls.clean_value(new, v)
53+
return items
54+
55+
@classmethod
56+
def clean_value(cls, k, v):
57+
if isinstance(v, list):
58+
for item in v:
59+
if not isinstance(item, str):
60+
raise ValueError("Error: List contains non-string values at {}".format(k))
61+
return v
62+
else:
63+
return str(v)
4964

5065
@classmethod
5166
def _unflatten(cls, d, sep='/'):
5267
"""Converts a "flattened" dict i.e. {"full/path": "value", ...} into a nested dict structure"""
5368
output = {}
54-
for k in d:
69+
for k, v in d.items():
5570
add(
5671
obj=output,
5772
path=k,
58-
value=d[k],
73+
value=v,
5974
sep=sep,
6075
)
6176
return output
@@ -66,15 +81,15 @@ def describe_diff(cls, plan):
6681
description = ""
6782
for k, v in plan['add'].items():
6883
# { key: new_value }
69-
description += colored("+", 'green'), "{} = {}".format(k, v) + '\n'
84+
description += colored("+", 'green') + "{} = {}".format(k, v) + '\n'
7085

7186
for k in plan['delete']:
7287
# { key: old_value }
73-
description += colored("-", 'red'), k + '\n'
88+
description += colored("-", 'red') + k + '\n'
7489

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

7994
return description
8095

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):

0 commit comments

Comments
 (0)