Merge "Add API to update hosts info about SUT"
[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):
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': {'name': name}
241             }
242         else:
243             self.resources[name] = {
244                 'type': 'OS::Neutron::ProviderNet',
245                 'properties': {
246                     'name': name,
247                     'network_type': 'vlan',
248                     'physical_network': physical_network
249                 }
250             }
251             if segmentation_id:
252                 seg_id_dit = {'segmentation_id': segmentation_id}
253                 self.resources[name]["properties"].update(seg_id_dit)
254
255     def add_server_group(self, name, policies):     # pragma: no cover
256         """add to the template a ServerGroup"""
257         log.debug("adding Nova::ServerGroup '%s'", name)
258         policies = policies if isinstance(policies, list) else [policies]
259         self.resources[name] = {
260             'type': 'OS::Nova::ServerGroup',
261             'properties': {'name': name,
262                            'policies': policies}
263         }
264
265     def add_subnet(self, name, network, cidr):
266         """add to the template a Neutron Subnet"""
267         log.debug("adding Neutron::Subnet '%s' in network '%s', cidr '%s'",
268                   name, network, cidr)
269         self.resources[name] = {
270             'type': 'OS::Neutron::Subnet',
271             'depends_on': network,
272             'properties': {
273                 'name': name,
274                 'cidr': cidr,
275                 'network_id': {'get_resource': network}
276             }
277         }
278
279         self._template['outputs'][name] = {
280             'description': 'subnet %s ID' % name,
281             'value': {'get_resource': name}
282         }
283         self._template['outputs'][name + "-cidr"] = {
284             'description': 'subnet %s cidr' % name,
285             'value': {'get_attr': [name, 'cidr']}
286         }
287         self._template['outputs'][name + "-gateway_ip"] = {
288             'description': 'subnet %s gateway_ip' % name,
289             'value': {'get_attr': [name, 'gateway_ip']}
290         }
291
292     def add_router(self, name, ext_gw_net, subnet_name):
293         """add to the template a Neutron Router and interface"""
294         log.debug("adding Neutron::Router:'%s', gw-net:'%s'", name, ext_gw_net)
295         self.resources[name] = {
296             'type': 'OS::Neutron::Router',
297             'depends_on': [subnet_name],
298             'properties': {
299                 'name': name,
300                 'external_gateway_info': {
301                     'network': ext_gw_net
302                 }
303             }
304         }
305
306     def add_router_interface(self, name, router_name, subnet_name):
307         """add to the template a Neutron RouterInterface and interface"""
308         log.debug("adding Neutron::RouterInterface '%s' router:'%s', "
309                   "subnet:'%s'", name, router_name, subnet_name)
310         self.resources[name] = {
311             'type': 'OS::Neutron::RouterInterface',
312             'depends_on': [router_name, subnet_name],
313             'properties': {
314                 'router_id': {'get_resource': router_name},
315                 'subnet_id': {'get_resource': subnet_name}
316             }
317         }
318
319     def add_port(self, name, network_name, subnet_name, sec_group_id=None,
320                  provider=None):
321         """add to the template a named Neutron Port"""
322         log.debug("adding Neutron::Port '%s', network:'%s', subnet:'%s', "
323                   "secgroup:%s", name, network_name, subnet_name, sec_group_id)
324         self.resources[name] = {
325             'type': 'OS::Neutron::Port',
326             'depends_on': [subnet_name],
327             'properties': {
328                 'name': name,
329                 'fixed_ips': [{'subnet': {'get_resource': subnet_name}}],
330                 'network_id': {'get_resource': network_name},
331                 'replacement_policy': 'AUTO',
332             }
333         }
334
335         if provider == PROVIDER_SRIOV:
336             self.resources[name]['properties']['binding:vnic_type'] = \
337                 'direct'
338
339         if sec_group_id:
340             self.resources[name]['depends_on'].append(sec_group_id)
341             self.resources[name]['properties']['security_groups'] = \
342                 [sec_group_id]
343
344         self._template['outputs'][name] = {
345             'description': 'Address for interface %s' % name,
346             'value': {'get_attr': [name, 'fixed_ips', 0, 'ip_address']}
347         }
348         self._template['outputs'][name + "-subnet_id"] = {
349             'description': 'Address for interface %s' % name,
350             'value': {'get_attr': [name, 'fixed_ips', 0, 'subnet_id']}
351         }
352         self._template['outputs'][name + "-mac_address"] = {
353             'description': 'MAC Address for interface %s' % name,
354             'value': {'get_attr': [name, 'mac_address']}
355         }
356         self._template['outputs'][name + "-device_id"] = {
357             'description': 'Device ID for interface %s' % name,
358             'value': {'get_attr': [name, 'device_id']}
359         }
360         self._template['outputs'][name + "-network_id"] = {
361             'description': 'Network ID for interface %s' % name,
362             'value': {'get_attr': [name, 'network_id']}
363         }
364
365     def add_floating_ip(self, name, network_name, port_name, router_if_name,
366                         secgroup_name=None):
367         """add to the template a Nova FloatingIP resource
368         see: https://bugs.launchpad.net/heat/+bug/1299259
369         """
370         log.debug("adding Nova::FloatingIP '%s', network '%s', port '%s', "
371                   "rif '%s'", name, network_name, port_name, router_if_name)
372
373         self.resources[name] = {
374             'type': 'OS::Nova::FloatingIP',
375             'depends_on': [port_name, router_if_name],
376             'properties': {
377                 'pool': network_name
378             }
379         }
380
381         if secgroup_name:
382             self.resources[name]["depends_on"].append(secgroup_name)
383
384         self._template['outputs'][name] = {
385             'description': 'floating ip %s' % name,
386             'value': {'get_attr': [name, 'ip']}
387         }
388
389     def add_floating_ip_association(self, name, floating_ip_name, port_name):
390         """add to the template a Nova FloatingIP Association resource
391         """
392         log.debug("adding Nova::FloatingIPAssociation '%s', server '%s', "
393                   "floating_ip '%s'", name, port_name, floating_ip_name)
394
395         self.resources[name] = {
396             'type': 'OS::Neutron::FloatingIPAssociation',
397             'depends_on': [port_name],
398             'properties': {
399                 'floatingip_id': {'get_resource': floating_ip_name},
400                 'port_id': {'get_resource': port_name}
401             }
402         }
403
404     def add_keypair(self, name, key_uuid):
405         """add to the template a Nova KeyPair"""
406         log.debug("adding Nova::KeyPair '%s'", name)
407         self.resources[name] = {
408             'type': 'OS::Nova::KeyPair',
409             'properties': {
410                 'name': name,
411                 # resource_string returns bytes, so we must decode to unicode
412                 'public_key': encodeutils.safe_decode(
413                     pkg_resources.resource_string(
414                         'yardstick.resources',
415                         'files/yardstick_key-' +
416                         get_short_key_uuid(key_uuid) + '.pub'),
417                     'utf-8')
418             }
419         }
420
421     def add_servergroup(self, name, policy):
422         """add to the template a Nova ServerGroup"""
423         log.debug("adding Nova::ServerGroup '%s', policy '%s'", name, policy)
424         if policy not in ["anti-affinity", "affinity"]:
425             raise ValueError(policy)
426
427         self.resources[name] = {
428             'type': 'OS::Nova::ServerGroup',
429             'properties': {
430                 'name': name,
431                 'policies': [policy]
432             }
433         }
434
435         self._template['outputs'][name] = {
436             'description': 'ID Server Group %s' % name,
437             'value': {'get_resource': name}
438         }
439
440     def add_security_group(self, name):
441         """add to the template a Neutron SecurityGroup"""
442         log.debug("adding Neutron::SecurityGroup '%s'", name)
443         self.resources[name] = {
444             'type': 'OS::Neutron::SecurityGroup',
445             'properties': {
446                 'name': name,
447                 'description': "Group allowing icmp and upd/tcp on all ports",
448                 'rules': [
449                     {'remote_ip_prefix': '0.0.0.0/0',
450                      'protocol': 'tcp',
451                      'port_range_min': '1',
452                      'port_range_max': '65535'},
453                     {'remote_ip_prefix': '0.0.0.0/0',
454                      'protocol': 'udp',
455                      'port_range_min': '1',
456                      'port_range_max': '65535'},
457                     {'remote_ip_prefix': '0.0.0.0/0',
458                      'protocol': 'icmp'}
459                 ]
460             }
461         }
462
463         self._template['outputs'][name] = {
464             'description': 'ID of Security Group',
465             'value': {'get_resource': name}
466         }
467
468     def add_server(self, name, image, flavor, flavors, ports=None,
469                    networks=None, scheduler_hints=None, user=None,
470                    key_name=None, user_data=None, metadata=None,
471                    additional_properties=None):
472         """add to the template a Nova Server"""
473         log.debug("adding Nova::Server '%s', image '%s', flavor '%s', "
474                   "ports %s", name, image, flavor, ports)
475
476         self.resources[name] = {
477             'type': 'OS::Nova::Server',
478             'depends_on': []
479         }
480
481         server_properties = {
482             'name': name,
483             'image': image,
484             'flavor': {},
485             'networks': []  # list of dictionaries
486         }
487
488         if flavor in flavors:
489             self.resources[name]['depends_on'].append(flavor)
490             server_properties["flavor"] = {'get_resource': flavor}
491         else:
492             server_properties["flavor"] = flavor
493
494         if user:
495             server_properties['admin_user'] = user
496
497         if key_name:
498             self.resources[name]['depends_on'].append(key_name)
499             server_properties['key_name'] = {'get_resource': key_name}
500
501         if ports:
502             self.resources[name]['depends_on'].extend(ports)
503             for port in ports:
504                 server_properties['networks'].append(
505                     {'port': {'get_resource': port}}
506                 )
507
508         if networks:
509             for i, _ in enumerate(networks):
510                 server_properties['networks'].append({'network': networks[i]})
511
512         if scheduler_hints:
513             server_properties['scheduler_hints'] = scheduler_hints
514
515         if user_data:
516             server_properties['user_data'] = user_data
517
518         if metadata:
519             assert isinstance(metadata, collections.Mapping)
520             server_properties['metadata'] = metadata
521
522         if additional_properties:
523             assert isinstance(additional_properties, collections.Mapping)
524             for prop in additional_properties:
525                 server_properties[prop] = additional_properties[prop]
526
527         server_properties['config_drive'] = True
528
529         self.resources[name]['properties'] = server_properties
530
531         self._template['outputs'][name] = {
532             'description': 'VM UUID',
533             'value': {'get_resource': name}
534         }
535
536     HEAT_WAIT_LOOP_INTERVAL = 2
537
538     def create(self, block=True, timeout=3600):
539         """
540         creates a template in the target cloud using heat
541         returns a dict with the requested output values from the template
542
543         :param block: Wait for Heat create to finish
544         :type block: bool
545         :param: timeout: timeout in seconds for Heat create, default 3600s
546         :type timeout: int
547         """
548         log.info("Creating stack '%s'", self.name)
549
550         # create stack early to support cleanup, e.g. ctrl-c while waiting
551         stack = HeatStack(self.name)
552
553         heat_client = self.heat_client
554         start_time = time.time()
555         stack.uuid = self.uuid = heat_client.stacks.create(
556             stack_name=self.name, template=self._template,
557             parameters=self.heat_parameters)['stack']['id']
558
559         if not block:
560             self.outputs = stack.outputs = {}
561             return stack
562
563         time_limit = start_time + timeout
564         for status in iter(self.status, u'CREATE_COMPLETE'):
565             log.debug("stack state %s", status)
566             if status == u'CREATE_FAILED':
567                 raise RuntimeError(
568                     heat_client.stacks.get(self.uuid).stack_status_reason)
569             if time.time() > time_limit:
570                 raise RuntimeError("Heat stack create timeout")
571
572             time.sleep(self.HEAT_WAIT_LOOP_INTERVAL)
573
574         end_time = time.time()
575         outputs = heat_client.stacks.get(self.uuid).outputs
576         log.info("Created stack '%s' in %d secs",
577                  self.name, end_time - start_time)
578
579         # keep outputs as unicode
580         self.outputs = {output["output_key"]: output["output_value"] for output
581                         in outputs}
582
583         stack.outputs = self.outputs
584         return stack