7
7
import yaml
8
8
from botocore .exceptions import ClientError , NoCredentialsError
9
9
10
- from .helpers import merge , add , search
10
+ from .helpers import merge , add , filter , search
11
11
12
12
13
13
def str_presenter (dumper , data ):
@@ -62,18 +62,34 @@ def to_yaml(cls, dumper, data):
62
62
63
63
class YAMLFile (object ):
64
64
"""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
67
73
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 ))
68
82
69
83
def get (self ):
70
84
try :
71
85
output = {}
72
86
with open (self .filename , 'rb' ) as f :
73
87
local = yaml .safe_load (f .read ())
88
+ self .validate_config (local )
89
+ local = self .nest_root (local )
74
90
for path in self .paths :
75
91
if path .strip ('/' ):
76
- output = merge (output , search (local , path ))
92
+ output = merge (output , filter (local , path ))
77
93
else :
78
94
return local
79
95
return output
@@ -87,7 +103,55 @@ def get(self):
87
103
return dict ()
88
104
raise
89
105
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
+
90
146
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
+ }
91
155
try :
92
156
with open (self .filename , 'wb' ) as f :
93
157
content = yaml .safe_dump (state , default_flow_style = False )
@@ -99,12 +163,21 @@ def save(self, state):
99
163
100
164
class ParameterStore (object ):
101
165
"""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 ):
103
167
if profile :
104
168
boto3 .setup_default_session (profile_name = profile )
105
169
self .ssm = boto3 .client ('ssm' )
106
170
self .diff_class = diff_class
107
171
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
+ })
108
181
109
182
def clone (self ):
110
183
p = self .ssm .get_paginator ('get_parameters_by_path' )
@@ -114,7 +187,9 @@ def clone(self):
114
187
for page in p .paginate (
115
188
Path = path ,
116
189
Recursive = True ,
117
- WithDecryption = True ):
190
+ WithDecryption = True ,
191
+ ParameterFilters = self .parameter_filters ,
192
+ ):
118
193
for param in page ['Parameters' ]:
119
194
add (obj = output ,
120
195
path = param ['Name' ],
@@ -136,10 +211,10 @@ def pull(self, local):
136
211
return diff .merge ()
137
212
138
213
def dry_run (self , local ):
139
- return self .diff_class (self .clone (), local ). plan
214
+ return self .diff_class (self .clone (), local )
140
215
141
216
def push (self , local ):
142
- plan = self .dry_run (local )
217
+ plan = self .dry_run (local ). plan
143
218
144
219
# plan
145
220
for k , v in plan ['add' ].items ():
0 commit comments