# Copyright 2017 AT&T Intellectual Property. All other rights reserved. # # 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. """Handle resources for boot action API endpoints. """ import tarfile import io import logging import jsonschema import json import ulid2 import falcon from drydock_provisioner.objects.fields import ActionResult from drydock_provisioner.objects.fields import BootactionAssetType import drydock_provisioner.objects as objects from .base import StatefulResource logger = logging.getLogger('drydock') class BootactionResource(StatefulResource): bootaction_schema = { '$schema': 'http://json-schema.org/schema#', 'type': 'object', 'additionalProperties': False, 'properties': { 'status': { 'type': 'string', 'enum': ['Failure', 'Success', 'failure', 'success'], }, 'details': { 'type': 'array', 'items': { 'type': 'object', 'additionalProperties': True, 'properties': { 'message': { 'type': 'string', }, 'error': { 'type': 'boolean', } }, 'required': ['message', 'error'], }, }, }, } def __init__(self, orchestrator=None, **kwargs): super().__init__(**kwargs) self.orchestrator = orchestrator def on_post(self, req, resp, action_id): """Post status messages or final status for a boot action. This endpoint does not use the standard oslo_policy enforcement as this endpoint is accessed by unmanned nodes. Instead it uses a internal key authentication :param req: falcon request :param resp: falcone response :param action_id: ULID ID of the boot action """ try: ba_entry = self.state_manager.get_boot_action(action_id) except Exception as ex: self.logger.error( "Error querying for boot action %s" % action_id, exc_info=ex) raise falcon.HTTPInternalServerError(str(ex)) if ba_entry is None: raise falcon.HTTPNotFound() BootactionUtils.check_auth(ba_entry, req) try: json_body = self.req_json(req) jsonschema.validate(json_body, BootactionResource.bootaction_schema) except Exception as ex: self.logger.error("Error processing boot action body", exc_info=ex) raise falcon.HTTPBadRequest(description="Error processing body.") if ba_entry.get('action_status').lower() != ActionResult.Incomplete: self.logger.info( "Attempt to update boot action %s after status finalized." % action_id) raise falcon.HTTPConflict( description= "Action %s status finalized, not available for update." % action_id) for m in json_body.get('details', []): rm = objects.TaskStatusMessage( m.get('message'), m.get('error'), 'bootaction', action_id) for f, v in m.items(): if f not in ['message', 'error']: rm['extra'] = dict() rm['extra'][f] = v self.state_manager.post_result_message(ba_entry['task_id'], rm) new_status = json_body.get('status', None) if new_status is not None: self.state_manager.put_bootaction_status( action_id, action_status=new_status.lower()) ba_entry = self.state_manager.get_boot_action(action_id) ba_entry.pop('identity_key') resp.status = falcon.HTTP_200 resp.content_type = 'application/json' ba_entry['task_id'] = str(ba_entry['task_id']) ba_entry['action_id'] = ulid2.encode_ulid_base32(ba_entry['action_id']) resp.body = json.dumps(ba_entry) return class BootactionAssetsResource(StatefulResource): def __init__(self, orchestrator=None, **kwargs): super().__init__(**kwargs) self.orchestrator = orchestrator def do_get(self, req, resp, hostname, asset_type): """Render ``unit`` type boot action assets for hostname. Get the boot action context for ``hostname`` from the database and render all ``unit`` type assets for the host. Validate host is providing the correct idenity key in the ``X-Bootaction-Key`` header. :param req: falcon request object :param resp: falcon response object :param hostname: URL path parameter indicating the calling host :param asset_type: Asset type to include in the response - ``unit``, ``file``, ``pkg_list``, ``all`` """ try: ba_ctx = self.state_manager.get_boot_action_context(hostname) except Exception as ex: self.logger.error( "Error locating boot action for %s" % hostname, exc_info=ex) raise falcon.HTTPNotFound() if ba_ctx is None: raise falcon.HTTPNotFound( description="Error locating boot action for %s" % hostname) BootactionUtils.check_auth(ba_ctx, req) asset_type_filter = None if asset_type == 'all' else asset_type try: task = self.state_manager.get_task(ba_ctx['task_id']) self.logger.debug("Loading design for task %s from design ref %s" % (ba_ctx['task_id'], task.design_ref)) design_status, site_design = self.orchestrator.get_effective_site( task.design_ref) assets = list() ba_status_list = self.state_manager.get_boot_actions_for_node( hostname) for ba in site_design.bootactions: if hostname in ba.target_nodes: ba_status = ba_status_list.get(ba.name, None) action_id = ba_status.get('action_id') action_key = ba_status.get('identity_key') assets.extend( ba.render_assets( hostname, site_design, action_id, action_key, task.design_ref, type_filter=asset_type_filter)) tarball = BootactionUtils.tarbuilder(asset_list=assets) resp.set_header('Content-Type', 'application/gzip') resp.set_header( 'Content-Disposition', "attachment; filename=\"%s-%s.tar.gz\"" % (hostname, asset_type)) resp.data = tarball resp.status = falcon.HTTP_200 return except Exception as ex: self.logger.debug("Exception in boot action API.", exc_info=ex) raise falcon.HTTPInternalServerError(str(ex)) class BootactionUnitsResource(BootactionAssetsResource): def __init__(self, **kwargs): super().__init__(**kwargs) def on_get(self, req, resp, hostname): self.logger.debug( "Accessing boot action units resource for host %s." % hostname) self.do_get(req, resp, hostname, 'unit') class BootactionFilesResource(BootactionAssetsResource): def __init__(self, **kwargs): super().__init__(**kwargs) def on_get(self, req, resp, hostname): self.do_get(req, resp, hostname, 'file') class BootactionUtils(object): """Utility class shared by Boot Action API resources.""" @staticmethod def check_auth(ba_ctx, req): """Check request authentication based on boot action context. Raise proper Falcon exception if authentication fails, otherwise silently return :param ba_ctx: Boot Action context from database :param req: The falcon request object of the API call """ identity_key = req.get_header('X-Bootaction-Key', default='') if identity_key == '': raise falcon.HTTPUnauthorized( title='Unauthorized', description='No X-Bootaction-Key', challenges=['Bootaction-Key']) if ba_ctx['identity_key'] != bytes.fromhex(identity_key): logger.warn( "Forbidding boot action access - node: %s, identity_key: %s, req header: %s" % (ba_ctx['node_name'], str(ba_ctx['identity_key']), str(bytes.fromhex(identity_key)))) raise falcon.HTTPForbidden( title='Unauthorized', description='Invalid X-Bootaction-Key') @staticmethod def tarbuilder(asset_list=None): """Create a tar file from rendered assets. Add each asset in ``asset_list`` to a tar file with the defined path and permission. The assets need to have the rendered_bytes field populated. Return a tarfile.TarFile. :param hostname: the hostname the tar is destined for :param balltype: the type of assets being included :param asset_list: list of objects.BootActionAsset instances """ tarbytes = io.BytesIO() tarball = tarfile.open( mode='w:gz', fileobj=tarbytes, format=tarfile.GNU_FORMAT) asset_list = [ a for a in asset_list if a.type != BootactionAssetType.PackageList ] for a in asset_list: fileobj = io.BytesIO(a.rendered_bytes) tarasset = tarfile.TarInfo(name=a.path) tarasset.size = len(a.rendered_bytes) tarasset.mode = a.permissions if a.permissions else 0o600 tarasset.uid = 0 tarasset.gid = 0 tarball.addfile(tarasset, fileobj=fileobj) tarball.close() return tarbytes.getvalue()