Skip to content

Commit 9b73491

Browse files
committed
Add plugin system for Diff* engine (relocated to engine) and simplified/standardized the interface (i.e. configure, merge, plan, and __init__)
1 parent f48e253 commit 9b73491

File tree

6 files changed

+270
-120
lines changed

6 files changed

+270
-120
lines changed

ssm-diff

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@ from __future__ import print_function
44
import argparse
55
import os
66

7-
from states import states
8-
from states.helpers import DiffResolver
7+
from states import *
98

109

1110
def configure_endpoints(args):
12-
# pre-configure resolver, but still accept remote and local at runtime
13-
diff_resolver = DiffResolver.configure(force=args.force)
14-
return states.ParameterStore(args.profile, diff_resolver, paths=args.path), states.YAMLFile(args.filename, paths=args.path)
11+
# configure() returns a DiffBase class (whose constructor may be wrapped in `partial` to pre-configure it)
12+
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)
1514

1615

1716
def init(args):
@@ -43,17 +42,16 @@ def plan(args):
4342
diff = remote.dry_run(local.get())
4443

4544
if diff.differ:
46-
print(diff.describe_diff())
45+
print(DiffBase.describe_diff(diff.plan))
4746
else:
4847
print("Remote state is up to date.")
4948

50-
return remote, local, diff
51-
5249

5350
if __name__ == "__main__":
5451
parser = argparse.ArgumentParser()
5552
parser.add_argument('-f', help='local state yml file', action='store', dest='filename', default='parameters.yml')
5653
parser.add_argument('--path', '-p', action='append', help='filter SSM path')
54+
parser.add_argument('--engine', '-e', help='diff engine to use when interacting with SSM', action='store', dest='engine', default='DiffResolver')
5755
parser.add_argument('--profile', help='AWS profile name', action='store', dest='profile')
5856
subparsers = parser.add_subparsers(dest='func', help='commands')
5957
subparsers.required = True

states/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
from .states import *
1+
from .storage import YAMLFile, ParameterStore
2+
from .engine import DiffBase

