From de9c84dc806fdc26bc2b4b9503748a97dc0f8b22 Mon Sep 17 00:00:00 2001 From: Mark Goddard Date: Tue, 1 Mar 2016 11:11:51 +0200 Subject: [PATCH] Initial commit Change-Id: I34e41fabb648368189b479e4cfbc78747d465b95 --- LICENSE | 176 +++ README.rst | 75 ++ bareon_ironic/__init__.py | 0 bareon_ironic/bareon.py | 108 ++ bareon_ironic/modules/__init__.py | 0 bareon_ironic/modules/bareon_base.py | 1051 ++++++++++++++++ bareon_ironic/modules/bareon_config.template | 6 + .../modules/bareon_config_live.template | 6 + bareon_ironic/modules/bareon_exception.py | 48 + bareon_ironic/modules/bareon_rsync.py | 71 ++ bareon_ironic/modules/bareon_swift.py | 35 + bareon_ironic/modules/bareon_utils.py | 251 ++++ bareon_ironic/modules/resources/__init__.py | 0 bareon_ironic/modules/resources/actions.py | 229 ++++ .../modules/resources/image_service.py | 373 ++++++ bareon_ironic/modules/resources/resources.py | 557 +++++++++ bareon_ironic/modules/resources/rsync.py | 67 + doc/Makefile | 193 +++ doc/source/conf.py | 201 +++ doc/source/index.rst | 9 + doc/source/install-guide.rst | 70 ++ doc/source/user-guide.rst | 1029 ++++++++++++++++ etc/ironic/ironic.conf.bareon_sample | 105 ++ patches/patch-ironic-stable-kilo | 311 +++++ patches/patch-nova-stable-kilo | 1081 +++++++++++++++++ requirements.txt | 8 + setup.cfg | 23 + setup.py | 29 + test-requirements.txt | 4 + tox.ini | 37 + 30 files changed, 6153 insertions(+) create mode 100644 LICENSE create mode 100644 README.rst create mode 100644 bareon_ironic/__init__.py create mode 100644 bareon_ironic/bareon.py create mode 100644 bareon_ironic/modules/__init__.py create mode 100644 bareon_ironic/modules/bareon_base.py create mode 100644 bareon_ironic/modules/bareon_config.template create mode 100644 bareon_ironic/modules/bareon_config_live.template create mode 100644 bareon_ironic/modules/bareon_exception.py create mode 100644 bareon_ironic/modules/bareon_rsync.py create mode 100644 bareon_ironic/modules/bareon_swift.py create mode 100644 bareon_ironic/modules/bareon_utils.py create mode 100644 bareon_ironic/modules/resources/__init__.py create mode 100644 bareon_ironic/modules/resources/actions.py create mode 100644 bareon_ironic/modules/resources/image_service.py create mode 100644 bareon_ironic/modules/resources/resources.py create mode 100644 bareon_ironic/modules/resources/rsync.py create mode 100644 doc/Makefile create mode 100644 doc/source/conf.py create mode 100644 doc/source/index.rst create mode 100644 doc/source/install-guide.rst create mode 100644 doc/source/user-guide.rst create mode 100644 etc/ironic/ironic.conf.bareon_sample create mode 100644 patches/patch-ironic-stable-kilo create mode 100644 patches/patch-nova-stable-kilo create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 test-requirements.txt create mode 100644 tox.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..68c771a --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..4a90c78 --- /dev/null +++ b/README.rst @@ -0,0 +1,75 @@ +========================================= +Bareon-based deployment driver for Ironic +========================================= + +``bareon_ironic`` package adds support for Bareon to OpenStack Ironic. +Ironic [#]_ is baremetal provisioning service with support of multiple hardware +types. Ironic architecture is able to work with deploy agents. Deploy agent +is a service that does provisioning tasks on the node side. Deploy agent is +integrated into bootstrap ramdisk image. +``bareon_ironic`` contains pluggable drivers code for Ironic that uses +Bareon [#]_ as deploy agent. Current implementation requires and tested with +Ironic/Nova Stable Kilo release. + +Features overview +================= + +Flexible deployment configuration +--------------------------------- +A JSON called deploy_config carries partitions schema, partitioning behavior, +images schema, various deployment args. It can be passed through various +places like nova VM metadata, image metadata, node metadata etc. Resulting +JSON is a result of merge operation, that works basing on the priorities +configureed in ``/etc/ironic/ironic.conf``. + +LVM support +----------- +Configuration JSON allows to define schemas with mixed partitions and logical +volumes. + +Multiple partitioning behaviors available +----------------------------------------- + +- Verify. Reads schema from the baremetal hardware and compares with user + schema. +- Verify+clean. Compares baremetal schema with user schema, wipes particular + filesystems basing on the user schema. +- Clean. Wipes disk, deploys from scratch. + +Multiple image deployment +------------------------- + +Configuration JSON allows to define more than 1 image. The Bareon Ironic driver +provides handles to switch between deployed images. This allows to perform a +baremetal node upgrades with minimal downtime. + +Block-level copy & file-level Image deployment +---------------------------------------------- + +Bareon Ironic driver allows to do both: bare_swift drivers for block-level and +bare_rsync drivers for file-level. + +Deployment termination +---------------------- + +The driver allows to teminate deployment in both silent (wait-callback) and +active (deploying) phases. + +Post-deployment hooks +--------------------- + +Two hook mechanisms available: on_fail_script and deploy actions. The first one +is a user-provided shell script which is executed inside the deploy ramdisk if +deployment has failed. The latter is a JSON-based, allows to define various +actions with associated resources and run them after the deployment has passed. + + +Building HTML docs +================== + +$ pip install sphinx +$ cd bareon-ironic/doc && make html + + +.. [#] https://wiki.openstack.org/wiki/Ironic +.. [#] https://wiki.openstack.org/wiki/Bareon diff --git a/bareon_ironic/__init__.py b/bareon_ironic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bareon_ironic/bareon.py b/bareon_ironic/bareon.py new file mode 100644 index 0000000..b966907 --- /dev/null +++ b/bareon_ironic/bareon.py @@ -0,0 +1,108 @@ +# +# Copyright 2016 Cray Inc., All 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. + +from ironic.drivers import base +from ironic.drivers.modules import discoverd +from ironic.drivers.modules import ipmitool +from ironic.drivers.modules import ssh + +from bareon_ironic.modules import bareon_rsync +from bareon_ironic.modules import bareon_swift + + +class BareonSwiftAndIPMIToolDriver(base.BaseDriver): + """Bareon Swift + IPMITool driver. + + This driver implements the `core` functionality, combining + :class:`ironic.drivers.modules.ipmitool.IPMIPower` (for power on/off and + reboot) with + :class:`ironic.drivers.modules.bareon_swift.BareonSwiftDeploy` + (for image deployment). + Implementations are in those respective classes; this class is merely the + glue between them. + """ + + def __init__(self): + self.power = ipmitool.IPMIPower() + self.deploy = bareon_swift.BareonSwiftDeploy() + self.management = ipmitool.IPMIManagement() + self.vendor = bareon_swift.BareonSwiftVendor() + self.inspect = discoverd.DiscoverdInspect.create_if_enabled( + 'BareonSwiftAndIPMIToolDriver') + + +class BareonSwiftAndSSHDriver(base.BaseDriver): + """Bareon Swift + SSH driver. + + NOTE: This driver is meant only for testing environments. + + This driver implements the `core` functionality, combining + :class:`ironic.drivers.modules.ssh.SSH` (for power on/off and reboot of + virtual machines tunneled over SSH), with + :class:`ironic.drivers.modules.bareon_swift.BareonSwiftDeploy` + (for image deployment). Implementations are in those respective classes; + this class is merely the glue between them. + """ + + def __init__(self): + self.power = ssh.SSHPower() + self.deploy = bareon_swift.BareonSwiftDeploy() + self.management = ssh.SSHManagement() + self.vendor = bareon_swift.BareonSwiftVendor() + self.inspect = discoverd.DiscoverdInspect.create_if_enabled( + 'BareonSwiftAndSSHDriver') + + +class BareonRsyncAndIPMIToolDriver(base.BaseDriver): + """Bareon Rsync + IPMITool driver. + + This driver implements the `core` functionality, combining + :class:`ironic.drivers.modules.ipmitool.IPMIPower` (for power on/off and + reboot) with + :class:`ironic.drivers.modules.bareon_rsync.BareonRsyncDeploy` + (for image deployment). + Implementations are in those respective classes; this class is merely the + glue between them. + """ + + def __init__(self): + self.power = ipmitool.IPMIPower() + self.deploy = bareon_rsync.BareonRsyncDeploy() + self.management = ipmitool.IPMIManagement() + self.vendor = bareon_rsync.BareonRsyncVendor() + self.inspect = discoverd.DiscoverdInspect.create_if_enabled( + 'BareonRsyncAndIPMIToolDriver') + + +class BareonRsyncAndSSHDriver(base.BaseDriver): + """Bareon Rsync + SSH driver. + + NOTE: This driver is meant only for testing environments. + + This driver implements the `core` functionality, combining + :class:`ironic.drivers.modules.ssh.SSH` (for power on/off and reboot of + virtual machines tunneled over SSH), with + :class:`ironic.drivers.modules.bareon_rsync.BareonRsyncDeploy` + (for image deployment). Implementations are in those respective classes; + this class is merely the glue between them. + """ + + def __init__(self): + self.power = ssh.SSHPower() + self.deploy = bareon_rsync.BareonRsyncDeploy() + self.management = ssh.SSHManagement() + self.vendor = bareon_rsync.BareonRsyncVendor() + self.inspect = discoverd.DiscoverdInspect.create_if_enabled( + 'BareonRsyncAndSSHDriver') diff --git a/bareon_ironic/modules/__init__.py b/bareon_ironic/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bareon_ironic/modules/bareon_base.py b/bareon_ironic/modules/bareon_base.py new file mode 100644 index 0000000..c37c482 --- /dev/null +++ b/bareon_ironic/modules/bareon_base.py @@ -0,0 +1,1051 @@ +# +# Copyright 2015 Mirantis, Inc. +# +# Copyright 2016 Cray Inc., All 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. + +""" +Bareon deploy driver. +""" + +import json +import os + +import eventlet +import six +from oslo_concurrency import processutils +from oslo_config import cfg +from oslo_utils import excutils + +from ironic.common import boot_devices +from ironic.common import dhcp_factory +from ironic.common import exception +from ironic.common import keystone +from ironic.common import pxe_utils +from ironic.common import states +from ironic.common import utils +from ironic.common.glance_service import service_utils +from ironic.common.i18n import _ +from ironic.common.i18n import _LE +from ironic.common.i18n import _LI +from ironic.conductor import task_manager +from ironic.conductor import utils as manager_utils +from ironic.drivers import base +from ironic.drivers.modules import deploy_utils +from ironic.drivers.modules import image_cache +from ironic.objects import node as db_node +from ironic.openstack.common import fileutils +from ironic.openstack.common import log +from ironic.openstack.common import loopingcall + +from bareon_ironic.modules import bareon_exception +from bareon_ironic.modules import bareon_utils +from bareon_ironic.modules.resources import actions +from bareon_ironic.modules.resources import image_service +from bareon_ironic.modules.resources import resources +from bareon_ironic.modules.resources import rsync + +agent_opts = [ + cfg.StrOpt('pxe_config_template', + default=os.path.join(os.path.dirname(__file__), + 'bareon_config.template'), + help='Template file for two-disk boot PXE configuration.'), + cfg.StrOpt('pxe_config_template_live', + default=os.path.join(os.path.dirname(__file__), + 'bareon_config_live.template'), + help='Template file for three-disk (live boot) PXE ' + 'configuration.'), + cfg.StrOpt('bareon_pxe_append_params', + default='nofb nomodeset vga=normal', + help='Additional append parameters for baremetal PXE boot.'), + cfg.StrOpt('deploy_kernel', + help='UUID (from Glance) of the default deployment kernel.'), + cfg.StrOpt('deploy_ramdisk', + help='UUID (from Glance) of the default deployment ramdisk.'), + cfg.StrOpt('deploy_squashfs', + help='UUID (from Glance) of the default deployment root FS.'), + cfg.StrOpt('deploy_config_priority', + default='instance:node:image:conf', + help='Priority for deploy config'), + cfg.StrOpt('deploy_config', + help='A uuid or name of glance image representing ' + 'deploy config.'), + cfg.IntOpt('deploy_timeout', + default=15, + help="Timeout in minutes for the node continue-deploy process " + "(deployment phase following the callback)."), + cfg.IntOpt('check_terminate_interval', + help='Time interval in seconds to check whether the deployment ' + 'driver has responded to termination signal', + default=5), + cfg.IntOpt('check_terminate_max_retries', + help='Max retries to check is node already terminated', + default=20), +] + +CONF = cfg.CONF +CONF.register_opts(agent_opts, group='bareon') + +LOG = log.getLogger(__name__) + +REQUIRED_PROPERTIES = {} +OTHER_PROPERTIES = { + 'deploy_kernel': _('UUID (from Glance) of the deployment kernel.'), + 'deploy_ramdisk': _('UUID (from Glance) of the deployment ramdisk.'), + 'deploy_squashfs': _('UUID (from Glance) of the deployment root FS image ' + 'mounted at boot time.'), + 'bareon_username': _('SSH username; default is "root" Optional.'), + 'bareon_key_filename': _('Name of SSH private key file; default is ' + '"/etc/ironic/fuel_key". Optional.'), + 'bareon_ssh_port': _('SSH port; default is 22. Optional.'), + 'bareon_deploy_script': _('path to bareon executable entry point; ' + 'default is "provision_ironic" Optional.'), + 'deploy_config': _('Deploy config Glance image id/name'), +} +COMMON_PROPERTIES = OTHER_PROPERTIES + +REQUIRED_BAREON_VERSION = "0.0." + +TERMINATE_FLAG = 'terminate_deployment' + + +@image_cache.cleanup(priority=25) +class AgentTFTPImageCache(image_cache.ImageCache): + def __init__(self, image_service=None): + super(AgentTFTPImageCache, self).__init__( + CONF.pxe.tftp_master_path, + # MiB -> B + CONF.pxe.image_cache_size * 1024 * 1024, + # min -> sec + CONF.pxe.image_cache_ttl * 60, + image_service=image_service) + + +def _create_rootfs_link(task): + """Create Swift temp url for deployment root FS.""" + rootfs = task.node.driver_info['deploy_squashfs'] + if service_utils.is_glance_image(rootfs): + glance = image_service.GlanceImageService(version=2, + context=task.context) + image_info = glance.show(rootfs) + temp_url = glance.swift_temp_url(image_info) + temp_url += '&filename=/root.squashfs' + return temp_url + + try: + image_service.HttpImageService().validate_href(rootfs) + except exception.ImageRefValidationFailed: + with excutils.save_and_reraise_exception(): + LOG.error(_LE("Agent deploy supports only HTTP URLs as " + "driver_info['deploy_squashfs']. Either %s " + "is not a valid HTTP URL or " + "is not reachable."), rootfs) + return rootfs + + +def _clean_up_images(task): + node = task.node + if node.instance_info.get('images_cleaned_up', False): + return + try: + with open(get_tenant_images_json_path(node)) as f: + images_json = json.loads(f.read()) + except Exception as ex: + LOG.warning("Cannot find tenant_images.json for the %s node to" + "finish cleanup." % node) + LOG.warning(str(ex)) + else: + images = resources.ResourceList.from_dict(images_json, task) + images.cleanup_resources() + bareon_utils.change_node_dict(task.node, 'instance_info', + {'images_cleaned_up': True}) + + +class BareonDeploy(base.DeployInterface): + """Interface for deploy-related actions.""" + + def get_properties(self): + """Return the properties of the interface. + + :returns: dictionary of : entries. + """ + return COMMON_PROPERTIES + + def validate(self, task): + """Validate the driver-specific Node deployment info. + + This method validates whether the properties of the supplied node + contain the required information for this driver to deploy images to + the node. + + :param task: a TaskManager instance + :raises: MissingParameterValue + """ + node = task.node + params = self._get_boot_files(node) + error_msg = _('Node %s failed to validate deploy image info. Some ' + 'parameters were missing') % node.uuid + deploy_utils.check_for_missing_params(params, error_msg) + + self._parse_driver_info(node) + + @task_manager.require_exclusive_lock + def deploy(self, task): + """Perform a deployment to a node. + + Perform the necessary work to deploy an image onto the specified node. + This method will be called after prepare(), which may have already + performed any preparatory steps, such as pre-caching some data for the + node. + + :param task: a TaskManager instance. + :returns: status of the deploy. One of ironic.common.states. + """ + self._do_pxe_boot(task) + return states.DEPLOYWAIT + + @task_manager.require_exclusive_lock + def tear_down(self, task): + """Tear down a previous deployment on the task's node. + + :param task: a TaskManager instance. + :returns: status of the deploy. One of ironic.common.states. + """ + manager_utils.node_power_action(task, states.POWER_OFF) + return states.DELETED + + def prepare(self, task): + """Prepare the deployment environment for this node. + + :param task: a TaskManager instance. + """ + self._fetch_resources(task) + self._prepare_pxe_boot(task) + + def clean_up(self, task): + """Clean up the deployment environment for this node. + + If preparation of the deployment environment ahead of time is possible, + this method should be implemented by the driver. It should erase + anything cached by the `prepare` method. + + If implemented, this method must be idempotent. It may be called + multiple times for the same node on the same conductor, and it may be + called by multiple conductors in parallel. Therefore, it must not + require an exclusive lock. + + This method is called before `tear_down`. + + :param task: a TaskManager instance. + """ + # NOTE(lobur): most of the cleanup is done immediately after + # deployment (see end of pass_deploy_info). + # - PXE resources are left till the node is unprovisioned because we + # plan to add support of tenant PXE boot. + # - Resources such as provision.json are left till the node is + # unprovisioned to simplify debugging. + self._clean_up_pxe(task) + _clean_up_images(task) + self._clean_up_resource_dirs(task) + + def take_over(self, task): + pass + + def _clean_up_pxe(self, task): + """Clean up left over PXE and DHCP files.""" + pxe_info = self._get_tftp_image_info(task.node) + for label in pxe_info: + path = pxe_info[label][1] + utils.unlink_without_raise(path) + AgentTFTPImageCache().clean_up() + pxe_utils.clean_up_pxe_config(task) + + def _fetch_resources(self, task): + self._fetch_provision_json(task) + self._fetch_actions(task) + + def _fetch_provision_json(self, task): + config = self._get_deploy_config(task) + config = self._add_image_deployment_config(task, config) + + deploy_data = config.get('deploy_data', {}) + if 'kernel_params' not in deploy_data: + deploy_data['kernel_params'] = CONF.bareon.bareon_pxe_append_params + config['deploy_data'] = deploy_data + + LOG.info('[{0}] Resulting provision.json is:\n{1}'.format( + task.node.uuid, config)) + + # On fail script is not passed to the agent, it is handled on + # Conductor. + on_fail_script_url = config.pop("on_fail_script", None) + self._fetch_on_fail_script(task, on_fail_script_url) + + filename = get_provision_json_path(task.node) + LOG.info('[{0}] Writing provision.json to:\n{1}'.format( + task.node.uuid, filename)) + with open(filename, 'w') as f: + f.write(json.dumps(config)) + + def _get_deploy_config(self, task): + node = task.node + instance_info = node.instance_info + + # Get options passed by nova, if any. + deploy_config_options = instance_info.get('deploy_config_options', {}) + # Get options available at ironic side. + deploy_config_options['node'] = node.driver_info.get('deploy_config') + deploy_config_options['conf'] = CONF.bareon.deploy_config + # Cleaning empty options. + deploy_config_options = {k: v for k, v in + six.iteritems(deploy_config_options) if v} + + configs = self._fetch_deploy_configs(task.context, node, + deploy_config_options) + return self._merge_configs(configs) + + def _fetch_deploy_configs(self, context, node, cfg_options): + configs = {} + for key, url in six.iteritems(cfg_options): + configs[key] = resources.url_download_json(context, node, + url) + return configs + + @staticmethod + def _merge_configs(configs): + # Merging first level attributes of configs according to priority + priority_list = CONF.bareon.deploy_config_priority.split(':') + unknown_sources = set(priority_list) - {'instance', 'node', 'conf', + 'image'} + if unknown_sources: + raise ValueError('Unknown deploy config source %s' % str( + unknown_sources)) + + result = {} + for k in priority_list[::-1]: + if k in configs: + result.update(configs[k]) + LOG.debug('Resulting deploy config:') + LOG.debug('%s', result) + return result + + def _fetch_on_fail_script(self, task, url): + if not url: + return + path = get_on_fail_script_path(task.node) + LOG.info('[{0}] Fetching on_fail_script to:\n{1}'.format( + task.node.uuid, path)) + resources.url_download(task.context, task.node, url, path) + + def _fetch_actions(self, task): + driver_actions_url = task.node.instance_info.get('driver_actions') + actions_data = resources.url_download_json(task.context, + task.node, + driver_actions_url) + if not actions_data: + LOG.info("[%s] No driver_actions specified" % task.node.uuid) + return + + controller = actions.ActionController(task, actions_data) + controller.fetch_action_resources() + + actions_data = controller.to_dict() + LOG.info('[{0}] Deploy actions for the node are:\n{1}'.format( + task.node.uuid, actions_data)) + + filename = get_actions_json_path(task.node) + LOG.info('[{0}] Writing actions.json to:\n{1}'.format( + task.node.uuid, filename)) + with open(filename, 'w') as f: + f.write(json.dumps(actions_data)) + + def _clean_up_resource_dirs(self, task): + utils.rmtree_without_raise( + resources.get_abs_node_workdir_path(task.node)) + utils.rmtree_without_raise( + rsync.get_abs_node_workdir_path(task.node)) + + def _build_instance_info_for_deploy(self, task): + raise NotImplementedError + + def _do_pxe_boot(self, task, ports=None): + """Reboot the node into the PXE ramdisk. + + :param task: a TaskManager instance + :param ports: a list of Neutron port dicts to update DHCP options on. + If None, will get the list of ports from the Ironic port objects. + """ + dhcp_opts = pxe_utils.dhcp_options_for_instance(task) + provider = dhcp_factory.DHCPFactory() + provider.update_dhcp(task, dhcp_opts, ports) + manager_utils.node_set_boot_device(task, boot_devices.PXE, + persistent=True) + manager_utils.node_power_action(task, states.REBOOT) + + def _cache_tftp_images(self, ctx, node, pxe_info): + """Fetch the necessary kernels and ramdisks for the instance.""" + fileutils.ensure_tree( + os.path.join(CONF.pxe.tftp_root, node.uuid)) + LOG.debug("Fetching kernel and ramdisk for node %s", + node.uuid) + deploy_utils.fetch_images(ctx, AgentTFTPImageCache(), + pxe_info.values()) + + def _prepare_pxe_boot(self, task): + """Prepare the files required for PXE booting the agent.""" + pxe_info = self._get_tftp_image_info(task.node) + + # Do live boot if squashfs is specified in either way. + is_live_boot = (task.node.driver_info.get('deploy_squashfs') or + CONF.bareon.deploy_squashfs) + pxe_options = self._build_pxe_config_options(task, pxe_info, + is_live_boot) + template = (CONF.bareon.pxe_config_template_live if is_live_boot + else CONF.bareon.pxe_config_template) + pxe_utils.create_pxe_config(task, + pxe_options, + template) + + self._cache_tftp_images(task.context, task.node, pxe_info) + + def _get_tftp_image_info(self, node): + params = self._get_boot_files(node) + return pxe_utils.get_deploy_kr_info(node.uuid, params) + + def _build_pxe_config_options(self, task, pxe_info, live_boot): + """Builds the pxe config options for booting agent. + + This method builds the config options to be replaced on + the agent pxe config template. + + :param task: a TaskManager instance + :param pxe_info: A dict containing the 'deploy_kernel' and + 'deploy_ramdisk' for the agent pxe config template. + :returns: a dict containing the options to be applied on + the agent pxe config template. + """ + ironic_api = (CONF.conductor.api_url or + keystone.get_service_url()).rstrip('/') + + agent_config_opts = { + 'deployment_aki_path': pxe_info['deploy_kernel'][1], + 'deployment_ari_path': pxe_info['deploy_ramdisk'][1], + 'bareon_pxe_append_params': CONF.bareon.bareon_pxe_append_params, + 'deployment_id': task.node.uuid, + 'api-url': ironic_api, + } + + if live_boot: + agent_config_opts['rootfs-url'] = _create_rootfs_link(task) + + return agent_config_opts + + def _get_boot_files(self, node): + d_info = node.driver_info + params = { + 'deploy_kernel': d_info.get('deploy_kernel', + CONF.bareon.deploy_kernel), + 'deploy_ramdisk': d_info.get('deploy_ramdisk', + CONF.bareon.deploy_ramdisk), + } + # Present only when live boot is used + squashfs = d_info.get('deploy_squashfs', CONF.bareon.deploy_squashfs) + if squashfs: + params['deploy_squashfs'] = squashfs + + return params + + def _get_image_resource_mode(self): + raise NotImplementedError + + def _get_deploy_driver(self): + raise NotImplementedError + + def _add_image_deployment_config(self, task, provision_config): + node = task.node + bareon_utils.change_node_dict( + node, 'instance_info', + {'deploy_driver': self._get_deploy_driver()}) + node.save() + + image_resource_mode = self._get_image_resource_mode() + boot_image = node.instance_info['image_source'] + default_user_images = [ + { + 'name': boot_image, + 'url': boot_image, + 'target': "/", + } + ] + user_images = provision_config.get('images', default_user_images) + + for image in user_images: + image_uuid, image_name = image_service.get_glance_image_uuid_name( + task, image['url']) + image['boot'] = (boot_image == image_uuid) + image['url'] = "glance:%s" % image_uuid + image['mode'] = image_resource_mode + image['image_uuid'] = image_uuid + image['image_name'] = image_name + + fetched_image_resources = self._fetch_images(task, user_images) + + image_deployment_config = [ + { + 'name': image.name, + 'image_pull_url': image.pull_url, + 'target': image.target, + 'boot': image.boot, + 'image_uuid': image.image_uuid, + 'image_name': image.image_name + } + for image in fetched_image_resources + ] + + bareon_utils.change_node_dict( + task.node, 'instance_info', + {'multiboot': len(image_deployment_config) > 1}) + node.save() + + provision_config['images'] = image_deployment_config + return provision_config + + def _fetch_images(self, task, image_resources): + images = resources.ResourceList({ + "name": "tenant_images", + "resources": image_resources + }, task) + images.fetch_resources() + + # NOTE(lobur): serialize tenant images json for further cleanup. + images_json = images.to_dict() + with open(get_tenant_images_json_path(task.node), 'w') as f: + f.write(json.dumps(images_json)) + + return images.resources + + @staticmethod + def _parse_driver_info(node): + """Gets the information needed for accessing the node. + + :param node: the Node object. + :returns: dictionary of information. + :raises: InvalidParameterValue if any required parameters are + incorrect. + :raises: MissingParameterValue if any required parameters are missing. + + """ + info = node.driver_info + d_info = {} + error_msgs = [] + + d_info['username'] = info.get('bareon_username', 'root') + d_info['key_filename'] = info.get('bareon_key_filename', + '/etc/ironic/fuel_key') + + if not os.path.isfile(d_info['key_filename']): + error_msgs.append(_("SSH key file %s not found.") % + d_info['key_filename']) + + try: + d_info['port'] = int(info.get('bareon_ssh_port', 22)) + except ValueError: + error_msgs.append(_("'bareon_ssh_port' must be an integer.")) + + if error_msgs: + msg = (_('The following errors were encountered while parsing ' + 'driver_info:\n%s') % '\n'.join(error_msgs)) + raise exception.InvalidParameterValue(msg) + + d_info['script'] = info.get('bareon_deploy_script', 'bareon-provision') + + return d_info + + def terminate_deployment(self, task): + node = task.node + if TERMINATE_FLAG not in node.instance_info: + + def _wait_for_node_to_become_terminated(retries, max_retries, + task): + task_node = task.node + retries[0] += 1 + if retries[0] > max_retries: + bareon_utils.change_node_dict( + task_node, 'instance_info', + {TERMINATE_FLAG: 'failed'}) + task_node.reservation = None + task_node.save() + + raise bareon_exception.RetriesException( + retry_count=max_retries) + + current_node = db_node.Node.get_by_uuid(task.context, + task_node.uuid) + if current_node.instance_info.get(TERMINATE_FLAG) == 'done': + raise loopingcall.LoopingCallDone() + + bareon_utils.change_node_dict( + node, 'instance_info', + {TERMINATE_FLAG: 'requested'}) + node.save() + + retries = [0] + interval = CONF.bareon.check_terminate_interval + max_retries = CONF.bareon.check_terminate_max_retries + + timer = loopingcall.FixedIntervalLoopingCall( + _wait_for_node_to_become_terminated, + retries, max_retries, task) + try: + timer.start(interval=interval).wait() + except bareon_exception.RetriesException as ex: + LOG.error('Failed to terminate node. Error: %(error)s' % { + 'error': ex}) + + @property + def can_terminate_deployment(self): + return True + + +class BareonVendor(base.VendorInterface): + def get_properties(self): + """Return the properties of the interface. + + :returns: dictionary of : entries. + """ + return COMMON_PROPERTIES + + def validate(self, task, method, **kwargs): + """Validate the driver-specific Node deployment info. + + :param task: a TaskManager instance + :param method: method to be validated + """ + if method == 'exec_actions': + return + + if method == 'switch_boot': + self.validate_switch_boot(task, **kwargs) + return + + if not kwargs.get('status'): + raise exception.MissingParameterValue(_('Unknown Bareon status' + ' on a node.')) + if not kwargs.get('address'): + raise exception.MissingParameterValue(_('Bareon must pass ' + 'address of a node.')) + BareonDeploy._parse_driver_info(task.node) + + def validate_switch_boot(self, task, **kwargs): + if not kwargs.get('image'): + raise exception.MissingParameterValue(_('No image info passed.')) + if not kwargs.get('ssh_key'): + raise exception.MissingParameterValue(_('No ssh key info passed.')) + if not kwargs.get('ssh_user'): + raise exception.MissingParameterValue(_('No ssh user info ' + 'passed.')) + + @base.passthru(['POST']) + @task_manager.require_exclusive_lock + def pass_deploy_info(self, task, **kwargs): + """Continues the deployment of baremetal node.""" + node = task.node + task.process_event('resume') + err_msg = _('Failed to continue deployment with Bareon.') + + agent_status = kwargs.get('status') + if agent_status != 'ready': + LOG.error(_LE('Deploy failed for node %(node)s. Bareon is not ' + 'in ready state, error: %(error)s'), + {'node': node.uuid, + 'error': kwargs.get('error_message')}) + deploy_utils.set_failed_state(task, err_msg) + return + + params = BareonDeploy._parse_driver_info(node) + params['host'] = kwargs.get('address') + + cmd = '%s --data_driver ironic --deploy_driver %s' % ( + params.pop('script'), node.instance_info['deploy_driver']) + if CONF.debug: + cmd += ' --debug' + instance_info = node.instance_info + + try: + ssh = bareon_utils.get_ssh_connection(task, **params) + sftp = ssh.open_sftp() + + self._check_bareon_version(ssh, node.uuid) + + provision_config_path = get_provision_json_path(task.node) + # TODO(yuriyz) no hardcode + sftp.put(provision_config_path, '/tmp/provision.json') + + # swift configdrive store should be disabled + configdrive = instance_info.get('configdrive') + if configdrive is not None: + # TODO(yuriyz) no hardcode + bareon_utils.sftp_write_to(sftp, configdrive, + '/tmp/config-drive.img') + + out, err = self._deploy(task, ssh, cmd, **params) + LOG.info(_LI('[%(node)s] Bareon pass on node %(node)s'), + {'node': node.uuid}) + LOG.debug('[%s] Bareon stdout is: "%s"', node.uuid, out) + LOG.debug('[%s] Bareon stderr is: "%s"', node.uuid, err) + + self._get_boot_info(task, ssh) + + self._run_actions(task, ssh, sftp, params) + + manager_utils.node_power_action(task, states.POWER_OFF) + manager_utils.node_set_boot_device(task, boot_devices.DISK, + persistent=True) + manager_utils.node_power_action(task, states.POWER_ON) + + except exception.SSHConnectFailed as e: + msg = ( + _('[%(node)s] SSH connect to node %(host)s failed. ' + 'Error: %(error)s') % {'host': params['host'], 'error': e, + 'node': node.uuid}) + self._deploy_failed(task, msg) + + except exception.ConfigInvalid as e: + msg = (_('[%(node)s] Invalid provision config. ' + 'Error: %(error)s') % {'error': e, 'node': node.uuid}) + self._deploy_failed(task, msg) + + except bareon_exception.DeployTerminationSucceed: + LOG.info(_LI('[%(node)s] Deployment was terminated'), + {'node': node.uuid}) + + except Exception as e: + self._run_on_fail_script(task, sftp, ssh) + + msg = (_('[%(node)s] Deploy failed for node %(node)s. ' + 'Error: %(error)s') % {'node': node.uuid, 'error': e}) + self._bareon_log(task, ssh) + self._deploy_failed(task, msg) + + else: + task.process_event('done') + LOG.info(_LI('Deployment to node %s done'), task.node.uuid) + + finally: + self._clean_up_deployment_resources(task) + + def _deploy_failed(self, task, msg): + LOG.error(msg) + deploy_utils.set_failed_state(task, msg) + + def _check_bareon_version(self, ssh, node_uuid): + try: + stdout, stderr = processutils.ssh_execute( + ssh, 'cat /etc/bareon-release') + + LOG.info(_LI("[{0}] Tracing Bareon version.\n{1}").format( + node_uuid, stdout)) + + version = "" + lines = stdout.splitlines() + if lines: + version_line = lines[0] + name, _, version = version_line.partition("==") + if version.startswith(REQUIRED_BAREON_VERSION): + return + + msg = ("Bareon version '%(req)s' is required, but version " + "'%(found)s' found on the ramdisk." + % dict(req=REQUIRED_BAREON_VERSION, + found=version)) + raise bareon_exception.IncompatibleRamdiskVersion(details=msg) + except processutils.ProcessExecutionError: + msg = "Bareon version cannot be read on the ramdisk." + raise bareon_exception.IncompatibleRamdiskVersion(details=msg) + + def _get_boot_info(self, task, ssh): + node = task.node + node_uuid = node.uuid + + if not node.instance_info.get('multiboot', False): + return + try: + stdout, stderr = processutils.ssh_execute( + ssh, 'cat /tmp/boot_entries.json') + except processutils.ProcessExecutionError as exec_err: + LOG.warning(_LI('[%(node)s] Error getting boot info. ' + 'Error: %(error)s') % {'node': node_uuid, + 'error': exec_err}) + raise + else: + multiboot_info = json.loads(stdout) + bareon_utils.change_node_dict(node, 'instance_info', { + 'multiboot_info': multiboot_info + }) + LOG.info("[{1}] {0} Multiboot info {0}\n{2}" + "\n".format("#" * 20, node_uuid, multiboot_info)) + + def _run_actions(self, task, ssh, sftp, sshparams): + actions_path = get_actions_json_path(task.node) + if not os.path.exists(actions_path): + LOG.info(_LI("[%(node)s] No actions specified. Skipping") + % {'node': task.node.uuid}) + return + + with open(actions_path) as f: + actions_data = json.loads(f.read()) + actions_controller = actions.ActionController( + task, actions_data + ) + + actions_controller.execute(ssh, sftp, **sshparams) + + def _bareon_log(self, task, ssh): + node_uuid = task.node.uuid + try: + # TODO(oberezovskyi): Chenge log pulling mechanism (e.g. use + # remote logging feature of syslog) + stdout, stderr = processutils.ssh_execute( + ssh, 'cat /var/log/bareon.log') + except processutils.ProcessExecutionError as exec_err: + LOG.warning(_LI('[%(node)s] Error getting Bareon log. ' + 'Error: %(error)s') % {'node': node_uuid, + 'error': exec_err}) + else: + LOG.info("[{1}] {0} Start Bareon log {0}\n{2}\n" + "[{1}] {0} End Bareon log {0}".format("#" * 20, + node_uuid, + stdout)) + + def _run_on_fail_script(self, task, sftp, ssh): + node = task.node + node_uuid = node.uuid + try: + on_fail_script_path = get_on_fail_script_path(node) + if not os.path.exists(on_fail_script_path): + LOG.info(_LI("[%(node)s] No on_fail_script passed. Skipping") + % {'node': node_uuid}) + return + + LOG.debug(_LI('[%(node)s] Uploading on_fail script to the node.'), + {'node': node_uuid}) + sftp.put(on_fail_script_path, '/tmp/bareon_on_fail.sh') + + LOG.debug("[%(node)s] Executing on_fail_script." + % {'node': node_uuid}) + out, err = processutils.ssh_execute( + ssh, "bash %s" % '/tmp/bareon_on_fail.sh') + + except processutils.ProcessExecutionError as ex: + LOG.warning(_LI('[%(node)s] Error executing OnFail script. ' + 'Error: %(er)s') % {'node': node_uuid, 'er': ex}) + + except exception.SSHConnectFailed as ex: + LOG.warning(_LI('[%(node)s] SSH connection error. ' + 'Error: %(er)s') % {'node': node_uuid, 'er': ex}) + + except Exception as ex: + LOG.warning(_LI('[%(node)s] Unknown error. ' + 'Error: %(error)s') % {'node': node_uuid, + 'error': ex}) + else: + LOG.info( + "{0} [{1}] on_fail sctipt result below {0}".format("#" * 40, + node_uuid)) + LOG.info(out) + LOG.info(err) + LOG.info("{0} [{1}] End on_fail script " + "result {0}".format("#" * 40, node_uuid)) + + def _clean_up_deployment_resources(self, task): + _clean_up_images(task) + self._clean_up_actions(task) + + def _clean_up_actions(self, task): + filename = get_actions_json_path(task.node) + if not os.path.exists(filename): + return + + with open(filename) as f: + actions_data = json.loads(f.read()) + + controller = actions.ActionController(task, actions_data) + controller.cleanup_action_resources() + + @base.passthru(['POST']) + @task_manager.require_exclusive_lock + def exec_actions(self, task, **kwargs): + actions_json = resources.url_download_json( + task.context, task.node, kwargs.get('driver_actions')) + if not actions_json: + LOG.info("[%s] No driver_actions specified." % task.node.uuid) + return + + ssh_user = actions_json.pop('action_user') + ssh_key_url = actions_json.pop('action_key') + node_ip = bareon_utils.get_node_ip(task) + + controller = actions.ActionController(task, actions_json) + controller.ssh_and_execute(node_ip, ssh_user, ssh_key_url) + + def _execute_deploy_script(self, task, ssh, cmd, *args, **kwargs): + # NOTE(oberezovskyi): minutes to seconds + timeout = CONF.bareon.deploy_timeout * 60 + LOG.debug('[%s] Running cmd (SSH): %s', task.node.uuid, cmd) + try: + out, err = bareon_utils.ssh_execute(ssh, cmd, timeout=timeout, + check_exit_code=True) + except exception.SSHCommandFailed as err: + LOG.debug('[%s] Deploy script execute failed: "%s"', + task.node.uuid, err) + raise bareon_exception.DeploymentTimeout(timeout=timeout) + return out, err + + def _deploy(self, task, ssh, cmd, **params): + deployment_thread = eventlet.spawn(self._execute_deploy_script, + task, ssh, cmd, **params) + + def _wait_for_deployment_finished(task, thread): + task_node = task.node + current_node = db_node.Node.get_by_uuid(task.context, + task_node.uuid) + + # NOTE(oberezovskyi): greenthread have no way to check is + # thread already finished, so need to access to + # private variable + if thread._exit_event.ready(): + raise loopingcall.LoopingCallDone() + + if (current_node.instance_info.get(TERMINATE_FLAG, + '') == 'requested'): + thread.kill() + bareon_utils.change_node_dict( + task_node, 'instance_info', + {TERMINATE_FLAG: 'done'}) + task_node.save() + raise bareon_exception.DeployTerminationSucceed() + + timer = loopingcall.FixedIntervalLoopingCall( + _wait_for_deployment_finished, task, deployment_thread) + timer.start(interval=5).wait() + return deployment_thread.wait() + + @base.passthru(['POST'], async=False) + @task_manager.require_exclusive_lock + def switch_boot(self, task, **kwargs): + # NOTE(oberezovskyi): exception messages should not be changed because + # of hardcode in nova-ironic driver + image = kwargs.get('image') + LOG.info('[{0}] Attempt to switch boot to {1} ' + 'image'.format(task.node.uuid, image)) + + msg = "" + try: + if not task.node.instance_info.get('multiboot', False): + msg = "[{}] Non-multiboot deployment".format(task.node.uuid) + raise exception.IronicException(message=msg, code=400) + + boot_info = task.node.instance_info.get('multiboot_info', + {'elements': []}) + + grub_id = next((element['grub_id'] + for element in boot_info['elements'] + if (element['image_uuid'] == image or + element['image_name'] == image)), None) + + if grub_id is None: + msg = ('[{}] Can\'t find desired multiboot ' + 'image'.format(task.node.uuid)) + raise exception.IronicException(message=msg, code=400) + + elif grub_id == boot_info.get('current_element', None): + msg = ('[{}] Already in desired boot ' + 'device.'.format(task.node.uuid)) + raise exception.IronicException(message=msg, code=400) + + node_ip = bareon_utils.get_node_ip(task) + ssh_key = resources.url_download_raw_secured(task.context, + task.node, + kwargs['ssh_key']) + ssh = bareon_utils.get_ssh_connection(task, **{ + 'host': node_ip, + 'username': kwargs['ssh_user'], + 'key_contents': ssh_key + }) + + tmp_path = processutils.ssh_execute(ssh, 'mktemp -d')[0].split()[0] + cfg_path = os.path.join(tmp_path, 'boot', 'grub2', 'grub.cfg') + + commands = [ + 'mount /dev/disk/by-uuid/{} {}'.format( + boot_info['multiboot_partition'], + tmp_path), + "sed -i 's/\(set default=\)[0-9]*/\\1{}/' {}".format(grub_id, + cfg_path), + 'umount {}'.format(tmp_path), + 'rmdir {}'.format(tmp_path) + ] + + map(lambda cmd: processutils.ssh_execute(ssh, 'sudo ' + cmd), + commands) + + except exception.SSHConnectFailed as e: + msg = ( + _('[%(node)s] SSH connect to node %(host)s failed. ' + 'Error: %(error)s') % {'host': node_ip, 'error': e, + 'node': task.node.uuid}) + raise exception.IronicException(message=msg, code=400) + + except exception.IronicException as e: + msg = str(e) + raise + + except Exception as e: + msg = (_('[%(node)s] Multiboot switch failed for node %(node)s. ' + 'Error: %(error)s') % {'node': task.node.uuid, + 'error': e}) + raise exception.IronicException(message=msg, code=400) + + else: + boot_info['current_element'] = grub_id + bareon_utils.change_node_dict( + task.node, 'instance_info', + {'multiboot_info': boot_info}) + task.node.save() + + finally: + if msg: + LOG.error(msg) + task.node.last_error = msg + task.node.save() + + +def get_provision_json_path(node): + return os.path.join(resources.get_node_resources_dir(node), + "provision.json") + + +def get_actions_json_path(node): + return os.path.join(resources.get_node_resources_dir(node), + "actions.json") + + +def get_on_fail_script_path(node): + return os.path.join(resources.get_node_resources_dir(node), + "on_fail_script.sh") + + +def get_tenant_images_json_path(node): + return os.path.join(resources.get_node_resources_dir(node), + "tenant_images.json") diff --git a/bareon_ironic/modules/bareon_config.template b/bareon_ironic/modules/bareon_config.template new file mode 100644 index 0000000..0a34a15 --- /dev/null +++ b/bareon_ironic/modules/bareon_config.template @@ -0,0 +1,6 @@ +default deploy + +label deploy +kernel {{ pxe_options.deployment_aki_path }} +append initrd={{ pxe_options.deployment_ari_path }} text {{ pxe_options.bareon_pxe_append_params|default("", true) }} deployment_id={{ pxe_options.deployment_id }} api-url={{ pxe_options['api-url'] }} +ipappend 2 \ No newline at end of file diff --git a/bareon_ironic/modules/bareon_config_live.template b/bareon_ironic/modules/bareon_config_live.template new file mode 100644 index 0000000..7a8e64d --- /dev/null +++ b/bareon_ironic/modules/bareon_config_live.template @@ -0,0 +1,6 @@ +default deploy + +label deploy +kernel {{ pxe_options.deployment_aki_path }} +append initrd={{ pxe_options.deployment_ari_path }} root=live:{{ pxe_options['rootfs-url'] }} boot=live text fetch={{ pxe_options['rootfs-url'] }} {{ pxe_options.bareon_pxe_append_params|default("", true) }} deployment_id={{ pxe_options.deployment_id }} api-url={{ pxe_options['api-url'] }} +ipappend 2 diff --git a/bareon_ironic/modules/bareon_exception.py b/bareon_ironic/modules/bareon_exception.py new file mode 100644 index 0000000..9e187b1 --- /dev/null +++ b/bareon_ironic/modules/bareon_exception.py @@ -0,0 +1,48 @@ +# +# Copyright 2016 Cray Inc., All 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. + +"""Bareon driver exceptions""" + +from ironic.common import exception +from ironic.common.i18n import _ + + +class IncompatibleRamdiskVersion(exception.IronicException): + message = _("Incompatible node ramdisk version. %(details)s") + + +class UnsafeUrlError(exception.IronicException): + message = _("URL '%(url)s' is not safe and cannot be used for sensitive " + "data. %(details)s") + + +class InvalidResourceState(exception.IronicException): + pass + + +class DeploymentTimeout(exception.IronicException): + message = _("Deployment timeout expired. Timeout: %(timeout)s") + + +class RetriesException(exception.IronicException): + message = _("Retries count exceeded. Retried %(retry_count)d times.") + + +class DeployTerminationSucceed(exception.IronicException): + message = _("Deploy termination succeed.") + + +class BootSwitchFailed(exception.IronicException): + message = _("Boot switch failed. Error: %(error)s") diff --git a/bareon_ironic/modules/bareon_rsync.py b/bareon_ironic/modules/bareon_rsync.py new file mode 100644 index 0000000..e5a6e5e --- /dev/null +++ b/bareon_ironic/modules/bareon_rsync.py @@ -0,0 +1,71 @@ +# +# Copyright 2016 Cray Inc., All 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. + +""" +Bareon Rsync deploy driver. +""" + +from oslo_config import cfg + +from bareon_ironic.modules import bareon_utils +from bareon_ironic.modules import bareon_base +from bareon_ironic.modules.resources import resources +from bareon_ironic.modules.resources import rsync + +rsync_opts = [ + cfg.StrOpt('rsync_master_path', + default='/rsync/master_images', + help='Directory where master rsync images are stored on disk.'), + cfg.IntOpt('image_cache_size', + default=20480, + help='Maximum size (in MiB) of cache for master images, ' + 'including those in use.'), + cfg.IntOpt('image_cache_ttl', + default=10080, + help='Maximum TTL (in minutes) for old master images in ' + 'cache.'), +] + +CONF = cfg.CONF +CONF.register_opts(rsync_opts, group='rsync') + + +class BareonRsyncDeploy(bareon_base.BareonDeploy): + """Interface for deploy-related actions.""" + + def _get_deploy_driver(self): + return 'rsync' + + def _get_image_resource_mode(self): + return resources.PullMountResource.MODE + + +class BareonRsyncVendor(bareon_base.BareonVendor): + def _execute_deploy_script(self, task, ssh, cmd, **kwargs): + if CONF.rsync.rsync_secure_transfer: + user = kwargs.get('username', 'root') + key_file = kwargs.get('key_filename', '/dev/null') + ssh_port = kwargs.get('bareon_ssh_port', 22) + host = (kwargs.get('host') or + bareon_utils.get_node_ip(kwargs.get('task'))) + with bareon_utils.ssh_tunnel(rsync.RSYNC_PORT, user, + key_file, host, ssh_port): + return super( + BareonRsyncVendor, self + )._execute_deploy_script(task, ssh, cmd, **kwargs) + else: + return super( + BareonRsyncVendor, self + )._execute_deploy_script(task, ssh, cmd, **kwargs) diff --git a/bareon_ironic/modules/bareon_swift.py b/bareon_ironic/modules/bareon_swift.py new file mode 100644 index 0000000..966a472 --- /dev/null +++ b/bareon_ironic/modules/bareon_swift.py @@ -0,0 +1,35 @@ +# +# Copyright 2016 Cray Inc., All 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. + +""" +Bareon Swift deploy driver. +""" + +from bareon_ironic.modules import bareon_base +from bareon_ironic.modules.resources import resources + + +class BareonSwiftDeploy(bareon_base.BareonDeploy): + """Interface for deploy-related actions.""" + + def _get_deploy_driver(self): + return 'swift' + + def _get_image_resource_mode(self): + return resources.PullSwiftTempurlResource.MODE + + +class BareonSwiftVendor(bareon_base.BareonVendor): + pass diff --git a/bareon_ironic/modules/bareon_utils.py b/bareon_ironic/modules/bareon_utils.py new file mode 100644 index 0000000..dbd41fb --- /dev/null +++ b/bareon_ironic/modules/bareon_utils.py @@ -0,0 +1,251 @@ +# +# Copyright 2016 Cray Inc., All 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. + +import contextlib +import copy +import hashlib +import os +import subprocess +import tempfile + +import six +from oslo_concurrency import processutils +from oslo_config import cfg +from oslo_utils import strutils + +from ironic.common import dhcp_factory +from ironic.common import exception +from ironic.common import keystone +from ironic.common import utils +from ironic.common.i18n import _, _LW +from ironic.openstack.common import log as logging + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +def get_service_tenant_id(): + ksclient = keystone._get_ksclient() + if not keystone._is_apiv3(CONF.keystone_authtoken.auth_uri, + CONF.keystone_authtoken.auth_version): + tenant_name = CONF.keystone_authtoken.admin_tenant_name + if tenant_name: + return ksclient.tenants.find(name=tenant_name).to_dict()['id'] + + +def change_node_dict(node, dict_name, new_data): + """Workaround for Ironic object model to update dict.""" + dict_data = getattr(node, dict_name).copy() + dict_data.update(new_data) + setattr(node, dict_name, dict_data) + + +def str_to_alnum(s): + if not s.isalnum(): + s = ''.join([c for c in s if c.isalnum()]) + return s + + +def str_replace_non_alnum(s, replace_by="_"): + if not s.isalnum(): + s = ''.join([(c if c.isalnum() else replace_by) for c in s]) + return s + + +def validate_json(required, raw): + for k in required: + if k not in raw: + raise exception.MissingParameterValue( + "%s object missing %s parameter" + % (str(raw), k) + ) + + +def get_node_ip(task): + provider = dhcp_factory.DHCPFactory() + addresses = provider.provider.get_ip_addresses(task) + if addresses: + return addresses[0] + return None + + +def get_ssh_connection(task, **kwargs): + ssh = utils.ssh_connect(kwargs) + + # Note(oberezovskyi): this is required to prevent printing private_key to + # the conductor log + if kwargs.get('key_contents'): + kwargs['key_contents'] = '*****' + + LOG.debug("SSH with params:") + LOG.debug(kwargs) + + return ssh + + +@contextlib.contextmanager +def ssh_tunnel(port, user, key_file, target_host, ssh_port=22): + tunnel = _create_ssh_tunnel(port, port, user, key_file, target_host, + local_forwarding=False) + try: + yield + finally: + tunnel.terminate() + + +def _create_ssh_tunnel(remote_port, local_port, user, key_file, target_host, + remote_ip='127.0.0.1', local_ip='127.0.0.1', + local_forwarding=True, + ssh_port=22): + cmd = ['ssh', '-N', '-o', 'StrictHostKeyChecking=no', '-o', + 'UserKnownHostsFile=/dev/null', '-p', str(ssh_port), '-i', key_file] + if local_forwarding: + cmd += ['-L', '{}:{}:{}:{}'.format(local_ip, local_port, remote_ip, + remote_port)] + else: + cmd += ['-R', '{}:{}:{}:{}'.format(remote_ip, remote_port, local_ip, + local_port)] + + cmd.append('@'.join((user, target_host))) + # TODO(lobur): Make this sync, check status. (may use ssh control socket). + return subprocess.Popen(cmd) + + +def sftp_write_to(sftp, data, path): + with tempfile.NamedTemporaryFile(dir=CONF.tempdir) as f: + f.write(data) + f.flush() + sftp.put(f.name, path) + + +def sftp_ensure_tree(sftp, path): + try: + sftp.mkdir(path) + except IOError: + pass + + +# TODO(oberezovskyi): merge this code with processutils.ssh_execute +def ssh_execute(ssh, cmd, process_input=None, + addl_env=None, check_exit_code=True, + binary=False, timeout=None): + sanitized_cmd = strutils.mask_password(cmd) + LOG.debug('Running cmd (SSH): %s', sanitized_cmd) + if addl_env: + raise exception.InvalidArgumentError( + _('Environment not supported over SSH')) + + if process_input: + # This is (probably) fixable if we need it... + raise exception.InvalidArgumentError( + _('process_input not supported over SSH')) + + stdin_stream, stdout_stream, stderr_stream = ssh.exec_command(cmd) + channel = stdout_stream.channel + + if timeout and not channel.status_event.wait(timeout=timeout): + raise exception.SSHCommandFailed(cmd=cmd) + + # NOTE(justinsb): This seems suspicious... + # ...other SSH clients have buffering issues with this approach + stdout = stdout_stream.read() + stderr = stderr_stream.read() + + stdin_stream.close() + + exit_status = channel.recv_exit_status() + + if six.PY3: + # Decode from the locale using using the surrogateescape error handler + # (decoding cannot fail). Decode even if binary is True because + # mask_password() requires Unicode on Python 3 + stdout = os.fsdecode(stdout) + stderr = os.fsdecode(stderr) + stdout = strutils.mask_password(stdout) + stderr = strutils.mask_password(stderr) + + # exit_status == -1 if no exit code was returned + if exit_status != -1: + LOG.debug('Result was %s' % exit_status) + if check_exit_code and exit_status != 0: + raise processutils.ProcessExecutionError(exit_code=exit_status, + stdout=stdout, + stderr=stderr, + cmd=sanitized_cmd) + + if binary: + if six.PY2: + # On Python 2, stdout is a bytes string if mask_password() failed + # to decode it, or an Unicode string otherwise. Encode to the + # default encoding (ASCII) because mask_password() decodes from + # the same encoding. + if isinstance(stdout, unicode): + stdout = stdout.encode() + if isinstance(stderr, unicode): + stderr = stderr.encode() + else: + # fsencode() is the reverse operation of fsdecode() + stdout = os.fsencode(stdout) + stderr = os.fsencode(stderr) + + return (stdout, stderr) + + +def umount_without_raise(loc, *args): + """Helper method to umount without raise.""" + try: + utils.umount(loc, *args) + except processutils.ProcessExecutionError as e: + LOG.warn(_LW("umount_without_raise unable to umount dir %(path)s, " + "error: %(e)s"), {'path': loc, 'e': e}) + + +def md5(url): + """Generate md5 has for the sting.""" + return hashlib.md5(url).hexdigest() + + +class RawToPropertyMixin(object): + """A helper mixin for json-based entities. + + Should be used for the json-based class definitions. If you have a class + corresponding to a json, use this mixin to get direct json <-> class + attribute mapping. It also gives out-of-the-box serialization back to json. + """ + + _raw = {} + + def __getattr__(self, item): + if not self._is_special_name(item): + return self._raw.get(item) + + def __setattr__(self, key, value): + if (not self._is_special_name(key)) and (key not in self.__dict__): + self._raw[key] = value + else: + self.__dict__[key] = value + + def _is_special_name(self, name): + return name.startswith("_") or name.startswith("__") + + def to_dict(self): + data = {} + for k, v in self._raw.iteritems(): + if (isinstance(v, list) and len(v) > 0 and + isinstance(v[0], RawToPropertyMixin)): + data[k] = [r.to_dict() for r in v] + else: + data[k] = v + return copy.deepcopy(data) diff --git a/bareon_ironic/modules/resources/__init__.py b/bareon_ironic/modules/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bareon_ironic/modules/resources/actions.py b/bareon_ironic/modules/resources/actions.py new file mode 100644 index 0000000..6afdb1f --- /dev/null +++ b/bareon_ironic/modules/resources/actions.py @@ -0,0 +1,229 @@ +# +# Copyright 2016 Cray Inc., All 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. + +""" +Deploy driver actions. +""" + +import datetime +import os +import tempfile + +from oslo_concurrency import processutils +from oslo_config import cfg + +from ironic.common import exception +from ironic.openstack.common import log + +from bareon_ironic.modules import bareon_utils +from bareon_ironic.modules.resources import resources +from bareon_ironic.modules.resources import rsync + +LOG = log.getLogger(__name__) +CONF = cfg.CONF + + +# This module allows to run a set of json-defined actions on the node. +# +# Actions json structure: +# { +# "actions": [ +# { +# "cmd": "echo 'test 1 run success!'", +# "name": "test_script_1", +# "terminate_on_fail": true, +# "args": "", +# "sudo": false, +# "resources": [ +# { +# "name": "swift prefetced", +# "mode": "push", +# "url": "swift:max_container/swift_pref1", +# "target": "/tmp/swift_prefetch_res" +# } +# ] +# }, +# { +# another action ... +# } +# ] +# } +# +# Each action carries a list of associated resources. Resource, and thus +# action, can be in two states: not_fetched and fetched. See Resource +# documentation. You should always pass a json of not_fetched resources. +# +# The workflow can be one of the following: +# 1. When you need to fetch actions while you have a proper context, +# then serialize them, and run later, deserializing from a file. +# - Create controller from user json -> fetch_action_resources() -> to_dict() +# - Do whatever while actions are serialized, e.g. wait for node boot. +# - Create controller from the serialized json -> execute() +# 2. A workflow when you need to fetch and run actions immediately. +# - Create controller from user json -> execute() + + +class Action(resources.ResourceList): + def __init__(self, action, task): + req = ('name', 'cmd', 'args', 'sudo', 'resources', + 'terminate_on_fail') + bareon_utils.validate_json(req, action) + + super(Action, self).__init__(action, task) + LOG.debug("[%s] Action created from %s" + % (self._task.node.uuid, action)) + + def execute(self, ssh, sftp): + """Execute action. + + Fetch resources, upload them, and run command. + """ + cmd = ("%s %s" % (self.cmd, self.args)) + if self.sudo: + cmd = "sudo %s" % cmd + + self.fetch_resources() + self.upload_resources(sftp) + return processutils.ssh_execute(ssh, cmd) + + @staticmethod + def from_dict(action, task): + return Action(action, task) + + +class ActionController(bareon_utils.RawToPropertyMixin): + def __init__(self, task, action_data): + self._raw = action_data + self._task = task + try: + req = ('actions',) + bareon_utils.validate_json(req, action_data) + self.actions = [Action.from_dict(a, self._task) + for a in action_data['actions']] + except Exception as ex: + self._save_exception_result(ex) + raise + + def fetch_action_resources(self): + """Fetch all resources of all actions. + + Must be idempotent. + """ + for action in self.actions: + try: + action.fetch_resources() + except Exception as ex: + # Cleanup is already done in ResourceList.fetch_resources() + self._save_exception_result(ex) + raise + + def cleanup_action_resources(self): + """Cleanup all resources of all actions. + + Must be idempotent. + Must return None if called when actions resources are not fetched. + """ + for action in self.actions: + action.cleanup_resources() + + def _execute(self, ssh, sftp): + results = [] + # Clean previous results at the beginning + self._save_results(results) + for action in self.actions: + try: + out, err = action.execute(ssh, sftp) + + results.append({'name': action.name, 'passed': True}) + LOG.info("[%s] Action '%s' finished with:" + "\n stdout: %s\n stderr: %s" % + (self._task.node.uuid, action.name, out, err)) + except Exception as ex: + results.append({'name': action.name, 'passed': False, + 'exception': str(ex)}) + LOG.info("[%s] Action '%s' failed with error: %s" % + (self._task.node.uuid, action.name, str(ex))) + if action.terminate_on_fail: + raise + finally: + # Save results after each action. Result list will grow until + # all actions are done. + self._save_results(results) + + def execute(self, ssh, sftp, **ssh_params): + """Execute using already opened SSH connection to the node.""" + try: + if CONF.rsync.rsync_secure_transfer: + ssh_user = ssh_params.get('username') + ssh_key_file = ssh_params.get('key_filename') + ssh_host = ssh_params.get('host') + ssh_port = ssh_params.get('port', 22) + with bareon_utils.ssh_tunnel(rsync.RSYNC_PORT, ssh_user, + ssh_key_file, ssh_host, ssh_port): + self._execute(ssh, sftp) + else: + self._execute(ssh, sftp) + + finally: + self.cleanup_action_resources() + + def ssh_and_execute(self, node_ip, ssh_user, ssh_key_url): + """Open an SSH connection to the node and execute.""" + + # NOTE(lobur): Security flaw. + # A random-name tempfile with private key contents exists on Conductor + # during the time of execution of tenant-image actions when + # rsync_secure_transfer is True. + # Because we are using a bash command to start a tunnel we need to + # have the private key in a file. + # To fix this we need to start tunnel using Paramiko, which + # is impossible currently. Paramiko would accept raw key contents, + # thus we won't need a file. + with tempfile.NamedTemporaryFile(delete=True) as key_file: + os.chmod(key_file.name, 0o700) + try: + if not (ssh_user and ssh_key_url): + raise exception.MissingParameterValue( + "Need action_user and action_key params to " + "execute actions") + + key_contents = resources.url_download_raw_secured( + self._task.context, self._task.node, ssh_key_url) + key_file.file.write(key_contents) + key_file.file.flush() + + ssh = bareon_utils.get_ssh_connection( + self._task, username=ssh_user, + key_contents=key_contents, host=node_ip) + sftp = ssh.open_sftp() + except Exception as ex: + self._save_exception_result(ex) + raise + else: + self.execute(ssh, sftp, + username=ssh_user, + key_filename=key_file.name, + host=node_ip) + + def _save_results(self, results): + bareon_utils.change_node_dict( + self._task.node, 'instance_info', + {'exec_actions': { + 'results': results, + 'finished_at': str(datetime.datetime.utcnow())}}) + self._task.node.save() + + def _save_exception_result(self, ex): + self._save_results({'exception': str(ex)}) diff --git a/bareon_ironic/modules/resources/image_service.py b/bareon_ironic/modules/resources/image_service.py new file mode 100644 index 0000000..6842e54 --- /dev/null +++ b/bareon_ironic/modules/resources/image_service.py @@ -0,0 +1,373 @@ +# +# Copyright 2016 Cray Inc., All 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. + +"""An extension for ironic/common/image_service.py""" + +import abc +import os +import shutil +import uuid + +from oslo_config import cfg +from oslo_concurrency import processutils +from oslo_utils import uuidutils +import requests +import six +import six.moves.urllib.parse as urlparse + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.openstack.common import log as logging +from ironic.common import image_service +from ironic.common import keystone +from ironic.common import utils +from ironic.common import swift + +from bareon_ironic.modules import bareon_utils + +swift_opts = [ + cfg.IntOpt('swift_native_temp_url_duration', + default=1200, + help='The length of time in seconds that the temporary URL ' + 'will be valid for. Defaults to 20 minutes. This option ' + 'is different from the "swift_temp_url_duration" defined ' + 'under [glance]. Glance option controls temp urls ' + 'obtained from Glance while this option controls ones ' + 'obtained from Swift directly, e.g. when ' + 'swift: ref is used.') +] + + +CONF = cfg.CONF +CONF.register_opts(swift_opts, group='swift') + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + +IMAGE_CHUNK_SIZE = 1024 * 1024 # 1mb + + +@six.add_metaclass(abc.ABCMeta) +class BaseImageService(image_service.BaseImageService): + """Provides retrieval of disk images.""" + + def __init__(self, *args, **kwargs): + super(BaseImageService, self).__init__() + + def get_image_unique_id(self, image_href): + """Get unique ID of the resource. + + If possible, the ID should change if resource contents are changed. + + :param image_href: Image reference. + :returns: Unique ID of the resource. + """ + # NOTE(vdrok): Doing conversion of href in case it's unicode + # string, UUID cannot be generated for unicode strings on python 2. + return str(uuid.uuid5(uuid.NAMESPACE_URL, + image_href.encode('utf-8'))) + + @abc.abstractmethod + def get_http_href(self, image_href): + """Get HTTP ref to the image. + + Validate given href and, if possible, convert it to HTTP ref. + Otherwise raise ImageRefValidationFailed with appropriate message. + + :param image_href: Image reference. + :raises: exception.ImageRefValidationFailed. + :returns: http reference to the image + """ + + +def _GlanceImageService(client=None, version=1, context=None): + module = image_service.import_versioned_module(version, 'image_service') + service_class = getattr(module, 'GlanceImageService') + if (context is not None and CONF.glance.auth_strategy == 'keystone' and + not context.auth_token): + context.auth_token = keystone.get_admin_auth_token() + return service_class(client, version, context) + + +class GlanceImageService(BaseImageService): + def __init__(self, client=None, version=1, context=None): + super(GlanceImageService, self).__init__() + self.glance = _GlanceImageService(client=client, + version=version, + context=context) + + def __getattr__(self, attr): + # NOTE(lobur): Known redirects: + # - swift_temp_url + return self.glance.__getattribute__(attr) + + def get_image_unique_id(self, image_href): + return self.validate_href(image_href)['id'] + + def get_http_href(self, image_href): + img_info = self.validate_href(image_href) + return self.glance.swift_temp_url(img_info) + + def validate_href(self, image_href): + parsed_ref = urlparse.urlparse(image_href) + # Supporting both glance:UUID and glance://UUID URLs + image_href = parsed_ref.path or parsed_ref.netloc + if not uuidutils.is_uuid_like(image_href): + images = self.glance.detail(filters={'name': image_href}) + if len(images) == 0: + raise exception.ImageNotFound(_( + 'No Glance images found by name %s') % image_href) + if len(images) > 1: + raise exception.ImageRefValidationFailed(_( + 'Multiple Glance images found by name %s') % image_href) + image_href = images[0]['id'] + return self.glance.show(image_href) + + def download(self, image_href, image_file): + image_href = self.validate_href(image_href)['id'] + return self.glance.download(image_href, image_file) + + def show(self, image_href): + return self.validate_href(image_href) + + +class HttpImageService(image_service.HttpImageService, BaseImageService): + """Provides retrieval of disk images using HTTP.""" + + def get_http_href(self, image_href): + self.validate_href(image_href) + return image_href + + def download(self, image_href, image_file): + """Downloads image to specified location. + + :param image_href: Image reference. + :param image_file: File object to write data to. + :raises: exception.ImageRefValidationFailed if GET request returned + response code not equal to 200. + :raises: exception.ImageDownloadFailed if: + * IOError happened during file write; + * GET request failed. + """ + try: + response = requests.get(image_href, stream=True) + if response.status_code != 200: + raise exception.ImageRefValidationFailed( + image_href=image_href, + reason=_( + "Got HTTP code %s instead of 200 in response to " + "GET request.") % response.status_code) + response.raw.decode_content = True + with response.raw as input_img: + shutil.copyfileobj(input_img, image_file, IMAGE_CHUNK_SIZE) + except (requests.RequestException, IOError) as e: + raise exception.ImageDownloadFailed(image_href=image_href, + reason=e) + + +class FileImageService(image_service.FileImageService, BaseImageService): + """Provides retrieval of disk images available locally on the conductor.""" + + def get_http_href(self, image_href): + raise exception.ImageRefValidationFailed( + "File image store is not able to provide HTTP reference.") + + def get_image_unique_id(self, image_href): + """Get unique ID of the resource. + + :param image_href: Image reference. + :raises: exception.ImageRefValidationFailed if source image file + doesn't exist. + :returns: Unique ID of the resource. + """ + path = self.validate_href(image_href) + stat = str(os.stat(path)) + return bareon_utils.md5(stat) + + +class SwiftImageService(BaseImageService): + def __init__(self, context): + self.client = self._get_swiftclient(context) + super(SwiftImageService, self).__init__() + + def get_image_unique_id(self, image_href): + return self.show(image_href)['properties']['etag'] + + def get_http_href(self, image_href): + container, object, headers = self.validate_href(image_href) + return self.client.get_temp_url( + container, object, CONF.swift.swift_native_temp_url_duration) + + def validate_href(self, image_href): + path = urlparse.urlparse(image_href).path.lstrip('/') + if not path: + raise exception.ImageRefValidationFailed( + _("No path specified in swift resource reference: %s. " + "Reference must be like swift:container/path") + % str(image_href)) + + container, s, object = path.partition('/') + try: + headers = self.client.head_object(container, object) + except exception.SwiftOperationError as e: + raise exception.ImageRefValidationFailed( + _("Cannot fetch %(url)s resource. %(exc)s") % + dict(url=str(image_href), exc=str(e))) + + return (container, object, headers) + + def download(self, image_href, image_file): + try: + container, object, headers = self.validate_href(image_href) + headers, body = self.client.get_object(container, object, + chunk_size=IMAGE_CHUNK_SIZE) + for chunk in body: + image_file.write(chunk) + except exception.SwiftOperationError as ex: + raise exception.ImageDownloadFailed( + _("Cannot fetch %(url)s resource. %(exc)s") % + dict(url=str(image_href), exc=str(ex))) + + def show(self, image_href): + container, object, headers = self.validate_href(image_href) + return { + 'size': int(headers['content-length']), + 'properties': headers + } + + @staticmethod + def _get_swiftclient(context): + return swift.SwiftAPI(user=context.user, + preauthtoken=context.auth_token, + preauthtenant=context.tenant) + + +class RsyncImageService(BaseImageService): + def get_http_href(self, image_href): + raise exception.ImageRefValidationFailed( + "Rsync image store is not able to provide HTTP reference.") + + def validate_href(self, image_href): + path = urlparse.urlparse(image_href).path.lstrip('/') + if not path: + raise exception.InvalidParameterValue( + _("No path specified in rsync resource reference: %s. " + "Reference must be like rsync:host::module/path") + % str(image_href)) + try: + stdout, stderr = utils.execute( + 'rsync', '--stats', '--dry-run', + path, + ".", + check_exit_code=[0], + log_errors=processutils.LOG_ALL_ERRORS) + return path, stdout, stderr + + except (processutils.ProcessExecutionError, OSError) as ex: + raise exception.ImageRefValidationFailed( + _("Cannot fetch %(url)s resource. %(exc)s") % + dict(url=str(image_href), exc=str(ex))) + + def download(self, image_href, image_file): + path, out, err = self.validate_href(image_href) + try: + utils.execute('rsync', '-tvz', + path, + image_file.name, + check_exit_code=[0], + log_errors=processutils.LOG_ALL_ERRORS) + except (processutils.ProcessExecutionError, OSError) as ex: + raise exception.ImageDownloadFailed( + _("Cannot fetch %(url)s resource. %(exc)s") % + dict(url=str(image_href), exc=str(ex))) + + def show(self, image_href): + path, out, err = self.validate_href(image_href) + # Example of the size str" + # "Total file size: 2218131456 bytes" + size_str = filter(lambda l: "Total file size" in l, + out.splitlines())[0] + size = filter(str.isdigit, size_str.split())[0] + return { + 'size': int(size), + 'properties': {} + } + + +protocol_mapping = { + 'http': HttpImageService, + 'https': HttpImageService, + 'file': FileImageService, + 'glance': GlanceImageService, + 'swift': SwiftImageService, + 'rsync': RsyncImageService, +} + + +def get_image_service(image_href, client=None, version=1, context=None): + """Get image service instance to download the image. + + :param image_href: String containing href to get image service for. + :param client: Glance client to be used for download, used only if + image_href is Glance href. + :param version: Version of Glance API to use, used only if image_href is + Glance href. + :param context: request context, used only if image_href is Glance href. + :raises: exception.ImageRefValidationFailed if no image service can + handle specified href. + :returns: Instance of an image service class that is able to download + specified image. + """ + scheme = urlparse.urlparse(image_href).scheme.lower() + try: + cls = protocol_mapping[scheme or 'glance'] + except KeyError: + raise exception.ImageRefValidationFailed( + image_href=image_href, + reason=_('Image download protocol ' + '%s is not supported.') % scheme + ) + + if cls == GlanceImageService: + return cls(client, version, context) + + return cls(context) + + +def _get_glanceclient(context): + return GlanceImageService(version=2, context=context) + + +def get_glance_image_uuid_name(task, url): + """Converting glance links. + + Links like: + glance:name + glance://name + glance:uuid + glance://uuid + name + uuid + are converted to tuple + uuid, name + + """ + urlobj = urlparse.urlparse(url) + if urlobj.scheme and urlobj.scheme != 'glance': + raise exception.InvalidImageRef("Only glance images are supported.") + + path = urlobj.path or urlobj.netloc + img_info = _get_glanceclient(task.context).show(path) + return img_info['id'], img_info['name'] diff --git a/bareon_ironic/modules/resources/resources.py b/bareon_ironic/modules/resources/resources.py new file mode 100644 index 0000000..d3952e9 --- /dev/null +++ b/bareon_ironic/modules/resources/resources.py @@ -0,0 +1,557 @@ +# +# Copyright 2016 Cray Inc., All 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. + + +import StringIO +import os + +from oslo_config import cfg +from oslo_serialization import jsonutils +from six.moves.urllib import parse + +from ironic.common import exception +from ironic.common import utils +from ironic.common.i18n import _ +from ironic.drivers.modules import image_cache +from ironic.openstack.common import fileutils +from ironic.openstack.common import log + +from bareon_ironic.modules import bareon_exception +from bareon_ironic.modules import bareon_utils +from bareon_ironic.modules.resources import rsync +from bareon_ironic.modules.resources import image_service + +opts = [ + cfg.StrOpt('default_resource_storage_prefix', + default='glance:', + help='A prefix that will be added when resource reference ' + 'is not url-like. E.g. if storage prefix is ' + '"rsync:10.0.0.10::module1/" then "resource_1" is treated ' + 'like rsync:10.0.0.10::module1/resource_1'), + cfg.StrOpt('resource_root_path', + default='/ironic_resources', + help='Directory where per-node resources are stored.'), + cfg.StrOpt('resource_cache_master_path', + default='/ironic_resources/master_resources', + help='Directory where master resources are stored.'), + cfg.IntOpt('resource_cache_size', + default=10240, + help='Maximum size (in MiB) of cache for master resources, ' + 'including those in use.'), + cfg.IntOpt('resource_cache_ttl', + default=1440, + help='Maximum TTL (in minutes) for old master resources in ' + 'cache.'), +] + +CONF = cfg.CONF +CONF.register_opts(opts, group='resources') + +LOG = log.getLogger(__name__) + + +@image_cache.cleanup(priority=25) +class ResourceCache(image_cache.ImageCache): + def __init__(self, image_service=None): + super(ResourceCache, self).__init__( + CONF.resources.resource_cache_master_path, + # MiB -> B + CONF.resources.resource_cache_size * 1024 * 1024, + # min -> sec + CONF.resources.resource_cache_ttl * 60, + image_service=image_service) + + +def get_abs_node_workdir_path(node): + return os.path.join(CONF.resources.resource_root_path, + node.uuid) + + +def get_node_resources_dir(node): + return get_abs_node_workdir_path(node) + + +def get_node_resources_dir_rsync(node): + return rsync.get_abs_node_workdir_path(node) + + +def _url_to_filename(url): + basename = bareon_utils.str_replace_non_alnum(os.path.basename(url)) + return "%(url_hash)s_%(name)s" % dict(url_hash=bareon_utils.md5(url), + name=basename) + + +def append_storage_prefix(node, url): + urlobj = parse.urlparse(url) + if not urlobj.scheme: + prefix = CONF.resources.default_resource_storage_prefix + LOG.info('[%(node)s] Plain reference given: "%(ref)s". Adding a ' + 'resource_storage_prefix "%(pref)s".' + % dict(node=node.uuid, ref=url, pref=prefix)) + url = prefix + url + return url + + +def url_download(context, node, url, dest_path=None): + if not url: + return + url = url.rstrip('/') + url = append_storage_prefix(node, url) + + LOG.info(_("[%(node)s] Downloading resource by the following url: %(url)s") + % dict(node=node.uuid, url=url)) + + if not dest_path: + dest_path = os.path.join(get_node_resources_dir(node), + _url_to_filename(url)) + fileutils.ensure_tree(os.path.dirname(dest_path)) + + resource_storage = image_service.get_image_service( + url, context=context) + # NOTE(lobur): http(s) and rsync resources are cached basing on the URL. + # They do not have per-revision object UUID / object hash, thus + # cache cannot identify change of the contents. If URL doesn't change, + # resource assumed to be the same - cache hit. + cache = ResourceCache(resource_storage) + cache.fetch_image(url, dest_path) + return dest_path + + +def url_download_raw(context, node, url): + if not url: + return + resource_path = url_download(context, node, url) + with open(resource_path) as f: + raw = f.read() + utils.unlink_without_raise(resource_path) + return raw + + +def url_download_json(context, node, url): + if not url: + return + raw = url_download_raw(context, node, url) + try: + return jsonutils.loads(raw) + except ValueError: + raise exception.InvalidParameterValue( + _('Resource %s is not a JSON.') % url) + + +def url_download_raw_secured(context, node, url): + """Download raw contents of the URL bypassing cache and temp files.""" + if not url: + return + url = url.rstrip('/') + url = append_storage_prefix(node, url) + + scheme = parse.urlparse(url).scheme + sources_with_tenant_isolation = ('glance', 'swift') + if scheme not in sources_with_tenant_isolation: + raise bareon_exception.UnsafeUrlError( + url=url, + details="Use one of the following storages " + "for this resource: %s" + % str(sources_with_tenant_isolation)) + + resource_storage = image_service.get_image_service( + url, context=context) + out = StringIO.StringIO() + try: + resource_storage.download(url, out) + return out.getvalue() + finally: + out.close() + + +class Resource(bareon_utils.RawToPropertyMixin): + """Base class for Ironic Resource + + Used to manage a resource which should be fetched from the URL and + then uploaded to the node. Each resource is a single file. + + Resource can be in two states: not_fetched and fetched. When fetched, + a resource has an additional attribute, local_path. + + """ + + def __init__(self, resource, task, resource_list_name): + self._raw = resource + self._resource_list_name = resource_list_name + self._task = task + self.name = bareon_utils.str_replace_non_alnum(resource['name']) + self._validate(resource) + + def _validate(self, raw): + """Validates resource source json depending of the resource state.""" + if self.is_fetched(): + req = ('name', 'mode', 'target', 'url', 'local_path') + else: + req = ('name', 'mode', 'target', 'url') + bareon_utils.validate_json(req, raw) + + def is_fetched(self): + """Shows whether the resouce has been fetched or not.""" + return bool(self._raw.get('local_path')) + + def get_dir_names(self): + """Returns a list of directory names + + These directories show where the resource can reside when serialized. + """ + raise NotImplemented + + def fetch(self): + """Download the resource from the URL and store locally. + + Must be idempotent. + """ + raise NotImplemented + + def upload(self, sftp): + """Take resource stored locally and put to the node at target path.""" + raise NotImplemented + + def cleanup(self): + """Cleanup files used to store the resource locally. + + Must be idempotent. + Must return None if called when resources is not fetched. + """ + if not self.is_fetched(): + return + utils.unlink_without_raise(self.local_path) + self._raw.pop('local_path', None) + + @staticmethod + def from_dict(resource, task, resource_list_name): + """Generic method used to instantiate a resource + + Returns particular implementation of the resource depending of the + 'mode' attribute value. + """ + mode = resource.get('mode') + if not mode: + raise exception.InvalidParameterValue( + "Missing resource 'mode' attribute for %s resource." + % str(resource) + ) + + mode_to_class = {} + for c in Resource.__subclasses__(): + mode_to_class[c.MODE] = c + + try: + return mode_to_class[mode](resource, task, resource_list_name) + except KeyError: + raise exception.InvalidParameterValue( + "Unknown resource mode: '%s'. Supported modes are: " + "%s " % (mode, str(mode_to_class.keys())) + ) + + +class PushResource(Resource): + """A resource with immediate upload + + Resource file is uploaded to the node at target path. + """ + + MODE = 'push' + + def get_dir_names(self): + my_dir = os.path.join( + get_node_resources_dir(self._task.node), + self._resource_list_name + ) + return [my_dir] + + def fetch(self): + if self.is_fetched(): + return + + res_dir = self.get_dir_names()[0] + local_path = os.path.join(res_dir, self.name) + url_download(self._task.context, self._task.node, self.url, + dest_path=local_path) + self.local_path = local_path + + def upload(self, sftp): + if not self.is_fetched(): + raise bareon_exception.InvalidResourceState( + "Cannot upload action '%s' because it is not fetched." + % self.name + ) + LOG.info("[%s] Uploading resource %s to the node at %s." % ( + self._task.node.uuid, self.name, self.target)) + bareon_utils.sftp_ensure_tree(sftp, os.path.dirname(self.target)) + sftp.put(self.local_path, self.target) + + +class PullResource(Resource): + """A resource with delayed upload + + It is fetched onto rsync share, and rsync pointer is uploaded to the + node at target path. The user (or action) can use this pointer to download + the resource from the node shell. + """ + + MODE = 'pull' + + def _validate(self, raw): + if self.is_fetched(): + req = ('name', 'mode', 'target', 'url', 'local_path', + 'pull_url') + else: + req = ('name', 'mode', 'target', 'url') + bareon_utils.validate_json(req, raw) + + def get_dir_names(self): + my_dir = os.path.join( + get_node_resources_dir_rsync(self._task.node), + self._resource_list_name + ) + return [my_dir] + + def fetch(self): + if self.is_fetched(): + return + + # NOTE(lobur): Security issue. + # Resources of all tenants are on the same rsync root, so tenant + # can change the URL manually, remove UUID of the node from path, + # and rsync whole resource share into it's instance. + # To solve this we need to create a user per-tenant on Conductor + # and separate access controls. + res_dir = self.get_dir_names()[0] + local_path = os.path.join(res_dir, self.name) + url_download(self._task.context, self._task.node, self.url, + dest_path=local_path) + pull_url = rsync.build_rsync_url_from_abs(local_path) + self.local_path = local_path + self.pull_url = pull_url + + def upload(self, sftp): + if not self.is_fetched(): + raise bareon_exception.InvalidResourceState( + "Cannot upload action '%s' because it is not fetched." + % self.name + ) + LOG.info("[%(node)s] Writing resource url '%(url)s' to " + "the '%(path)s' for further pull." + % dict(node=self._task.node.uuid, + url=self.pull_url, + path=self.target)) + bareon_utils.sftp_ensure_tree(sftp, os.path.dirname(self.target)) + bareon_utils.sftp_write_to(sftp, self.pull_url, self.target) + + +class PullSwiftTempurlResource(Resource): + """A resource with delayed upload + + The URL of this resource is converted to Swift temp URL, and written + to the node at target path. The user (or action) can use this pointer + to download the resource from the node shell. + """ + + MODE = 'pull-swift-tempurl' + + def _validate(self, raw): + if self.is_fetched(): + req = ('name', 'mode', 'target', 'url', 'local_path', + 'pull_url') + else: + req = ('name', 'mode', 'target', 'url') + bareon_utils.validate_json(req, raw) + + url = append_storage_prefix(self._task.node, raw['url']) + scheme = parse.urlparse(url).scheme + storages_supporting_tempurl = ( + 'glance', + 'swift' + ) + # NOTE(lobur): Even though we could also use HTTP image service in a + # manner of tempurl this will not meet user expectation. Swift + # tempurl in contrast to plain HTTP reference supposed to give + # scalable access, e.g. allow an image to be pulled from high number + # of nodes simultaneously without speed degradation. + if scheme not in storages_supporting_tempurl: + raise exception.InvalidParameterValue( + "%(res)s resource can be used only with the " + "following storages: %(st)s" % + dict(res=self.__class__.__name__, + st=storages_supporting_tempurl) + ) + + def get_dir_names(self): + res_dir = os.path.join( + get_node_resources_dir(self._task.node), + self._resource_list_name + ) + return [res_dir] + + def fetch(self): + if self.is_fetched(): + return + + url = append_storage_prefix(self._task.node, self.url) + # NOTE(lobur): Only Glance and Swift can be here. See _validate. + img_service = image_service.get_image_service( + url, version=2, context=self._task.context) + temp_url = img_service.get_http_href(url) + + res_dir = self.get_dir_names()[0] + fileutils.ensure_tree(res_dir) + local_path = os.path.join(res_dir, self.name) + with open(local_path, 'w') as f: + f.write(temp_url) + + self.local_path = local_path + self.pull_url = temp_url + + def upload(self, sftp): + if not self.is_fetched(): + raise bareon_exception.InvalidResourceState( + "Cannot upload action '%s' because it is not fetched." + % self.name + ) + LOG.info("[%s] Writing %s resource tempurl to the node at %s." % ( + self._task.node.uuid, self.name, self.target)) + bareon_utils.sftp_ensure_tree(sftp, os.path.dirname(self.target)) + sftp.put(self.local_path, self.target) + + +class PullMountResource(Resource): + """A resource with delayed upload + + A resource of this type is supposed to be a raw image. It is fetched + and mounted to the the rsync share. The rsync pointer is uploaded to + the node at target path. The user (or action) can use this pointer + to download the resource from the node shell. + """ + + MODE = 'pull-mount' + + def _validate(self, raw): + if self.is_fetched(): + req = ('name', 'mode', 'target', 'url', 'local_path', + 'mount_point', 'pull_url') + else: + req = ('name', 'mode', 'target', 'url') + bareon_utils.validate_json(req, raw) + + def get_dir_names(self): + res_dir = os.path.join( + get_node_resources_dir(self._task.node), + self._resource_list_name + ) + mount_dir = os.path.join( + get_node_resources_dir_rsync(self._task.node), + self._resource_list_name, + ) + return [res_dir, mount_dir] + + def fetch(self): + if self.is_fetched(): + return + res_dir, mount_dir = self.get_dir_names() + local_path = os.path.join(res_dir, self.name) + url_download(self._task.context, self._task.node, self.url, + dest_path=local_path) + mount_point = os.path.join(mount_dir, self.name) + fileutils.ensure_tree(mount_point) + utils.mount(local_path, mount_point, '-o', 'ro') + pull_url = rsync.build_rsync_url_from_abs(mount_point, + trailing_slash=True) + self.local_path = local_path + self.mount_point = mount_point + self.pull_url = pull_url + + def upload(self, sftp): + if not self.is_fetched(): + raise bareon_exception.InvalidResourceState( + "Cannot upload action '%s' because it is not fetched." + % self.name + ) + LOG.info("[%(node)s] Writing resource url '%(url)s' to " + "the '%(path)s' for further pull." + % dict(node=self._task.node.uuid, + url=self.pull_url, + path=self.target)) + bareon_utils.sftp_ensure_tree(sftp, os.path.dirname(self.target)) + bareon_utils.sftp_write_to(sftp, self.pull_url, self.target) + + def cleanup(self): + if not self.is_fetched(): + return + bareon_utils.umount_without_raise(self.mount_point, '-fl') + self._raw.pop('mount_point', None) + utils.unlink_without_raise(self.local_path) + self._raw.pop('local_path', None) + + +class ResourceList(bareon_utils.RawToPropertyMixin): + """Class representing a list of resources.""" + + def __init__(self, resource_list, task): + self._raw = resource_list + self._task = task + + req = ('name', 'resources') + bareon_utils.validate_json(req, resource_list) + + self.name = bareon_utils.str_replace_non_alnum(resource_list['name']) + self.resources = [Resource.from_dict(res, self._task, self.name) + for res in resource_list['resources']] + + def fetch_resources(self): + """Fetch all resources of the list. + + Must be idempotent. + """ + try: + for r in self.resources: + r.fetch() + except Exception: + self.cleanup_resources() + raise + + def upload_resources(self, sftp): + """Upload all resources of the list.""" + for r in self.resources: + r.upload(sftp) + + def cleanup_resources(self): + """Cleanup all resources of the list. + + Must be idempotent. + Must return None if called when action resources are not fetched. + """ + # Cleanup resources + for res in self.resources: + res.cleanup() + + # Cleanup resource dirs + res_dirs = [] + for res in self.resources: + res_dirs.extend(res.get_dir_names()) + for dir in set(res_dirs): + utils.rmtree_without_raise(dir) + + def __getitem__(self, item): + return self.resources[item] + + @staticmethod + def from_dict(resouce_list, task): + return ResourceList(resouce_list, task) diff --git a/bareon_ironic/modules/resources/rsync.py b/bareon_ironic/modules/resources/rsync.py new file mode 100644 index 0000000..ebe4cb4 --- /dev/null +++ b/bareon_ironic/modules/resources/rsync.py @@ -0,0 +1,67 @@ +# +# Copyright 2016 Cray Inc., All 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. + + +import os + +from oslo_config import cfg + +from ironic.openstack.common import log + +rsync_opts = [ + cfg.StrOpt('rsync_server', + default='$my_ip', + help='IP address of Ironic compute node\'s rsync server.'), + cfg.StrOpt('rsync_root', + default='/rsync', + help='Ironic compute node\'s rsync root path.'), + cfg.StrOpt('rsync_module', + default='ironic_rsync', + help='Ironic compute node\'s rsync module name.'), + cfg.BoolOpt('rsync_secure_transfer', + default=False, + help='Whether the driver will use secure rsync transfer ' + 'over ssh'), +] + +CONF = cfg.CONF +CONF.register_opts(rsync_opts, group='rsync') + +LOG = log.getLogger(__name__) + +RSYNC_PORT = 873 + + +def get_abs_node_workdir_path(node): + return os.path.join(CONF.rsync.rsync_root, node.uuid) + + +def build_rsync_url_from_rel(rel_path, trailing_slash=False): + if CONF.rsync.rsync_secure_transfer: + rsync_server_ip = '127.0.0.1' + else: + rsync_server_ip = CONF.rsync.rsync_server + return '%(ip)s::%(mod)s/%(path)s%(sl)s' % dict( + ip=rsync_server_ip, + mod=CONF.rsync.rsync_module, + path=rel_path, + sl='/' if trailing_slash else '' + ) + + +def build_rsync_url_from_abs(abs_path, trailing_slash=False): + rel_path = os.path.relpath(abs_path, + CONF.rsync.rsync_root) + return build_rsync_url_from_rel(rel_path, trailing_slash) diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 0000000..56d35d2 --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,193 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter + +# Default OPENSTACK_RELEASE to kilo +ifndef OPENSTACK_RELEASE + OPENSTACK_RELEASE=kilo +endif + +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) \ + -D openstack_release=$(OPENSTACK_RELEASE) \ + -D version=$(OPENSTACK_RELEASE) \ + -D release=$(OPENSTACK_RELEASE)\ $(shell date +%Y-%m-%d) \ + source +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/bareon-ironic.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/bareon-ironic.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/bareon-ironic" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/bareon-ironic" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100644 index 0000000..2a800f9 --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2016 Cray Inc., All Rights Reserved +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ['sphinx.ext.ifconfig'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Bareon-Ironic' +copyright = u'Copyright 2016 Cray Inc., All Rights Reserved' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +# NOTE: version is set by the Makefile (-D version=) +# version = '1.0' +# The full version, including alpha/beta/rc tags. +# NOTE: version is set by the Makefile (-D release=) +# release = '1.0.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = [] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +html_theme_options = { + "headbgcolor" : "#E9F2FF", + "sidebarbgcolor" : "#003D73", + "sidebarlinkcolor" : "#D3E5FF", + "relbarbgcolor" : "#3669A9", + "footerbgcolor" : "#000000" +} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +#html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +html_sidebars = { + "**": ['localtoc.html', 'relations.html', 'sourcelink.html'] + } + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +html_use_index = False + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' +#html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# Now only 'ja' uses this config value +#html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +#html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Newtdoc' diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 0000000..a162744 --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,9 @@ +======================================== +Welcome to Bareon-Ironic's documentation +======================================== + +.. toctree:: + :maxdepth: 0 + + install-guide + user-guide diff --git a/doc/source/install-guide.rst b/doc/source/install-guide.rst new file mode 100644 index 0000000..c0ca5d3 --- /dev/null +++ b/doc/source/install-guide.rst @@ -0,0 +1,70 @@ +Installation Guide +================== + +This guide describes installation on top of OpenStack Kilo release. +The ``bare_swift_ipmi`` driver used as example. + +1. Install bareon_ironic package + +2. Patch Nova service with bareon patch ``(patches/patch-nova-stable-kilo)`` + +3. Restart nova compute-service + +4. Patch Ironic service with bareon patch ``(patches/patch-ironic-stable-kilo)`` + +5. Enable the driver: add ``bare_swift_ipmi`` to the list of ``enabled_drivers`` + in ``[DEFAULT]`` section of ``/etc/ironic/ironic.conf``. + +6. Update ironic.conf using bareon sample ``(etc/ironic/ironic.conf.bareon_sample)`` + +7. Restart ``ironic-api`` and ``ironic-conductor`` services + +8. Build a Bareon ramdisk: + + 8.1 Get Bareon source code ([1]_) + + 8.2 Run build + + .. code-block:: console + + $ cd bareon && bash bareon/tests_functional/image_build/centos_minimal.sh + + Resulting images and SSH private key needed to access it will appear + at /tmp/rft_image_build/. + +9. Upload kernel and initrd images to the Glance image service. + +10. Create a node in Ironic with ``bare_swift_ipmi`` driver and associate port with the node + + .. code-block:: console + + $ ironic node-create -d bare_swift_ipmi + $ ironic port-create -n -a + +11. Set IPMI address and credentials as described in the Ironic documentation [2]_. + +12. Setup nova flavor as described in the Ironic documentation [2]_. + +13. Set Bareon related driver's parameters for the node + + .. code-block:: console + + $ KERNEL= + $ INITRD= + $ PRIVATE_KEY_PATH=/tmp/rft_image_build/fuel_key + $ ironic node-update add driver_info/deploy_kernel=$KERNEL \ + driver_info/deploy_ramdisk=$INITRD \ + driver_info/bareon_key_filename=$PRIVATE_KEY_PATH + +14. Issue ironic validate command to check for errors + + .. code-block:: console + + $ ironic node-validate + +After steps above the node is ready for deploying. User can invoke +``nova boot`` command and link appropriate deploy_config as described in User +Guide + +.. [1] https://github.com/openstack/bareon +.. [2] http://docs.openstack.org/developer/ironic/deploy/install-guide.html diff --git a/doc/source/user-guide.rst b/doc/source/user-guide.rst new file mode 100644 index 0000000..8010a8d --- /dev/null +++ b/doc/source/user-guide.rst @@ -0,0 +1,1029 @@ +User Guide +========== + +The Bareon Ironic driver is controlled by a JSON file specified using the +deploy_config parameter. This file has a number of sections that control +various deployment stages. These sections and their effect are described below. + +The deploy config file contains multiple attributes on the 1st level. + +Currently supported attributes are: + +:: + + { + "partitions": ... + "partitions_policy": ... + "image_deploy_flags": ... + } + +This JSON file is distributed via the deploy configuration image. The reference to +the image can be specified in multiple ways: + +* in */etc/ironic/ironic.conf* (mandatory, Cloud Default Configuration Image) +* passed to nova boot (optional) +* linked to the tenant image (optional) +* linked to ironic node (optional). + +.. _ironic_bareon_cloud_default_config: + +Creating A Bareon Cloud Default Configuration Image +--------------------------------------------------- +To use the Ironic bareon driver you must create a Cloud Default Configuration JSON +file in the resource storage. The driver will automatically +refer to it, using the URL "cloud_default_deploy_config". For example: + +.. code-block:: console + + root@example # cat cloud_default_deploy_config + { + "image_deploy_flags": { + "rsync_flags": "-a -A -X --timeout 300" + } + } + +.. warning:: Since an error in the JSON file will prevent the deploy from + succeeding, you may want to validate the JSON syntax, for example by + executing `python -m json.tool < cloud_default_deploy_config`. + +To use default resource storage (Glance) create a Glance image using the JSON +file as input. + +.. code-block:: console + + root@example # glance image-create --is-public True --disk-format raw \ + --container-format bare --name cloud_default_deploy_config \ + --file cloud_default_deploy_config + +See :ref:`url_resolution` for information on how to use alternate storage sources. + +Ironic will automatically refer to this image by the URL +*cloud_default_deploy_config* and use it for all deployments. Thus it is highly +recommended to not put any node-specific or image-specific details into the +cloud default config. Attributes in this config can be overridden according +to the priorities in */etc/ironic/ironic.conf*. + +.. _ironic_bareon_deploy_config: + +Creating A Bareon Deploy Configuration JSON +------------------------------------------- + +To use the Ironic bareon driver you must create a deploy configuration JSON file +in the resource storage to reference during the deploy process. + +This configuration file should define the desired disk partitioning scheme for +the Ironic nodes. For example: + +.. code-block:: console + + root@example # cat deploy_config_example + { + "partitions_policy": "clean", + "partitions": [ + { + "type": "disk", + "id": { + "type": "name", + "value": "sda" + }, + "size": "10000 MiB", + "volumes": [ + { + "type": "partition", + "mount": "/", + "file_system": "ext4", + "size": "4976 MiB" + }, + { + "type": "partition", + "mount": "/opt", + "file_system": "ext4", + "size": "2000 MiB" + }, + { + "type": "pv", + "size": "3000 MiB", + "vg": "home" + } + ] + }, + { + "type": "vg", + "id": "home", + "volumes": [ + { + "type": "lv", + "name": "home", + "mount": "/home", + "size": "2936 MiB", + "file_system": "ext3" + } + ] + } + ] + } + +The JSON structure is explained in the next section. + +Refer to :ref:`implicitly_taken_space` for explanation of uneven size values. + +To use default resource storage (Glance) create a Glance image using the +JSON deploy configuration file as input. + +.. warning:: Since an error in the JSON file will prevent the deploy from + succeeding, you may want to validate the JSON syntax, for example by + executing `python -m json.tool < deploy_config_example`. + +.. code-block:: console + + root@example # glance image-create --is-public True --disk-format raw \ + --container-format bare --name deploy_config_example \ + --file deploy_config_example + +See :ref:`url_resolution` for information on how to use alternate storage sources. + +Then the Nova metadata must include a reference to the desired deploy configuration +image, in this example ``deploy_config=deploy_config_example``. This may be +specified as part of the Nova boot command or as OS::Nova::Server metadata in a Heat +template. An example of the former: + +.. code-block:: console + + root@example # nova boot --nic net-id=23c11dbb-421e-44ca-b303-41656a4e6344 \ + --image centos-7.1.1503.raw --flavor ironic_flavor \ + --meta deploy_config=deploy_config_example --key-name=default bareon_test + +.. _ironic_bareon_deploy_config_structure: + +Deploy Configuration JSON Structure +----------------------------------- + +partitions_policy +^^^^^^^^^^^^^^^^^ + +Defines the partitioning behavior of the driver. Optional, default is "verify". +General structure is: + +:: + + "partitions_policy": "verify" + + +The partitions policy can take one of the following values: + +**verify** - Applied in two steps: + +1. Do verification. Compare partitions schema with existing partitions on the + disk(s). If the schema matches the on-disk partition layout + (including registered fstab mount points) then deployment succeeds. + If the schema does not match the on-disk layout, deployment fails and the + node is returned to the pool. No modification to the on-disk content is + made in this case. Any disks present on the target node that are not + mentioned in the schema are ignored. + +.. note:: File */etc/fstab* must be present on the node, and written + using partition UUIDs. bareon tries to find it on the 1st disk with + bootloader present, on the 1st primary/logical partition (skipping ones + marked as bios_grub). + +.. note:: LVM verification is not supported currently. PVs/VGs/LVs are not being + read from the node. + +2. Clean data on filesystems marked as keep_data=False. See partitions + sections below. + +**clean** - Applied in a single step: + +1. Ignore existing partitions on the disk(s). Clean the disk and create + partitions according to the schema. Any disks present on the target node + that are not mentioned in the schema are ignored. + +.. _partitions: + +partitions +^^^^^^^^^^ + +An attribute called partitions holds a partitioning schema being applied +to the node during deployment. Required. + +General structure and schema flow is: + +:: + + "partitions": [ + { + "type": "disk", + ... + "volumes": [ + { + "type": "partition", + ... + }, + ..., + { + "type": "pv", + ... + }, + ... + ] + }, + { + "type": "vg", + ... + "volumes": [ + { + "type": "lv", + ... + }, + ... + ] + }, + ] + +.. _partitions_disk: + +disk +"""" + +- type - "disk". Required. +- id - Used to find a device. Required. For example: + + :: + + "id":{"type": "scsi", "value": "6:1:0:0"} + + "id":{"type": "path", + "value" : "disk/by-path/pci-0000:00:07.0-virtio-pci-virtio3"} + + "id":{"type": "name", "value": "vda"} + + +- size - Size of disk. Required. +- volumes - Array of partitions / physical volumes. See below. Required. + +.. note:: All "size" values are strings containing either an integer number + and size unit (e.g., "100 MiB" or 100MiB"). In the case of partitions a + value relative to the size of the disk (e.g., "40%") may also be used. + + Available measurement units are: 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', + 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'. + + Relative values use the size of the containing device or physical volume + as a base. For example, specifying "40%" for a 100MiB device would result + in a 40MiB partition. Relative sizes cannot be used for disks. + + You can also specify "remaining" as a size value for a volume in a disk or + volume group. When "remaining" is specified, all remaining free space on + the drive after allocations are made for all other volumes will be used for + this volume. + +.. _partitions_partition: + +partition +""""""""" + +- type - "partition". Required. +- size - Size of partition. Required. +- mount - Mount point, e.g. "/", "/usr". Optional (not mounted by default). +- file_system - File system type. Passed down to mkfs call. + Optional (xfs by default). +- disk_label - Filesystem label. Optional (empty by default). +- partition_guid - GUID that will be assigned to partition. Optional. +- fstab_enabled - boolean value that specifies whether the partition will be + included in /etc/fstab and mounted. Optional (true by default). +- fstab_options - string to specify fstab mount options. + Optional ('defaults' by default). +- keep_data - Boolean flag specifying whether or not to preserve data on this + partition. Applied when *verify* partitions_policy is used. Optional (True + by default). +- images - A list of strings, specifies the images this partition belongs to. + Belonging to an image means that the partition will be mounted into the filesystem + tree before the image deployment, and then included into fstab file of the filesystem + tree. Applies to multiboot node deployments (More than 1 image). Images are referred + by name specified in "name" attribute of the image (see :ref:`images`). + Optional (by default the partition belongs to the first image in the list of + images). Example: *"images": ["centos", "ubuntu"]*. + +.. warning:: If you are using the bareon swift deployment driver (bareon_swift_*), + care must be taken when declaring mount points in your deployment + configuration file that may conflict with those that exist in the tenant + image. Doing this will cause the mount points defined in the deployment + configuration to mask the corresponding directories in the tenant image + when the deployment completes. For example, if your deployment + configuration file contains a definition for '/etc/', the deployment will + create an empty filesystem on disk and mount it on /etc in the tenant image. + This will hide the contents of '/etc' from the original tenant image with + the on-disk filesystem which was created during deployment. + +.. _partitions_pv: + +physical volume +""""""""""""""" + +- type - "pv". Required. +- size - Size of the physical volume. Required. +- vg - id of the volume group this physical volume should belong to. Required. +- lvm_meta_size - a size that given to lvm to store metadata. + Optional (64 MiB by default). Minimum allowable value: 10 MiB. + +.. _partitions_vg: + +volume group +"""""""""""" + +- type - "vg". Required. +- id - Volume group name. Should be refered at least once from pv. Required. +- volumes - Array of logical volumes. See below. Required. + +.. _partitions_lv: + +logical volume +"""""""""""""" + +- type - "lv". Required. +- name - Name of the logical volume. Required. +- size - Size of the logical volume. Required. +- mount - Mount point, e.g. "/", "/usr". Optional. +- file_system - File system type. Passed down to mkfs call. + Optional (xfs by default). +- disk_label - Filesystem label. Optional (empty by default). +- images - A list of strings, specifies the images this volume belongs to. + Belonging to an image means that the volume will be mounted into the filesystem + tree before the image deployment, and then included into fstab file of the filesystem + tree. Applies to multiboot node deployments (More than 1 image). Images are referred + by name specified in "name" attribute of the image (see :ref:`images`). + Optional (by default the partition belongs to the first image in the list of + images). Example: *"images": ["centos", "ubuntu"]*. + +.. warning:: If you are using the bareon swift deployment driver (bareon_swift_*), + care must be taken when declaring mount points in your deployment + configuration file that may conflict with those that exist in the tenant + image. Doing this will cause the mount points defined in the deployment + configuration to mask the corresponding directories in the tenant image + when the deployment completes. For example, if your deployment + configuration file contains a definition for '/etc/', the deployment will + create an empty filesystem on disk and mount it on /etc in the tenant image. + This will hide the contents of '/etc' from the original tenant image with + the on-disk filesystem which was created during deployment. + +.. note:: Putting a "/" partition on LVM requires a standalone "/boot" partition + defined in the schema and the node should be managed by the bareon_rsync Ironic + driver. + +.. _images: + +images +^^^^^^ + +An attribute called 'images' can be used to specify multiple images for the node +(a so called 'multiboot' node). It is an optional attribute, skip it if you don't +need more than 1 image deployed to the node. By default it will be a list of +one image: the one passed via --image arg of nova boot command. + +An example of the deploy_config for two-image deployment below: + + :: + + { + "images": [ + { + "name": "centos", + "url": "centos-7.1.1503", + "target": "/" + }, + { + "name": "ubuntu", + "url": "ubuntu-14.04", + "target": "/" + } + ], + "partitions": [ + { + "id": { + "type": "name", + "value": "vda" + }, + "extra": [], + "free_space": "10000", + "volumes": [ + { + "mount": "/", + "images": ["centos"], + "type": "partition", + "file_system": "ext4", + "size": "4000" + }, + { + "mount": "/", + "images": ["ubuntu"], + "type": "partition", + "file_system": "ext4", + "size": "4000" + } + ], + "type": "disk", + "size": "10000" + } + ] + } + +During the multi-image deployment, the initial boot image is specified via +nova --image attribute. For example with the config shown above, if you need the +node to start from ubuntu, pass '--image ubuntu-14.04' to nova boot. + +The process of switching of the active image described in :ref:`switch_boot_image` +section. + +Images JSON attributes and their effect described below. + +.. _images_name: + +**name** + +An alias name of the image. Used to be referred from the 'images' attribute of the +partition or logical volume (see :ref:`partitions_partition`, :ref:`partitions_lv`). +Required. + +.. _images_url: + +**url** + +Name or UUID of the image in Glance. Required. + +.. _images_target: + +**target** + +A point in the filesystem tree where the image should be deployed to. Required. +For all the standard cloud images this will be a *"/"*. Utility images can have +a different value, like */usr/share/utils*. Example below: + + :: + + { + "images": [ + { # Centos image }, + { # Ubuntu image }, + { + "name": "utils", + "url": "utils-ver1.0", + "target": "/usr/share/utils" + } + ], + "partitions": [ + { + ... + "volumes": [ + { + "mount": "/", + "images": ["centos", "utils"], + "type": "partition", + "file_system": "ext4", + "size": "4000" + }, + { + "mount": "/", + "images": ["ubuntu", "utils"], + "type": "partition", + "file_system": "ext4", + "size": "4000" + } + ] + } + ] + } + +In this case both Centos and Ubuntu images will get */usr/share/utils* directory +populated from the "utils-ver1.0" image. + +Alternatively utilities image can be deployed to a standalone partition. Example +below: + + :: + + { + "images": [ + { # Centos image }, + { # Ubuntu image }, + { + "name": "utils", + "url": "utils-ver1.0", + "target": "/usr/share/utils" + } + ], + "partitions": [ + { + "volumes": [ + { + # Centos root + "images": ["centos"], + }, + { + # Ubuntu root + "images": ["ubuntu"], + }, + { + "mount": "/usr/share/utils", + "images": ["centos", "ubuntu", "utils"], + "type": "partition", + "file_system": "ext4", + "size": "2000" + } + ], + ... + } + ] + } + +In this case the utilities image is deployed to it's own partition, which is +included into fstab file of both Centos and Ubuntu. Note that partition "images" +list also includes "utils" image as well. This is required for correct deployment: +the utils partition virtually belongs to "utils" image, and mounted +to the fs tree before "utils" image deployment (fake root in this case). + +image_deploy_flags +^^^^^^^^^^^^^^^^^^ + +The attribute image_deploy_flags is composed in JSON, and is used to set flags +in the deployment tool. Optional (default for the "rsync_flags" +attribute is "-a -A -X"}). + +.. note:: Currently used only by rsync. + +The general structure is: + +:: + + "image_deploy_flags": {"rsync_flags": "-a -A -X --timeout 300"} + + +on_fail_script +^^^^^^^^^^^^^^ + +Carries a URL reference to a shell script (bash) executed inside ramdisk in case +of non-zero return code from bareon. Optional (default is empty shell). + +General structure is: + +:: + + "on_fail_script": "my_on_fail_script.sh" + + +Where my_on_fail_script.sh is the URL pointing to the object in resource storage. +To add your script to default resource storage (Glance), use the following commands: + +.. code-block:: console + + root@example # cat my_on_fail_script.sh + cat /proc/cmdline + ps aux | grep -i ssh + dmesg | tail + + root@example # glance image-create --is-public True --disk-format raw \ + --container-format bare --name my_on_fail_script.sh \ + --file my_on_fail_script.sh + +See :ref:`url_resolution` for information on how to use alternate storage sources. + +Once the script is executed, the output is printed to Ironic-Conductor log. + +.. _implicitly_taken_space: + +Implicitly taken space in partitions schema +------------------------------------------- + +In the example of partitions schema you may have noticed uneven size values like +4976. This is because bareon driver implicitly creates a number of partitions/spaces: + +- For every disk in schema bareon driver creates a 24 MiB partition at the beginning. + This is to allow correct installation of Grub Stage 1.5 data. It is implicitly + created for every disk in schema even if the disk does not have /boot partition. + Thus if 10000 MiB disk size is declared by schema, 9876 MiB is available + for partitions/pvs. 24 MiB value is not configurable. + +- Every physical volume has a 64 MiB less space than in takes on disk. If you + declare a physical volume of size 5000 MiB, the volume group will get 4936 MiB + available. If there are two physical volumes of 5000 MiB, the resulting + volume group will have 9872 MiB (10000 - 2*64) available. This extra space is + left for LVM metadata. 64 MiB value can be overriden by lvm_meta_size attribute + of the pv, see :ref:`partitions_pv`. + +- In case of multi-image deployment (see :ref:`images`) an additional 100 MiB partition + is created on the boot disk (the 1st disk referred from deploy_config). This + partition is used to install grub. + +The partitions schema example is written to take all the declared space. Usually +you don't need to precisely calculate how much is left. You may leave for example +100 MiB free on each disk, and about 100-200 MiB in each volume group, depending +of how many physical volumes are in the group. Alternatively you can use a +"remaining" keyword to let bareon driver calculate for you, see :ref:`partitions_disk`. + +.. _url_resolution: + +URL resolution in Bareon Ironic driver +-------------------------------------- + +References given to the bareon driver (e.g. deploy_config) as well as references +from the deploy_config (e.g. on_fail_script) are URLs. Currently 4 types of +URL sources are supported: + +- **glance**. URL structure is "glance:". Or simply + "" if default_resource_storage_prefix is set to "glance:" + (default value) in */etc/ironic/ironic.conf* . This storage uses user-context + based authorization and thus has tenant isolation. + +- **swift**. URL structure is "swift:container_name/object_name". + Simply "object_name" or "container_name/object_name" can be used if + default_resource_storage_prefix set appropriately in + */etc/ironic/ironic.conf*. This storage uses user-context based authorization + and thus has tenant isolation. + +.. note:: Due to Ironic API limitation, to use a swift resource during + deployment (e.g. '--meta deploy_config=swift:*'), the user should have an + 'admin' role in his tenant. + +- **http**. URL structure is "http://site/path_to_resource". Contents should + be directly accessible via URL, with no auth. Use "raw" links in services like + http://paste.openstack.org/. The default_resource_storage_prefix option of + */etc/ironic/ironic.conf* can be used to shorten the URL, e.g. set + to "http://my-config-site/raw/". This storage does not support + authentication/authorization and thus it does not have tenant isolation. + + +- **rsync**. URL structure is "rsync:SERVER_IP::rsync_module/path_to_resource". + To use this kind of URL, the rsync server IP should be accessible from + Ironic Conductor nodes. The default_resource_storage_prefix option of + */etc/ironic/ironic.conf* can be used to shorten the URL, e.g. set to + "rsync:SERVER_IP::rsync_module/". This storage does not support + authentication/authorization and thus it does not have tenant isolation. + + +The "default_resource_storage_prefix" option can be used to +shorten the URL for the most frequently used URL type. If set, it can still +be overridden if the full URL is passed. For example, if the option is set to +"http://my-config-site/raw/", you can still use another http site if you specify +a full URL like: "http://my-other-config-site/raw/resource_id". If another storage +type is needed, use the full URL to specify that source. + +Bareon Ironic driver actions +---------------------------- + +The ironic bareon driver can execute arbitrary user actions provided in a JSON +file describing the actions to be performed. This file has a number of +sections to control the execution of these actions. These sections and their +effect are described below. + +Creating A Bareon Actions List +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To use Ironic bareon driver actions execution you must create an action list in +the resource storage to reference. + +Create a JSON configuration file that defines the desired action list to be +executed. For example: + +.. code-block:: console + + root@example # cat actions_list_example + { + "action_key": "private-key-url", + "action_user": "centos", + "action_list": + [ + { + "cmd": "cat", + "name": "print_config_file", + "terminate_on_fail": true, + "args": "/tmp/conf", + "sudo": true, + "resources": + [ + { + "name": "resource_1", + "url": "my-resource-url", + "mode": "push" + "target": "/tmp/conf" + } + ] + } + ] + } + +The JSON structure explained in the next section. + +.. warning:: Since an error in the JSON file will prevent the deploy from + succeeding, you may want to validate the JSON syntax, for example by executing + `python -m json.tool < actions_list_example`. + +To use default resource storage (Glance), create a glance image using the JSON +deploy configuration file as input. + +.. code-block:: console + + root@example # glance image-create --is-public True --disk-format raw \ + --container-format bare --name actions_list_example \ + --file actions_list_example + +See :ref:`url_resolution` for information on how to use alternate storage sources. + +Invoking Driver Actions +^^^^^^^^^^^^^^^^^^^^^^^ + +Actions can be invoked in two cases: + +- during deployment, right after the bareon has run + (reference to JSON file is passed via --meta driver_actions); +- at any time when node is deployed and running + (invoked via vendor-passthru). + +Execution during deployment +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In order to execute actions during deployment, the Nova metadata must include +a reference to the desired action list JSON, in this +example ``driver_actions=actions_list_example``. This may be specified as +part of the Nova boot command or as OS::Nova::Server metadata in a Heat +template. An example of the former: + +.. code-block:: console + + root@example # nova boot --nic net-id=23c11dbb-421e-44ca-b303-41656a4e6344 \ + --image centos-7.1.1503.raw --flavor ironic_flavor \ + --meta deploy_config=deploy_config_example \ + --meta driver_actions=actions_list_example --key-name=default bareon_test + +Execution on a working node +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In order to execute actions whilst the node is running, you should specify +``exec_actions`` node-vendor-passthru method, +``driver_actions=actions_list_example`` property and node uuid. +For example: + +.. code-block:: console + + root@example # ironic node-vendor-passthru --http-method POST \ + node_uuid exec_actions driver_actions=actions_list_example + +.. _actions_json: + +Actions List JSON Structure +--------------------------- + +.. _actions_json_key: + +action_key +^^^^^^^^^^ + +An attribute called action_key holds a resource storage URL pointing to ssh +private key contents being used to establish ssh connection to the node. + +Only sources with tenant isolation can be used for this URL. See +:ref:`url_resolution` for available storage sources. + +:: + + "action_key": "ssh_key_url" + +.. note:: This parameter is ignored when actions are invoked during deployment. + The Default bareon key is used. + +.. _actions_json_user: + +action_user +^^^^^^^^^^^ + +An attribute called action_user holds a name of the user used to establish +an ssh connection to the node with the key provided in action_key. + +:: + + "action_user": "centos" + +.. note:: This parameter is ignored when actions are invoked during deployment. + Default bareon user is being used. + +.. _actions_json_list: + +action_list +^^^^^^^^^^^ +An attribute called action_list holds a list of actions being applied +to the node. Actions are executed in the order in which they appear in the list. + +General structure is: + +:: + + "action_list": + [ + { + "cmd": "cat", + "name": "print_config_file", + "terminate_on_fail": true, + "args": "/tmp/conf", + "sudo": true, + "resources": + [ + { + "name": "resource_1", + "url": "my-resource-url-1", + "mode": "push", + "target": "/tmp/conf" + }, + { + "name": "resource_2", + "url": "my-resource-url-2", + "mode": "push", + "target": "/tmp/other-file" + }, + { + ...more resources + } + ] + }, + { + ...more actions + } + ] + + +- cmd - shell command to execute. Required. +- args - arguments for cmd. Required. +- name - alpha-numeric name of the action. Required. +- terminate_on_fail - flag to specify if actions execution should be terminated + in case of action failure. Required. +- sudo - flag to specify if execution should be executed with sudo. Required. +- resources - array of resources. See resource. Required. May be an empty list. + +resource +"""""""" + +Defines the resource required to execute an action. General structure is: + +:: + + { + "name": "resource_1", + "url": "resource-url", + "mode": "push", + "target": "/tmp/conf" + } + +- name - alpha-numeric name of the resource. Required. +- url - a URL pointing to resource. See :ref:`url_resolution` for available + storage sources. +- mode - resource mode. See below. Required. +- target - target file name on the node. Required. + +Resource **mode** can take one of the following: + +- **push**. A resource of this type is cached by the Ironic Conductor and + uploaded to the node at target path. + +- **pull**. A resource of this type is cached by the Ironic Conductor and the + reference to the resource is passed to the node (the reference is written + to the file specified by the 'target' attribute) so that it can be pulled + as part of the action. The reference is an rsync path that allows the node + to pull the resource from the conductor. A typical way to pull the + resource is: + +.. code-block:: console + + root@baremetal-node # rsync $(cat /ref/file/path) . + + +- **pull-mount**. Like resources in pull mode, the resource is cached and the + reference is passed to the target node. However, pull-mount resources are + assumed to be file system images and are mounted in loopback mode by the + Ironic Conductor. This allows the referencing action to pull from the + filesystem tree as is done during rsync-based deployments. The following + example will pull the contents of the image to the /root/path: + +.. code-block:: console + + root@baremetal-node # rsync -avz $(cat /ref/file/path) /root/path + + +- **pull-swift-tempurl**. For resources of this type, Ironic obtains a Swift + tempurl reference to the object and writes this tempurl to the file + specified by the resource 'target' attribute. The tempurl duration is + controlled by the */etc/ironic/ironic.conf*: + + * for *glance:* URLs an option *swift_temp_url_duration* from [glance] + section is used; + * for *swift:* URLs an option *swift_native_temp_url_duration* + from [swift] section is used. + +.. note:: To use 'pull-swift-tempurl' resource with Glance store Glance must be + set to have Swift as a backend. + +.. note:: Although all the Glance images are stored in the same Swift container, + tempurls obtained from Glance are considered tenant-isolated because the + tenant is checked by Glance as part of the generation of the temporary URL. + +Resources of all modes can be mixed in a single action. + +.. _switch_boot_image: + +Switching boot image in a 'Multiboot' node +------------------------------------------ + +If a node has more than one images deployed (see :ref:`images`), the user can +switch boot image in two ways. Both of them require Ironic Conductor to SSH to +the node, thus SSH user/key needs to be provided. + +Switching via nova rebuild +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To list images available to boot: + +.. code-block:: console + + root@example # nova show VM_NAME + +In show result the 'metadata' attribute will show a list of available images, like: + +.. code-block:: console + + "available_images": "[u'centos-7.1.1503', u'ubuntu-14.04'] + +Currently booted image is shown by 'image' attribute of the VM. Let's say the +current image is 'centos-7.1.1503'. To switch to 'ubuntu-14.04' do: + +.. code-block:: console + + root@example # nova rebuild VM_NAME 'ubuntu-14.04' \ + --meta sb_key=swift:container/file --meta sb_user=centos + +Alternatively you can use image UUID to refer the image. + +Note sb_key and sb_user attributes passed to metadata. They stand for 'switch boot +user' and 'switch boot key'. They are the username and a URL pointing +to the SSH private key used to ssh to the node. This is similar to :ref:`actions_json`. + +Nova VM will be in "rebuild_spawning" state during switch process. Once it is active +the Node will start booting the specified image. If switch did not happen, +issue another "nova show" and check for "switch_boot_error" attribute in VM metadata. + +For single-boot nodes a rebuild command will trigger a standard rebuild flow: +redeploying the node from scratch. + +For multiboot nodes it is still possible to trigger a standard rebuild flow using +force_rebuild meta flag: + +.. code-block:: console + + root@example # nova rebuild VM_NAME 'ubuntu-14.04' --meta force_rebuild=True + +Switching via ironic node vendor-passthru +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To list images available to boot: + +.. code-block:: console + + root@example # ironic node-show NODE_NAME + +In show result the 'instance_info/multiboot_info/elements' attribute will carry a +list of available images. Every element has 'grub_id' which shows the ID in grub menu. +An 'instance_info/multiboot_info/current_element' shows the ID of the currently +selected image. To switch to another image do: + +.. code-block:: console + + root@example # ironic node-vendor-passthru NODE_NAME switch_boot \ + image= ssh_key=swift:container/file ssh_user=centos + +The API is synchronous, it will block until the switch is done. Node will start +booting the new image once it is done. If nothing happened, issue another +'ironic node-show' and check the last_error attribute. + +.. note:: If ironic CLI is used to switch boot device, nova VM 'image', as well as Ironic + 'instance_info/image_source' are not updated to point the currently booted image. + + +Rebuilding nodes (nova rebuild) +------------------------------- + +Since bareon driver requires deploy_config reference passed to work, during rebuild +process, the user has two options: + +- Add --meta deploy_config=new_deploy_config attribute to the 'nova rebuild' command. + The new deploy_config will be used to re-deploy the node. +- Skip --meta attribute. In this case deploy_config reference used during original + deployment will be used. + +Same applies to driver_actions. + + +Deployment termination +---------------------- + +Deployment can be terminated in both silent (wait-callback) and active +(deploying) phases using plain nova delete command. + + +.. code-block:: console + + root@example # node delete diff --git a/etc/ironic/ironic.conf.bareon_sample b/etc/ironic/ironic.conf.bareon_sample new file mode 100644 index 0000000..308592a --- /dev/null +++ b/etc/ironic/ironic.conf.bareon_sample @@ -0,0 +1,105 @@ +[bareon] + +# Template file for two-disk boot PXE configuration. (string +# value) +#pxe_config_template=$pybasedir/modules/bareon_config.template + +# Template file for three-disk (live boot) PXE configuration. +# (string value) +#pxe_config_template_live=$pybasedir/modules/bareon_config_live.template + +# Additional append parameters for baremetal PXE boot. (string +# value) +#bareon_pxe_append_params=nofb nomodeset vga=normal + +# UUID (from Glance) of the default deployment kernel. (string +# value) +#deploy_kernel= + +# UUID (from Glance) of the default deployment ramdisk. +# (string value) +#deploy_ramdisk= + +# UUID (from Glance) of the default deployment root FS. +# (string value) +#deploy_squashfs= + +# Priority for deploy config (string value) +#deploy_config_priority=instance:node:image:conf + +# A uuid or name of glance image representing deploy config. +# (string value) +#deploy_config= + +# Timeout in minutes for the node continue-deploy process +# (deployment phase following the callback). (integer value) +#deploy_timeout=15 + +# Time interval in seconds to check whether the deployment +# driver has responded to termination signal (integer value) +#check_terminate_interval=5 + +# Max retries to check is node already terminated (integer +# value) +#check_terminate_max_retries=20 + +[resources] + +# A prefix that will be added when resource reference is not +# url-like. E.g. if storage prefix is +# "rsync:10.0.0.10::module1/" then "resource_1" is treated +# like rsync:10.0.0.10::module1/resource_1 (string value) +#default_resource_storage_prefix=glance: + +# Directory where per-node resources are stored. (string +# value) +#resource_root_path=/ironic_resources + +# Directory where master resources are stored. (string value) +#resource_cache_master_path=/ironic_resources/master_resources + +# Maximum size (in MiB) of cache for master resources, +# including those in use. (integer value) +#resource_cache_size=10240 + +# Maximum TTL (in minutes) for old master resources in cache. +# (integer value) +#resource_cache_ttl=1440 + +[rsync] + +# Directory where master rsync images are stored on disk. +# (string value) +#rsync_master_path=/rsync/master_images + +# Maximum size (in MiB) of cache for master images, including +# those in use. (integer value) +#image_cache_size=20480 + +# Maximum TTL (in minutes) for old master images in cache. +# (integer value) +#image_cache_ttl=10080 + +# IP address of Ironic compute node's rsync server. (string +# value) +#rsync_server=$my_ip + +# Ironic compute node's rsync root path. (string value) +#rsync_root=/rsync + +# Ironic compute node's rsync module name. (string value) +#rsync_module=ironic_rsync + +# Whether the driver will use secure rsync transfer over ssh +# (boolean value) +#rsync_secure_transfer=false + +[swift] + +# The length of time in seconds that the temporary URL will be +# valid for. Defaults to 20 minutes. This option is different +# from the "swift_temp_url_duration" defined under [glance]. +# Glance option controls temp urls obtained from Glance while +# this option controls ones obtained from Swift directly, e.g. +# when swift: ref is used. (integer value) +#swift_native_temp_url_duration=1200 diff --git a/patches/patch-ironic-stable-kilo b/patches/patch-ironic-stable-kilo new file mode 100644 index 0000000..de3ccc8 --- /dev/null +++ b/patches/patch-ironic-stable-kilo @@ -0,0 +1,311 @@ +diff --git a/ironic/api/config.py b/ironic/api/config.py +index 38938c1..18c82fd 100644 +--- a/ironic/api/config.py ++++ b/ironic/api/config.py +@@ -31,7 +31,8 @@ app = { + '/', + '/v1', + '/v1/drivers/[a-z_]*/vendor_passthru/lookup', +- '/v1/nodes/[a-z0-9\-]+/vendor_passthru/heartbeat' ++ '/v1/nodes/[a-z0-9\-]+/vendor_passthru/heartbeat', ++ '/v1/nodes/[a-z0-9\-]+/vendor_passthru/pass_deploy_info', + ], + } + +diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py +index ce48e09..0df9a3f 100644 +--- a/ironic/api/controllers/v1/node.py ++++ b/ironic/api/controllers/v1/node.py +@@ -381,13 +381,17 @@ class NodeStatesController(rest.RestController): + rpc_node = api_utils.get_rpc_node(node_ident) + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + ++ driver = api_utils.get_driver_by_name(rpc_node.driver) ++ driver_can_terminate = (driver and ++ driver.deploy.can_terminate_deployment) + # Normally, we let the task manager recognize and deal with + # NodeLocked exceptions. However, that isn't done until the RPC calls + # below. In order to main backward compatibility with our API HTTP + # response codes, we have this check here to deal with cases where + # a node is already being operated on (DEPLOYING or such) and we + # want to continue returning 409. Without it, we'd return 400. +- if rpc_node.reservation: ++ if (not (target == ir_states.DELETED and driver_can_terminate) and ++ rpc_node.reservation): + raise exception.NodeLocked(node=rpc_node.uuid, + host=rpc_node.reservation) + +diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py +index 6132e12..91ca0f2 100644 +--- a/ironic/api/controllers/v1/utils.py ++++ b/ironic/api/controllers/v1/utils.py +@@ -19,6 +19,7 @@ from oslo_utils import uuidutils + import pecan + import wsme + ++from ironic.common import driver_factory + from ironic.common import exception + from ironic.common.i18n import _ + from ironic.common import utils +@@ -102,3 +103,12 @@ def is_valid_node_name(name): + :returns: True if the name is valid, False otherwise. + """ + return utils.is_hostname_safe(name) and (not uuidutils.is_uuid_like(name)) ++ ++ ++def get_driver_by_name(driver_name): ++ _driver_factory = driver_factory.DriverFactory() ++ try: ++ driver = _driver_factory[driver_name] ++ return driver.obj ++ except Exception: ++ return None +diff --git a/ironic/common/context.py b/ironic/common/context.py +index aaeffb3..d167e26 100644 +--- a/ironic/common/context.py ++++ b/ironic/common/context.py +@@ -63,5 +63,4 @@ class RequestContext(context.RequestContext): + @classmethod + def from_dict(cls, values): + values.pop('user', None) +- values.pop('tenant', None) + return cls(**values) +diff --git a/ironic/common/states.py b/ironic/common/states.py +index 7ebd052..df30c2f 100644 +--- a/ironic/common/states.py ++++ b/ironic/common/states.py +@@ -218,6 +218,9 @@ machine.add_state(INSPECTFAIL, target=MANAGEABLE, **watchers) + # A deployment may fail + machine.add_transition(DEPLOYING, DEPLOYFAIL, 'fail') + ++# A deployment may be terminated ++machine.add_transition(DEPLOYING, DELETING, 'delete') ++ + # A failed deployment may be retried + # ironic/conductor/manager.py:do_node_deploy() + machine.add_transition(DEPLOYFAIL, DEPLOYING, 'rebuild') +diff --git a/ironic/common/swift.py b/ironic/common/swift.py +index a4444e2..4cc36c4 100644 +--- a/ironic/common/swift.py ++++ b/ironic/common/swift.py +@@ -23,6 +23,7 @@ from swiftclient import utils as swift_utils + from ironic.common import exception + from ironic.common.i18n import _ + from ironic.common import keystone ++from ironic.common import utils + from ironic.openstack.common import log as logging + + swift_opts = [ +@@ -36,6 +37,13 @@ swift_opts = [ + CONF = cfg.CONF + CONF.register_opts(swift_opts, group='swift') + ++CONF.import_opt('swift_endpoint_url', ++ 'ironic.common.glance_service.v2.image_service', ++ group='glance') ++CONF.import_opt('swift_api_version', ++ 'ironic.common.glance_service.v2.image_service', ++ group='glance') ++ + CONF.import_opt('admin_user', 'keystonemiddleware.auth_token', + group='keystone_authtoken') + CONF.import_opt('admin_tenant_name', 'keystonemiddleware.auth_token', +@@ -60,7 +68,9 @@ class SwiftAPI(object): + tenant_name=CONF.keystone_authtoken.admin_tenant_name, + key=CONF.keystone_authtoken.admin_password, + auth_url=CONF.keystone_authtoken.auth_uri, +- auth_version=CONF.keystone_authtoken.auth_version): ++ auth_version=CONF.keystone_authtoken.auth_version, ++ preauthtoken=None, ++ preauthtenant=None): + """Constructor for creating a SwiftAPI object. + + :param user: the name of the user for Swift account +@@ -68,15 +78,40 @@ class SwiftAPI(object): + :param key: the 'password' or key to authenticate with + :param auth_url: the url for authentication + :param auth_version: the version of api to use for authentication ++ :param preauthtoken: authentication token (if you have already ++ authenticated) note authurl/user/key/tenant_name ++ are not required when specifying preauthtoken ++ :param preauthtenant a tenant that will be accessed using the ++ preauthtoken + """ +- auth_url = keystone.get_keystone_url(auth_url, auth_version) +- params = {'retries': CONF.swift.swift_max_retries, +- 'insecure': CONF.keystone_authtoken.insecure, +- 'user': user, +- 'tenant_name': tenant_name, +- 'key': key, +- 'authurl': auth_url, +- 'auth_version': auth_version} ++ params = { ++ 'retries': CONF.swift.swift_max_retries, ++ 'insecure': CONF.keystone_authtoken.insecure ++ } ++ ++ if preauthtoken: ++ # Determining swift url for the user's tenant account. ++ tenant_id = utils.get_tenant_id(tenant_name=preauthtenant) ++ url = "{endpoint}/{api_ver}/AUTH_{tenant}".format( ++ endpoint=CONF.glance.swift_endpoint_url, ++ api_ver=CONF.glance.swift_api_version, ++ tenant=tenant_id ++ ) ++ # authurl/user/key/tenant_name are not required when specifying ++ # preauthtoken ++ params.update({ ++ 'preauthtoken': preauthtoken, ++ 'preauthurl': url ++ }) ++ else: ++ auth_url = keystone.get_keystone_url(auth_url, auth_version) ++ params.update({ ++ 'user': user, ++ 'tenant_name': tenant_name, ++ 'key': key, ++ 'authurl': auth_url, ++ 'auth_version': auth_version ++ }) + + self.connection = swift_client.Connection(**params) + +@@ -128,8 +163,8 @@ class SwiftAPI(object): + operation = _("head account") + raise exception.SwiftOperationError(operation=operation, + error=e) +- +- storage_url, token = self.connection.get_auth() ++ storage_url = (self.connection.os_options.get('object_storage_url') or ++ self.connection.get_auth()[0]) + parse_result = parse.urlparse(storage_url) + swift_object_path = '/'.join((parse_result.path, container, object)) + temp_url_key = account_info['x-account-meta-temp-url-key'] +@@ -186,3 +221,23 @@ class SwiftAPI(object): + except swift_exceptions.ClientException as e: + operation = _("post object") + raise exception.SwiftOperationError(operation=operation, error=e) ++ ++ def get_object(self, container, object, object_headers=None, ++ chunk_size=None): ++ """Get Swift object. ++ ++ :param container: The name of the container in which Swift object ++ is placed. ++ :param object: The name of the object in Swift ++ :param object_headers: the headers for the object to pass to Swift ++ :param chunk_size: size of the chunk used read to read from response ++ :returns: Tuple (body, headers) ++ :raises: SwiftOperationError, if operation with Swift fails. ++ """ ++ try: ++ return self.connection.get_object(container, object, ++ headers=object_headers, ++ resp_chunk_size=chunk_size) ++ except swift_exceptions.ClientException as e: ++ operation = _("get object") ++ raise exception.SwiftOperationError(operation=operation, error=e) +diff --git a/ironic/common/utils.py b/ironic/common/utils.py +index 3633f82..4d1ca28 100644 +--- a/ironic/common/utils.py ++++ b/ironic/common/utils.py +@@ -38,6 +38,7 @@ from ironic.common import exception + from ironic.common.i18n import _ + from ironic.common.i18n import _LE + from ironic.common.i18n import _LW ++from ironic.common import keystone + from ironic.openstack.common import log as logging + + utils_opts = [ +@@ -536,3 +537,8 @@ def dd(src, dst, *args): + def is_http_url(url): + url = url.lower() + return url.startswith('http://') or url.startswith('https://') ++ ++ ++def get_tenant_id(tenant_name): ++ ksclient = keystone._get_ksclient() ++ return ksclient.tenants.find(name=tenant_name).to_dict()['id'] +diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py +index c2b75bc..53f516b 100644 +--- a/ironic/conductor/manager.py ++++ b/ironic/conductor/manager.py +@@ -766,6 +766,11 @@ class ConductorManager(periodic_task.PeriodicTasks): + """ + LOG.debug("RPC do_node_tear_down called for node %s." % node_id) + ++ with task_manager.acquire(context, node_id, shared=True) as task: ++ if (task.node.provision_state == states.DEPLOYING and ++ task.driver.deploy.can_terminate_deployment): ++ task.driver.deploy.terminate_deployment(task) ++ + with task_manager.acquire(context, node_id, shared=False) as task: + try: + # NOTE(ghe): Valid power driver values are needed to perform +diff --git a/ironic/drivers/base.py b/ironic/drivers/base.py +index e0685d0..d1fa4bc 100644 +--- a/ironic/drivers/base.py ++++ b/ironic/drivers/base.py +@@ -318,6 +318,13 @@ class DeployInterface(BaseInterface): + """ + pass + ++ def terminate_deployment(self, *args, **kwargs): ++ pass ++ ++ @property ++ def can_terminate_deployment(self): ++ return False ++ + + @six.add_metaclass(abc.ABCMeta) + class PowerInterface(BaseInterface): +diff --git a/ironic/drivers/modules/image_cache.py b/ironic/drivers/modules/image_cache.py +index d7b27c0..eb3ec55 100644 +--- a/ironic/drivers/modules/image_cache.py ++++ b/ironic/drivers/modules/image_cache.py +@@ -25,9 +25,9 @@ import uuid + + from oslo_concurrency import lockutils + from oslo_config import cfg ++from oslo_utils import uuidutils + + from ironic.common import exception +-from ironic.common.glance_service import service_utils + from ironic.common.i18n import _LI + from ironic.common.i18n import _LW + from ironic.common import images +@@ -100,15 +100,15 @@ class ImageCache(object): + + # TODO(ghe): have hard links and counts the same behaviour in all fs + +- # NOTE(vdrok): File name is converted to UUID if it's not UUID already, +- # so that two images with same file names do not collide +- if service_utils.is_glance_image(href): +- master_file_name = service_utils.parse_image_ref(href)[0] ++ if uuidutils.is_uuid_like(href): ++ master_file_name = href ++ elif (self._image_service and ++ hasattr(self._image_service, 'get_image_unique_id')): ++ master_file_name = self._image_service.get_image_unique_id(href) + else: +- # NOTE(vdrok): Doing conversion of href in case it's unicode +- # string, UUID cannot be generated for unicode strings on python 2. + master_file_name = str(uuid.uuid5(uuid.NAMESPACE_URL, + href.encode('utf-8'))) ++ + master_path = os.path.join(self.master_dir, master_file_name) + + if CONF.parallel_image_downloads: +diff --git a/ironic/tests/test_swift.py b/ironic/tests/test_swift.py +index 9daa06e..aaa1b7c 100644 +--- a/ironic/tests/test_swift.py ++++ b/ironic/tests/test_swift.py +@@ -113,6 +113,7 @@ class SwiftTestCase(base.TestCase): + connection_obj_mock.get_auth.return_value = auth + head_ret_val = {'x-account-meta-temp-url-key': 'secretkey'} + connection_obj_mock.head_account.return_value = head_ret_val ++ connection_obj_mock.os_options = {} + gen_temp_url_mock.return_value = 'temp-url-path' + temp_url_returned = swiftapi.get_temp_url('container', 'object', 10) + connection_obj_mock.get_auth.assert_called_once_with() diff --git a/patches/patch-nova-stable-kilo b/patches/patch-nova-stable-kilo new file mode 100644 index 0000000..8156970 --- /dev/null +++ b/patches/patch-nova-stable-kilo @@ -0,0 +1,1081 @@ +diff --git a/nova/tests/unit/virt/ironic/test_driver.py b/nova/tests/unit/virt/ironic/test_driver.py +index b19c6eb..6305ff7 100644 +--- a/nova/tests/unit/virt/ironic/test_driver.py ++++ b/nova/tests/unit/virt/ironic/test_driver.py +@@ -24,6 +24,7 @@ from oslo_utils import uuidutils + from nova.api.metadata import base as instance_metadata + from nova.compute import power_state as nova_states + from nova.compute import task_states ++from nova.compute import vm_states + from nova import context as nova_context + from nova import exception + from nova import objects +@@ -143,8 +144,9 @@ class IronicDriverTestCase(test.NoDBTestCase): + ironic_driver._validate_instance_and_node, + ironicclient, instance) + ++ @mock.patch.object(objects.Instance, 'refresh') + @mock.patch.object(ironic_driver, '_validate_instance_and_node') +- def test__wait_for_active_pass(self, fake_validate): ++ def test__wait_for_active_pass(self, fake_validate, fake_refresh): + instance = fake_instance.fake_instance_obj(self.ctx, + uuid=uuidutils.generate_uuid()) + node = ironic_utils.get_test_node( +@@ -152,10 +154,12 @@ class IronicDriverTestCase(test.NoDBTestCase): + + fake_validate.return_value = node + self.driver._wait_for_active(FAKE_CLIENT, instance) +- self.assertTrue(fake_validate.called) ++ fake_validate.assert_called_once_with(FAKE_CLIENT, instance) ++ fake_refresh.assert_called_once_with() + ++ @mock.patch.object(objects.Instance, 'refresh') + @mock.patch.object(ironic_driver, '_validate_instance_and_node') +- def test__wait_for_active_done(self, fake_validate): ++ def test__wait_for_active_done(self, fake_validate, fake_refresh): + instance = fake_instance.fake_instance_obj(self.ctx, + uuid=uuidutils.generate_uuid()) + node = ironic_utils.get_test_node( +@@ -165,10 +169,12 @@ class IronicDriverTestCase(test.NoDBTestCase): + self.assertRaises(loopingcall.LoopingCallDone, + self.driver._wait_for_active, + FAKE_CLIENT, instance) +- self.assertTrue(fake_validate.called) ++ fake_validate.assert_called_once_with(FAKE_CLIENT, instance) ++ fake_refresh.assert_called_once_with() + ++ @mock.patch.object(objects.Instance, 'refresh') + @mock.patch.object(ironic_driver, '_validate_instance_and_node') +- def test__wait_for_active_fail(self, fake_validate): ++ def test__wait_for_active_fail(self, fake_validate, fake_refresh): + instance = fake_instance.fake_instance_obj(self.ctx, + uuid=uuidutils.generate_uuid()) + node = ironic_utils.get_test_node( +@@ -178,7 +184,31 @@ class IronicDriverTestCase(test.NoDBTestCase): + self.assertRaises(exception.InstanceDeployFailure, + self.driver._wait_for_active, + FAKE_CLIENT, instance) +- self.assertTrue(fake_validate.called) ++ fake_validate.assert_called_once_with(FAKE_CLIENT, instance) ++ fake_refresh.assert_called_once_with() ++ ++ @mock.patch.object(objects.Instance, 'refresh') ++ @mock.patch.object(ironic_driver, '_validate_instance_and_node') ++ def _wait_for_active_abort(self, instance_params, fake_validate, ++ fake_refresh): ++ instance = fake_instance.fake_instance_obj(self.ctx, ++ uuid=uuidutils.generate_uuid(), ++ **instance_params) ++ self.assertRaises(exception.InstanceDeployFailure, ++ self.driver._wait_for_active, ++ FAKE_CLIENT, instance) ++ # Assert _validate_instance_and_node wasn't called ++ self.assertFalse(fake_validate.called) ++ fake_refresh.assert_called_once_with() ++ ++ def test__wait_for_active_abort_deleting(self): ++ self._wait_for_active_abort({'task_state': task_states.DELETING}) ++ ++ def test__wait_for_active_abort_deleted(self): ++ self._wait_for_active_abort({'vm_state': vm_states.DELETED}) ++ ++ def test__wait_for_active_abort_error(self): ++ self._wait_for_active_abort({'vm_state': vm_states.ERROR}) + + @mock.patch.object(ironic_driver, '_validate_instance_and_node') + def test__wait_for_power_state_pass(self, fake_validate): +@@ -626,6 +656,7 @@ class IronicDriverTestCase(test.NoDBTestCase): + result = self.driver.macs_for_instance(instance) + self.assertIsNone(result) + ++ @mock.patch.object(ironic_driver.IronicDriver, '_get_switch_boot_options') + @mock.patch.object(objects.Instance, 'save') + @mock.patch.object(loopingcall, 'FixedIntervalLoopingCall') + @mock.patch.object(FAKE_CLIENT, 'node') +@@ -634,7 +665,7 @@ class IronicDriverTestCase(test.NoDBTestCase): + @mock.patch.object(ironic_driver.IronicDriver, '_plug_vifs') + @mock.patch.object(ironic_driver.IronicDriver, '_start_firewall') + def _test_spawn(self, mock_sf, mock_pvifs, mock_adf, mock_wait_active, +- mock_node, mock_looping, mock_save): ++ mock_node, mock_looping, mock_save, mock_sb_options): + node_uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + node = ironic_utils.get_test_node(driver='fake', uuid=node_uuid) + instance = fake_instance.fake_instance_obj(self.ctx, node=node_uuid) +@@ -668,6 +699,7 @@ class IronicDriverTestCase(test.NoDBTestCase): + fake_looping_call.start.assert_called_once_with( + interval=CONF.ironic.api_retry_interval) + fake_looping_call.wait.assert_called_once_with() ++ mock_sb_options.assert_called_once_with(self.ctx, instance, node_uuid) + + @mock.patch.object(ironic_driver.IronicDriver, '_generate_configdrive') + @mock.patch.object(configdrive, 'required_by') +@@ -720,14 +752,61 @@ class IronicDriverTestCase(test.NoDBTestCase): + self.driver.spawn, self.ctx, instance, None, [], None) + mock_destroy.assert_called_once_with(self.ctx, instance, None) + ++ @mock.patch.object(FAKE_CLIENT, 'node') ++ def test__get_switch_boot_options(self, mock_node): ++ node_uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' ++ node = ironic_utils.get_test_node( ++ driver='fake', uuid=node_uuid, ++ instance_info={ ++ 'multiboot': True, ++ 'multiboot_info': {'elements': [ ++ {'image_name': "name_1"}, ++ {'image_name': "name_2"}, ++ ]}} ++ ) ++ mock_node.get.return_value = node ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, node=node_uuid, expected_attrs=('metadata',)) ++ ++ self.driver._get_switch_boot_options(self.ctx, ++ instance, node_uuid) ++ ++ exp_meta = {'available_images': str(['name_1', 'name_2'])} ++ self.assertEqual(exp_meta, instance.metadata) ++ ++ @mock.patch.object(FAKE_CLIENT, 'node') ++ def test__get_switch_boot_options_not_multiboot(self, mock_node): ++ node_uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' ++ node = ironic_utils.get_test_node( ++ driver='fake', uuid=node_uuid, ++ instance_info={} ++ ) ++ mock_node.get.return_value = node ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, node=node_uuid, expected_attrs=('metadata',)) ++ ++ self.driver._get_switch_boot_options(self.ctx, ++ instance, node_uuid) ++ ++ self.assertEqual({}, instance.metadata) ++ ++ @mock.patch.object(ironic_driver.IronicDriver, ++ "_get_deploy_config_options") + @mock.patch.object(FAKE_CLIENT.node, 'update') +- def test__add_driver_fields_good(self, mock_update): ++ def test__add_driver_fields_good(self, mock_update, ++ mock_get_depl_conf_opts): + node = ironic_utils.get_test_node(driver='fake') +- instance = fake_instance.fake_instance_obj(self.ctx, +- node=node.uuid) ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, ++ node=node.uuid, ++ expected_attrs=('metadata',)) + image_meta = ironic_utils.get_test_image_meta() ++ mock_get_depl_conf_opts.return_value = {'foo': 'bar123'} ++ instance['metadata']['driver_actions'] = {'bar': 'foo123'} + flavor = ironic_utils.get_test_flavor() ++ + self.driver._add_driver_fields(node, instance, image_meta, flavor) ++ + expected_patch = [{'path': '/instance_info/image_source', 'op': 'add', + 'value': image_meta['id']}, + {'path': '/instance_info/root_gb', 'op': 'add', +@@ -735,21 +814,96 @@ class IronicDriverTestCase(test.NoDBTestCase): + {'path': '/instance_info/swap_mb', 'op': 'add', + 'value': str(flavor['swap'])}, + {'path': '/instance_uuid', 'op': 'add', +- 'value': instance.uuid}] ++ 'value': instance.uuid}, ++ {'path': '/instance_info/deploy_config_options', ++ 'op': 'add', ++ 'value': {'foo': 'bar123'}}, ++ {'path': '/instance_info/driver_actions', ++ 'op': 'add', ++ 'value': {'bar': 'foo123'}}, ++ ] + mock_update.assert_called_once_with(node.uuid, expected_patch) + + @mock.patch.object(FAKE_CLIENT.node, 'update') + def test__add_driver_fields_fail(self, mock_update): + mock_update.side_effect = ironic_exception.BadRequest() + node = ironic_utils.get_test_node(driver='fake') +- instance = fake_instance.fake_instance_obj(self.ctx, +- node=node.uuid) ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, ++ node=node.uuid, ++ expected_attrs=('metadata',)) + image_meta = ironic_utils.get_test_image_meta() + flavor = ironic_utils.get_test_flavor() + self.assertRaises(exception.InstanceDeployFailure, + self.driver._add_driver_fields, + node, instance, image_meta, flavor) + ++ def test__get_deploy_config_options_all_present(self): ++ node = ironic_utils.get_test_node( ++ driver='fake', driver_info={'deploy_config': "node-conf"}) ++ image_meta = ironic_utils.get_test_image_meta( ++ deploy_config="image-conf") ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, node=node.uuid, expected_attrs=('metadata',), ++ metadata={'deploy_config': "instance-conf"}) ++ ++ opts = self.driver._get_deploy_config_options(node, instance, ++ image_meta) ++ ++ expected = {"node": "node-conf", ++ "image": "image-conf", ++ "instance": "instance-conf" ++ } ++ self.assertEqual(expected, opts) ++ ++ def test__get_deploy_config_options_on_node_rebuild(self): ++ # In case of rebuild a set of options is also present in the node ++ # already. We take them, override with the new ones, and pass back. ++ node = ironic_utils.get_test_node( ++ driver='fake', driver_info={'deploy_config': "node-conf"}, ++ instance_info={"deploy_config_options": { ++ "instance": "previous_inst_conf", ++ "image": "previous_image_conf", ++ }} ++ ) ++ image_meta = ironic_utils.get_test_image_meta( ++ deploy_config="image-conf") ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, node=node.uuid, expected_attrs=('metadata',)) ++ opts = self.driver._get_deploy_config_options(node, instance, ++ image_meta) ++ ++ expected = {"node": "node-conf", ++ "image": "image-conf", ++ "instance": "previous_inst_conf" ++ } ++ self.assertEqual(expected, opts) ++ ++ def test__get_deploy_config_options_some_present(self): ++ node = ironic_utils.get_test_node(driver='fake') ++ image_meta = ironic_utils.get_test_image_meta() ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, node=node.uuid, expected_attrs=('metadata',), ++ metadata={'deploy_config': "instance-conf"}) ++ ++ opts = self.driver._get_deploy_config_options(node, instance, ++ image_meta) ++ ++ expected = {"instance": "instance-conf"} ++ self.assertEqual(expected, opts) ++ ++ def test__get_deploy_config_options_none_present(self): ++ node = ironic_utils.get_test_node(driver='fake') ++ image_meta = ironic_utils.get_test_image_meta() ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, node=node.uuid, expected_attrs=('metadata',)) ++ ++ opts = self.driver._get_deploy_config_options(node, instance, ++ image_meta) ++ ++ expected = {} ++ self.assertEqual(expected, opts) ++ + @mock.patch.object(FAKE_CLIENT.node, 'update') + def test__cleanup_deploy_good_with_flavor(self, mock_update): + node = ironic_utils.get_test_node(driver='fake', +@@ -781,8 +935,10 @@ class IronicDriverTestCase(test.NoDBTestCase): + node = ironic_utils.get_test_node(driver='fake', + instance_uuid=self.instance_uuid) + flavor = ironic_utils.get_test_flavor(extra_specs={}) +- instance = fake_instance.fake_instance_obj(self.ctx, +- node=node.uuid) ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, ++ node=node.uuid, ++ expected_attrs=('metadata',)) + instance.flavor = flavor + self.assertRaises(exception.InstanceTerminationFailure, + self.driver._cleanup_deploy, +@@ -796,7 +952,8 @@ class IronicDriverTestCase(test.NoDBTestCase): + node_uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + node = ironic_utils.get_test_node(driver='fake', uuid=node_uuid) + flavor = ironic_utils.get_test_flavor() +- instance = fake_instance.fake_instance_obj(self.ctx, node=node_uuid) ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, node=node_uuid, expected_attrs=('metadata',)) + instance.flavor = flavor + + mock_node.validate.return_value = ironic_utils.get_test_validation( +@@ -821,7 +978,8 @@ class IronicDriverTestCase(test.NoDBTestCase): + node_uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + node = ironic_utils.get_test_node(driver='fake', uuid=node_uuid) + flavor = ironic_utils.get_test_flavor() +- instance = fake_instance.fake_instance_obj(self.ctx, node=node_uuid) ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, node=node_uuid, expected_attrs=('metadata',)) + instance.flavor = flavor + mock_node.get.return_value = node + mock_node.validate.return_value = ironic_utils.get_test_validation() +@@ -851,7 +1009,8 @@ class IronicDriverTestCase(test.NoDBTestCase): + node_uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + node = ironic_utils.get_test_node(driver='fake', uuid=node_uuid) + flavor = ironic_utils.get_test_flavor() +- instance = fake_instance.fake_instance_obj(self.ctx, node=node_uuid) ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, node=node_uuid, expected_attrs=('metadata',)) + instance.flavor = flavor + image_meta = ironic_utils.get_test_image_meta() + +@@ -880,7 +1039,8 @@ class IronicDriverTestCase(test.NoDBTestCase): + node_uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + node = ironic_utils.get_test_node(driver='fake', uuid=node_uuid) + flavor = ironic_utils.get_test_flavor() +- instance = fake_instance.fake_instance_obj(self.ctx, node=node_uuid) ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, node=node_uuid, expected_attrs=('metadata',)) + instance.flavor = flavor + image_meta = ironic_utils.get_test_image_meta() + +@@ -912,7 +1072,8 @@ class IronicDriverTestCase(test.NoDBTestCase): + fake_net_info = utils.get_test_network_info() + node = ironic_utils.get_test_node(driver='fake', uuid=node_uuid) + flavor = ironic_utils.get_test_flavor() +- instance = fake_instance.fake_instance_obj(self.ctx, node=node_uuid) ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, node=node_uuid, expected_attrs=('metadata',)) + instance.flavor = flavor + image_meta = ironic_utils.get_test_image_meta() + +@@ -945,7 +1106,8 @@ class IronicDriverTestCase(test.NoDBTestCase): + node_uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + node = ironic_utils.get_test_node(driver='fake', uuid=node_uuid) + flavor = ironic_utils.get_test_flavor(ephemeral_gb=1) +- instance = fake_instance.fake_instance_obj(self.ctx, node=node_uuid) ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, node=node_uuid, expected_attrs=('metadata',)) + instance.flavor = flavor + mock_node.get_by_instance_uuid.return_value = node + mock_node.set_provision_state.return_value = mock.MagicMock() +@@ -957,12 +1119,12 @@ class IronicDriverTestCase(test.NoDBTestCase): + + @mock.patch.object(FAKE_CLIENT, 'node') + @mock.patch.object(ironic_driver.IronicDriver, '_cleanup_deploy') +- def test_destroy(self, mock_cleanup_deploy, mock_node): ++ def _test_destroy(self, state, mock_cleanup_deploy, mock_node): + node_uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + network_info = 'foo' + + node = ironic_utils.get_test_node(driver='fake', uuid=node_uuid, +- provision_state=ironic_states.ACTIVE) ++ provision_state=state) + instance = fake_instance.fake_instance_obj(self.ctx, node=node_uuid) + + def fake_set_provision_state(*_): +@@ -971,29 +1133,22 @@ class IronicDriverTestCase(test.NoDBTestCase): + mock_node.get_by_instance_uuid.return_value = node + mock_node.set_provision_state.side_effect = fake_set_provision_state + self.driver.destroy(self.ctx, instance, network_info, None) +- mock_node.set_provision_state.assert_called_once_with(node_uuid, +- 'deleted') ++ + mock_node.get_by_instance_uuid.assert_called_with(instance.uuid) + mock_cleanup_deploy.assert_called_with(self.ctx, node, + instance, network_info) + +- @mock.patch.object(FAKE_CLIENT, 'node') +- @mock.patch.object(ironic_driver.IronicDriver, '_cleanup_deploy') +- def test_destroy_ignore_unexpected_state(self, mock_cleanup_deploy, +- mock_node): +- node_uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' +- network_info = 'foo' ++ # For states that makes sense check if set_provision_state has ++ # been called ++ if state in ironic_driver._UNPROVISION_STATES: ++ mock_node.set_provision_state.assert_called_once_with( ++ node_uuid, 'deleted') ++ else: ++ self.assertFalse(mock_node.set_provision_state.called) + +- node = ironic_utils.get_test_node(driver='fake', uuid=node_uuid, +- provision_state=ironic_states.DELETING) +- instance = fake_instance.fake_instance_obj(self.ctx, node=node_uuid) +- +- mock_node.get_by_instance_uuid.return_value = node +- self.driver.destroy(self.ctx, instance, network_info, None) +- self.assertFalse(mock_node.set_provision_state.called) +- mock_node.get_by_instance_uuid.assert_called_with(instance.uuid) +- mock_cleanup_deploy.assert_called_with(self.ctx, node, instance, +- network_info) ++ def test_destroy(self): ++ for state in ironic_states.PROVISION_STATE_LIST: ++ self._test_destroy(state) + + @mock.patch.object(FAKE_CLIENT, 'node') + @mock.patch.object(ironic_driver.IronicDriver, '_cleanup_deploy') +@@ -1287,6 +1442,7 @@ class IronicDriverTestCase(test.NoDBTestCase): + self.driver.refresh_instance_security_rules(fake_group) + mock_risr.assert_called_once_with(fake_group) + ++ @mock.patch.object(ironic_driver.IronicDriver, '_get_switch_boot_options') + @mock.patch.object(ironic_driver.IronicDriver, '_wait_for_active') + @mock.patch.object(loopingcall, 'FixedIntervalLoopingCall') + @mock.patch.object(FAKE_CLIENT.node, 'set_provision_state') +@@ -1295,7 +1451,7 @@ class IronicDriverTestCase(test.NoDBTestCase): + @mock.patch.object(objects.Instance, 'save') + def _test_rebuild(self, mock_save, mock_get, mock_driver_fields, + mock_set_pstate, mock_looping, mock_wait_active, +- preserve=False): ++ mock_sb_options, preserve=False): + node_uuid = uuidutils.generate_uuid() + node = ironic_utils.get_test_node(uuid=node_uuid, + instance_uuid=self.instance_uuid, +@@ -1306,10 +1462,12 @@ class IronicDriverTestCase(test.NoDBTestCase): + flavor_id = 5 + flavor = objects.Flavor(flavor_id=flavor_id, name='baremetal') + +- instance = fake_instance.fake_instance_obj(self.ctx, +- uuid=self.instance_uuid, +- node=node_uuid, +- instance_type_id=flavor_id) ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, ++ uuid=self.instance_uuid, ++ node=node_uuid, ++ instance_type_id=flavor_id, ++ expected_attrs=('metadata',)) + instance.flavor = flavor + + fake_looping_call = FakeLoopingCall() +@@ -1333,6 +1491,7 @@ class IronicDriverTestCase(test.NoDBTestCase): + fake_looping_call.start.assert_called_once_with( + interval=CONF.ironic.api_retry_interval) + fake_looping_call.wait.assert_called_once_with() ++ mock_sb_options.assert_called_once_with(self.ctx, instance, node_uuid) + + def test_rebuild_preserve_ephemeral(self): + self._test_rebuild(preserve=True) +@@ -1356,10 +1515,12 @@ class IronicDriverTestCase(test.NoDBTestCase): + flavor_id = 5 + flavor = objects.Flavor(flavor_id=flavor_id, name='baremetal') + +- instance = fake_instance.fake_instance_obj(self.ctx, +- uuid=self.instance_uuid, +- node=node_uuid, +- instance_type_id=flavor_id) ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, ++ uuid=self.instance_uuid, ++ node=node_uuid, ++ instance_type_id=flavor_id, ++ expected_attrs=('metadata',)) + instance.flavor = flavor + + exceptions = [ +@@ -1375,6 +1536,305 @@ class IronicDriverTestCase(test.NoDBTestCase): + injected_files=None, admin_password=None, bdms=None, + detach_block_devices=None, attach_block_devices=None) + ++ @mock.patch.object(ironic_driver.IronicDriver, '_do_rebuild') ++ @mock.patch.object(ironic_driver.IronicDriver, '_get_switch_boot_options') ++ @mock.patch.object(ironic_driver.IronicDriver, '_wait_for_active') ++ @mock.patch.object(loopingcall, 'FixedIntervalLoopingCall') ++ @mock.patch.object(FAKE_CLIENT.node, 'set_provision_state') ++ @mock.patch.object(ironic_driver.IronicDriver, '_add_driver_fields') ++ @mock.patch.object(FAKE_CLIENT.node, 'get') ++ @mock.patch.object(objects.Instance, 'save') ++ def test_rebuild_multiboot_force_rebuild(self, mock_save, mock_get, ++ mock_driver_fields, ++ mock_set_pstate, mock_looping, ++ mock_wait_active, ++ mock_sb_options, rebuild_mock): ++ node_uuid = uuidutils.generate_uuid() ++ node = ironic_utils.get_test_node(uuid=node_uuid, ++ instance_uuid=self.instance_uuid, ++ instance_type_id=5, ++ instance_info={'multiboot': True}) ++ mock_get.return_value = node ++ ++ image_meta = ironic_utils.get_test_image_meta() ++ flavor_id = 5 ++ flavor = objects.Flavor(flavor_id=flavor_id, name='baremetal') ++ ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, ++ uuid=self.instance_uuid, ++ node=node_uuid, ++ instance_type_id=flavor_id, ++ expected_attrs=('metadata',), ++ metadata={'force_rebuild': True}) ++ instance.flavor = flavor ++ ++ fake_looping_call = FakeLoopingCall() ++ mock_looping.return_value = fake_looping_call ++ ++ self.driver.rebuild( ++ context=self.ctx, instance=instance, image_meta=image_meta, ++ injected_files=None, admin_password=None, bdms=None, ++ detach_block_devices=None, attach_block_devices=None, ++ preserve_ephemeral=False) ++ ++ rebuild_mock.assert_called_once_with( ++ self.ctx, FAKE_CLIENT_WRAPPER, node, instance, image_meta, ++ None, ++ None, None, None, ++ None, network_info=None, ++ recreate=False, ++ block_device_info=None, ++ preserve_ephemeral=False) ++ ++ @mock.patch.object(FAKE_CLIENT.node, 'get') ++ @mock.patch.object(ironic_driver.IronicDriver, '_do_switch_boot_device') ++ @mock.patch.object(objects.Instance, 'save') ++ def test_rebuild_multiboot_switch_boot(self, mock_save, ++ mock_sb, mock_get): ++ node_uuid = uuidutils.generate_uuid() ++ node = ironic_utils.get_test_node(uuid=node_uuid, ++ instance_uuid=self.instance_uuid, ++ instance_type_id=5, ++ instance_info={'multiboot': True}) ++ mock_get.return_value = node ++ image_meta = ironic_utils.get_test_image_meta() ++ flavor_id = 5 ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, ++ uuid=self.instance_uuid, ++ node=node_uuid, ++ instance_type_id=flavor_id, ++ expected_attrs=('metadata',)) ++ ++ self.driver.rebuild( ++ context=self.ctx, instance=instance, image_meta=image_meta, ++ injected_files=None, admin_password=None, bdms=None, ++ detach_block_devices=None, attach_block_devices=None, ++ preserve_ephemeral=False) ++ ++ mock_sb.assert_called_once_with(self.ctx, FAKE_CLIENT_WRAPPER, node, ++ instance, image_meta) ++ ++ @mock.patch.object(FAKE_CLIENT.node, 'vendor_passthru') ++ @mock.patch.object(FAKE_CLIENT.node, 'set_power_state') ++ @mock.patch.object(FAKE_CLIENT.node, 'update') ++ @mock.patch.object(objects.Instance, 'save') ++ def test__do_switch_boot_device(self, mock_save, upd_mock, ++ sp_mock, vp_mock): ++ node_uuid = uuidutils.generate_uuid() ++ node = ironic_utils.get_test_node(uuid=node_uuid, ++ instance_uuid=self.instance_uuid, ++ instance_type_id=5, ++ instance_info={'multiboot': True}) ++ ++ image_meta = ironic_utils.get_test_image_meta() ++ flavor_id = 5 ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, ++ uuid=self.instance_uuid, ++ node=node_uuid, ++ instance_type_id=flavor_id, ++ expected_attrs=('metadata',), ++ metadata={'sb_key': 'key1', 'sb_user': 'usr1', 'extras': '123'}) ++ ++ self.driver._do_switch_boot_device( ++ self.ctx, FAKE_CLIENT_WRAPPER, ++ node, instance, image_meta) ++ ++ vp_mock.assert_called_once_with(node_uuid, 'switch_boot', ++ {'image': image_meta['id'], ++ 'ssh_user': 'usr1', ++ 'ssh_key': 'key1'}) ++ sp_mock.assert_called_once_with(node_uuid, 'reboot') ++ upd_mock.assert_called_once_with( ++ node_uuid, [{'path': '/instance_info/image_source', 'op': 'add', ++ 'value': image_meta['id']}]) ++ ++ @mock.patch.object(FAKE_CLIENT.node, 'vendor_passthru') ++ @mock.patch.object(FAKE_CLIENT.node, 'set_power_state') ++ @mock.patch.object(FAKE_CLIENT.node, 'update') ++ @mock.patch.object(objects.Instance, 'save') ++ def test__do_switch_boot_device_no_key(self, mock_save, upd_mock, ++ sp_mock, vp_mock): ++ node_uuid = uuidutils.generate_uuid() ++ node = ironic_utils.get_test_node( ++ uuid=node_uuid, ++ instance_uuid=self.instance_uuid, ++ instance_type_id=5, ++ instance_info={'multiboot': True, ++ 'image_source': 'original_image', ++ }) ++ ++ image_meta = ironic_utils.get_test_image_meta() ++ flavor_id = 5 ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, ++ uuid=self.instance_uuid, ++ node=node_uuid, ++ instance_type_id=flavor_id, ++ expected_attrs=('metadata',), ++ metadata={'sb_user': 'usr1'}) ++ ++ self.driver._do_switch_boot_device( ++ self.ctx, FAKE_CLIENT_WRAPPER, ++ node, instance, image_meta) ++ ++ self.assertEqual(instance.image_ref, 'original_image') ++ self.assertIn('Invalid metadata', ++ instance.metadata['switch_boot_error']) ++ mock_save.assert_called_once_with() ++ vp_mock.assert_has_calls([]) ++ sp_mock.assert_has_calls([]) ++ upd_mock.assert_has_calls([]) ++ ++ @mock.patch.object(FAKE_CLIENT.node, 'vendor_passthru') ++ @mock.patch.object(FAKE_CLIENT.node, 'set_power_state') ++ @mock.patch.object(FAKE_CLIENT.node, 'update') ++ @mock.patch.object(objects.Instance, 'save') ++ def test__do_switch_boot_device_not_supported_by_driver( ++ self, mock_save, upd_mock, sp_mock, vp_mock): ++ node_uuid = uuidutils.generate_uuid() ++ node = ironic_utils.get_test_node( ++ uuid=node_uuid, ++ instance_uuid=self.instance_uuid, ++ instance_type_id=5, ++ instance_info={'multiboot': True, ++ 'image_source': 'original_image', ++ }) ++ ++ image_meta = ironic_utils.get_test_image_meta() ++ flavor_id = 5 ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, ++ uuid=self.instance_uuid, ++ node=node_uuid, ++ instance_type_id=flavor_id, ++ expected_attrs=('metadata',), ++ metadata={'sb_key': 'key1', 'sb_user': 'usr1'}) ++ ++ vp_mock.side_effect = ironic_exception.BadRequest() ++ ++ self.driver._do_switch_boot_device( ++ self.ctx, FAKE_CLIENT_WRAPPER, ++ node, instance, image_meta) ++ ++ self.assertEqual(instance.image_ref, 'original_image') ++ self.assertIn('Bad Request', ++ instance.metadata['switch_boot_error']) ++ mock_save.assert_called_once_with() ++ sp_mock.assert_has_calls([]) ++ upd_mock.assert_has_calls([]) ++ ++ @mock.patch.object(FAKE_CLIENT.node, 'vendor_passthru') ++ @mock.patch.object(FAKE_CLIENT.node, 'set_power_state') ++ @mock.patch.object(FAKE_CLIENT.node, 'update') ++ @mock.patch.object(objects.Instance, 'save') ++ def test__do_switch_boot_device_already_in_desired_device( ++ self, mock_save, upd_mock, sp_mock, vp_mock): ++ node_uuid = uuidutils.generate_uuid() ++ node = ironic_utils.get_test_node( ++ uuid=node_uuid, ++ instance_uuid=self.instance_uuid, ++ instance_type_id=5, ++ instance_info={'multiboot': True, ++ 'image_source': 'original_image', ++ }) ++ ++ image_meta = ironic_utils.get_test_image_meta() ++ flavor_id = 5 ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, ++ uuid=self.instance_uuid, ++ node=node_uuid, ++ instance_type_id=flavor_id, ++ expected_attrs=('metadata',), ++ metadata={'sb_key': 'key1', 'sb_user': 'usr1'}) ++ ++ vp_mock.side_effect = ironic_exception.BadRequest( ++ message="Node 123 Already in desired boot device.") ++ ++ self.driver._do_switch_boot_device( ++ self.ctx, FAKE_CLIENT_WRAPPER, ++ node, instance, image_meta) ++ ++ mock_save.assert_has_calls([]) ++ sp_mock.assert_has_calls([]) ++ upd_mock.assert_has_calls([]) ++ ++ @mock.patch.object(FAKE_CLIENT.node, 'vendor_passthru') ++ @mock.patch.object(FAKE_CLIENT.node, 'set_power_state') ++ @mock.patch.object(FAKE_CLIENT.node, 'update') ++ @mock.patch.object(objects.Instance, 'save') ++ def test__do_switch_boot_device_reboot_error( ++ self, mock_save, upd_mock, sp_mock, vp_mock): ++ node_uuid = uuidutils.generate_uuid() ++ node = ironic_utils.get_test_node( ++ uuid=node_uuid, ++ instance_uuid=self.instance_uuid, ++ instance_type_id=5, ++ instance_info={'multiboot': True, ++ 'image_source': 'original_image', ++ }) ++ ++ image_meta = ironic_utils.get_test_image_meta() ++ flavor_id = 5 ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, ++ uuid=self.instance_uuid, ++ node=node_uuid, ++ instance_type_id=flavor_id, ++ expected_attrs=('metadata',), ++ metadata={'sb_key': 'key1', 'sb_user': 'usr1'}) ++ ++ sp_mock.side_effect = ironic_exception.BadRequest() ++ ++ self.driver._do_switch_boot_device( ++ self.ctx, FAKE_CLIENT_WRAPPER, ++ node, instance, image_meta) ++ ++ self.assertEqual(instance.image_ref, 'original_image') ++ self.assertIn('Bad Request', ++ instance.metadata['switch_boot_error']) ++ mock_save.assert_called_once_with() ++ upd_mock.assert_has_calls([]) ++ ++ @mock.patch.object(FAKE_CLIENT.node, 'vendor_passthru') ++ @mock.patch.object(FAKE_CLIENT.node, 'set_power_state') ++ @mock.patch.object(FAKE_CLIENT.node, 'update') ++ @mock.patch.object(objects.Instance, 'save') ++ def test__do_switch_boot_device_update_node_error( ++ self, mock_save, upd_mock, sp_mock, vp_mock): ++ node_uuid = uuidutils.generate_uuid() ++ node = ironic_utils.get_test_node( ++ uuid=node_uuid, ++ instance_uuid=self.instance_uuid, ++ instance_type_id=5, ++ instance_info={'multiboot': True, ++ 'image_source': 'original_image', ++ }) ++ ++ image_meta = ironic_utils.get_test_image_meta() ++ flavor_id = 5 ++ instance = fake_instance.fake_instance_obj( ++ self.ctx, ++ uuid=self.instance_uuid, ++ node=node_uuid, ++ instance_type_id=flavor_id, ++ expected_attrs=('metadata',), ++ metadata={'sb_key': 'key1', 'sb_user': 'usr1'}) ++ ++ upd_mock.side_effect = ironic_exception.BadRequest() ++ ++ self.driver._do_switch_boot_device( ++ self.ctx, FAKE_CLIENT_WRAPPER, ++ node, instance, image_meta) ++ ++ self.assertEqual(instance.image_ref, 'original_image') ++ self.assertIn('Bad Request', ++ instance.metadata['switch_boot_error']) ++ mock_save.assert_called_once_with() ++ + + @mock.patch.object(instance_metadata, 'InstanceMetadata') + @mock.patch.object(configdrive, 'ConfigDriveBuilder') +diff --git a/nova/tests/unit/virt/ironic/utils.py b/nova/tests/unit/virt/ironic/utils.py +index d43f290..f3ec825 100644 +--- a/nova/tests/unit/virt/ironic/utils.py ++++ b/nova/tests/unit/virt/ironic/utils.py +@@ -42,6 +42,7 @@ def get_test_node(**kw): + 'driver': kw.get('driver', 'fake'), + 'driver_info': kw.get('driver_info', {}), + 'properties': kw.get('properties', {}), ++ 'instance_info': kw.get('instance_info', {}), + 'reservation': kw.get('reservation'), + 'maintenance': kw.get('maintenance', False), + 'extra': kw.get('extra', {}), +@@ -72,7 +73,11 @@ def get_test_flavor(**kw): + + + def get_test_image_meta(**kw): +- return {'id': kw.get('id', 'cccccccc-cccc-cccc-cccc-cccccccccccc')} ++ return {'id': kw.get('id', 'cccccccc-cccc-cccc-cccc-cccccccccccc'), ++ 'properties': { ++ 'deploy_config': kw.get('deploy_config', ''), ++ 'driver_actions': kw.get('driver_actions', ''), ++ }} + + + class FakePortClient(object): +@@ -110,6 +115,9 @@ class FakeNodeClient(object): + def validate(self, node_uuid): + pass + ++ def vendor_passthru(self, node_uuid, method, args): ++ pass ++ + + class FakeClient(object): + +diff --git a/nova/virt/ironic/driver.py b/nova/virt/ironic/driver.py +index b21c782..81dcdba 100644 +--- a/nova/virt/ironic/driver.py ++++ b/nova/virt/ironic/driver.py +@@ -40,6 +40,7 @@ from nova.compute import hv_type + from nova.compute import power_state + from nova.compute import task_states + from nova.compute import vm_mode ++from nova.compute import vm_states + from nova import context as nova_context + from nova import exception + from nova.i18n import _ +@@ -107,6 +108,10 @@ _POWER_STATE_MAP = { + ironic_states.POWER_OFF: power_state.SHUTDOWN, + } + ++_UNPROVISION_STATES = (ironic_states.ACTIVE, ironic_states.DEPLOYFAIL, ++ ironic_states.ERROR, ironic_states.DEPLOYWAIT, ++ ironic_states.DEPLOYING) ++ + + def map_power_state(state): + try: +@@ -326,6 +331,17 @@ class IronicDriver(virt_driver.ComputeDriver): + # Associate the node with an instance + patch.append({'path': '/instance_uuid', 'op': 'add', + 'value': instance.uuid}) ++ ++ deploy_config_options = self._get_deploy_config_options( ++ node, instance, image_meta) ++ patch.append( ++ {'path': '/instance_info/deploy_config_options', 'op': 'add', ++ 'value': deploy_config_options}) ++ ++ patch.append( ++ {'path': '/instance_info/driver_actions', 'op': 'add', ++ 'value': instance.metadata.get('driver_actions', '')}) ++ + try: + self.ironicclient.call('node.update', node.uuid, patch) + except ironic.exc.BadRequest: +@@ -335,6 +351,13 @@ class IronicDriver(virt_driver.ComputeDriver): + LOG.error(msg) + raise exception.InstanceDeployFailure(msg) + ++ def _update_driver_fields_after_switch_boot(self, context, node, ++ instance, image_meta): ++ patch = [] ++ patch.append({'path': '/instance_info/image_source', 'op': 'add', ++ 'value': image_meta.get('id')}) ++ self.ironicclient.call('node.update', node.uuid, patch) ++ + def _cleanup_deploy(self, context, node, instance, network_info, + flavor=None): + if flavor is None: +@@ -358,6 +381,12 @@ class IronicDriver(virt_driver.ComputeDriver): + + def _wait_for_active(self, ironicclient, instance): + """Wait for the node to be marked as ACTIVE in Ironic.""" ++ instance.refresh() ++ if (instance.task_state == task_states.DELETING or ++ instance.vm_state in (vm_states.ERROR, vm_states.DELETED)): ++ raise exception.InstanceDeployFailure( ++ _("Instance %s provisioning was aborted") % instance.uuid) ++ + node = _validate_instance_and_node(ironicclient, instance) + if node.provision_state == ironic_states.ACTIVE: + # job is done +@@ -714,9 +743,19 @@ class IronicDriver(virt_driver.ComputeDriver): + + # trigger the node deploy + try: +- self.ironicclient.call("node.set_provision_state", node_uuid, +- ironic_states.ACTIVE, +- configdrive=configdrive_value) ++ # NOTE(lobur): set_provision_state to ++ # ACTIVE, REBUILD, and switch_boot_device are the only Ironic API ++ # calls where the user context needs to be passed to Ironic. This ++ # is needed to be able to fetch a tenant-owned resources for ++ # deployment, e.g. deploy_config stored in Swift. The user should ++ # have admin role, otherwise this context will be replaced by a ++ # standard Ironic context (admin tenant). It is also required to ++ # have a standalone instance of ironicclient to make sure ++ # no other calls use user context cached in the client. ++ ironicclient = client_wrapper.IronicClientWrapper() ++ ironicclient.call("node.set_provision_state", node_uuid, ++ ironic_states.ACTIVE, ++ configdrive=configdrive_value) + except Exception as e: + with excutils.save_and_reraise_exception(): + msg = (_LE("Failed to request Ironic to provision instance " +@@ -739,6 +778,17 @@ class IronicDriver(virt_driver.ComputeDriver): + {'instance': instance.uuid, + 'node': node_uuid}) + self.destroy(context, instance, network_info) ++ else: ++ self._get_switch_boot_options(context, instance, node_uuid) ++ ++ def _get_switch_boot_options(self, context, instance, node_uuid): ++ # Reload node to see if multiboot flag appeared. ++ node = self.ironicclient.call("node.get", node_uuid) ++ if node.instance_info.get('multiboot'): ++ multiboot_meta = node.instance_info.get('multiboot_info', {}) ++ available_images = [img['image_name'] for img in ++ multiboot_meta.get('elements', [])] ++ instance.metadata['available_images'] = str(available_images) + + def _unprovision(self, ironicclient, instance, node): + """This method is called from destroy() to unprovision +@@ -814,10 +864,7 @@ class IronicDriver(virt_driver.ComputeDriver): + # without raising any exceptions. + return + +- if node.provision_state in (ironic_states.ACTIVE, +- ironic_states.DEPLOYFAIL, +- ironic_states.ERROR, +- ironic_states.DEPLOYWAIT): ++ if node.provision_state in _UNPROVISION_STATES: + self._unprovision(self.ironicclient, instance, node) + + self._cleanup_deploy(context, node, instance, network_info) +@@ -1074,24 +1121,127 @@ class IronicDriver(virt_driver.ComputeDriver): + node_uuid = instance.node + node = self.ironicclient.call("node.get", node_uuid) + +- self._add_driver_fields(node, instance, image_meta, instance.flavor, +- preserve_ephemeral) ++ # NOTE(lobur): set_provision_state to ++ # ACTIVE, REBUILD, and switch_boot_device are the only Ironic API ++ # calls where the user context needs to be passed to Ironic. This ++ # is needed to be able to fetch tenant-owned resources for ++ # deployment, e.g. deploy_config stored in Swift. The user should ++ # have admin role, otherwise this context will be replaced by a ++ # standard Ironic context (admin tenant). It is also required to ++ # have a standalone instance of ironicclient to make sure ++ # no other calls use user context cached in the client. ++ ironicclient = client_wrapper.IronicClientWrapper() ++ ++ # To get a multiboot node rebuilt through the standard flow we ++ # require a separate force_rebuild flag in meta. ++ forced_rebuild = instance.metadata.pop('force_rebuild', False) ++ ++ if node.instance_info.get('multiboot') and not forced_rebuild: ++ self._do_switch_boot_device( ++ context, ironicclient, node, instance, image_meta) ++ else: ++ self._do_rebuild( ++ context, ironicclient, node, instance, image_meta, ++ injected_files, ++ admin_password, bdms, detach_block_devices, ++ attach_block_devices, network_info=network_info, ++ recreate=recreate, ++ block_device_info=block_device_info, ++ preserve_ephemeral=preserve_ephemeral) + +- # Trigger the node rebuild/redeploy. ++ self._get_switch_boot_options(context, instance, node_uuid) ++ ++ def _do_switch_boot_device(self, context, ironicclient, node, instance, ++ image_meta): ++ old_image_ref = node.instance_info.get("image_source", "") ++ try: ++ sb_user, sb_key = self._get_switch_boot_user_key(instance.metadata) ++ args = dict(ssh_user=sb_user, ++ ssh_key=sb_key, ++ image=image_meta['id']) ++ ironicclient.call("node.vendor_passthru", ++ node.uuid, "switch_boot", ++ args) ++ self.ironicclient.call("node.set_power_state", node.uuid, 'reboot') ++ self._update_driver_fields_after_switch_boot( ++ context, node, instance, image_meta) ++ except (exception.InvalidMetadata, # Bad Nova API call ++ exception.NovaException, # Retry failed ++ ironic.exc.InternalServerError, # Validations ++ ironic.exc.BadRequest) as e: # Maintenance or no such API ++ # Ironic Vendor API always return 200/400/500, so the only way ++ # to check the error is introspecting its message. ++ if "Already in desired boot device" in six.text_type(e): ++ msg = (_("Ironic node %(node)s already has desired " ++ "boot device set.") % {'node': node.uuid}) ++ LOG.warning(msg) ++ else: ++ # Rollback nova image ref ++ instance.image_ref = old_image_ref ++ # Cutting error msg to fit DB table. ++ instance.metadata['switch_boot_error'] = six.text_type(e)[:255] ++ instance.save() ++ msg = (_("Failed to switch Ironic %(node)s node boot " ++ "device: %(err)s") ++ % {'node': node.uuid, 'err': six.text_type(e)}) ++ LOG.error(msg) ++ ++ def _get_switch_boot_user_key(self, metadata): ++ sb_user = metadata.pop('sb_user', None) ++ sb_key = metadata.pop('sb_key', None) ++ if sb_user and sb_key: ++ return sb_user, sb_key ++ else: ++ raise exception.InvalidMetadata( ++ reason="To trigger switch boot device flow, both 'sb_user' " ++ "and 'sb_key' metadata params are required. To " ++ "trigger a standard rebuild flow, use " ++ "force_rebuild=True metadata flag.") ++ ++ def _do_rebuild(self, context, ironicclient, node, instance, image_meta, ++ injected_files, ++ admin_password, bdms, detach_block_devices, ++ attach_block_devices, network_info=None, ++ recreate=False, block_device_info=None, ++ preserve_ephemeral=False): ++ ++ self._add_driver_fields(node, instance, image_meta, ++ instance.flavor, preserve_ephemeral) + try: +- self.ironicclient.call("node.set_provision_state", +- node_uuid, ironic_states.REBUILD) ++ ++ ironicclient.call("node.set_provision_state", ++ node.uuid, ironic_states.REBUILD) + except (exception.NovaException, # Retry failed + ironic.exc.InternalServerError, # Validations + ironic.exc.BadRequest) as e: # Maintenance + msg = (_("Failed to request Ironic to rebuild instance " +- "%(inst)s: %(reason)s") % {'inst': instance.uuid, +- 'reason': six.text_type(e)}) ++ "%(inst)s: %(reason)s") % ++ {'inst': instance.uuid, ++ 'reason': six.text_type(e)}) + raise exception.InstanceDeployFailure(msg) + +- # Although the target provision state is REBUILD, it will actually go +- # to ACTIVE once the redeploy is finished. ++ # Although the target provision state is REBUILD, it will ++ # actually go to ACTIVE once the redeploy is finished. + timer = loopingcall.FixedIntervalLoopingCall(self._wait_for_active, + self.ironicclient, + instance) + timer.start(interval=CONF.ironic.api_retry_interval).wait() ++ ++ def _get_deploy_config_options(self, node, instance, image_meta): ++ # Taking into account previous options, if any. This is to support ++ # rebuild flow where the user might or might not pass deploy_config ++ # reference. If no reference was passed, we'll take the option used for ++ # initial deployment. ++ res = node.instance_info.get('deploy_config_options', {}) ++ ++ curr_options = { ++ 'image': image_meta.get('properties', {}).get('deploy_config', ''), ++ 'instance': instance.metadata.get('deploy_config', ''), ++ 'node': node.driver_info.get('deploy_config', ''), ++ } ++ # Filter out empty ones ++ curr_options = {key: value for key, value in ++ curr_options.items() if value} ++ # Override previous by current. ++ res.update(curr_options) ++ return res +diff --git a/nova/virt/ironic/ironic_states.py b/nova/virt/ironic/ironic_states.py +index e521f16..a02ddcf 100644 +--- a/nova/virt/ironic/ironic_states.py ++++ b/nova/virt/ironic/ironic_states.py +@@ -138,3 +138,13 @@ POWER_OFF = 'power off' + + REBOOT = 'rebooting' + """ Node is rebooting. """ ++ ++################## ++# Helper constants ++################## ++ ++PROVISION_STATE_LIST = (NOSTATE, MANAGEABLE, AVAILABLE, ACTIVE, DEPLOYWAIT, ++ DEPLOYING, DEPLOYFAIL, DEPLOYDONE, DELETING, DELETED, ++ CLEANING, CLEANFAIL, ERROR, REBUILD, ++ INSPECTING, INSPECTFAIL) ++""" A list of all provision states. """ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d9487d9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration +# process, which may cause wedges in the gate later. +# The driver uses Ironic code base and it's requirements, no additional +# requirements needed +# Since Ironic is not published to pip, Ironic must be installed on the system +# before test run +#ironic>=4.3.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..340263a --- /dev/null +++ b/setup.cfg @@ -0,0 +1,23 @@ +[metadata] +name = bareon-ironic +version = 1.0.0 +author = Cray Inc. +summary = Bareon-based deployment driver for Ironic +classifier = + Programming Language :: Python + +[files] +packages = + bareon_ironic + +extra_files = + bareon_ironic/modules/bareon_config.template + bareon_ironic/modules/bareon_config_live.template + + +[entry_points] +ironic.drivers = + bare_swift_ssh = bareon_ironic.bareon:BareonSwiftAndSSHDriver + bare_swift_ipmi = bareon_ironic.bareon:BareonSwiftAndIPMIToolDriver + bare_rsync_ssh = bareon_ironic.bareon:BareonRsyncAndSSHDriver + bare_rsync_ipmi = bareon_ironic.bareon:BareonRsyncAndIPMIToolDriver diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..782bb21 --- /dev/null +++ b/setup.py @@ -0,0 +1,29 @@ +# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. +# +# 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. + +# THIS FILE IS MANAGED BY THE GLOBAL REQUIREMENTS REPO - DO NOT EDIT +import setuptools + +# In python < 2.7.4, a lazy loading of package `pbr` will break +# setuptools if some other modules registered functions in `atexit`. +# solution from: http://bugs.python.org/issue15881#msg170215 +try: + import multiprocessing # noqa +except ImportError: + pass + +setuptools.setup( + setup_requires=['pbr>=1.8'], + pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..6ca24ea --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,4 @@ +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration +# process, which may cause wedges in the gate later. +hacking<0.11,>=0.10.2 # Apache-2.0 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..56648d8 --- /dev/null +++ b/tox.ini @@ -0,0 +1,37 @@ +[tox] +minversion = 1.6 +skipsdist = True +envlist = pep8,py27 + +[testenv] +usedevelop = True +install_command = pip install --allow-external -U {opts} {packages} +setenv = VIRTUAL_ENV={envdir} + PYTHONDONTWRITEBYTECODE = 1 + LANGUAGE=en_US +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +whitelist_externals = bash +commands = + bash -c "TESTS_DIR=./bareon_ironic/tests/ python setup.py testr --slowest --testr-args='{posargs}'" + +[tox:jenkins] +downloadcache = ~/cache/pip + +[testenv:pep8] +commands = + flake8 {posargs} + +[testenv:venv] +commands = + +[testenv:py27] +commands = + +[flake8] +ignore = H102,H306,H307 +exclude = .venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build,tools,*ironic/nova* +max-complexity=17 + +[hacking] +import_exceptions = testtools.matchers, ironic.common.i18n