summaryrefslogtreecommitdiff
path: root/tuskar_ui/api/heat.py
blob: 18056de5ce8ed6bae19ee510d9588fbcc8804452 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

import logging
import os
import tempfile
import urlparse

from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from heatclient.common import template_utils
from heatclient.exc import HTTPNotFound
from horizon.utils import memoized
from openstack_dashboard.api import base
from openstack_dashboard.api import heat
from openstack_dashboard.api import keystone

from tuskar_ui.api import node
from tuskar_ui.api import tuskar
from tuskar_ui.cached_property import cached_property  # noqa
from tuskar_ui.handle_errors import handle_errors  # noqa
from tuskar_ui.utils import utils


LOG = logging.getLogger(__name__)


@memoized.memoized
def overcloud_keystoneclient(request, endpoint, password):
    """Returns a client connected to the Keystone backend.

    Several forms of authentication are supported:

        * Username + password -> Unscoped authentication
        * Username + password + tenant id -> Scoped authentication
        * Unscoped token -> Unscoped authentication
        * Unscoped token + tenant id -> Scoped authentication
        * Scoped token -> Scoped authentication

    Available services and data from the backend will vary depending on
    whether the authentication was scoped or unscoped.

    Lazy authentication if an ``endpoint`` parameter is provided.

    Calls requiring the admin endpoint should have ``admin=True`` passed in
    as a keyword argument.

    The client is cached so that subsequent API calls during the same
    request/response cycle don't have to be re-authenticated.
    """
    api_version = keystone.VERSIONS.get_active_version()

    # TODO(lsmola) add support of certificates and secured http and rest of
    # parameters according to horizon and add configuration to local settings
    # (somehow plugin based, we should not maintain a copy of settings)
    LOG.debug("Creating a new keystoneclient connection to %s." % endpoint)

    # TODO(lsmola) we should create tripleo-admin user for this purpose
    # this needs to be done first on tripleo side
    conn = api_version['client'].Client(username="admin",
                                        password=password,
                                        tenant_name="admin",
                                        auth_url=endpoint)

    return conn


def _save_templates(templates):
    """Saves templates into tmpdir on server

    This should go away and get replaced by libutils.save_templates from
    tripleo-common https://github.com/openstack/tripleo-common/
    """
    output_dir = tempfile.mkdtemp()

    for template_name, template_content in templates.items():

        # It's possible to organize the role templates and their dependent
        # files into directories, in which case the template_name will carry
        # the directory information. If that's the case, first create the
        # directory structure (if it hasn't already been created by another
        # file in the templates list).
        template_dir = os.path.dirname(template_name)
        output_template_dir = os.path.join(output_dir, template_dir)
        if template_dir and not os.path.exists(output_template_dir):
            os.makedirs(output_template_dir)

        filename = os.path.join(output_dir, template_name)
        with open(filename, 'w+') as template_file:
            template_file.write(template_content)
    return output_dir


def _process_templates(templates):
    """Process templates

    Due to bug in heat api
    https://bugzilla.redhat.com/show_bug.cgi?id=1212740, we need to
    save the templates in tmpdir, reprocess them with template_utils
    from heatclient and then we can use them in creating/updating stack.

    This should be replaced by the same code that is in tripleo-common and
    eventually it will not be needed at all.
    """

    tpl_dir = _save_templates(templates)

    tpl_files, template = template_utils.get_template_contents(
        template_file=os.path.join(tpl_dir, tuskar.MASTER_TEMPLATE_NAME))
    env_files, env = (
        template_utils.process_multiple_environments_and_files(
            env_paths=[os.path.join(tpl_dir, tuskar.ENVIRONMENT_NAME)]))

    files = dict(list(tpl_files.items()) + list(env_files.items()))

    return template, env, files


