Merge "Update NSBPerf CLI to adapt to new output format"
[yardstick.git] / yardstick / orchestrator / heat.py
1 #############################################################################
2 # Copyright (c) 2015-2017 Ericsson AB and others.
3 #
4 # All rights reserved. This program and the accompanying materials
5 # are made available under the terms of the Apache License, Version 2.0
6 # which accompanies this distribution, and is available at
7 # http://www.apache.org/licenses/LICENSE-2.0
8 ##############################################################################
9
10 """Heat template and stack management"""
11
12 from __future__ import absolute_import
13 from __future__ import print_function
14 from six.moves import range
15
16 import collections
17 import datetime
18 import getpass
19 import logging
20
21 import socket
22 import time
23
24 import heatclient
25 import pkg_resources
26
27 from oslo_utils import encodeutils
28
29 import yardstick.common.openstack_utils as op_utils
30 from yardstick.common import template_format
31
32 log = logging.getLogger(__name__)
33
34
35 HEAT_KEY_UUID_LENGTH = 8
36
37 PROVIDER_SRIOV = "sriov"
38
39
40 def get_short_key_uuid(uuid):
41     return str(uuid)[:HEAT_KEY_UUID_LENGTH]
42
43
44 class HeatObject(object):
45     """base class for template and stack"""
46
47     def __init__(self):
48         self._heat_client = None
49         self.uuid = None
50
51     @property
52     def heat_client(self):
53         """returns a heat client instance"""
54
55         if self._heat_client is None:
56             sess = op_utils.get_session()
57             heat_endpoint = op_utils.get_endpoint(service_type='orchestration')
58             self._heat_client = heatclient.client.Client(
59                 op_utils.get_heat_api_version(),
60                 endpoint=heat_endpoint, session=sess)
61
62         return self._heat_client
63
64     def status(self):
65         """returns stack state as a string"""
66         heat_client = self.heat_client
67         stack = heat_client.stacks.get(self.uuid)
68         return stack.stack_status
69
70
71 class HeatStack(HeatObject):
72     """Represents a Heat stack (deployed template) """
73     stacks = []
74
75     def __init__(self, name):
76         super(HeatStack, self).__init__()
77         self.uuid = None
78         self.name = name
79         self.outputs = None
80         HeatStack.stacks.append(self)
81
82     @staticmethod
83     def stacks_exist():
84         """check if any stack has been deployed"""
85         return len(HeatStack.stacks) > 0
86
87     def _delete(self):
88         """deletes a stack from the target cloud using heat"""
89         if self.uuid is None:
90             return
91
92         log.info("Deleting stack '%s', uuid:%s", self.name, self.uuid)
93         heat = self.heat_client
94         template = heat.stacks.get(self.uuid)
95         start_time = time.time()
96         template.delete()
97
98         for status in iter(self.status, u'DELETE_COMPLETE'):
99             log.debug("stack state %s", status)
100             if status == u'DELETE_FAILED':
101                 raise RuntimeError(
102                     heat.stacks.get(self.uuid).stack_status_reason)
103
104             time.sleep(2)
105
106         end_time = time.time()
107         log.info("Deleted stack '%s' in %d secs", self.name,
108                  end_time - start_time)
109         self.uuid = None
110
111     def delete(self, block=True, retries=3):
112         """deletes a stack in the target cloud using heat (with retry)
113         Sometimes delete fail with "InternalServerError" and the next attempt
114         succeeds. So it is worthwhile to test a couple of times.
115         """
116         if self.uuid is None:
117             return
118
119         if not block:
120             self._delete()
121             return
122
123         for _ in range(retries):
124             try:
125                 self._delete()
126                 break
127             except RuntimeError as err:
128                 log.warning(err.args)
129                 time.sleep(2)
130
131         # if still not deleted try once more and let it fail everything
132         if self.uuid is not None:
133             self._delete()
134
135         HeatStack.stacks.remove(self)
136
137     @staticmethod
138     def delete_all():
139         for stack in HeatStack.stacks[:]:
140             stack.delete()
141
142     def update(self):
143         """update a stack"""
144         raise RuntimeError("not implemented")
145
146
147 class HeatTemplate(HeatObject):
148     """Describes a Heat template and a method to deploy template to a stack"""
149
150     DESCRIPTION_TEMPLATE = """\
151 Stack built by the yardstick framework for %s on host %s %s.
152 All referred generated resources are prefixed with the template
153 name (i.e. %s).\
154 """
155
156     def _init_template(self):
157         timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
158         self._template = {
159             'heat_template_version': '2013-05-23',
160             'description': self.DESCRIPTION_TEMPLATE % (
161                 getpass.getuser(),
162                 socket.gethostname(),
163                 timestamp,
164                 self.name
165             ),
166             'resources': {},
167             'outputs': {}
168         }
169
170         # short hand for resources part of template
171         self.resources = self._template['resources']
172
173     def __init__(self, name, template_file=None, heat_parameters=None):
174         super(HeatTemplate, self).__init__()
175         self.name = name
176         self.state = "NOT_CREATED"
177         self.keystone_client = None
178         self.heat_parameters = {}
179
180         # heat_parameters is passed to heat in stack create, empty dict when
181         # yardstick creates the template (no get_param in resources part)
182         if heat_parameters:
183             self.heat_parameters = heat_parameters
184
185         if template_file:
186             with open(template_file) as stream:
187                 print("Parsing external template:", template_file)
188                 template_str = stream.read()
189             self._template = template_format.parse(template_str)
190             self._parameters = heat_parameters
191         else:
192             self._init_template()
193
194         # holds results of requested output after deployment
195         self.outputs = {}
196
197         log.debug("template object '%s' created", name)
198
199     def add_flavor(self, name, vcpus=1, ram=1024, disk=1, ephemeral=0,
200                    is_public=True, rxtx_factor=1.0, swap=0,
201                    extra_specs=None):
202         """add to the template a Flavor description"""
203         if name is None:
204             name = 'auto'
205         log.debug("adding Nova::Flavor '%s' vcpus '%d' ram '%d' disk '%d' " +
206                   "ephemeral '%d' is_public '%s' rxtx_factor '%d' " +
207                   "swap '%d' extra_specs '%s' ",
208                   name, vcpus, ram, disk, ephemeral, is_public,
209                   rxtx_factor, swap, str(extra_specs))
210
211         if extra_specs:
212             assert isinstance(extra_specs, collections.Mapping)
213
214         self.resources[name] = {
215             'type': 'OS::Nova::Flavor',
216             'properties': {'name': name,
217                            'disk': disk,
218                            'vcpus': vcpus,
219                            'swap': swap,
220                            'flavorid': name,
221                            'rxtx_factor': rxtx_factor,
222                            'ram': ram,
223                            'is_public': is_public,
224                            'ephemeral': ephemeral,
225                            'extra_specs': extra_specs}
226         }
227
228         self._template['outputs'][name] = {
229             'description': 'Flavor %s ID' % name,
230             'value': {'get_resource': name}
231         }
232
233     def add_network(self, name, physical_network='physnet1', provider=None,
234                     segmentation_id=None, port_security_enabled=None):
235         """add to the template a Neutron Net"""
236         log.debug("adding Neutron::Net '%s'", name)
237         if provider is None:
238             self.resources[name] = {
239                 'type': 'OS::Neutron::Net',
240                 'properties': {
241                     'name': name,
242                 }
243             }
244         else:
245             self.resources[name] = {
246                 'type': 'OS::Neutron::ProviderNet',
247                 'properties': {
248                     'name': name,
249                     'network_type': 'vlan',
250                     'physical_network': physical_network,
251                 },
252             }
253             if segmentation_id:
254                 self.resources[name]['properties']['segmentation_id'] = segmentation_id
255         # if port security is not defined then don't add to template:
256         # some deployments don't have port security plugin installed
257         if port_security_enabled is not None:
258             self.resources[name]['properties']['port_security_enabled'] = port_security_enabled
259
260     def add_server_group(self, name, policies):     # pragma: no cover
261         """add to the template a ServerGroup"""
262         log.debug("adding Nova::ServerGroup '%s'", name)
263         policies = policies if isinstance(policies, list) else [policies]
264         self.resources[name] = {
265             'type': 'OS::Nova::ServerGroup',
266             'properties': {'name': name,
267                            'policies': policies}
268         }
269
270     def add_subnet(self, name, network, cidr, enable_dhcp='true', gateway_ip=None):
271         """add to the template a Neutron Subnet
272         """
273         log.debug("adding Neutron::Subnet '%s' in network '%s', cidr '%s'",
274                   name, network, cidr)
275         self.resources[name] = {
276             'type': 'OS::Neutron::Subnet',
277             'depends_on': network,
278             'properties': {
279                 'name': name,
280                 'cidr': cidr,
281                 'network_id': {'get_resource': network},
282                 'enable_dhcp': enable_dhcp,
283             }
284         }
285         if gateway_ip is not None:
286             self.resources[name]['properties']['gateway_ip'] = gateway_ip
287
288         self._template['outputs'][name] = {
289             'description': 'subnet %s ID' % name,
290             'value': {'get_resource': name}
291         }
292         self._template['outputs'][name + "-cidr"] = {
293             'description': 'subnet %s cidr' % name,
294             'value': {'get_attr': [name, 'cidr']}
295         }
296         self._template['outputs'][name + "-gateway_ip"] = {
297             'description': 'subnet %s gateway_ip' % name,
298             'value': {'get_attr': [name, 'gateway_ip']}
299         }
300
301     def add_router(self, name, ext_gw_net, subnet_name):
302         """add to the template a Neutron Router and interface"""
303         log.debug("adding Neutron::Router:'%s', gw-net:'%s'", name, ext_gw_net)
304         self.resources[name] = {
305             'type': 'OS::Neutron::Router',
306             'depends_on': [subnet_name],
307             'properties': {
308                 'name': name,
309                 'external_gateway_info': {
310                     'network': ext_gw_net
311                 }
312             }
313         }
314
315     def add_router_interface(self, name, router_name, subnet_name):
316         """add to the template a Neutron RouterInterface and interface"""
317         log.debug("adding Neutron::RouterInterface '%s' router:'%s', "
318                   "subnet:'%s'", name, router_name, subnet_name)
319         self.resources[name] = {
320             'type': 'OS::Neutron::RouterInterface',
321             'depends_on': [router_name, subnet_name],
322             'properties': {
323                 'router_id': {'get_resource': router_name},
324                 'subnet_id': {'get_resource': subnet_name}
325             }
326         }
327
328     def add_port(self, name, network_name, subnet_name, sec_group_id=None, provider=None,
329                  allowed_address_pairs=None):
330         """add to the template a named Neutron Port
331         """
332         log.debug("adding Neutron::Port '%s', network:'%s', subnet:'%s', "
333                   "secgroup:%s", name, network_name, subnet_name, sec_group_id)
334         self.resources[name] = {
335             'type': 'OS::Neutron::Port',
336             'depends_on': [subnet_name],
337             'properties': {
338                 'name': name,
339                 'fixed_ips': [{'subnet': {'get_resource': subnet_name}}],
340                 'network_id': {'get_resource': network_name},
341                 'replacement_policy': 'AUTO',
342             }
343         }
344
345         if provider == PROVIDER_SRIOV:
346             self.resources[name]['properties']['binding:vnic_type'] = \
347                 'direct'
348
349         if sec_group_id:
350             self.resources[name]['depends_on'].append(sec_group_id)
351             self.resources[name]['properties']['security_groups'] = \
352                 [sec_group_id]
353
354         if allowed_address_pairs:
355             self.resources[name]['properties'][
356                 'allowed_address_pairs'] = allowed_address_pairs
357
358         self._template['outputs'][name] = {
359             'description': 'Address for interface %s' % name,
360             'value': {'get_attr': [name, 'fixed_ips', 0, 'ip_address']}
361         }
362         self._template['outputs'][name + "-subnet_id"] = {
363             'description': 'Address for interface %s' % name,
364             'value': {'get_attr': [name, 'fixed_ips', 0, 'subnet_id']}
365         }
366         self._template['outputs'][name + "-mac_address"] = {
367             'description': 'MAC Address for interface %s' % name,
368             'value': {'get_attr': [name, 'mac_address']}
369         }
370         self._template['outputs'][name + "-device_id"] = {
371             'description': 'Device ID for interface %s' % name,
372             'value': {'get_attr': [name, 'device_id']}
373         }
374         self._template['outputs'][name + "-network_id"] = {
375             'description': 'Network ID for interface %s' % name,
376             'value': {'get_attr': [name, 'network_id']}
377         }
378
379     def add_floating_ip(self, name, network_name, port_name, router_if_name,
380                         secgroup_name=None):
381         """add to the template a Nova FloatingIP resource
382         see: https://bugs.launchpad.net/heat/+bug/1299259
383         """
384         log.debug("adding Nova::FloatingIP '%s', network '%s', port '%s', "
385                   "rif '%s'", name, network_name, port_name, router_if_name)
386
387         self.resources[name] = {
388             'type': 'OS::Nova::FloatingIP',
389             'depends_on': [port_name, router_if_name],
390             'properties': {
391                 'pool': network_name
392             }
393         }
394
395         if secgroup_name:
396             self.resources[name]["depends_on"].append(secgroup_name)
397
398         self._template['outputs'][name] = {
399             'description': 'floating ip %s' % name,
400             'value': {'get_attr': [name, 'ip']}
401         }
402
403     def add_floating_ip_association(self, name, floating_ip_name, port_name):
404         """add to the template a Nova FloatingIP Association resource
405         """
406         log.debug("adding Nova::FloatingIPAssociation '%s', server '%s', "
407                   "floating_ip '%s'", name, port_name, floating_ip_name)
408
409         self.resources[name] = {
410             'type': 'OS::Neutron::FloatingIPAssociation',
411             'depends_on': [port_name],
412             'properties': {
413                 'floatingip_id': {'get_resource': floating_ip_name},
414                 'port_id': {'get_resource': port_name}
415             }
416         }
417
418     def add_keypair(self, name, key_uuid):
419         """add to the template a Nova KeyPair"""
420         log.debug("adding Nova::KeyPair '%s'", name)
421         self.resources[name] = {
422             'type': 'OS::Nova::KeyPair',
423             'properties': {
424                 'name': name,
425                 # resource_string returns bytes, so we must decode to unicode
426                 'public_key': encodeutils.safe_decode(
427                     pkg_resources.resource_string(
428                         'yardstick.resources',
429                         'files/yardstick_key-' +
430                         get_short_key_uuid(key_uuid) + '.pub'),
431                     'utf-8')
432             }
433         }
434
435     def add_servergroup(self, name, policy):
436         """add to the template a Nova ServerGroup"""
437         log.debug("adding Nova::ServerGroup '%s', policy '%s'", name, policy)
438         if policy not in ["anti-affinity", "affinity"]:
439             raise ValueError(policy)
440
441         self.resources[name] = {
442             'type': 'OS::Nova::ServerGroup',
443             'properties': {
444                 'name': name,
445                 'policies': [policy]
446             }
447         }
448
449         self._template['outputs'][name] = {
450             'description': 'ID Server Group %s' % name,
451             'value': {'get_resource': name}
452         }
453
454     def add_security_group(self, name):
455         """add to the template a Neutron SecurityGroup"""
456         log.debug("adding Neutron::SecurityGroup '%s'", name)
457         self.resources[name] = {
458             'type': 'OS::Neutron::SecurityGroup',
459             'properties': {
460                 'name': name,
461                 'description': "Group allowing icmp and upd/tcp on all ports",
462                 'rules': [
463                     {'remote_ip_prefix': '0.0.0.0/0',
464                      'protocol': 'tcp',
465                      'port_range_min': '1',
466                      'port_range_max': '65535'},
467                     {'remote_ip_prefix': '0.0.0.0/0',
468                      'protocol': 'udp',
469                      'port_range_min': '1',
470                      'port_range_max': '65535'},
471                     {'remote_ip_prefix': '0.0.0.0/0',
472                      'protocol': 'icmp'}
473                 ]
474             }
475         }
476
477         self._template['outputs'][name] = {
478             'description': 'ID of Security Group',
479             'value': {'get_resource': name}
480         }
481
482     def add_server(self, name, image, flavor, flavors, ports=None,
483                    networks=None, scheduler_hints=None, user=None,
484                    key_name=None, user_data=None, metadata=None,
485                    additional_properties=None):
486         """add to the template a Nova Server"""
487         log.debug("adding Nova::Server '%s', image '%s', flavor '%s', "
488                   "ports %s", name, image, flavor, ports)
489
490         self.resources[name] = {
491             'type': 'OS::Nova::Server',
492             'depends_on': []
493         }
494
495         server_properties = {
496             'name': name,
497             'image': image,
498             'flavor': {},
499             'networks': []  # list of dictionaries
500         }
501
502         if flavor in flavors:
503             self.resources[name]['depends_on'].append(flavor)
504             server_properties["flavor"] = {'get_resource': flavor}
505         else:
506             server_properties["flavor"] = flavor
507
508         if user:
509             server_properties['admin_user'] = user
510
511         if key_name:
512             self.resources[name]['depends_on'].append(key_name)
513             server_properties['key_name'] = {'get_resource': key_name}
514
515         if ports:
516             self.resources[name]['depends_on'].extend(ports)
517             for port in ports:
518                 server_properties['networks'].append(
519                     {'port': {'get_resource': port}}
520                 )
521
522         if networks:
523             for i, _ in enumerate(networks):
524                 server_properties['networks'].append({'network': networks[i]})
525
526         if scheduler_hints:
527             server_properties['scheduler_hints'] = scheduler_hints
528
529         if user_data:
530             server_properties['user_data'] = user_data
531
532         if metadata:
533             assert isinstance(metadata, collections.Mapping)
534             server_properties['metadata'] = metadata
535
536         if additional_properties:
537             assert isinstance(additional_properties, collections.Mapping)
538             for prop in additional_properties:
539                 server_properties[prop] = additional_properties[prop]
540
541         server_properties['config_drive'] = True
542
543         self.resources[name]['properties'] = server_properties
544
545         self._template['outputs'][name] = {
546             'description': 'VM UUID',
547             'value': {'get_resource': name}
548         }
549
550     HEAT_WAIT_LOOP_INTERVAL = 2
551     HEAT_CREATE_COMPLETE_STATUS = u'CREATE_COMPLETE'
552
553     def create(self, block=True, timeout=3600):
554         """
555         creates a template in the target cloud using heat
556         returns a dict with the requested output values from the template
557
558         :param block: Wait for Heat create to finish
559         :type block: bool
560         :param: timeout: timeout in seconds for Heat create, default 3600s
561         :type timeout: int
562         """
563         log.info("Creating stack '%s'", self.name)
564
565         # create stack early to support cleanup, e.g. ctrl-c while waiting
566         stack = HeatStack(self.name)
567
568         heat_client = self.heat_client
569         start_time = time.time()
570         stack.uuid = self.uuid = heat_client.stacks.create(
571             stack_name=self.name, template=self._template,
572             parameters=self.heat_parameters)['stack']['id']
573
574         if not block:
575             self.outputs = stack.outputs = {}
576             end_time = time.time()
577             log.info("Created stack '%s' in %.3e secs",
578                      self.name, end_time - start_time)
579             return stack
580
581         time_limit = start_time + timeout
582         for status in iter(self.status, self.HEAT_CREATE_COMPLETE_STATUS):
583             log.debug("stack state %s", status)
584             if status == u'CREATE_FAILED':
585                 stack_status_reason = heat_client.stacks.get(self.uuid).stack_status_reason
586                 heat_client.stacks.delete(self.uuid)
587                 raise RuntimeError(stack_status_reason)
588             if time.time() > time_limit:
589                 raise RuntimeError("Heat stack create timeout")
590
591             time.sleep(self.HEAT_WAIT_LOOP_INTERVAL)
592
593         end_time = time.time()
594         outputs = heat_client.stacks.get(self.uuid).outputs
595         log.info("Created stack '%s' in %.3e secs",
596                  self.name, end_time - start_time)
597
598         # keep outputs as unicode
599         self.outputs = {output["output_key"]: output["output_value"] for output
600                         in outputs}
601
602         stack.outputs = self.outputs
603         return stack