diff --git a/ironicclient/common/utils.py b/ironicclient/common/utils.py index 963d2f286..769ef3151 100644 --- a/ironicclient/common/utils.py +++ b/ironicclient/common/utils.py @@ -435,3 +435,24 @@ def handle_json_arg(json_arg, info_desc): if json_arg: json_arg = handle_json_or_file_arg(json_arg) return json_arg + + +def get_json_data(data): + """Check if the binary data is JSON and parse it if so. + + Only supports dictionaries. + """ + # We don't want to simply loads() a potentially large binary. Doing so, + # in my testing, is orders of magnitude (!!) slower than this process. + for idx in range(len(data)): + char = data[idx:idx + 1] + if char.isspace(): + continue + if char != b'{' and char != 'b[': + return None # not JSON, at least not JSON we care about + break # maybe JSON + + try: + return json.loads(data) + except ValueError: + return None diff --git a/ironicclient/osc/v1/baremetal_node.py b/ironicclient/osc/v1/baremetal_node.py index 7201097a7..c36226118 100755 --- a/ironicclient/osc/v1/baremetal_node.py +++ b/ironicclient/osc/v1/baremetal_node.py @@ -32,11 +32,11 @@ CONFIG_DRIVE_ARG_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 OR a JSON object to build " - "config drive from. In case it's a directory, a config drive will be " - "generated from it. In case it's a JSON object with optional keys " - "`meta_data`, `user_data` and `network_data`, a config drive will " - "be generated on the server side (see the bare metal API reference for " - "more details).") + "config drive from OR the path to the JSON file. In case it's a " + "directory, a config drive will be generated from it. In case it's a JSON " + "object with optional keys `meta_data`, `user_data` and `network_data` " + "or a JSON file, a config drive will be generated on the server side " + "(see the bare metal API reference for more details).") NETWORK_DATA_ARG_HELP = _( diff --git a/ironicclient/tests/unit/common/test_utils.py b/ironicclient/tests/unit/common/test_utils.py index a3c9972ce..c0ab0673f 100644 --- a/ironicclient/tests/unit/common/test_utils.py +++ b/ironicclient/tests/unit/common/test_utils.py @@ -413,3 +413,16 @@ class HandleJsonFileTest(test_utils.BaseTestCase): "from file", utils.handle_json_or_file_arg, f.name) mock_open.assert_called_once_with(f.name, 'r') + + +class GetJsonDataTest(test_utils.BaseTestCase): + + def test_success(self): + result = utils.get_json_data(b'\n{"answer": 42}') + self.assertEqual({"answer": 42}, result) + + def test_definitely_not_json(self): + self.assertIsNone(utils.get_json_data(b'0x010x020x03')) + + def test_could_be_json(self): + self.assertIsNone(utils.get_json_data(b'{"hahaha, just kidding\x00')) diff --git a/ironicclient/tests/unit/v1/test_node.py b/ironicclient/tests/unit/v1/test_node.py index 83808d569..7cdde36a9 100644 --- a/ironicclient/tests/unit/v1/test_node.py +++ b/ironicclient/tests/unit/v1/test_node.py @@ -1599,6 +1599,23 @@ class NodeManagerTest(testtools.TestCase): ] self.assertEqual(expect, self.api.calls) + def test_node_set_provision_state_with_configdrive_json_file(self): + target_state = 'active' + file_content = b'{"user_data": "foo bar"}' + + with tempfile.NamedTemporaryFile() as f: + f.write(file_content) + f.flush() + self.mgr.set_provision_state(NODE1['uuid'], target_state, + configdrive=f.name) + + body = {'target': target_state, + 'configdrive': {"user_data": "foo bar"}} + expect = [ + ('PUT', '/v1/nodes/%s/states/provision' % NODE1['uuid'], {}, body), + ] + self.assertEqual(expect, self.api.calls) + @mock.patch.object(common_utils, 'make_configdrive', autospec=True) def test_node_set_provision_state_with_configdrive_dir(self, mock_configdrive): diff --git a/ironicclient/v1/node.py b/ironicclient/v1/node.py index 80a32c899..337410c20 100644 --- a/ironicclient/v1/node.py +++ b/ironicclient/v1/node.py @@ -684,13 +684,18 @@ class NodeManager(base.CreateManager): :param state: The desired provision state. One of 'active', 'deleted', 'rebuild', 'inspect', 'provide', 'manage', 'clean', 'abort', 'rescue', 'unrescue'. - :param configdrive: 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 OR a dictionary to - build config drive from. In case it's a directory, a config drive - will be generated from it. In case it's a dictionary, a config - drive will be generated on the server side (requires API version - 1.56). This is only valid when setting state to 'active'. + :param configdrive: One of: + + * a gzipped, base64-encoded configuration drive string + * a dictionary to build config drive from + * a path to the configuration drive file (ISO 9660 or VFAT) + * a path to a directory containing the config drive files + * a path to a JSON file to build config from + + In case it's a directory, a config drive will be generated from + it. In case it's a dictionary or a JSON file, a config drive will + be generated on the server side (requires API version 1.56). + This is only valid when setting state to 'active'. :param cleansteps: The clean steps as a list of clean-step dictionaries; each dictionary should have keys 'interface' and 'step', and optional key 'args'. This must be specified (and is @@ -718,6 +723,9 @@ class NodeManager(base.CreateManager): if os.path.isfile(configdrive): with open(configdrive, 'rb') as f: configdrive = f.read() + json_data = utils.get_json_data(configdrive) + if json_data is not None: + configdrive = json_data elif os.path.isdir(configdrive): configdrive = utils.make_configdrive(configdrive) else: diff --git a/releasenotes/notes/configdrive-json-b9d173dde111cf22.yaml b/releasenotes/notes/configdrive-json-b9d173dde111cf22.yaml new file mode 100644 index 000000000..b60d3a1ae --- /dev/null +++ b/releasenotes/notes/configdrive-json-b9d173dde111cf22.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + The ``--config-drive`` argument to the ``node deploy`` CLI command, as well + as the underlying ``configdrive`` argument to the ``set_provision_state`` + call now accept a JSON file with a dictionary.