Merge "Make removing nodes from scaled items possible."
[apex-tripleo-heat-templates.git] / tripleo_heat_merge / merge.py
1 import os
2 import sys
3 import yaml
4 import argparse
5
6
7 class Cfn(object):
8
9     base_template = {
10         'HeatTemplateFormatVersion': '2012-12-12',
11         'Description': []
12     }
13     get_resource = 'Ref'
14     get_param = 'Ref'
15     description = 'Description'
16     parameters = 'Parameters'
17     outputs = 'Outputs'
18     resources = 'Resources'
19     type = 'Type'
20     properties = 'Properties'
21     metadata = 'Metadata'
22     depends_on = 'DependsOn'
23     get_attr = 'Fn::GetAtt'
24
25
26 class Hot(object):
27
28     base_template = {
29         'heat_template_version': '2013-05-23',
30         'description': []
31     }
32     get_resource = 'get_resource'
33     get_param = 'get_param'
34     description = 'description'
35     parameters = 'parameters'
36     outputs = 'outputs'
37     resources = 'resources'
38     type = 'type'
39     properties = 'properties'
40     metadata = 'metadata'
41     depends_on = 'depends_on'
42     get_attr = 'get_attr'
43
44
45 lang = Cfn()
46
47
48 def apply_maps(template):
49     """Apply Merge::Map within template.
50
51     Any dict {'Merge::Map': {'Foo': 'Bar', 'Baz': 'Quux'}}
52     will resolve to ['Bar', 'Quux'] - that is a dict with key
53     'Merge::Map' is replaced entirely by that dict['Merge::Map'].values().
54     """
55     if isinstance(template, dict):
56         if 'Merge::Map' in template:
57             return sorted(
58                 apply_maps(value) for value in template['Merge::Map'].values()
59                 )
60         else:
61             return dict((key, apply_maps(value))
62                 for key, value in template.items())
63     elif isinstance(template, list):
64         return [apply_maps(item) for item in template]
65     else:
66         return template
67
68
69 def apply_scaling(template, scaling, in_copies=None):
70     """Apply a set of scaling operations to template.
71
72     This is a single pass recursive function: for each call we process one
73     dict or list and recurse to handle children containers.
74
75     Values are handled via scale_value.
76
77     Keys in dicts are copied per the scaling rule.
78     Values are either replaced or copied depending on whether the given
79     scaling rule is in in_copies.
80
81     in_copies is reset to None when a dict {'Merge::Map': someobject} is
82     encountered.
83
84     :param scaling: A dict of prefix -> (count, blacklists).
85     """
86     in_copies = dict(in_copies or {})
87     # Shouldn't be needed but to avoid unexpected side effects/bugs we short
88     # circuit no-ops.
89     if not scaling:
90         return template
91     if isinstance(template, dict):
92         if 'Merge::Map' in template:
93             in_copies = None
94         new_template = {}
95         for key, value in template.items():
96             for prefix, copy_num, new_key in scale_value(
97                 key, scaling, in_copies):
98                 if prefix:
99                     # e.g. Compute0, 1, Compute1Foo
100                     in_copies[prefix] = prefix[:-1] + str(copy_num)
101                 if isinstance(value, (dict, list)):
102                     new_value = apply_scaling(value, scaling, in_copies)
103                     new_template[new_key] = new_value
104                 else:
105                     new_values = list(scale_value(value, scaling, in_copies))
106                     # We have nowhere to multiply a non-container value of a
107                     # dict, so it may be copied or unchanged but not scaled.
108                     assert len(new_values) == 1
109                     new_template[new_key] = new_values[0][2]
110                 if prefix:
111                     del in_copies[prefix]
112         return new_template
113     elif isinstance(template, list):
114         new_template = []
115         for value in template:
116             if isinstance(value, (dict, list)):
117                 new_template.append(apply_scaling(value, scaling, in_copies))
118             else:
119                 for _, _, new_value in scale_value(value, scaling, in_copies):
120                     new_template.append(new_value)
121         return new_template
122     else:
123         raise Exception("apply_scaling called with non-container %r" % template)
124
125
126 def scale_value(value, scaling, in_copies):
127     """Scale out a value.
128
129     :param value: The value to scale (not a container).
130     :param scaling: The scaling map (prefix-> (copies, blacklist) to use.
131     :param in_copies: What containers we're currently copying.
132     :return: An iterator of the new values for the value as tuples:
133         (prefix, copy_num, value). E.g. Compute0, 1, Compute1Foo
134         prefix and copy_num are only set when:
135          - a prefix in scaling matches value
136          - and that prefix is not in in_copies
137     """
138     if isinstance(value, (str, unicode)):
139         for prefix, (copies, blacklist) in scaling.items():
140             if not value.startswith(prefix):
141                 continue
142             suffix = value[len(prefix):]
143             if prefix in in_copies:
144                 # Adjust to the copy number we're on
145                 yield None, None, in_copies[prefix] + suffix
146                 return
147             else:
148                 for n in range(copies):
149                     if n not in blacklist:
150                         yield prefix, n, prefix[:-1] + str(n) + suffix
151                 return
152         yield None, None, value
153     else:
154         yield None, None, value
155
156
157 def parse_scaling(scaling_args):
158     """Translate a list of scaling requests to a dict prefix:count."""
159     scaling_args = scaling_args or []
160     result = {}
161     for item in scaling_args:
162         key, values = item.split('=')
163         values = values.split(',')
164         value = int(values[0])
165         blacklist = frozenset(int(v) for v in values[1:] if v)
166         result[key + '0'] = value, blacklist
167     return result
168
169
170 def _translate_role(role, master_role, slave_roles):
171     if not master_role:
172         return role
173     if role == master_role:
174         return role
175     if role not in slave_roles:
176         return role
177     return master_role
178
179 def translate_role(role, master_role, slave_roles):
180     r = _translate_role(role, master_role, slave_roles)
181     if not isinstance(r, basestring):
182         raise Exception('%s -> %r' % (role, r))
183     return r
184
185 def resolve_params(item, param, value):
186     if item in ({lang.get_param: param}, {lang.get_resource: param}):
187         return value
188     if isinstance(item, dict):
189         copy_item = dict(item)
190         for k, v in iter(copy_item.items()):
191             item[k] = resolve_params(v, param, value)
192     elif isinstance(item, list):
193         copy_item = list(item)
194         new_item = []
195         for v in copy_item:
196             new_item.append(resolve_params(v, param, value))
197         item = new_item
198     return item
199
200 MERGABLE_TYPES = {'OS::Nova::Server':
201                   {'image': 'image'},
202                   'AWS::EC2::Instance':
203                   {'image': 'ImageId'},
204                   'AWS::AutoScaling::LaunchConfiguration':
205                   {},
206                  }
207 INCLUDED_TEMPLATE_DIR = os.getcwd()
208
209
210 def resolve_includes(template, params=None):
211     new_template = {}
212     if params is None:
213         params = {}
214     for key, value in iter(template.items()):
215         if key == '__include__':
216             new_params = dict(params) # do not propagate up the stack
217             if not isinstance(value, dict):
218                 raise ValueError('__include__ must be a mapping')
219             if 'path' not in value:
220                 raise ValueError('__include__ must have path')
221             if 'params' in value:
222                 if not isinstance(value['params'], dict):
223                     raise ValueError('__include__ params must be a mapping')
224                 new_params.update(value['params'])
225             with open(value['path']) as include_file:
226                 sub_template = yaml.safe_load(include_file.read())
227                 if 'subkey' in value:
228                     if ((not isinstance(value['subkey'], int)
229                          and not isinstance(sub_template, dict))):
230                         raise RuntimeError('subkey requires mapping root or'
231                                            ' integer for list root')
232                     sub_template = sub_template[value['subkey']]
233                 for k, v in iter(new_params.items()):
234                     sub_template = resolve_params(sub_template, k, v)
235                 new_template.update(resolve_includes(sub_template))
236         else:
237             if isinstance(value, dict):
238                 new_template[key] = resolve_includes(value)
239             else:
240                 new_template[key] = value
241     return new_template
242
243 def main(argv=None):
244     if argv is None:
245         argv = sys.argv[1:]
246     parser = argparse.ArgumentParser()
247     parser.add_argument('templates', nargs='+')
248     parser.add_argument('--master-role', nargs='?',
249                         help='Translate slave_roles to this')
250     parser.add_argument('--slave-roles', nargs='*',
251                         help='Translate all of these to master_role')
252     parser.add_argument('--included-template-dir', nargs='?',
253                         default=INCLUDED_TEMPLATE_DIR,
254                         help='Path for resolving included templates')
255     parser.add_argument('--output',
256                         help='File to write output to. - for stdout',
257                         default='-')
258     parser.add_argument('--scale', action="append",
259         help="Names to scale out. Pass Prefix=2 to cause a key Prefix0Foo to "
260         "be copied to Prefix1Foo in the output, and value Prefix0Bar to be"
261         "renamed to Prefix1Bar inside that copy, or copied to Prefix1Bar "
262         "outside of any copy. Pass Prefix=3,1 to cause Prefix1* to be elided"
263         "when scaling Prefix out. Prefix=4,1,2 will likewise elide Prefix1 and"
264         "Prefix2.")
265     parser.add_argument(
266         '--change-image-params', action='store_true', default=False,
267         help="Change parameters in templates to match resource names. This was "
268              " the default at one time but it causes issues when parameter "
269              " names need to remain stable.")
270     parser.add_argument(
271         '--hot', action='store_true', default=False,
272         help="Assume source templates are in the HOT format, and generate a "
273              "HOT template artifact.")
274     args = parser.parse_args(argv)
275     if args.hot:
276         global lang
277         lang = Hot()
278
279     templates = args.templates
280     scaling = parse_scaling(args.scale)
281     merged_template = merge(templates, args.master_role, args.slave_roles,
282                             args.included_template_dir, scaling=scaling,
283                             change_image_params=args.change_image_params)
284     if args.output == '-':
285         out_file = sys.stdout
286     else:
287         out_file = file(args.output, 'wt')
288     out_file.write(merged_template)
289
290
291 def merge(templates, master_role=None, slave_roles=None,
292           included_template_dir=INCLUDED_TEMPLATE_DIR,
293           scaling=None, change_image_params=None):
294     scaling = scaling or {}
295     errors = []
296     end_template = dict(lang.base_template)
297     resource_changes=[]
298     for template_path in templates:
299         template = yaml.safe_load(open(template_path))
300         # Resolve __include__ tags
301         template = resolve_includes(template)
302         end_template[lang.description].append(template.get(lang.description,
303                                                         template_path))
304         new_parameters = template.get(lang.parameters, {})
305         for p, pbody in sorted(new_parameters.items()):
306             if p in end_template.get(lang.parameters, {}):
307                 if pbody != end_template[lang.parameters][p]:
308                     errors.append('Parameter %s from %s conflicts.' % (p,
309                                                                        template_path))
310                 continue
311             if lang.parameters not in end_template:
312                 end_template[lang.parameters] = {}
313             end_template[lang.parameters][p] = pbody
314
315         new_outputs = template.get(lang.outputs, {})
316         for o, obody in sorted(new_outputs.items()):
317             if o in end_template.get(lang.outputs, {}):
318                 if pbody != end_template[lang.outputs][p]:
319                     errors.append('Output %s from %s conflicts.' % (o,
320                                                                        template_path))
321                 continue
322             if lang.outputs not in end_template:
323                 end_template[lang.outputs] = {}
324             end_template[lang.outputs][o] = obody
325
326         new_resources = template.get(lang.resources, {})
327         for r, rbody in sorted(new_resources.items()):
328             if rbody[lang.type] in MERGABLE_TYPES:
329                 if change_image_params:
330                     if 'image' in MERGABLE_TYPES[rbody[lang.type]]:
331                         image_key = MERGABLE_TYPES[rbody[lang.type]]['image']
332                         # XXX Assuming ImageId is always a Ref
333                         ikey_val = end_template[lang.parameters][rbody[lang.properties][image_key][lang.get_param]]
334                         del end_template[lang.parameters][rbody[lang.properties][image_key][lang.get_param]]
335                 role = rbody.get(lang.metadata, {}).get('OpenStack::Role', r)
336                 role = translate_role(role, master_role, slave_roles)
337                 if role != r:
338                     resource_changes.append((r, role))
339                 if role in end_template.get(lang.resources, {}):
340                     new_metadata = rbody.get(lang.metadata, {})
341                     for m, mbody in iter(new_metadata.items()):
342                         if m in end_template[lang.resources][role].get(lang.metadata, {}):
343                             if m == 'OpenStack::ImageBuilder::Elements':
344                                 end_template[lang.resources][role][lang.metadata][m].extend(mbody)
345                                 continue
346                             if mbody != end_template[lang.resources][role][lang.metadata][m]:
347                                 errors.append('Role %s metadata key %s conflicts.' %
348                                               (role, m))
349                             continue
350                         role_res = end_template[lang.resources][role]
351                         if role_res[lang.type] == 'OS::Heat::StructuredConfig':
352                             end_template[lang.resources][role][lang.properties]['config'][m] = mbody
353                         else:
354                             end_template[lang.resources][role][lang.metadata][m] = mbody
355                     continue
356                 if lang.resources not in end_template:
357                     end_template[lang.resources] = {}
358                 end_template[lang.resources][role] = rbody
359                 if change_image_params:
360                     if 'image' in MERGABLE_TYPES[rbody[lang.type]]:
361                         ikey = '%sImage' % (role)
362                         end_template[lang.resources][role][lang.properties][image_key] = {lang.get_param: ikey}
363                         end_template[lang.parameters][ikey] = ikey_val
364             elif rbody[lang.type] == 'FileInclude':
365                 # we trust os.path.join to DTRT: if FileInclude path isn't
366                 # absolute, join to included_template_dir (./)
367                 with open(os.path.join(included_template_dir, rbody['Path'])) as rfile:
368                     include_content = yaml.safe_load(rfile.read())
369                     subkeys = rbody.get('SubKey','').split('.')
370                     while len(subkeys) and subkeys[0]:
371                         include_content = include_content[subkeys.pop(0)]
372                     for replace_param, replace_value in iter(rbody.get(lang.parameters,
373                                                                        {}).items()):
374                         include_content = resolve_params(include_content,
375                                                          replace_param,
376                                                          replace_value)
377                     if lang.resources not in end_template:
378                         end_template[lang.resources] = {}
379                     end_template[lang.resources][r] = include_content
380             else:
381                 if r in end_template.get(lang.resources, {}):
382                     if rbody != end_template[lang.resources][r]:
383                         errors.append('Resource %s from %s conflicts' % (r,
384                                                                          template_path))
385                     continue
386                 if lang.resources not in end_template:
387                     end_template[lang.resources] = {}
388                 end_template[lang.resources][r] = rbody
389
390     end_template = apply_scaling(end_template, scaling)
391     end_template = apply_maps(end_template)
392
393     def fix_ref(item, old, new):
394         if isinstance(item, dict):
395             copy_item = dict(item)
396             for k, v in sorted(copy_item.items()):
397                 if k == lang.get_resource and v == old:
398                     item[k] = new
399                     continue
400                 if k == lang.depends_on and v == old:
401                     item[k] = new
402                     continue
403                 if k == lang.get_attr and isinstance(v, list) and v[0] == old:
404                     new_list = list(v)
405                     new_list[0] = new
406                     item[k] = new_list
407                     continue
408                 if k == 'AllowedResources' and isinstance(v, list) and old in v:
409                     while old in v:
410                         pos = v.index(old)
411                         v[pos] = new
412                     continue
413                 fix_ref(v, old, new)
414         elif isinstance(item, list):
415             copy_item = list(item)
416             for v in item:
417                 fix_ref(v, old, new)
418
419     for change in resource_changes:
420         fix_ref(end_template, change[0], change[1])
421
422     if errors:
423         for e in errors:
424             sys.stderr.write("ERROR: %s\n" % e)
425     end_template[lang.description] = ','.join(end_template[lang.description])
426     return yaml.safe_dump(end_template, default_flow_style=False)
427
428 if __name__ == "__main__":
429       main()