From ca2045b829162461bdf804793dd5ec481a2117d7 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Tue, 11 Dec 2018 17:51:00 +0100 Subject: [PATCH] Import code for building ironic-compatible configdrives Building a correct configdrive may not be trivial. We have the code in ironicclient to do it, which was later cargo-culted to metalsmith. This is a cleaned up version of it that does not carry any assumptions about the first boot software (cloud-init vs anything). Change-Id: Iedc07e8f86bdc4561b150bf03a7cb59522ef7616 --- doc/source/user/proxies/baremetal.rst | 9 ++ openstack/baremetal/configdrive.py | 116 ++++++++++++++++++ openstack/baremetal/v1/_proxy.py | 3 +- openstack/baremetal/v1/node.py | 3 +- .../tests/unit/baremetal/test_configdrive.py | 48 ++++++++ 5 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 openstack/baremetal/configdrive.py create mode 100644 openstack/tests/unit/baremetal/test_configdrive.py diff --git a/doc/source/user/proxies/baremetal.rst b/doc/source/user/proxies/baremetal.rst index 6bc3ed572..fd37b61b0 100644 --- a/doc/source/user/proxies/baremetal.rst +++ b/doc/source/user/proxies/baremetal.rst @@ -74,3 +74,12 @@ VIF Operations .. automethod:: openstack.baremetal.v1._proxy.Proxy.attach_vif_to_node .. automethod:: openstack.baremetal.v1._proxy.Proxy.detach_vif_from_node .. automethod:: openstack.baremetal.v1._proxy.Proxy.list_node_vifs + +Utilities +--------- + +Building config drives +^^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: openstack.baremetal.configdrive + :members: diff --git a/openstack/baremetal/configdrive.py b/openstack/baremetal/configdrive.py new file mode 100644 index 000000000..1fee2847f --- /dev/null +++ b/openstack/baremetal/configdrive.py @@ -0,0 +1,116 @@ +# 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. + +"""Helpers for building configdrive compatible with the Bare Metal service.""" + +import base64 +import contextlib +import gzip +import json +import os +import shutil +import subprocess +import tempfile + +import six + + +@contextlib.contextmanager +def populate_directory(metadata, user_data, versions=None): + """Populate a directory with configdrive files. + + :param dict metadata: Metadata. + :param bytes user_data: Vendor-specific user data. + :param versions: List of metadata versions to support. + :return: a context manager yielding a directory with files + """ + d = tempfile.mkdtemp() + versions = versions or ('2012-08-10', 'latest') + try: + for version in versions: + subdir = os.path.join(d, 'openstack', version) + if not os.path.exists(subdir): + os.makedirs(subdir) + + with open(os.path.join(subdir, 'meta_data.json'), 'w') as fp: + json.dump(metadata, fp) + + if user_data: + with open(os.path.join(subdir, 'user_data'), 'wb') as fp: + fp.write(user_data) + + yield d + finally: + shutil.rmtree(d) + + +def build(metadata, user_data, versions=None): + """Make a configdrive compatible with the Bare Metal service. + + Requires the genisoimage utility to be available. + + :param dict metadata: Metadata. + :param user_data: Vendor-specific user data. + :param versions: List of metadata versions to support. + :return: configdrive contents as a base64-encoded string. + """ + with populate_directory(metadata, user_data, versions) as path: + return pack(path) + + +def pack(path): + """Pack a directory with files into a Bare Metal service configdrive. + + Creates an ISO image with the files and label "config-2". + + :param str path: Path to directory with files + :return: configdrive contents as a base64-encoded string. + """ + with tempfile.NamedTemporaryFile() as tmpfile: + try: + p = subprocess.Popen(['genisoimage', + '-o', tmpfile.name, + '-ldots', '-allow-lowercase', + '-allow-multidot', '-l', + '-publisher', 'metalsmith', + '-quiet', '-J', + '-r', '-V', 'config-2', + path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + except OSError as e: + raise RuntimeError( + 'Error generating the configdrive. Make sure the ' + '"genisoimage" tool is installed. Error: %s') % e + + stdout, stderr = p.communicate() + if p.returncode != 0: + raise RuntimeError( + 'Error generating the configdrive.' + 'Stdout: "%(stdout)s". Stderr: "%(stderr)s"' % + {'stdout': stdout, 'stderr': stderr}) + + tmpfile.seek(0) + + with tempfile.NamedTemporaryFile() as tmpzipfile: + with gzip.GzipFile(fileobj=tmpzipfile, mode='wb') as gz_file: + shutil.copyfileobj(tmpfile, gz_file) + + tmpzipfile.seek(0) + cd = base64.b64encode(tmpzipfile.read()) + + # NOTE(dtantsur): Ironic expects configdrive to be a string, but base64 + # returns bytes on Python 3. + if not isinstance(cd, six.string_types): + cd = cd.decode('utf-8') + + return cd diff --git a/openstack/baremetal/v1/_proxy.py b/openstack/baremetal/v1/_proxy.py index 7d395edd3..59d2635ec 100644 --- a/openstack/baremetal/v1/_proxy.py +++ b/openstack/baremetal/v1/_proxy.py @@ -270,7 +270,8 @@ class Proxy(proxy.Proxy): :param target: Provisioning action, e.g. ``active``, ``provide``. See the Bare Metal service documentation for available actions. :param config_drive: Config drive to pass to the node, only valid - for ``active` and ``rebuild`` targets. + for ``active` and ``rebuild`` targets. You can use functions from + :mod:`openstack.baremetal.configdrive` to build it. :param clean_steps: Clean steps to execute, only valid for ``clean`` target. :param rescue_password: Password for the rescue operation, only valid diff --git a/openstack/baremetal/v1/node.py b/openstack/baremetal/v1/node.py index 80a93842a..edfb6e9c0 100644 --- a/openstack/baremetal/v1/node.py +++ b/openstack/baremetal/v1/node.py @@ -267,7 +267,8 @@ class Node(_common.ListMixin, resource.Resource): :param target: Provisioning action, e.g. ``active``, ``provide``. See the Bare Metal service documentation for available actions. :param config_drive: Config drive to pass to the node, only valid - for ``active` and ``rebuild`` targets. + for ``active` and ``rebuild`` targets. You can use functions from + :mod:`openstack.baremetal.configdrive` to build it. :param clean_steps: Clean steps to execute, only valid for ``clean`` target. :param rescue_password: Password for the rescue operation, only valid diff --git a/openstack/tests/unit/baremetal/test_configdrive.py b/openstack/tests/unit/baremetal/test_configdrive.py new file mode 100644 index 000000000..f6765d038 --- /dev/null +++ b/openstack/tests/unit/baremetal/test_configdrive.py @@ -0,0 +1,48 @@ +# Copyright 2018 Red Hat, Inc. +# +# 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 json +import os + +import testtools + +from openstack.baremetal import configdrive + + +class TestPopulateDirectory(testtools.TestCase): + def _check(self, metadata, user_data=None): + with configdrive.populate_directory(metadata, user_data) as d: + for version in ('2012-08-10', 'latest'): + with open(os.path.join(d, 'openstack', version, + 'meta_data.json')) as fp: + actual_metadata = json.load(fp) + + self.assertEqual(metadata, actual_metadata) + user_data_file = os.path.join(d, 'openstack', version, + 'user_data') + if user_data is None: + self.assertFalse(os.path.exists(user_data_file)) + else: + with open(user_data_file, 'rb') as fp: + self.assertEqual(user_data, fp.read()) + + # Clean up in __exit__ + self.assertFalse(os.path.exists(d)) + + def test_without_user_data(self): + self._check({'foo': 42}) + + def test_with_user_data(self): + self._check({'foo': 42}, b'I am user data')