summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLucas Alvares Gomes <lucasagomes@gmail.com>2015-03-25 17:17:49 +0000
committerLucas Alvares Gomes <lucasagomes@gmail.com>2015-03-26 09:52:59 +0000
commit48a95b642da1c27ad6c92153078dc2a936820685 (patch)
treef1b513ca3fc664d05df8435eaa6bca9e134613bc
parent9991b26db1f094286f0d9ad8dbf1f6fb9f13c03a (diff)
downloadpython-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.py62
-rw-r--r--ironicclient/tests/unit/test_utils.py62
-rw-r--r--ironicclient/tests/unit/v1/test_node.py18
-rw-r--r--ironicclient/v1/node.py3
-rw-r--r--ironicclient/v1/node_shell.py8
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':