states/engine.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import collections
2+
import logging
3+
from functools import partial
4+
5+
from termcolor import colored
6+
7+
from .helpers import add
8+
9+
10+
class DiffMount(type):
11+
"""Metaclass for Diff plugin system"""
12+
# noinspection PyUnusedLocal,PyMissingConstructor
13+
def __init__(cls, *args, **kwargs):
14+
if not hasattr(cls, 'plugins'):
15+
cls.plugins = dict()
16+
else:
17+
cls.plugins[cls.__name__] = cls
18+
19+
20+
class DiffBase(metaclass=DiffMount):
21+
"""Superclass for diff plugins"""
22+
def __init__(self, remote, local):
23+
self.logger = logging.getLogger(self.__module__)
24+
self.remote_flat, self.local_flat = self._flatten(remote), self._flatten(local)
25+
self.remote_set, self.local_set = set(self.remote_flat.keys()), set(self.local_flat.keys())
26+
27+
# noinspection PyUnusedLocal
28+
@classmethod
29+
def get_plugin(cls, name):
30+
if name in cls.plugins:
31+
return cls.plugins[name]()
32+
33+
@classmethod
34+
def configure(cls, args):
35+
"""Extract class-specific configurations from CLI args and pre-configure the __init__ method using functools.partial"""
36+
return cls
37+
38+
@classmethod
39+
def _flatten(cls, d, current_path='', sep='/'):
40+
"""Convert a nested dict structure into a "flattened" dict i.e. {"full/path": "value", ...}"""
41+
items = []
42+
for k in d:
43+
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())
46+
else:
47+
items.append((sep + new, d[k]))
48+
return dict(items)
49+
50+
@classmethod
51+
def _unflatten(cls, d, sep='/'):
52+
"""Converts a "flattened" dict i.e. {"full/path": "value", ...} into a nested dict structure"""
53+
output = {}
54+
for k in d:
55+
add(
56+
obj=output,
57+
path=k,
58+
value=d[k],
59+
sep=sep,
60+
)
61+
return output
62+
63+
@classmethod
64+
def describe_diff(cls, plan):
65+
"""Return a (multi-line) string describing all differences"""
66+
description = ""
67+
for k, v in plan['add'].items():
68+
# { key: new_value }
69+
description += colored("+", 'green'), "{} = {}".format(k, v) + '\n'
70+
71+
for k in plan['delete']:
72+
# { key: old_value }
73+
description += colored("-", 'red'), k + '\n'
74+
75+
for k, v in plan['change'].items():
76+
# { key: {'old': value, 'new': value} }
77+
description += colored("~", 'yellow'), "{}:\n\t< {}\n\t> {}".format(k, v['old'], v['new']) + '\n'
78+
79+
return description
80+
81+
@property
82+
def plan(self):
83+
"""Returns a `dict` of operations for updating the remote storage i.e. {'add': {...}, 'change': {...}, 'delete': {...}}"""
84+
raise NotImplementedError
85+
86+
def merge(self):
87+
"""Generate a merge of the local and remote dicts, following configurations set during __init__"""
88+
raise NotImplementedError
89+
90+
91+
class DiffResolver(DiffBase):
92+
"""Determines diffs between two dicts, where the remote copy is considered the baseline"""
93+
def __init__(self, remote, local, force=False):
94+
super().__init__(remote, local)
95+
self.intersection = self.remote_set.intersection(self.local_set)
96+
self.force = force
97+
98+
if self.added() or self.removed() or self.changed():
99+
self.differ = True
100+
else:
101+
self.differ = False
102+
103+
@classmethod
104+
def configure(cls, args):
105+
return partial(cls, force=args.diffresolver_force)
106+
107+
def added(self):
108+
"""Returns a (flattened) dict of added leaves i.e. {"full/path": value, ...}"""
109+
return self.local_set - self.intersection
110+
111+
def removed(self):
112+
"""Returns a (flattened) dict of removed leaves i.e. {"full/path": value, ...}"""
113+
return self.remote_set - self.intersection
114+
115+
def changed(self):
116+
"""Returns a (flattened) dict of changed leaves i.e. {"full/path": value, ...}"""
117+
return set(k for k in self.intersection if self.remote_flat[k] != self.local_flat[k])
118+
119+
def unchanged(self):
120+
"""Returns a (flattened) dict of unchanged leaves i.e. {"full/path": value, ...}"""
121+
return set(k for k in self.intersection if self.remote_flat[k] == self.local_flat[k])
122+
123+
@property
124+
def plan(self):
125+
return {
126+
'add': {
127+
k: self.local_flat[k] for k in self.added()
128+
},
129+
'delete': {
130+
k: self.remote_flat[k] for k in self.removed()
131+
},
132+
'change': {
133+
k: {'old': self.remote_flat[k], 'new': self.local_flat[k]} for k in self.changed()
134+
}
135+
}
136+
137+
def merge(self):
138+
dictfilter = lambda original, keep_keys: dict([(i, original[i]) for i in original if i in set(keep_keys)])
139+
if self.force:
140+
# Overwrite local changes (i.e. only preserve added keys)
141+
# NOTE: Currently the system cannot tell the difference between a remote delete and a local add
142+
prior_set = self.changed().union(self.removed()).union(self.unchanged())
143+
current_set = self.added()
144+
else:
145+
# Preserve added keys and changed keys
146+
# NOTE: Currently the system cannot tell the difference between a remote delete and a local add
147+
prior_set = self.unchanged().union(self.removed())
148+
current_set = self.added().union(self.changed())
149+
state = dictfilter(original=self.remote_flat, keep_keys=prior_set)
150+
state.update(dictfilter(original=self.local_flat, keep_keys=current_set))
151+
return self._unflatten(state)

states/helpers.py

