diff options
| author | Lucas Alvares Gomes <lucasagomes@gmail.com> | 2015-03-25 17:17:49 +0000 |
|---|---|---|
| committer | Lucas Alvares Gomes <lucasagomes@gmail.com> | 2015-03-26 09:52:59 +0000 |
| commit | 48a95b642da1c27ad6c92153078dc2a936820685 (patch) | |
| tree | f1b513ca3fc664d05df8435eaa6bca9e134613bc | |
| parent | 9991b26db1f094286f0d9ad8dbf1f6fb9f13c03a (diff) | |
| download | python-ironicclient-48a95b642da1c27ad6c92153078dc2a936820685.tar.gz | |
Add support for generating a config drive
This patch is adding support for generating the config drive as part of
the node-set-provision-state command. If a directory is passed via the
--config-drive parameter, the client will then generate a configdrive
with the contents of that directory and give it to Ironic.
Change-Id: I9163846acb30b34d34953f3b82b797ec944569d9
| -rw-r--r-- | ironicclient/common/utils.py | 62 | ||||
| -rw-r--r-- | ironicclient/tests/unit/test_utils.py | 62 | ||||
| -rw-r--r-- | ironicclient/tests/unit/v1/test_node.py | 18 | ||||
| -rw-r--r-- | ironicclient/v1/node.py | 3 | ||||
| -rw-r--r-- | ironicclient/v1/node_shell.py | 8 |
5 files changed, 150 insertions, 3 deletions
diff --git a/ironicclient/common/utils.py b/ironicclient/common/utils.py index d3e0339..919be3e 100644 --- a/ironicclient/common/utils.py +++ b/ironicclient/common/utils.py @@ -18,7 +18,14 @@ from __future__ import print_function import argparse +import base64 +import contextlib +import gzip import json +import os +import shutil +import subprocess +import tempfile from ironicclient.common.i18n import _ from ironicclient import exc @@ -182,3 +189,58 @@ def common_filters(marker=None, limit=None, sort_key=None, sort_dir=None): if sort_dir is not None: filters.append('sort_dir=%s' % sort_dir) return filters + + +@contextlib.contextmanager +def tempdir(*args, **kwargs): + dirname = tempfile.mkdtemp(*args, **kwargs) + try: + yield dirname + finally: + shutil.rmtree(dirname) + + +def make_configdrive(path): + """Make the config drive file. + + :param path: The directory containing the config drive files. + :returns: A gzipped and base64 encoded configdrive string. + + """ + # Make sure path it's readable + if not os.access(path, os.R_OK): + raise exc.CommandError(_('The directory "%s" is not readable') % path) + + with tempfile.NamedTemporaryFile() as tmpfile: + with tempfile.NamedTemporaryFile() as tmpzipfile: + publisher = 'ironicclient-configdrive 0.1' + try: + p = subprocess.Popen(['genisoimage', '-o', tmpfile.name, + '-ldots', '-allow-lowercase', + '-allow-multidot', '-l', + '-publisher', publisher, + '-quiet', '-J', + '-r', '-V', 'config-2', + path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + except OSError as e: + raise exc.CommandError( + _('Error generating the config drive. Make sure the ' + '"genisoimage" tool is installed. Error: %s') % e) + + stdout, stderr = p.communicate() + if p.returncode != 0: + raise exc.CommandError( + _('Error generating the config drive.' + 'Stdout: "%(stdout)s". Stderr: %(stderr)s') % + {'stdout': stdout, 'stderr': stderr}) + + # Compress file + tmpfile.seek(0) + g = gzip.GzipFile(fileobj=tmpzipfile, mode='wb') + shutil.copyfileobj(tmpfile, g) + g.close() + + tmpzipfile.seek(0) + return base64.b64encode(tmpzipfile.read()) diff --git a/ironicclient/tests/unit/test_utils.py b/ironicclient/tests/unit/test_utils.py index e529dbf..2d8581c 100644 --- a/ironicclient/tests/unit/test_utils.py +++ b/ironicclient/tests/unit/test_utils.py @@ -15,6 +15,9 @@ # License for the specific language governing permissions and limitations # under the License. +import os +import subprocess + import mock from ironicclient.common import utils @@ -173,3 +176,62 @@ class CommonFiltersTest(test_utils.BaseTestCase): for key in ('marker', 'sort_key', 'sort_dir'): result = utils.common_filters(**{key: 'test'}) self.assertEqual(['%s=test' % key], result) + + +@mock.patch.object(subprocess, 'Popen') +class MakeConfigDriveTest(test_utils.BaseTestCase): + + def setUp(self): + super(MakeConfigDriveTest, self).setUp() + # expected genisoimage cmd + self.genisoimage_cmd = ['genisoimage', '-o', mock.ANY, + '-ldots', '-allow-lowercase', + '-allow-multidot', '-l', + '-publisher', 'ironicclient-configdrive 0.1', + '-quiet', '-J', '-r', '-V', + 'config-2', mock.ANY] + + def test_make_configdrive(self, mock_popen): + fake_process = mock.Mock(returncode=0) + fake_process.communicate.return_value = ('', '') + mock_popen.return_value = fake_process + + with utils.tempdir() as dirname: + utils.make_configdrive(dirname) + + mock_popen.assert_called_once_with(self.genisoimage_cmd, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE) + fake_process.communicate.assert_called_once_with() + + @mock.patch.object(os, 'access') + def test_make_configdrive_non_readable_dir(self, mock_access, mock_popen): + mock_access.return_value = False + self.assertRaises(exc.CommandError, utils.make_configdrive, 'fake-dir') + mock_access.assert_called_once_with('fake-dir', os.R_OK) + self.assertFalse(mock_popen.called) + + @mock.patch.object(os, 'access') + def test_make_configdrive_oserror(self, mock_access, mock_popen): + mock_access.return_value = True + mock_popen.side_effect = OSError('boom') + + self.assertRaises(exc.CommandError, utils.make_configdrive, 'fake-dir') + mock_access.assert_called_once_with('fake-dir', os.R_OK) + mock_popen.assert_called_once_with(self.genisoimage_cmd, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE) + + @mock.patch.object(os, 'access') + def test_make_configdrive_non_zero_returncode(self, mock_access, + mock_popen): + fake_process = mock.Mock(returncode=123) + fake_process.communicate.return_value = ('', '') + mock_popen.return_value = fake_process + + self.assertRaises(exc.CommandError, utils.make_configdrive, 'fake-dir') + mock_access.assert_called_once_with('fake-dir', os.R_OK) + mock_popen.assert_called_once_with(self.genisoimage_cmd, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE) + fake_process.communicate.assert_called_once_with() diff --git a/ironicclient/tests/unit/v1/test_node.py b/ironicclient/tests/unit/v1/test_node.py index 8019989..3b2cf24 100644 --- a/ironicclient/tests/unit/v1/test_node.py +++ b/ironicclient/tests/unit/v1/test_node.py @@ -21,6 +21,7 @@ import mock import testtools from testtools.matchers import HasLength +from ironicclient.common import utils as common_utils from ironicclient import exc from ironicclient.tests.unit import utils from ironicclient.v1 import node @@ -677,6 +678,23 @@ class NodeManagerTest(testtools.TestCase): ] self.assertEqual(expect, self.api.calls) + @mock.patch.object(common_utils, 'make_configdrive') + def test_node_set_provision_state_with_configdrive_dir(self, + mock_configdrive): + mock_configdrive.return_value = 'fake-configdrive' + target_state = 'active' + + with common_utils.tempdir() as dirname: + self.mgr.set_provision_state(NODE1['uuid'], target_state, + configdrive=dirname) + mock_configdrive.assert_called_once_with(dirname) + + body = {'target': target_state, 'configdrive': 'fake-configdrive'} + expect = [ + ('PUT', '/v1/nodes/%s/states/provision' % NODE1['uuid'], {}, body), + ] + self.assertEqual(expect, self.api.calls) + def test_node_states(self): states = self.mgr.states(NODE1['uuid']) expect = [ diff --git a/ironicclient/v1/node.py b/ironicclient/v1/node.py index e20cc2c..a8ca40c 100644 --- a/ironicclient/v1/node.py +++ b/ironicclient/v1/node.py @@ -225,6 +225,9 @@ class NodeManager(base.Manager): if os.path.isfile(configdrive): with open(configdrive, 'rb') as f: configdrive = f.read() + if os.path.isdir(configdrive): + configdrive = utils.make_configdrive(configdrive) + body['configdrive'] = configdrive return self._update(self._path(path), body, method='PUT') diff --git a/ironicclient/v1/node_shell.py b/ironicclient/v1/node_shell.py index 1742c41..761fdf7 100644 --- a/ironicclient/v1/node_shell.py +++ b/ironicclient/v1/node_shell.py @@ -332,9 +332,11 @@ def do_node_set_power_state(cc, args): '--config-drive', metavar='<config-drive>', default=None, - help=('A gzipped, base64-encoded configuration drive string or the path ' - 'to the configuration drive file. Only valid when setting provision ' - 'state to "active".')) + help=("A gzipped, base64-encoded configuration drive string OR the path " + "to the configuration drive file OR the path to a directory " + "containing the config drive files. In case it's a directory, a " + "config drive will be generated from it. This parameter is only " + "valid when setting provision state to 'active'.")) def do_node_set_provision_state(cc, args): """Provision, rebuild, delete, inspect, provide or manage an instance.""" if args.config_drive and args.provision_state != 'active': |
