Skip to content

Commit d01c7dc

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 d01c7dc

File tree

5 files changed

+532
-64
lines changed

5 files changed

+532
-64
lines changed

ssm-diff

Lines changed: 40 additions & 17 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):
@@ -39,18 +51,12 @@ def apply(args):
3951
def plan(args):
4052
"""Print a representation of the changes that would be applied to SSM Parameter Store if applied (per config in args)"""
4153
remote, local = configure_endpoints(args)
42-
diff = remote.dry_run(local.get())
43-
44-
if diff.differ:
45-
print(DiffBase.describe_diff(diff.plan))
46-
else:
47-
print("Remote state is up to date.")
54+
print(DiffBase.describe_diff(remote.dry_run(local.get())))
4855

4956

5057
if __name__ == "__main__":
5158
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')
59+
parser.add_argument('-f', help='local state yml file', action='store', dest='filename')
5460
parser.add_argument('--engine', '-e', help='diff engine to use when interacting with SSM', action='store', dest='engine', default='DiffResolver')
5561
parser.add_argument('--profile', help='AWS profile name', action='store', dest='profile')
5662
subparsers = parser.add_subparsers(dest='func', help='commands')
@@ -70,12 +76,29 @@ if __name__ == "__main__":
7076
parser_apply.set_defaults(func=apply)
7177

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

states/engine.py

Lines changed: 15 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
@@ -38,24 +39,24 @@ def configure(cls, args):
3839
@classmethod
3940
def _flatten(cls, d, current_path='', sep='/'):
4041
"""Convert a nested dict structure into a "flattened" dict i.e. {"full/path": "value", ...}"""
41-
items = []
42-
for k in d:
42+
items = {}
43+
for k, v in d.items():
4344
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())
45+
if isinstance(v, collections.MutableMapping):
46+
items.update(cls._flatten(v, new, sep=sep).items())
4647
else:
47-
items.append((sep + new, d[k]))
48-
return dict(items)
48+
items[sep + new] = v
49+
return items
4950

5051
@classmethod
5152
def _unflatten(cls, d, sep='/'):
5253
"""Converts a "flattened" dict i.e. {"full/path": "value", ...} into a nested dict structure"""
5354
output = {}
54-
for k in d:
55+
for k, v in d.items():
5556
add(
5657
obj=output,
5758
path=k,
58-
value=d[k],
59+
value=v,
5960
sep=sep,
6061
)
6162
return output
@@ -66,15 +67,18 @@ def describe_diff(cls, plan):
6667
description = ""
6768
for k, v in plan['add'].items():
6869
# { key: new_value }
69-
description += colored("+", 'green'), "{} = {}".format(k, v) + '\n'
70+
description += colored("+", 'green') + "{} = {}".format(k, v) + '\n'
7071

7172
for k in plan['delete']:
7273
# { key: old_value }
73-
description += colored("-", 'red'), k + '\n'
74+
description += colored("-", 'red') + k + '\n'
7475

7576
for k, v in plan['change'].items():
7677
# { key: {'old': value, 'new': value} }
77-
description += colored("~", 'yellow'), "{}:\n\t< {}\n\t> {}".format(k, v['old'], v['new']) + '\n'
78+
description += colored("~", 'yellow') + "{}:\n\t< {}\n\t> {}".format(k, v['old'], v['new']) + '\n'
79+
80+
if description == "":
81+
description = "No Changes Detected"
7882

7983
return description
8084

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)