class Stack(base.APIResourceWrapper):
    _attrs = ('id', 'stack_name', 'outputs', 'stack_status', 'parameters')

    def __init__(self, apiresource, request=None):
        super(Stack, self).__init__(apiresource)
        self._request = request

    @classmethod
    def create(cls, request, stack_name, templates):
        template, environment, files = _process_templates(templates)

        fields = {
            'stack_name': stack_name,
            'template': template,
            'environment': environment,
            'files': files,
            'timeout_mins': 240,
        }
        password = getattr(settings, 'UNDERCLOUD_ADMIN_PASSWORD', None)
        stack = heat.stack_create(request, password, **fields)
        return cls(stack, request=request)

    def update(self, request, stack_name, templates):
        template, environment, files = _process_templates(templates)

        fields = {
            'stack_name': stack_name,
            'template': template,
            'environment': environment,
            'files': files,
        }
        password = getattr(settings, 'UNDERCLOUD_ADMIN_PASSWORD', None)
        heat.stack_update(request, self.id, password, **fields)

    @classmethod
    @handle_errors(_("Unable to retrieve heat stacks"), [])
    def list(cls, request):
        """Return a list of stacks in Heat

        :param request: request object
        :type  request: django.http.HttpRequest

        :return: list of Heat stacks, or an empty list if there
                 are none
        :rtype:  list of tuskar_ui.api.heat.Stack
        """
        stacks, has_more_data, has_prev_data = heat.stacks_list(request)
        return [cls(stack, request=request) for stack in stacks]

    @classmethod
    @handle_errors(_("Unable to retrieve stack"))
    def get(cls, request, stack_id):
        """Return the Heat Stack associated with this Overcloud

        :return: Heat Stack associated with the stack_id; or None
                 if no Stack is associated, or no Stack can be
                 found
        :rtype:  tuskar_ui.api.heat.Stack or None
        """
        return cls(heat.stack_get(request, stack_id), request=request)

    @classmethod
    @handle_errors(_("Unable to retrieve stack"))
    def get_by_plan(cls, request, plan):
        """Return the Heat Stack associated with a Plan

        :return: Heat Stack associated with the plan; or None
                 if no Stack is associated, or no Stack can be
                 found
        :rtype:  tuskar_ui.api.heat.Stack or None
        """
        # TODO(lsmola) until we have working deployment through Tuskar-API,
        # this will not work
        # for stack in Stack.list(request):
        #     if stack.plan and (stack.plan.id == plan.id):
        #         return stack
        try:
            stack = Stack.list(request)[0]
        except IndexError:
            return None
        # TODO(lsmola) stack list actually does not contain all the detail
        # info, there should be call for that, investigate
        return Stack.get(request, stack.id)

    @classmethod
    @handle_errors(_("Unable to delete Heat stack"), [])
    def delete(cls, request, stack_id):
        heat.stack_delete(request, stack_id)

    @memoized.memoized
    def resources(self, with_joins=True, role=None):
        """Return list of OS::Nova::Server Resources

        Return list of OS::Nova::Server Resources associated with the Stack
        and which are associated with a Role

        :param with_joins: should we also retrieve objects associated with each
                           retrieved Resource?
        :type  with_joins: bool

        :return: list of all Resources or an empty list if there are none
        :rtype:  list of tuskar_ui.api.heat.Resource
        """

        if role:
            roles = [role]
        else:
            roles = self.plan.role_list
        resource_dicts = []

        # A provider resource is deployed as a nested stack, so we have to
        # drill down and retrieve those that match a tuskar role
        for role in roles:
            resource_group_name = role.name
            try:
                resource_group = heat.resource_get(self._request,
                                                   self.id,
                                                   resource_group_name)

                group_resources = heat.resources_list(
                    self._request, resource_group.physical_resource_id)
                for group_resource in group_resources:
                    if not group_resource.physical_resource_id:
                        # Skip groups who has no physical resource.
                        continue
                    nova_resources = heat.resources_list(
                        self._request,
                        group_resource.physical_resource_id)
                    resource_dicts.extend([{"resource": resource,
                                            "role": role}
                                           for resource in nova_resources])

            except HTTPNotFound:
                pass

        if not with_joins:
            return [Resource(rd['resource'], request=self._request,
                             stack=self, role=rd['role'])
                    for rd in resource_dicts]

        nodes_dict = utils.list_to_dict(node.Node.list(self._request,
                                                       associated=True),
                                        key_attribute='instance_uuid')
        joined_resources = []
        for rd in resource_dicts:
            resource = rd['resource']
            joined_resources.append(
                Resource(resource,
                         node=nodes_dict.get(resource.physical_resource_id,
                                             None),
                         request=self._request, stack=self, role=rd['role']))
        # TODO(lsmola) I want just resources with nova instance
        # this could be probably filtered a better way, investigate
        return [r for r in joined_resources if r.node is not None]

    @memoized.memoized
    def resources_count(self, overcloud_role=None):
        """Return count of associated Resources

        :param overcloud_role: role of resources to be counted; None means all
        :type  overcloud_role: tuskar_ui.api.tuskar.Role

        :return: Number of matching resources
        :rtype:  int
        """
        # TODO(dtantsur): there should be better way to do it, rather than
        # fetching and calling len()
        # FIXME(dtantsur): should also be able to use with_joins=False
        # but unable due to bug #1289505
        if overcloud_role is None:
            resources = self.resources()
        else:
            resources = self.resources(role=overcloud_role)
        return len(resources)

    @cached_property
    def plan(self):
        """return associated Plan if a plan_id exists within stack parameters.

        :return: associated Plan if plan_id exists and a matching plan
                 exists as well; None otherwise
        :rtype:  tuskar_ui.api.tuskar.Plan
        """
        # TODO(lsmola) replace this by actual reference, I am pretty sure
        # the relation won't be stored in parameters, that would mean putting
        # that into template, which doesn't make sense
        # if 'plan_id' in self.parameters:
        #     return tuskar.Plan.get(self._request,
        #                                     self.parameters['plan_id'])
        try:
            plan = tuskar.Plan.list(self._request)[0]
        except IndexError:
            return None
        return plan

    @cached_property
    def is_initialized(self):
        """Check if this Stack is successfully initialized.

        :return: True if this Stack is successfully initialized, False
                 otherwise
        :rtype:  bool
        """
        return len(self.dashboard_urls) > 0

    @cached_property
    def is_deployed(self):
        """Check if this Stack is successfully deployed.

        :return: True if this Stack is successfully deployed, False otherwise
        :rtype:  bool
        """
        return self.stack_status in ('CREATE_COMPLETE',
                                     'UPDATE_COMPLETE')

    @cached_property
    def is_deploying(self):
        """Check if this Stack is currently deploying.

        :return: True if deployment is in progress, False otherwise.
        :rtype: bool
        """
        return self.stack_status in ('CREATE_IN_PROGRESS',)

    @cached_property
    def is_updating(self):
        """Check if this Stack is currently updating.

        :return: True if updating is in progress, False otherwise.
        :rtype: bool
        """
        return self.stack_status in ('UPDATE_IN_PROGRESS',)

    @cached_property
    def is_failed(self):
        """Check if this Stack failed to update or deploy.

        :return: True if deployment there was an error, False otherwise.
        :rtype: bool
        """
        return self.stack_status in ('CREATE_FAILED',
                                     'UPDATE_FAILED',)

    @cached_property
    def is_deleting(self):
        """Check if this Stack is deleting.

        :return: True if Stack is deleting, False otherwise.
        :rtype: bool
        """
        return self.stack_status in ('DELETE_IN_PROGRESS', )

    @cached_property
    def is_delete_failed(self):
        """Check if Stack deleting has failed.

        :return: True if Stack deleting has failed, False otherwise.
        :rtype: bool
        """
        return self.stack_status in ('DELETE_FAILED', )

    @cached_property
    def events(self):
        """Return the Heat Events associated with this Stack

        :return: list of Heat Events associated with this Stack;
                 or an empty list if there is no Stack associated with
                 this Stack, or there are no Events
        :rtype:  list of heatclient.v1.events.Event
        """
        return heat.events_list(self._request,
                                self.stack_name)

    @property
    def stack_outputs(self):
        return getattr(self, 'outputs', [])

    @cached_property
    def keystone_auth_url(self):
        for output in self.stack_outputs:
            if output['output_key'] == 'KeystoneURL':
                return output['output_value']

    @cached_property
    def keystone_ip(self):
        if self.keystone_auth_url:
            return urlparse.urlparse(self.keystone_auth_url).hostname

    @cached_property
    def overcloud_keystone(self):
        try:
            return overcloud_keystoneclient(
                self._request,
                self.keystone_auth_url,
                self.plan.parameter_value('Controller-1::AdminPassword'))
        except Exception:
            LOG.debug('Unable to connect to overcloud keystone.')
            return None

    @cached_property
    def dashboard_urls(self):
        client = self.overcloud_keystone
        if not client:
            return []

        try:
            services = client.services.list()
            for service in services:
                if service.name == 'horizon':
                    break
            else:
                return []
        except Exception:
            return []

        admin_urls = [endpoint.adminurl for endpoint
                      in client.endpoints.list()
                      if endpoint.service_id == service.id]

        return admin_urls