Lines changed: 0 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,96 +1,4 @@
1-
import collections
21
from copy import deepcopy
3-
from functools import partial
4-
5-
from termcolor import colored
6-
7-
8-
class DiffResolver(object):
9-
"""Determines diffs between two dicts, where the remote copy is considered the baseline"""
10-
def __init__(self, remote, local, force=False):
11-
self.remote_flat, self.local_flat = self._flatten(remote), self._flatten(local)
12-
self.remote_set, self.local_set = set(self.remote_flat.keys()), set(self.local_flat.keys())
13-
self.intersection = self.remote_set.intersection(self.local_set)
14-
self.force = force
15-
16-
if self.added() or self.removed() or self.changed():
17-
self.differ = True
18-
else:
19-
self.differ = False
20-
21-
@classmethod
22-
def configure(cls, *args, **kwargs):
23-
return partial(cls, *args, **kwargs)
24-
25-
def added(self):
26-
"""Returns a (flattened) dict of added leaves i.e. {"full/path": value, ...}"""
27-
return self.local_set - self.intersection
28-
29-
def removed(self):
30-
"""Returns a (flattened) dict of removed leaves i.e. {"full/path": value, ...}"""
31-
return self.remote_set - self.intersection
32-
33-
def changed(self):
34-
"""Returns a (flattened) dict of changed leaves i.e. {"full/path": value, ...}"""
35-
return set(k for k in self.intersection if self.remote_flat[k] != self.local_flat[k])
36-
37-
def unchanged(self):
38-
"""Returns a (flattened) dict of unchanged leaves i.e. {"full/path": value, ...}"""
39-
return set(k for k in self.intersection if self.remote_flat[k] == self.local_flat[k])
40-
41-
def describe_diff(self):
42-
"""Return a (multi-line) string describing all differences"""
43-
description = ""
44-
for k in self.added():
45-
description += colored("+", 'green'), "{} = {}".format(k, self.local_flat[k]) + '\n'
46-
47-
for k in self.removed():
48-
description += colored("-", 'red'), k + '\n'
49-
50-
for k in self.changed():
51-
description += colored("~", 'yellow'), "{}:\n\t< {}\n\t> {}".format(k, self.remote_flat[k], self.local_flat[k]) + '\n'
52-
53-
return description
54-
55-
def _flatten(self, d, current_path='', sep='/'):
56-
"""Convert a nested dict structure into a "flattened" dict i.e. {"full/path": "value", ...}"""
57-
items = []
58-
for k in d:
59-
new = current_path + sep + k if current_path else k
60-
if isinstance(d[k], collections.MutableMapping):
61-
items.extend(self._flatten(d[k], new, sep=sep).items())
62-
else:
63-
items.append((sep + new, d[k]))
64-
return dict(items)
65-
66-
def _unflatten(self, d, sep='/'):
67-
"""Converts a "flattened" dict i.e. {"full/path": "value", ...} into a nested dict structure"""
68-
output = {}
69-
for k in d:
70-
add(
71-
obj=output,
72-
path=k,
73-
value=d[k],
74-
sep=sep,
75-
)
76-
return output
77-
78-
def merge(self):
79-
"""Generate a merge of the local and remote dicts, following configurations set during __init__"""
80-
dictfilter = lambda original, keep_keys: dict([(i, original[i]) for i in original if i in set(keep_keys)])
81-
if self.force:
82-
# Overwrite local changes (i.e. only preserve added keys)
83-
# NOTE: Currently the system cannot tell the difference between a remote delete and a local add
84-
prior_set = self.changed().union(self.removed()).union(self.unchanged())
85-
current_set = self.added()
86-
else:
87-
# Preserve added keys and changed keys
88-
# NOTE: Currently the system cannot tell the difference between a remote delete and a local add
89-
prior_set = self.unchanged().union(self.removed())
90-
current_set = self.added().union(self.changed())
91-
state = dictfilter(original=self.remote_flat, keep_keys=prior_set)
92-
state.update(dictfilter(original=self.local_flat, keep_keys=current_set))
93-
return self._unflatten(state)
942

953

964
def add(obj, path, value, sep='/'):

states/states.py renamed to states/storage.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -136,30 +136,34 @@ def pull(self, local):
136136
return diff.merge()
137137

138138
def dry_run(self, local):
139-
return self.diff_class(self.clone(), local)
139+
return self.diff_class(self.clone(), local).plan
140140

141141
def push(self, local):
142-
diff = self.dry_run(local)
142+
plan = self.dry_run(local)
143143

144-
# diff.added|removed|changed return a "flattened" dict i.e. {"full/path": "value", ...}
145-
for k in diff.added():
144+
# plan
145+
for k, v in plan['add'].items():
146+
# { key: new_value }
146147
ssm_type = 'String'
147-
if isinstance(diff.local[k], list):
148+
if isinstance(v, list):
148149
ssm_type = 'StringList'
149-
if isinstance(diff.local[k], SecureTag):
150+
if isinstance(v, SecureTag):
150151
ssm_type = 'SecureString'
151152
self.ssm.put_parameter(
152153
Name=k,
153-
Value=repr(diff.local[k]) if type(diff.local[k]) == SecureTag else str(diff.local[k]),
154+
Value=repr(v) if type(v) == SecureTag else str(v),
154155
Type=ssm_type)
155156

156-
for k in diff.removed():
157+
for k in plan['delete']:
158+
# { key: old_value }
157159
self.ssm.delete_parameter(Name=k)
158160

159-
for k in diff.changed():
160-
ssm_type = 'SecureString' if isinstance(diff.local[k], SecureTag) else 'String'
161+
for k, delta in plan['change']:
162+
# { key: {'old': value, 'new': value} }
163+
v = delta['new']
164+
ssm_type = 'SecureString' if isinstance(v, SecureTag) else 'String'
161165
self.ssm.put_parameter(
162166
Name=k,
163-
Value=repr(diff.local[k]) if type(diff.local[k]) == SecureTag else str(diff.local[k]),
167+
Value=repr(v) if type(v) == SecureTag else str(v),
164168
Overwrite=True,
165169
Type=ssm_type)

0 commit comments

Comments
 (0)