drydock/python/drydock_provisioner/objects/bootaction.py

420 lines
15 KiB
Python

# 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.
"""Object models for BootActions."""
import base64
from jinja2 import Template
import ulid2
import yaml
import oslo_versionedobjects.fields as ovo_fields
import drydock_provisioner.objects.base as base
import drydock_provisioner.objects.fields as hd_fields
import drydock_provisioner.config as config
import drydock_provisioner.error as errors
from drydock_provisioner.statemgmt.design.resolver import ReferenceResolver
@base.DrydockObjectRegistry.register
class BootAction(base.DrydockPersistentObject, base.DrydockObject):
VERSION = '1.0'
fields = {
'name':
ovo_fields.StringField(),
'source':
hd_fields.ModelSourceField(nullable=False),
'asset_list':
ovo_fields.ObjectField('BootActionAssetList', nullable=False),
'node_filter':
ovo_fields.ObjectField('NodeFilterSet', nullable=True),
'target_nodes':
ovo_fields.ListOfStringsField(nullable=True),
'signaling':
ovo_fields.BooleanField(default=True),
}
def __init__(self, **kwargs):
super().__init__(**kwargs)
if not self.target_nodes:
self.target_nodes = []
# NetworkLink keyed by name
def get_id(self):
return self.get_name()
def get_name(self):
return self.name
def render_assets(self,
nodename,
site_design,
action_id,
action_key,
design_ref,
type_filter=None):
"""Render all of the assets in this bootaction.
Render the assets of this bootaction and return them in a list.
The ``nodename`` and ``action_id`` will be
used to build the context for any assets utilizing the ``template``
pipeline segment.
:param nodename: name of the node the assets are destined for
:param site_design: a objects.SiteDesign instance holding the design sets
:param action_id: a 128-bit ULID action_id of the boot action
the assets are part of
:param action_key: a 256-bit random key to authenticate API calls
for updating the status of this bootaction
:param design_ref: the design ref this boot action was initiated under
:param type_filter: optional filter of the types of assets to render
"""
assets = list()
for a in self.asset_list:
if type_filter is None or (type_filter is not None
and a.type == type_filter):
a.render(nodename, site_design, action_id, action_key,
design_ref)
assets.append(a)
return assets
@base.DrydockObjectRegistry.register
class BootActionList(base.DrydockObjectListBase, base.DrydockObject):
VERSION = '1.0'
fields = {
'objects': ovo_fields.ListOfObjectsField('BootAction'),
}
@base.DrydockObjectRegistry.register
class BootActionAsset(base.DrydockObject):
VERSION = '1.0'
fields = {
'type': hd_fields.BootactionAssetTypeField(nullable=True),
'path': ovo_fields.StringField(nullable=True),
'location': ovo_fields.StringField(nullable=True),
'data': ovo_fields.StringField(nullable=True),
'package_list': ovo_fields.DictOfNullableStringsField(nullable=True),
'location_pipeline': ovo_fields.ListOfStringsField(nullable=True),
'data_pipeline': ovo_fields.ListOfStringsField(nullable=True),
'permissions': ovo_fields.IntegerField(nullable=True),
}
def __init__(self, **kwargs):
if 'permissions' in kwargs:
mode = kwargs.pop('permissions')
if isinstance(mode, str):
mode = int(mode, base=8)
else:
mode = None
ba_type = kwargs.get('type', None)
package_list = None
if ba_type == hd_fields.BootactionAssetType.PackageList:
if isinstance(kwargs.get('data'), dict):
package_list = self._extract_package_list(kwargs.pop('data'))
# If the data section doesn't parse as a dictionary
# then the package data needs to be sourced dynamically
# Otherwise the Bootaction is invalid
elif not kwargs.get('location'):
raise errors.InvalidPackageListFormat(
"Requires a top-level mapping/object.")
super().__init__(package_list=package_list, permissions=mode, **kwargs)
self.rendered_bytes = None
def render(self, nodename, site_design, action_id, action_key, design_ref):
"""Render this asset into a base64 encoded string.
The ``nodename`` and ``action_id`` will be used to construct
the context for evaluating the ``template`` pipeline segment
:param nodename: the name of the node where the asset will be deployed
:param site_design: instance of objects.SiteDesign
:param action_id: a 128-bit ULID boot action id
:param action_key: a 256-bit random key for API auth
:param design_ref: The design ref this bootaction was initiated under
"""
tpl_ctx = self._get_template_context(nodename, site_design, action_id,
action_key, design_ref)
if self.location is not None:
rendered_location = self.execute_pipeline(
self.location, self.location_pipeline, tpl_ctx=tpl_ctx)
data_block = self.resolve_asset_location(rendered_location)
if self.type == hd_fields.BootactionAssetType.PackageList:
self._parse_package_list(data_block)
elif self.type != hd_fields.BootactionAssetType.PackageList:
data_block = self.data.encode('utf-8')
if self.type != hd_fields.BootactionAssetType.PackageList:
value = self.execute_pipeline(
data_block, self.data_pipeline, tpl_ctx=tpl_ctx)
if isinstance(value, str):
value = value.encode('utf-8')
self.rendered_bytes = value
def _parse_package_list(self, data):
"""Parse data expecting a list of packages to install.
Expect data to be a bytearray reprsenting a JSON or YAML
document.
:param data: A bytearray of data to parse
"""
try:
data_string = data.decode('utf-8')
parsed_data = yaml.safe_load(data_string)
if isinstance(parsed_data, dict):
self.package_list = self._extract_package_list(parsed_data)
else:
raise errors.InvalidPackageListFormat(
"Package data should have a top-level mapping/object.")
except yaml.YAMLError as ex:
raise errors.InvalidPackageListFormat(
"Invalid YAML in package list: %s" % str(ex))
def _extract_package_list(self, pkg_dict):
"""Extract package data into object model.
:param pkg_dict: a dictionary of packages to install
"""
package_list = dict()
for k, v in pkg_dict.items():
if (isinstance(k, str) and (not v or isinstance(v, str))):
package_list[k] = v
else:
raise errors.InvalidPackageListFormat(
"Keys and values must be strings.")
return package_list
def _get_template_context(self, nodename, site_design, action_id,
action_key, design_ref):
"""Create a context to be used for template rendering.
:param nodename: The name of the node for the bootaction
:param site_design: The full site design
:param action_id: the ULID assigned to the boot action using this context
:param action_key: a 256 bit random key for API auth
:param design_ref: The design reference representing ``site_design``
"""
return dict(
node=self._get_node_context(nodename, site_design),
action=self._get_action_context(action_id, action_key, design_ref))
def _get_action_context(self, action_id, action_key, design_ref):
"""Create the action-specific context items for template rendering.
:param action_id: ULID of this boot action
:param action_key: random key of this boot action
:param design_ref: Design reference representing the site design
"""
return dict(
action_id=ulid2.ulid_to_base32(action_id),
action_key=action_key.hex(),
report_url=config.config_mgr.conf.bootactions.report_url,
design_ref=design_ref)
def _get_node_context(self, nodename, site_design):
"""Create the node-specific context items for template rendering.
:param nodename: name of the node this boot action targets
:param site_design: full site design
"""
node = site_design.get_baremetal_node(nodename)
return dict(
hostname=nodename,
domain=node.get_domain(site_design),
tags=[t for t in node.tags],
labels={k: v
for (k, v) in node.owner_data.items()},
network=self._get_node_network_context(node, site_design),
interfaces=self._get_node_interface_context(node))
def _get_node_network_context(self, node, site_design):
"""Create a node's network configuration context.
:param node: node object
:param site_design: full site design
"""
network_context = dict()
for a in node.addressing:
if a.address is not None:
network = site_design.get_network(a.network)
network_context[a.network] = dict(
ip=a.address,
cidr=network.cidr,
dns_suffix=network.dns_domain)
if a.network == node.primary_network:
network_context['default'] = network_context[a.network]
return network_context
def _get_node_interface_context(self, node):
"""Create a node's network interface context.
:param node: the node object
"""
interface_context = dict()
for i in node.interfaces:
interface_context[i.device_name] = dict(sriov=i.sriov)
if i.sriov:
interface_context[i.device_name]['vf_count'] = i.vf_count
interface_context[i.device_name]['trustedmode'] = i.trustedmode
return interface_context
def resolve_asset_location(self, asset_url):
"""Retrieve the data asset from the url.
Returns the asset as a bytestring.
:param asset_url: URL to retrieve the data asset from
"""
try:
return ReferenceResolver.resolve_reference(asset_url)
except Exception as ex:
raise errors.InvalidAssetLocation(
"Unable to resolve asset reference %s: %s" % (asset_url,
str(ex)))
def execute_pipeline(self, data, pipeline, tpl_ctx=None):
"""Execute a pipeline against a data element.
Returns the manipulated ``data`` element
:param data: The data element to be manipulated by the pipeline
:param pipeline: list of pipeline segments to execute
:param tpl_ctx: The optional context to be made available to the ``template`` pipeline
"""
segment_funcs = {
'base64_encode': self.eval_base64_encode,
'base64_decode': self.eval_base64_decode,
'utf8_decode': self.eval_utf8_decode,
'utf8_encode': self.eval_utf8_encode,
'template': self.eval_template,
}
for s in pipeline:
try:
data = segment_funcs[s](data, ctx=tpl_ctx)
except KeyError:
raise errors.UnknownPipelineSegment(
"Bootaction pipeline segment %s unknown." % s)
except Exception as ex:
raise errors.PipelineFailure(
"Error when running bootaction pipeline segment %s: %s - %s"
% (s, type(ex).__name__, str(ex)))
return data
def eval_base64_encode(self, data, ctx=None):
"""Encode data as base64.
Light weight wrapper around base64 library to shed the ctx kwarg
:param data: data to be encoded
:param ctx: throwaway, just allows a generic interface for pipeline segments
"""
return base64.b64encode(data)
def eval_base64_decode(self, data, ctx=None):
"""Decode data from base64.
Light weight wrapper around base64 library to shed the ctx kwarg
:param data: data to be decoded
:param ctx: throwaway, just allows a generic interface for pipeline segments
"""
return base64.b64decode(data)
def eval_utf8_decode(self, data, ctx=None):
"""Decode data from bytes to UTF-8 string.
:param data: data to be decoded
:param ctx: throwaway, just allows a generic interface for pipeline segments
"""
return data.decode('utf-8')
def eval_utf8_encode(self, data, ctx=None):
"""Encode data from UTF-8 to bytes.
:param data: data to be encoded
:param ctx: throwaway, just allows a generic interface for pipeline segments
"""
return data.encode('utf-8')
def eval_template(self, data, ctx=None):
"""Evaluate data as a Jinja2 template.
:param data: The template
:param ctx: Optional ctx to inject into the template render
"""
template = Template(data)
return template.render(ctx)
@base.DrydockObjectRegistry.register
class BootActionAssetList(base.DrydockObjectListBase, base.DrydockObject):
VERSION = '1.0'
fields = {
'objects': ovo_fields.ListOfObjectsField('BootActionAsset'),
}
@base.DrydockObjectRegistry.register
class NodeFilterSet(base.DrydockObject):
VERSION = '1.0'
fields = {
'filter_set_type': ovo_fields.StringField(nullable=False),
'filter_set': ovo_fields.ListOfObjectsField('NodeFilter'),
}
def __init__(self, **kwargs):
super().__init__(**kwargs)
@base.DrydockObjectRegistry.register
class NodeFilter(base.DrydockObject):
VERSION = '1.0'
fields = {
'filter_type': ovo_fields.StringField(nullable=False),
'node_names': ovo_fields.ListOfStringsField(nullable=True),
'node_tags': ovo_fields.ListOfStringsField(nullable=True),
'node_labels': ovo_fields.DictOfStringsField(nullable=True),
'rack_names': ovo_fields.ListOfStringsField(nullable=True),
'rack_labels': ovo_fields.DictOfStringsField(nullable=True),
}
def __init__(self, **kwargs):
super().__init__(**kwargs)