77import yaml
88from botocore .exceptions import ClientError , NoCredentialsError
99
10- from .helpers import merge , add , search
10+ from .helpers import merge , add , filter , search
1111
1212
1313def str_presenter (dumper , data ):
@@ -62,18 +62,34 @@ def to_yaml(cls, dumper, data):
6262
6363class 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
100164class 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