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