class Resource(base.APIResourceWrapper):
    _attrs = ('resource_name', 'resource_type', 'resource_status',
              'physical_resource_id')

    def __init__(self, apiresource, request=None, **kwargs):
        """Initialize a resource

        :param apiresource: apiresource we want to wrap
        :type  apiresource: heatclient.v1.resources.Resource

        :param request: request
        :type  request: django.core.handlers.wsgi.WSGIRequest

        :param node: node relation we want to cache
        :type  node: tuskar_ui.api.node.Node

        :return: Resource object
        :rtype:  Resource
        """
        super(Resource, self).__init__(apiresource)
        self._request = request
        if 'node' in kwargs:
            self._node = kwargs['node']
        if 'stack' in kwargs:
            self._stack = kwargs['stack']
        if 'role' in kwargs:
            self._role = kwargs['role']

    @classmethod
    @memoized.memoized
    def _resources_by_nodes(cls, request):
        return {resource.physical_resource_id: resource
                for resource in cls.list_all_resources(request)}

    @classmethod
    def get_by_node(cls, request, node):
        """Return the specified Heat Resource given a Node

        :param request: request object
        :type  request: django.http.HttpRequest

        :param node: node to match
        :type  node: tuskar_ui.api.node.Node

        :return: matching Resource, or raises LookupError if no
                 resource matches the node
        :rtype:  tuskar_ui.api.heat.Resource
        """
        return cls._resources_by_nodes(request)[node.instance_uuid]

    @classmethod
    def list_all_resources(cls, request):
        """Iterate through all the stacks and return all relevant resources

        :param request: request object
        :type  request: django.http.HttpRequest

        :return: list of resources
        :rtype:  list of tuskar_ui.api.heat.Resource
        """
        all_resources = []
        for stack in Stack.list(request):
            all_resources.extend(stack.resources(with_joins=False))
        return all_resources

    @cached_property
    def role(self):
        """Return the Role associated with this Resource

        :return: Role associated with this Resource, or None if no
                 Role is associated
        :rtype:  tuskar_ui.api.tuskar.Role
        """
        if hasattr(self, '_role'):
            return self._role

    @cached_property
    def node(self):
        """Return the Ironic Node associated with this Resource

        :return: Ironic Node associated with this Resource, or None if no
                 Node is associated
        :rtype:  tuskar_ui.api.node.Node

        :raises: ironicclient.exc.HTTPNotFound if there is no Node with the
                 matching instance UUID
        """
        if hasattr(self, '_node'):
            return self._node
        if self.physical_resource_id:
            return node.Node.get_by_instance_uuid(self._request,
                                                  self.physical_resource_id)
        return None

    @cached_property
    def stack(self):
        """Return the Stack associated with this Resource

        :return: Stack associated with this Resource, or None if no
                 Stack is associated
        :rtype:  tuskar_ui.api.heat.Stack
        """
        if hasattr(self, '_stack'):
            return self._stack