From dbafa8057a6c029a36b184567e3f1f239d555851 Mon Sep 17 00:00:00 2001 From: Vonteddu Chaithra Date: Tue, 18 Aug 2026 13:19:43 +0530 Subject: [PATCH] Added Support For Storage Control Unit APIs Signed-off-by: Vonteddu Chaithra --- docs/appendix.rst | 16 + docs/resources.rst | 40 + tests/end2end/mocked_hmc_z14.yaml | 38 +- tests/end2end/mocked_hmc_z16.yaml | 38 +- tests/end2end/mocked_hmc_z17.yaml | 38 +- tests/end2end/test_storage_control_unit.py | 298 +++++++ tests/end2end/test_storage_path.py | 461 ++++++++++ .../zhmcclient/test_storage_control_unit.py | 792 ++++++++++++++++++ tests/unit/zhmcclient/test_storage_path.py | 533 ++++++++++++ .../unit/zhmcclient/test_storage_subsystem.py | 27 + zhmcclient/__init__.py | 2 + zhmcclient/_console.py | 14 + zhmcclient/_storage_control_unit.py | 382 +++++++++ zhmcclient/_storage_path.py | 296 +++++++ zhmcclient/_utils.py | 4 + zhmcclient/mock/_hmc.py | 196 +++++ zhmcclient/mock/_session.py | 42 + zhmcclient/mock/_urihandler.py | 428 +++++++++- 18 files changed, 3621 insertions(+), 24 deletions(-) create mode 100644 tests/end2end/test_storage_control_unit.py create mode 100644 tests/end2end/test_storage_path.py create mode 100644 tests/unit/zhmcclient/test_storage_control_unit.py create mode 100644 tests/unit/zhmcclient/test_storage_path.py create mode 100644 zhmcclient/_storage_control_unit.py create mode 100644 zhmcclient/_storage_path.py diff --git a/docs/appendix.rst b/docs/appendix.rst index bedf7bd3..9862d28d 100644 --- a/docs/appendix.rst +++ b/docs/appendix.rst @@ -544,6 +544,22 @@ Resources scoped to CPCs in DPM mode For details, see section :ref:`Storage Fabrics`. + Storage Control Unit + A logical entity within a :term:`Storage Subsystem` that manages a set + of storage volumes. A storage control unit is identified within its + parent subsystem by a two-character hexadecimal ``logical-address``. It + can have up to 8 :term:`storage paths ` and a set of + volume ranges that describe the volumes it manages. + + For details, see section :ref:`Storage Control Units`. + + Storage Path + An element object nested inside a :term:`Storage Control Unit` that + defines a communication path from the control unit to a storage adapter, + optionally through one or two storage switches. + + For details, see section :ref:`Storage Paths`. + Storage Subsystem A physical storage device (e.g. an IBM DS8000 series system) in the FICON storage configuration of a DPM-enabled :term:`CPC`. A storage subsystem diff --git a/docs/resources.rst b/docs/resources.rst index 43831032..d65f9e8b 100644 --- a/docs/resources.rst +++ b/docs/resources.rst @@ -518,6 +518,46 @@ Storage Switches :special-members: __str__ +.. _`Storage Control Units`: + +Storage Control Units +--------------------- + +.. automodule:: zhmcclient._storage_control_unit + +.. autoclass:: zhmcclient.StorageControlUnitManager + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + +.. autoclass:: zhmcclient.StorageControlUnit + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + + +.. _`Storage Paths`: + +Storage Paths +------------- + +.. automodule:: zhmcclient._storage_path + +.. autoclass:: zhmcclient.StoragePathManager + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + +.. autoclass:: zhmcclient.StoragePath + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + + .. _`Storage Subsystems`: Storage Subsystems diff --git a/tests/end2end/mocked_hmc_z14.yaml b/tests/end2end/mocked_hmc_z14.yaml index 8b3ed34b..d7b8f585 100644 --- a/tests/end2end/mocked_hmc_z14.yaml +++ b/tests/end2end/mocked_hmc_z14.yaml @@ -428,7 +428,8 @@ hmc_definition: name: "DS8886 A" description: "DS8886 A storage subsystem at Primary Site" storage-site-uri: /api/storage-sites/ss1 - storage-control-unit-uris: [] + storage-control-unit-uris: + - /api/storage-control-units/cu1 connection-endpoints: - endpoint-uri: /api/storage-switches/sw1 endpoint-class: storage-switch @@ -441,12 +442,45 @@ hmc_definition: name: "DS8886 B" description: "DS8886 B storage subsystem at Alternate Site" storage-site-uri: /api/storage-sites/ss2 - storage-control-unit-uris: [] + storage-control-unit-uris: + - /api/storage-control-units/cu2 connection-endpoints: - endpoint-uri: /api/storage-switches/sw2 endpoint-class: storage-switch port-id: "00" + storage_control_units: + - properties: + # class: created automatically + # object-uri: created automatically + object-id: cu1 + name: "Control Unit 50" + description: "Mocked storage control unit 50 in DS8886 A" + logical-address: "50" + parent: /api/storage-subsystems/subsys1 + storage_paths: + - properties: + # class: created automatically + # element-uri: created automatically + adapter-port-uri: /api/adapters/fcp1/storage-ports/port0 + exit-switch-uri: /api/storage-switches/sw1 + exit-port: "10" + - properties: + # class: created automatically + # object-uri: created automatically + object-id: cu2 + name: "Control Unit 60" + description: "Mocked storage control unit 60 in DS8886 B" + logical-address: "60" + parent: /api/storage-subsystems/subsys2 + storage_paths: + - properties: + # class: created automatically + # element-uri: created automatically + adapter-port-uri: /api/adapters/fcp1/storage-ports/port0 + exit-switch-uri: /api/storage-switches/sw2 + exit-port: "10" + hw_messages: - properties: # class: created automatically diff --git a/tests/end2end/mocked_hmc_z16.yaml b/tests/end2end/mocked_hmc_z16.yaml index 1a198e69..fce88f96 100644 --- a/tests/end2end/mocked_hmc_z16.yaml +++ b/tests/end2end/mocked_hmc_z16.yaml @@ -502,7 +502,8 @@ hmc_definition: name: "DS8886 A" description: "DS8886 A storage subsystem at Primary Site" storage-site-uri: /api/storage-sites/ss1 - storage-control-unit-uris: [] + storage-control-unit-uris: + - /api/storage-control-units/cu1 connection-endpoints: - endpoint-uri: /api/storage-switches/sw1 endpoint-class: storage-switch @@ -515,12 +516,45 @@ hmc_definition: name: "DS8886 B" description: "DS8886 B storage subsystem at Alternate Site" storage-site-uri: /api/storage-sites/ss2 - storage-control-unit-uris: [] + storage-control-unit-uris: + - /api/storage-control-units/cu2 connection-endpoints: - endpoint-uri: /api/storage-switches/sw2 endpoint-class: storage-switch port-id: "00" + storage_control_units: + - properties: + # class: created automatically + # object-uri: created automatically + object-id: cu1 + name: "Control Unit 50" + description: "Mocked storage control unit 50 in DS8886 A" + logical-address: "50" + parent: /api/storage-subsystems/subsys1 + storage_paths: + - properties: + # class: created automatically + # element-uri: created automatically + adapter-port-uri: /api/adapters/fcp1/storage-ports/port0 + exit-switch-uri: /api/storage-switches/sw1 + exit-port: "10" + - properties: + # class: created automatically + # object-uri: created automatically + object-id: cu2 + name: "Control Unit 60" + description: "Mocked storage control unit 60 in DS8886 B" + logical-address: "60" + parent: /api/storage-subsystems/subsys2 + storage_paths: + - properties: + # class: created automatically + # element-uri: created automatically + adapter-port-uri: /api/adapters/fcp1/storage-ports/port0 + exit-switch-uri: /api/storage-switches/sw2 + exit-port: "10" + hw_messages: - properties: # class: created automatically diff --git a/tests/end2end/mocked_hmc_z17.yaml b/tests/end2end/mocked_hmc_z17.yaml index 1af965c2..2adea1dd 100644 --- a/tests/end2end/mocked_hmc_z17.yaml +++ b/tests/end2end/mocked_hmc_z17.yaml @@ -527,7 +527,8 @@ hmc_definition: name: "DS8886 A" description: "DS8886 A storage subsystem at Primary Site" storage-site-uri: /api/storage-sites/ss1 - storage-control-unit-uris: [] + storage-control-unit-uris: + - /api/storage-control-units/cu1 connection-endpoints: - endpoint-uri: /api/storage-switches/sw1 endpoint-class: storage-switch @@ -540,12 +541,45 @@ hmc_definition: name: "DS8886 B" description: "DS8886 B storage subsystem at Alternate Site" storage-site-uri: /api/storage-sites/ss2 - storage-control-unit-uris: [] + storage-control-unit-uris: + - /api/storage-control-units/cu2 connection-endpoints: - endpoint-uri: /api/storage-switches/sw2 endpoint-class: storage-switch port-id: "00" + storage_control_units: + - properties: + # class: created automatically + # object-uri: created automatically + object-id: cu1 + name: "Control Unit 50" + description: "Mocked storage control unit 50 in DS8886 A" + logical-address: "50" + parent: /api/storage-subsystems/subsys1 + storage_paths: + - properties: + # class: created automatically + # element-uri: created automatically + adapter-port-uri: /api/adapters/fcp1/storage-ports/port0 + exit-switch-uri: /api/storage-switches/sw1 + exit-port: "10" + - properties: + # class: created automatically + # object-uri: created automatically + object-id: cu2 + name: "Control Unit 60" + description: "Mocked storage control unit 60 in DS8886 B" + logical-address: "60" + parent: /api/storage-subsystems/subsys2 + storage_paths: + - properties: + # class: created automatically + # element-uri: created automatically + adapter-port-uri: /api/adapters/fcp1/storage-ports/port0 + exit-switch-uri: /api/storage-switches/sw2 + exit-port: "10" + hw_messages: - properties: # class: created automatically diff --git a/tests/end2end/test_storage_control_unit.py b/tests/end2end/test_storage_control_unit.py new file mode 100644 index 00000000..a48bd2a4 --- /dev/null +++ b/tests/end2end/test_storage_control_unit.py @@ -0,0 +1,298 @@ +# Copyright 2026 IBM Corp. 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. + +""" +End2end tests for Storage Control Units and Storage Paths +(on CPCs in DPM mode). + +These tests exercise the list, get-properties, update-properties, +define, undefine, add-volume-range, remove-volume-range, and storage path +CRUD operations. They do not permanently modify existing resources. +""" + + +from requests.packages import urllib3 + +import zhmcclient + +from .utils import skip_warn, pick_test_resources, \ + runtest_find_list, runtest_get_properties + +urllib3.disable_warnings() + +# Properties in minimalistic StorageControlUnit objects +SCU_MINIMAL_PROPS = ['object-uri', 'name'] + +# Properties in StorageControlUnit objects returned by list() without full props +SCU_LIST_PROPS = ['object-uri', 'name', 'logical-address'] + +# Properties whose values can change between retrievals +SCU_VOLATILE_PROPS = [] + + +def test_scu_find_list(hmc_session): + """ + Test list(), find(), findall() for Storage Control Units. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + cu_list = pick_test_resources(cu_list) + + for cu in cu_list: + print(f"Testing with Storage Control Unit {cu.name!r}") + runtest_find_list( + hmc_session, console.storage_control_units, cu.name, + 'name', 'object-uri', SCU_VOLATILE_PROPS, + SCU_MINIMAL_PROPS, SCU_LIST_PROPS) + + +def test_scu_property(hmc_session): + """ + Test property related methods for Storage Control Units. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + cu_list = pick_test_resources(cu_list) + + for cu in cu_list: + print(f"Testing with Storage Control Unit {cu.name!r}") + + non_list_prop = 'class' + + runtest_get_properties(cu.manager, non_list_prop) + + +def test_scu_full_properties(hmc_session): + """ + Test that pull_full_properties() fills in all expected properties. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + cu_list = pick_test_resources(cu_list) + + for cu in cu_list: + print(f"Testing with Storage Control Unit {cu.name!r}") + + cu.pull_full_properties() + + for prop in ('object-uri', 'object-id', 'class', 'parent', + 'name', 'logical-address', + 'storage-path-uris', 'volume-ranges'): + assert prop in cu.properties, ( + f"Expected property {prop!r} missing from " + f"Storage Control Unit {cu.name!r}") + + assert cu.properties['class'] == 'storage-control-unit' + assert isinstance(cu.properties['storage-path-uris'], list) + assert isinstance(cu.properties['volume-ranges'], list) + + +def test_scu_list_filter_name(hmc_session): + """ + Test list() with name filter for Storage Control Units. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + cu = cu_list[0] + cu_name = cu.name + + filtered = console.storage_control_units.list( + filter_args={'name': cu_name}) + + assert len(filtered) >= 1 + names = [c.name for c in filtered] + assert cu_name in names + print(f"name filter returned {len(filtered)} CU(s) for name {cu_name!r}") + + +def test_scu_list_filter_logical_address(hmc_session): + """ + Test list() with logical-address filter for Storage Control Units. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + cu = cu_list[0] + cu.pull_full_properties() + logical_addr = cu.properties.get('logical-address') + if not logical_addr: + skip_warn( + f"Storage Control Unit {cu.name!r} has no logical-address") + + filtered = console.storage_control_units.list( + filter_args={'logical-address': logical_addr}) + + assert len(filtered) >= 1 + for c in filtered: + c.pull_full_properties() + assert c.properties['logical-address'] == logical_addr + print(f"logical-address filter returned {len(filtered)} CU(s)") + + +def test_scu_list_by_subsystem(hmc_session): + """ + Test listing storage control units via the storage-subsystems sub-resource + endpoint. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + cu = cu_list[0] + cu.pull_full_properties() + parent_uri = cu.properties.get('parent') + if not parent_uri: + skip_warn( + f"Storage Control Unit {cu.name!r} has no parent property") + + ss_id = parent_uri.split('/')[-1] + + result = hmc_session.get( + f'/api/storage-subsystems/{ss_id}/storage-control-units') + + assert 'storage-control-units' in result + returned_uris = {c['object-uri'] for c in result['storage-control-units']} + assert cu.uri in returned_uris + print(f"Subsystem {parent_uri!r} has " + f"{len(result['storage-control-units'])} control unit(s)") + + +def test_scu_update_description(hmc_session): + """ + Test update_properties() – update the description of an existing + storage control unit and verify the change, then restore. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + cu = pick_test_resources(cu_list)[0] + cu.pull_full_properties() + original_desc = cu.properties.get('description', '') + + new_desc = f"{original_desc}__zhmcclient_e2e_test" + + try: + cu.update_properties({'description': new_desc}) + + assert cu.properties['description'] == new_desc + cu.pull_full_properties() + assert cu.properties['description'] == new_desc + print(f"Updated description of Storage Control Unit {cu.name!r}") + + finally: + try: + cu.update_properties({'description': original_desc}) + print(f"Restored description of Storage Control Unit " + f"{cu.name!r}") + except zhmcclient.HTTPError as exc: + print(f"Failed to restore description: {exc}") + + +def test_scu_storage_paths(hmc_session): + """ + Test that storage-path-uris property is accessible and paths can be + retrieved. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + for cu in pick_test_resources(cu_list): + cu.pull_full_properties() + path_uris = cu.properties.get('storage-path-uris', []) + + print(f"Storage Control Unit {cu.name!r} has " + f"{len(path_uris)} storage path(s)") + + for path_uri in path_uris: + props = hmc_session.get(path_uri) + assert 'adapter-port-uri' in props, ( + f"Missing 'adapter-port-uri' in storage path {path_uri!r} " + f"of CU {cu.name!r}") + assert props.get('class') == 'storage-path', ( + f"Unexpected class {props.get('class')!r} for " + f"storage path {path_uri!r}") + + +def test_scu_volume_ranges(hmc_session): + """ + Test that volume-ranges property is accessible and has correct structure. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + for cu in pick_test_resources(cu_list): + cu.pull_full_properties() + volume_ranges = cu.properties.get('volume-ranges', []) + + print(f"Storage Control Unit {cu.name!r} has " + f"{len(volume_ranges)} volume range(s)") + + for vr in volume_ranges: + assert 'starting-volume' in vr, ( + f"Missing 'starting-volume' in volume range of CU " + f"{cu.name!r}: {vr!r}") + assert 'ending-volume' in vr, ( + f"Missing 'ending-volume' in volume range of CU " + f"{cu.name!r}: {vr!r}") + assert vr.get('type') in ('base', 'alias'), ( + f"Unexpected type {vr.get('type')!r} in volume range of " + f"CU {cu.name!r}") diff --git a/tests/end2end/test_storage_path.py b/tests/end2end/test_storage_path.py new file mode 100644 index 00000000..c8e6b072 --- /dev/null +++ b/tests/end2end/test_storage_path.py @@ -0,0 +1,461 @@ +# Copyright 2026 IBM Corp. 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. + +""" +End2end tests for Storage Paths (on CPCs in DPM mode). + +These tests exercise the list, get-properties, update-properties, create, and +delete operations on StoragePath element resources nested inside Storage Control +Units. They do not permanently modify any existing resources. +""" + + +from requests.packages import urllib3 + +import zhmcclient + +from .utils import skip_warn, pick_test_resources + +urllib3.disable_warnings() + +# Properties in minimalistic StoragePath objects returned by list() +PATH_MINIMAL_PROPS = ['element-uri'] + +# Properties expected on a fully-retrieved StoragePath +PATH_FULL_PROPS = [ + 'element-uri', + 'element-id', + 'class', + 'parent', + 'adapter-port-uri', +] + +# Properties that legitimately change between two retrievals +PATH_VOLATILE_PROPS = [] + + +def _first_cu_with_paths(hmc_session, min_paths=1): + """ + Return *(console, cu, path_list)* for the first Storage Control Unit that + has at least *min_paths* storage paths, or ``None`` if none is found. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + cu_list = console.storage_control_units.list() + for cu in cu_list: + cu.pull_full_properties() + path_list = cu.storage_paths.list(full_properties=True) + if len(path_list) >= min_paths: + return console, cu, path_list + return None + + +# ── list() ──────────────────────────────────────────────────────────────────── + +def test_path_list(hmc_session): + """ + Test StoragePathManager.list() returns StoragePath objects. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + for cu in pick_test_resources(cu_list): + path_list = cu.storage_paths.list() + print(f"CU {cu.name!r}: {len(path_list)} path(s) from list()") + for path in path_list: + assert isinstance(path, zhmcclient.StoragePath), ( + f"Expected StoragePath, got {type(path)!r}") + + +def test_path_list_full_properties(hmc_session): + """ + Test StoragePathManager.list(full_properties=True) returns paths with all + expected properties. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + for cu in pick_test_resources(cu_list): + path_list = cu.storage_paths.list(full_properties=True) + for path in path_list: + for prop in PATH_FULL_PROPS: + assert prop in path.properties, ( + f"Expected property {prop!r} missing from storage path " + f"{path.uri!r} on CU {cu.name!r}") + assert path.properties['class'] == 'storage-path', ( + f"Unexpected class {path.properties['class']!r} for " + f"storage path {path.uri!r}") + + +# ── pull_full_properties() ──────────────────────────────────────────────────── + +def test_path_pull_full_properties(hmc_session): + """ + Test StoragePath.pull_full_properties() populates all expected properties. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + found_path = False + for cu in pick_test_resources(cu_list): + path_list = cu.storage_paths.list() + if not path_list: + continue + found_path = True + path = path_list[0] + path.pull_full_properties() + for prop in PATH_FULL_PROPS: + assert prop in path.properties, ( + f"Expected property {prop!r} missing after " + f"pull_full_properties on path {path.uri!r} " + f"of CU {cu.name!r}") + break + + if not found_path: + skip_warn( + f"No Storage Control Unit on HMC {hd.host} has any storage paths") + + +# ── class and parent properties ─────────────────────────────────────────────── + +def test_path_class_property(hmc_session): + """ + Test that the 'class' property of a StoragePath is 'storage-path'. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + found_path = False + for cu in pick_test_resources(cu_list): + path_list = cu.storage_paths.list(full_properties=True) + if not path_list: + continue + found_path = True + for path in path_list: + assert path.properties.get('class') == 'storage-path', ( + f"Unexpected class {path.properties.get('class')!r} for " + f"storage path {path.uri!r}") + break + + if not found_path: + skip_warn( + f"No Storage Control Unit on HMC {hd.host} has any storage paths") + + +def test_path_parent_property(hmc_session): + """ + Test that the 'parent' property of a StoragePath points to its CU URI. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + found_path = False + for cu in pick_test_resources(cu_list): + path_list = cu.storage_paths.list(full_properties=True) + if not path_list: + continue + found_path = True + for path in path_list: + assert path.properties.get('parent') == cu.uri, ( + f"Expected parent {cu.uri!r}, got " + f"{path.properties.get('parent')!r} for path {path.uri!r}") + break + + if not found_path: + skip_warn( + f"No Storage Control Unit on HMC {hd.host} has any storage paths") + + +# ── adapter-port-uri ────────────────────────────────────────────────────────── + +def test_path_adapter_port_uri(hmc_session): + """ + Test that 'adapter-port-uri' is present and points to a valid adapter port. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + found_path = False + for cu in pick_test_resources(cu_list): + path_list = cu.storage_paths.list(full_properties=True) + if not path_list: + continue + found_path = True + for path in path_list: + port_uri = path.properties.get('adapter-port-uri') + assert port_uri, ( + f"Missing 'adapter-port-uri' on storage path {path.uri!r} " + f"of CU {cu.name!r}") + assert port_uri.startswith('/api/'), ( + f"Unexpected adapter-port-uri format {port_uri!r}") + break + + if not found_path: + skip_warn( + f"No Storage Control Unit on HMC {hd.host} has any storage paths") + + +# ── Consistency: storage-path-uris on parent CU ─────────────────────────────── + +def test_path_uris_consistent_with_cu(hmc_session): + """ + Test that the URIs returned by storage_paths.list() match the parent CU's + storage-path-uris property. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + for cu in pick_test_resources(cu_list): + cu.pull_full_properties() + expected_uris = set(cu.properties.get('storage-path-uris', [])) + listed_paths = cu.storage_paths.list() + listed_uris = {p.uri for p in listed_paths} + assert listed_uris == expected_uris, ( + f"Mismatch between storage-path-uris ({expected_uris!r}) and " + f"storage_paths.list() URIs ({listed_uris!r}) for CU {cu.name!r}") + + +# ── update_properties() ─────────────────────────────────────────────────────── + +def test_path_update_properties(hmc_session): + """ + Test StoragePath.update_properties() - update a writeable property and + verify the change is persisted, then restore the original value. + + Uses the first path found that has an 'exit-switch-uri' set, so we can + toggle 'exit-port'. If no paths have a switch, the test is skipped. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + target_path = None + target_cu = None + for cu in pick_test_resources(cu_list): + path_list = cu.storage_paths.list(full_properties=True) + for path in path_list: + if path.properties.get('exit-switch-uri'): + target_path = path + target_cu = cu + break + if target_path: + break + + if target_path is None: + skip_warn( + f"No storage path with an exit-switch-uri found on HMC {hd.host}; " + "skipping update test") + + original_port = target_path.properties.get('exit-port', '') + # Use a different port value that is unlikely to conflict + new_port = 'ff' if original_port != 'ff' else 'fe' + + try: + target_path.update_properties({'exit-port': new_port}) + assert target_path.properties['exit-port'] == new_port, ( + "Local property not updated by update_properties()") + + target_path.pull_full_properties() + assert target_path.properties['exit-port'] == new_port, ( + f"HMC property not updated for path {target_path.uri!r} " + f"of CU {target_cu.name!r}") + print(f"Updated exit-port of path {target_path.uri!r} on " + f"CU {target_cu.name!r}") + + finally: + try: + target_path.update_properties({'exit-port': original_port}) + print(f"Restored exit-port of path {target_path.uri!r}") + except zhmcclient.HTTPError as exc: + print(f"Failed to restore exit-port: {exc}") + + +# ── create() and delete() ───────────────────────────────────────────────────── + +def test_path_create_and_delete(hmc_session): + """ + Test creating a new StoragePath and then deleting it. + + The test looks for a CU that already has at least one path, picks the + adapter-port-uri from that path, then creates a second path on a different + adapter port (if available). If no suitable resources exist the test is + skipped. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + # Find a CU with at least one existing path (so we know the adapter-port + # URI pattern) and that has fewer than 8 paths (so we can add one more). + target_cu = None + ref_port_uri = None + for cu in pick_test_resources(cu_list): + cu.pull_full_properties() + path_list = cu.storage_paths.list(full_properties=True) + if not path_list: + continue + if len(path_list) >= 8: + continue # Cannot add more paths + # Use the same adapter-port-uri for simplicity — will fail if it + # results in a duplicate; that is caught and skipped below. + ref_port_uri = path_list[0].properties.get('adapter-port-uri') + target_cu = cu + break + + if target_cu is None: + skip_warn( + f"No suitable Storage Control Unit found on HMC {hd.host} for " + "create/delete test (need < 8 existing paths and at least 1)") + + new_path = None + try: + try: + new_path = target_cu.storage_paths.create({ + 'adapter-port-uri': ref_port_uri, + }) + except zhmcclient.HTTPError as exc: + if exc.http_status == 400 and exc.reason == 8: + skip_warn( + f"Duplicate path rejected by HMC {hd.host}; " + "skipping create/delete test") + raise + + assert isinstance(new_path, zhmcclient.StoragePath), ( + f"create() did not return a StoragePath, got {type(new_path)!r}") + assert new_path.uri.startswith(target_cu.uri + '/storage-paths/'), ( + f"Unexpected path URI {new_path.uri!r}") + + # Verify the new path appears in list() + path_list_after = target_cu.storage_paths.list() + uris = {p.uri for p in path_list_after} + assert new_path.uri in uris, ( + f"Newly created path {new_path.uri!r} not found in list() result") + + # Verify the path URI appears in the parent CU's storage-path-uris + target_cu.pull_full_properties() + assert new_path.uri in target_cu.properties['storage-path-uris'], ( + f"Newly created path {new_path.uri!r} not in " + f"CU storage-path-uris") + + print(f"Created path {new_path.uri!r} on CU {target_cu.name!r}") + + finally: + if new_path is not None: + try: + new_path.delete() + print(f"Deleted path {new_path.uri!r} on CU {target_cu.name!r}") + except zhmcclient.HTTPError as exc: + print(f"Failed to delete test path {new_path.uri!r}: {exc}") + + +def test_path_delete_removes_from_cu_uris(hmc_session): + """ + Test that deleting a StoragePath removes its URI from the parent CU's + storage-path-uris property. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + cu_list = console.storage_control_units.list() + if not cu_list: + skip_warn(f"No Storage Control Units defined on HMC {hd.host}") + + target_cu = None + ref_port_uri = None + for cu in pick_test_resources(cu_list): + cu.pull_full_properties() + path_list = cu.storage_paths.list(full_properties=True) + if not path_list or len(path_list) >= 8: + continue + ref_port_uri = path_list[0].properties.get('adapter-port-uri') + target_cu = cu + break + + if target_cu is None: + skip_warn( + f"No suitable Storage Control Unit on HMC {hd.host} for " + "delete consistency test") + + new_path = None + try: + try: + new_path = target_cu.storage_paths.create({ + 'adapter-port-uri': ref_port_uri, + }) + except zhmcclient.HTTPError as exc: + if exc.http_status == 400 and exc.reason == 8: + skip_warn( + f"Duplicate path rejected on HMC {hd.host}; skipping test") + raise + + path_uri = new_path.uri + new_path.delete() + new_path = None # do not attempt cleanup again in finally + + target_cu.pull_full_properties() + assert path_uri not in target_cu.properties['storage-path-uris'], ( + f"Deleted path {path_uri!r} still present in " + f"CU storage-path-uris after delete") + print(f"Verified deletion of {path_uri!r} from CU {target_cu.name!r}") + + finally: + if new_path is not None: + try: + new_path.delete() + except zhmcclient.HTTPError as exc: + print(f"Failed to cleanup test path {new_path.uri!r}: {exc}") diff --git a/tests/unit/zhmcclient/test_storage_control_unit.py b/tests/unit/zhmcclient/test_storage_control_unit.py new file mode 100644 index 00000000..80442a99 --- /dev/null +++ b/tests/unit/zhmcclient/test_storage_control_unit.py @@ -0,0 +1,792 @@ +# Copyright 2026 IBM Corp. 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. + +""" +Unit tests for Storage Control Units and Storage Paths using the faked HMC. +""" + +import pytest + +import zhmcclient +from zhmcclient.mock import FakedSession +from zhmcclient import Client, HTTPError + +# ── Test fixture constants ───────────────────────────────────────────────── + +CPC_OID = 'fake-cpc1-oid' +CPC_URI = f'/api/cpcs/{CPC_OID}' + +SSITE1_OID = 'site1-oid' +SSITE1_URI = f'/api/storage-sites/{SSITE1_OID}' +SSITE1_NAME = 'Primary Site' + +SUBSYS1_OID = 'subsys1-oid' +SUBSYS1_URI = f'/api/storage-subsystems/{SUBSYS1_OID}' +SUBSYS1_NAME = 'DS8886 A' + +SUBSYS2_OID = 'subsys2-oid' +SUBSYS2_URI = f'/api/storage-subsystems/{SUBSYS2_OID}' +SUBSYS2_NAME = 'DS8886 B' + +ADAPTER_OID = 'adap1-oid' +ADAPTER_PORT_OID = 'port1-oid' +ADAPTER_PORT_URI = ( + f'/api/adapters/{ADAPTER_OID}/storage-ports/{ADAPTER_PORT_OID}') + +SWITCH1_OID = 'sw1-oid' +SWITCH1_URI = f'/api/storage-switches/{SWITCH1_OID}' + +CU1_OID = 'cu1-oid' +CU1_NAME = 'Control unit 50' +CU1_ADDR = '50' + +CU2_OID = 'cu2-oid' +CU2_NAME = 'Control unit 60' +CU2_ADDR = '60' + + +class TestStorageControlUnit: + """Tests for StorageControlUnit and StoragePath resources.""" + + def setup_method(self): + # pylint: disable=attribute-defined-outside-init + """Set up faked session, HMC, and common resources.""" + self.session = FakedSession('fake-host', 'fake-hmc', '2.16.0', '4.10') + self.client = Client(self.session) + + self.session.hmc.cpcs.add({ + 'object-id': CPC_OID, + 'parent': None, + 'class': 'cpc', + 'name': 'CPC1', + 'dpm-enabled': True, + }) + self.faked_console = self.session.hmc.consoles.add({ + 'name': 'HMC1', + }) + self.console = self.client.consoles.console + + # Add storage site and subsystem + self.faked_site1 = self.faked_console.storage_sites.add({ + 'object-id': SSITE1_OID, + 'name': SSITE1_NAME, + }) + self.faked_subsys1 = self.faked_console.storage_subsystems.add({ + 'object-id': SUBSYS1_OID, + 'name': SUBSYS1_NAME, + 'storage-site-uri': SSITE1_URI, + }) + self.faked_subsys2 = self.faked_console.storage_subsystems.add({ + 'object-id': SUBSYS2_OID, + 'name': SUBSYS2_NAME, + 'storage-site-uri': SSITE1_URI, + }) + + # Add an adapter with a storage port for path tests + faked_cpc = self.session.hmc.cpcs.lookup_by_oid(CPC_OID) + faked_adapter = faked_cpc.adapters.add({ + 'object-id': ADAPTER_OID, + 'name': 'Adapter1', + 'adapter-family': 'ficon', + 'type': 'fcp', + 'storage-port-uris': [], + }) + faked_adapter.ports.add({ + 'element-id': ADAPTER_PORT_OID, + 'name': 'Port0', + }) + + # Add a storage switch for path tests + self.faked_switch1 = self.faked_console.storage_switches.add({ + 'object-id': SWITCH1_OID, + 'name': 'SW1', + 'domain-id': '1', + 'storage-site-uri': SSITE1_URI, + 'storage-fabric-uri': '/api/storage-fabrics/fab1', + }) + + def _add_cu1(self): + """Add control unit 1 as child of subsys1.""" + return self.faked_console.storage_control_units.add({ + 'object-id': CU1_OID, + 'name': CU1_NAME, + 'logical-address': CU1_ADDR, + 'parent': SUBSYS1_URI, + }) + + def _add_cu2(self): + """Add control unit 2 as child of subsys1.""" + return self.faked_console.storage_control_units.add({ + 'object-id': CU2_OID, + 'name': CU2_NAME, + 'logical-address': CU2_ADDR, + 'parent': SUBSYS1_URI, + }) + + # ── Manager initial attrs ─────────────────────────────────────────────── + + def test_cum_initial_attrs(self): + """StorageControlUnitManager has correct initial attributes.""" + mgr = self.console.storage_control_units + assert isinstance(mgr, zhmcclient.StorageControlUnitManager) + assert mgr.console is self.console + + # ── list() ───────────────────────────────────────────────────────────── + + def test_cum_list_empty(self): + """list() returns empty when no control units exist.""" + result = self.console.storage_control_units.list() + assert result == [] + + def test_cum_list_one(self): + """list() returns one control unit.""" + self._add_cu1() + result = self.console.storage_control_units.list() + assert len(result) == 1 + assert isinstance(result[0], zhmcclient.StorageControlUnit) + + def test_cum_list_two(self): + """list() returns both control units.""" + self._add_cu1() + self._add_cu2() + result = self.console.storage_control_units.list() + assert len(result) == 2 + names = {cu.properties.get('name') for cu in result} + assert names == {CU1_NAME, CU2_NAME} + + def test_cum_list_filter_by_name(self): + """list() with name filter returns matching CU only.""" + self._add_cu1() + self._add_cu2() + result = self.console.storage_control_units.list( + filter_args={'name': CU1_NAME}) + assert len(result) == 1 + assert result[0].properties['name'] == CU1_NAME + + def test_cum_list_filter_by_addr(self): + """list() with logical-address filter returns matching CU.""" + self._add_cu1() + self._add_cu2() + result = self.console.storage_control_units.list( + filter_args={'logical-address': CU2_ADDR}) + assert len(result) == 1 + assert result[0].properties['logical-address'] == CU2_ADDR + + # ── Default properties ───────────────────────────────────────────────── + + def test_cu_default_props(self): + """FakedStorageControlUnit.add() sets correct defaults.""" + faked_cu = self._add_cu1() + assert faked_cu.properties.get('description') == '' + assert faked_cu.properties.get('storage-path-uris') == [] + assert faked_cu.properties.get('volume-ranges') == [] + + def test_cu_class_property(self): + """StorageControlUnit 'class' property is 'storage-control-unit'.""" + faked_cu = self._add_cu1() + assert faked_cu.properties.get('class') == 'storage-control-unit' + + # ── Subsystem back-reference ──────────────────────────────────────────── + + def test_cu_add_registers_in_subsystem(self): + """Adding a CU registers its URI in parent subsystem's cu-uris.""" + faked_cu = self._add_cu1() + cu_uris = self.faked_subsys1.properties.get( + 'storage-control-unit-uris', []) + assert faked_cu.uri in cu_uris + + def test_two_cus_both_in_subsys(self): + """Both CU URIs appear in parent subsystem's cu-uris.""" + faked_cu1 = self._add_cu1() + faked_cu2 = self._add_cu2() + cu_uris = self.faked_subsys1.properties.get( + 'storage-control-unit-uris', []) + assert faked_cu1.uri in cu_uris + assert faked_cu2.uri in cu_uris + + # ── repr ─────────────────────────────────────────────────────────────── + + def test_cu_repr(self): + """StorageControlUnit.__repr__() returns a non-empty string.""" + self._add_cu1() + cu = self.console.storage_control_units.find(name=CU1_NAME) + assert repr(cu) + + # ── update_properties() ──────────────────────────────────────────────── + + def test_cu_update_description(self): + """update_properties() updates description locally.""" + self._add_cu1() + cu = self.console.storage_control_units.find(name=CU1_NAME) + cu.update_properties({'description': 'updated desc'}) + assert cu.properties['description'] == 'updated desc' + + def test_cu_update_name(self): + """update_properties() with name updates name-URI cache.""" + self._add_cu1() + cu = self.console.storage_control_units.find(name=CU1_NAME) + cu.update_properties({'name': 'New CU Name'}) + assert cu.properties['name'] == 'New CU Name' + found = self.console.storage_control_units.find(name='New CU Name') + assert found.uri == cu.uri + + # ── Handler: GET /api/storage-control-units ──────────────────────────── + + def test_list_handler_global(self): + """GET /api/storage-control-units returns all CUs.""" + self._add_cu1() + self._add_cu2() + result = self.session.get('/api/storage-control-units') + assert 'storage-control-units' in result + assert len(result['storage-control-units']) == 2 + + def test_list_handler_filter_name(self): + """GET /api/storage-control-units?name=... filters correctly.""" + self._add_cu1() + self._add_cu2() + result = self.session.get( + f'/api/storage-control-units?name={CU1_NAME}') + assert len(result['storage-control-units']) == 1 + assert result['storage-control-units'][0]['name'] == CU1_NAME + + # ── Handler: GET /api/storage-subsystems/{id}/storage-control-units ──── + + def test_list_by_subsystem(self): + """Listing CUs by subsystem scopes results correctly.""" + self._add_cu1() + self._add_cu2() + # Add a CU for a different subsystem + self.faked_console.storage_control_units.add({ + 'name': 'Other CU', 'logical-address': 'ff', + 'parent': SUBSYS2_URI, + }) + result = self.session.get( + f'/api/storage-subsystems/{SUBSYS1_OID}/storage-control-units') + assert 'storage-control-units' in result + returned_names = {cu['name'] for cu in result['storage-control-units']} + assert returned_names == {CU1_NAME, CU2_NAME} + + def test_list_by_subsys_not_found(self): + """Listing CUs for a non-existent subsystem returns 404.""" + with pytest.raises(HTTPError) as exc_info: + self.session.get( + '/api/storage-subsystems/no-such/storage-control-units') + assert exc_info.value.http_status == 404 + + # ── Handler: POST define-storage-control-unit ────────────────────────── + + def test_define_cu_handler(self): + """Define creates a CU and returns its URI.""" + result = self.session.post( + f'/api/storage-subsystems/{SUBSYS1_OID}/operations/' + 'define-storage-control-unit', + body={'logical-address': '50'}) + assert 'object-uri' in result + assert '/api/storage-control-units/' in result['object-uri'] + + def test_define_cu_registers_in_subsys(self): + """Define updates parent subsystem's storage-control-unit-uris.""" + result = self.session.post( + f'/api/storage-subsystems/{SUBSYS1_OID}/operations/' + 'define-storage-control-unit', + body={'logical-address': '51'}) + cu_uri = result['object-uri'] + cu_uris = self.faked_subsys1.properties.get( + 'storage-control-unit-uris', []) + assert cu_uri in cu_uris + + def test_define_cu_default_name(self): + """Define sets default name to 'Control unit {addr}'.""" + self.session.post( + f'/api/storage-subsystems/{SUBSYS1_OID}/operations/' + 'define-storage-control-unit', + body={'logical-address': '55'}) + cus = self.console.storage_control_units.list( + filter_args={'logical-address': '55'}) + assert len(cus) == 1 + assert cus[0].properties['name'] == 'Control unit 55' + + def test_define_cu_custom_name(self): + """Define with explicit name uses that name.""" + self.session.post( + f'/api/storage-subsystems/{SUBSYS1_OID}/operations/' + 'define-storage-control-unit', + body={'logical-address': '56', 'name': 'My CU'}) + cus = self.console.storage_control_units.list( + filter_args={'name': 'My CU'}) + assert len(cus) == 1 + + def test_define_cu_missing_logical_addr(self): + """Define without logical-address returns 400.""" + with pytest.raises(HTTPError) as exc_info: + self.session.post( + f'/api/storage-subsystems/{SUBSYS1_OID}/operations/' + 'define-storage-control-unit', + body={'name': 'Bad CU'}) + assert exc_info.value.http_status == 400 + + def test_define_cu_dup_logical_addr(self): + """Define with duplicate logical-address returns 409/447.""" + self._add_cu1() + with pytest.raises(HTTPError) as exc_info: + self.session.post( + f'/api/storage-subsystems/{SUBSYS1_OID}/operations/' + 'define-storage-control-unit', + body={'logical-address': CU1_ADDR}) + assert exc_info.value.http_status == 409 + assert exc_info.value.reason == 447 + + def test_define_cu_dup_name(self): + """Define with duplicate name returns 400/8.""" + self._add_cu1() + with pytest.raises(HTTPError) as exc_info: + self.session.post( + f'/api/storage-subsystems/{SUBSYS1_OID}/operations/' + 'define-storage-control-unit', + body={'logical-address': 'ff', 'name': CU1_NAME}) + assert exc_info.value.http_status == 400 + assert exc_info.value.reason == 8 + + def test_define_cu_subsys_not_found(self): + """Define on non-existent subsystem returns 404.""" + with pytest.raises(HTTPError) as exc_info: + self.session.post( + '/api/storage-subsystems/no-such/operations/' + 'define-storage-control-unit', + body={'logical-address': '50'}) + assert exc_info.value.http_status == 404 + + # ── Handler: POST undefine ───────────────────────────────────────────── + + def test_undefine_cu_handler(self): + """Undefine removes the CU from the HMC.""" + faked_cu = self._add_cu1() + cu_uri = faked_cu.uri + self.session.post(cu_uri + '/operations/undefine', body=None) + with pytest.raises(HTTPError): + self.session.get(cu_uri) + + def test_undefine_cu_removes_from_subsys(self): + """Undefine removes CU URI from parent subsystem's cu-uris.""" + faked_cu = self._add_cu1() + cu_uri = faked_cu.uri + self.session.post(cu_uri + '/operations/undefine', body=None) + cu_uris = self.faked_subsys1.properties.get( + 'storage-control-unit-uris', []) + assert cu_uri not in cu_uris + + def test_undefine_cu_removes_paths(self): + """Undefine also removes all child storage paths.""" + faked_cu = self._add_cu1() + # Add a storage path manually + faked_cu.storage_paths.add({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + }) + assert len(faked_cu.storage_paths.list(None)) == 1 + self.session.post(faked_cu.uri + '/operations/undefine', body=None) + # After undefine, CU is gone; verify via manager list + result = self.console.storage_control_units.list() + assert all(cu.uri != faked_cu.uri for cu in result) + + def test_undefine_cu_not_found(self): + """Undefine on non-existent CU returns 404.""" + with pytest.raises(HTTPError) as exc_info: + self.session.post( + '/api/storage-control-units/no-such/operations/undefine', + body=None) + assert exc_info.value.http_status == 404 + + # ── StorageControlUnit.undefine() client method ──────────────────────── + + def test_cu_undefine_method(self): + """StorageControlUnit.undefine() removes the resource.""" + self._add_cu1() + cu = self.console.storage_control_units.find(name=CU1_NAME) + cu.undefine() + result = self.console.storage_control_units.list() + assert all(c.uri != cu.uri for c in result) + + # ── Volume ranges ────────────────────────────────────────────────────── + + def test_add_volume_range(self): + """add_volume_range() appends a range to volume-ranges.""" + self._add_cu1() + cu = self.console.storage_control_units.find(name=CU1_NAME) + cu.add_volume_range('00', '0f', 'base') + cu.pull_full_properties() + vr = cu.properties.get('volume-ranges', []) + assert len(vr) == 1 + assert vr[0]['starting-volume'] == '00' + assert vr[0]['ending-volume'] == '0f' + assert vr[0]['type'] == 'base' + + def test_add_volume_range_default_ending(self): + """add_volume_range() defaults ending-volume to starting-volume.""" + self._add_cu1() + cu = self.console.storage_control_units.find(name=CU1_NAME) + cu.add_volume_range('05') + cu.pull_full_properties() + vr = cu.properties.get('volume-ranges', []) + assert len(vr) == 1 + assert vr[0]['ending-volume'] == '05' + + def test_add_volume_range_handler(self): + """Handler for add-volume-range appends to volume-ranges.""" + faked_cu = self._add_cu1() + self.session.post( + faked_cu.uri + '/operations/add-volume-range', + body={'starting-volume': 'a0', 'ending-volume': 'af', + 'type': 'alias'}) + vr = faked_cu.properties.get('volume-ranges', []) + assert len(vr) == 1 + assert vr[0]['type'] == 'alias' + + def test_add_volume_range_missing_field(self): + """Handler for add-volume-range without starting-volume returns 400.""" + faked_cu = self._add_cu1() + with pytest.raises(HTTPError) as exc_info: + self.session.post( + faked_cu.uri + '/operations/add-volume-range', + body={'ending-volume': '0f'}) + assert exc_info.value.http_status == 400 + + def test_remove_volume_range(self): + """remove_volume_range() removes the matching range.""" + self._add_cu1() + cu = self.console.storage_control_units.find(name=CU1_NAME) + cu.add_volume_range('00', '0f', 'base') + cu.remove_volume_range('00', '0f', 'base') + cu.pull_full_properties() + vr = cu.properties.get('volume-ranges', []) + assert len(vr) == 0 + + def test_remove_volume_range_not_found(self): + """Handler for remove-volume-range on missing range returns 409.""" + faked_cu = self._add_cu1() + with pytest.raises(HTTPError) as exc_info: + self.session.post( + faked_cu.uri + '/operations/remove-volume-range', + body={'starting-volume': 'ff'}) + assert exc_info.value.http_status == 409 + + # ── Storage paths ────────────────────────────────────────────────────── + + def test_path_manager_initial(self): + """StoragePath manager is accessible and empty initially.""" + self._add_cu1() + cu = self.console.storage_control_units.find(name=CU1_NAME) + paths = cu.storage_paths.list() + assert paths == [] + + def test_create_path(self): + """StoragePathManager.create() creates a path and returns it.""" + self._add_cu1() + cu = self.console.storage_control_units.find(name=CU1_NAME) + path = cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + assert isinstance(path, zhmcclient.StoragePath) + assert path.uri.startswith(cu.uri + '/storage-paths/') + + def test_create_path_handler(self): + """POST to storage-paths creates a path and updates cu uris.""" + faked_cu = self._add_cu1() + result = self.session.post( + faked_cu.uri + '/storage-paths', + body={'adapter-port-uri': ADAPTER_PORT_URI}) + assert 'element-uri' in result + path_uris = faked_cu.properties.get('storage-path-uris', []) + assert result['element-uri'] in path_uris + + def test_create_path_registers_in_cu(self): + """FakedStoragePathManager.add() registers URI in cu's path-uris.""" + faked_cu = self._add_cu1() + faked_path = faked_cu.storage_paths.add({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + }) + path_uris = faked_cu.properties.get('storage-path-uris', []) + assert faked_path.uri in path_uris + + def test_create_path_with_switch(self): + """Creating a path with exit-switch-uri and exit-port succeeds.""" + faked_cu = self._add_cu1() + result = self.session.post( + faked_cu.uri + '/storage-paths', + body={ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': SWITCH1_URI, + 'exit-port': '00', + }) + assert 'element-uri' in result + + def test_path_coreq_missing_port(self): + """exit-switch-uri without exit-port returns 400/442.""" + faked_cu = self._add_cu1() + with pytest.raises(HTTPError) as exc_info: + self.session.post( + faked_cu.uri + '/storage-paths', + body={ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': SWITCH1_URI, + }) + assert exc_info.value.http_status == 400 + assert exc_info.value.reason == 442 + + def test_path_coreq_missing_switch(self): + """exit-port without exit-switch-uri returns 400/442.""" + faked_cu = self._add_cu1() + with pytest.raises(HTTPError) as exc_info: + self.session.post( + faked_cu.uri + '/storage-paths', + body={ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-port': '00', + }) + assert exc_info.value.http_status == 400 + assert exc_info.value.reason == 442 + + def test_create_path_adapter_not_found(self): + """Creating a path with unknown adapter-port-uri returns 404/2.""" + faked_cu = self._add_cu1() + with pytest.raises(HTTPError) as exc_info: + self.session.post( + faked_cu.uri + '/storage-paths', + body={'adapter-port-uri': '/api/adapters/no/storage-ports/x'}) + assert exc_info.value.http_status == 404 + assert exc_info.value.reason == 2 + + def test_create_path_missing_required_field(self): + """Creating a path without adapter-port-uri returns 400.""" + faked_cu = self._add_cu1() + with pytest.raises(HTTPError) as exc_info: + self.session.post( + faked_cu.uri + '/storage-paths', + body={'exit-port': '00'}) + assert exc_info.value.http_status == 400 + + def test_create_path_cu_not_found(self): + """Creating a path for non-existent CU returns 404.""" + with pytest.raises(HTTPError) as exc_info: + self.session.post( + '/api/storage-control-units/no-such/storage-paths', + body={'adapter-port-uri': ADAPTER_PORT_URI}) + assert exc_info.value.http_status == 404 + + def test_create_path_max_exceeded(self): + """Creating more than 8 paths for one CU returns 409/486.""" + faked_cu = self._add_cu1() + # Add 8 paths directly to the faked CU to reach the maximum + for i in range(8): + faked_cu.storage_paths.add({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + '_idx': i, # distinguish them + }) + with pytest.raises(HTTPError) as exc_info: + self.session.post( + faked_cu.uri + '/storage-paths', + body={'adapter-port-uri': ADAPTER_PORT_URI}) + assert exc_info.value.http_status == 409 + assert exc_info.value.reason == 486 + + def test_create_path_duplicate(self): + """Creating a duplicate path returns 400/8.""" + faked_cu = self._add_cu1() + # Create first path + self.session.post( + faked_cu.uri + '/storage-paths', + body={'adapter-port-uri': ADAPTER_PORT_URI}) + # Try to create an identical path + with pytest.raises(HTTPError) as exc_info: + self.session.post( + faked_cu.uri + '/storage-paths', + body={'adapter-port-uri': ADAPTER_PORT_URI}) + assert exc_info.value.http_status == 400 + assert exc_info.value.reason == 8 + + def test_list_paths_handler(self): + """GET storage-paths lists all paths of the CU.""" + faked_cu = self._add_cu1() + faked_cu.storage_paths.add({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + }) + result = self.session.get(faked_cu.uri + '/storage-paths') + assert 'storage-paths' in result + assert len(result['storage-paths']) == 1 + + def test_get_path_properties(self): + """GET on a single storage path returns its properties.""" + faked_cu = self._add_cu1() + result = self.session.post( + faked_cu.uri + '/storage-paths', + body={'adapter-port-uri': ADAPTER_PORT_URI}) + path_uri = result['element-uri'] + props = self.session.get(path_uri) + assert props['adapter-port-uri'] == ADAPTER_PORT_URI + + def test_update_path_properties(self): + """POST on a storage path updates its properties.""" + faked_cu = self._add_cu1() + result = self.session.post( + faked_cu.uri + '/storage-paths', + body={'adapter-port-uri': ADAPTER_PORT_URI}) + path_uri = result['element-uri'] + self.session.post(path_uri, + body={'exit-switch-uri': SWITCH1_URI, + 'exit-port': '00'}) + props = self.session.get(path_uri) + assert props['exit-switch-uri'] == SWITCH1_URI + assert props['exit-port'] == '00' + + def test_delete_path_handler(self): + """DELETE on a storage path removes it.""" + faked_cu = self._add_cu1() + result = self.session.post( + faked_cu.uri + '/storage-paths', + body={'adapter-port-uri': ADAPTER_PORT_URI}) + path_uri = result['element-uri'] + self.session.delete(path_uri) + with pytest.raises(HTTPError): + self.session.get(path_uri) + + def test_delete_path_removes_from_cu_uris(self): + """Deleting a path removes its URI from cu's storage-path-uris.""" + faked_cu = self._add_cu1() + result = self.session.post( + faked_cu.uri + '/storage-paths', + body={'adapter-port-uri': ADAPTER_PORT_URI}) + path_uri = result['element-uri'] + self.session.delete(path_uri) + path_uris = faked_cu.properties.get('storage-path-uris', []) + assert path_uri not in path_uris + + def test_delete_path_not_found(self): + """DELETE on non-existent path returns 404.""" + faked_cu = self._add_cu1() + with pytest.raises(HTTPError) as exc_info: + self.session.delete(faked_cu.uri + '/storage-paths/no-such') + assert exc_info.value.http_status == 404 + + def test_storagepath_delete_method(self): + """StoragePath.delete() method removes the path resource.""" + self._add_cu1() + cu = self.console.storage_control_units.find(name=CU1_NAME) + path = cu.storage_paths.create( + {'adapter-port-uri': ADAPTER_PORT_URI}) + path.delete() + result = self.session.get(cu.uri + '/storage-paths') + assert len(result['storage-paths']) == 0 + + def test_storagepath_update_method(self): + """StoragePath.update_properties() updates properties locally.""" + self._add_cu1() + cu = self.console.storage_control_units.find(name=CU1_NAME) + path = cu.storage_paths.create( + {'adapter-port-uri': ADAPTER_PORT_URI}) + path.update_properties( + {'exit-switch-uri': SWITCH1_URI, 'exit-port': '01'}) + assert path.properties['exit-switch-uri'] == SWITCH1_URI + assert path.properties['exit-port'] == '01' + + # ── Dump ─────────────────────────────────────────────────────────────── + + def test_cu_dump(self): + """StorageControlUnit.dump() returns a dict with properties.""" + self._add_cu1() + cu = self.console.storage_control_units.find(name=CU1_NAME) + cu.pull_full_properties() + result = cu.dump() + assert isinstance(result, dict) + assert 'properties' in result + + def test_path_dump(self): + """StoragePath.dump() returns a dict with properties.""" + self._add_cu1() + cu = self.console.storage_control_units.find(name=CU1_NAME) + path = cu.storage_paths.create( + {'adapter-port-uri': ADAPTER_PORT_URI}) + path.pull_full_properties() + result = path.dump() + assert isinstance(result, dict) + assert 'properties' in result + + # ── Undefine cascade ─────────────────────────────────────────────────── + + def test_undefine_cascades_to_paths(self): + """Undefining a CU also removes its storage paths from HMC.""" + faked_cu = self._add_cu1() + path_result = self.session.post( + faked_cu.uri + '/storage-paths', + body={'adapter-port-uri': ADAPTER_PORT_URI}) + path_uri = path_result['element-uri'] + # Undefine the CU + self.session.post(faked_cu.uri + '/operations/undefine', body=None) + # Both the CU and the path should now be gone + with pytest.raises(HTTPError): + self.session.get(faked_cu.uri) + with pytest.raises(HTTPError): + self.session.get(path_uri) + + # ── add_resources() schema wiring ────────────────────────────────────── + + def test_add_resources_wiring(self): + """add_resources() correctly wires storage_control_units on console.""" + self.session.hmc.consoles.console.add_resources({ + 'storage_control_units': [ + { + 'properties': { + 'name': 'CU via add_resources', + 'logical-address': 'aa', + 'parent': SUBSYS1_URI, + } + } + ] + }) + cus = self.console.storage_control_units.list( + filter_args={'name': 'CU via add_resources'}) + assert len(cus) == 1 + + # ── Inventory ────────────────────────────────────────────────────────── + + def test_inventory_storage_control_unit_empty(self): # pylint: disable=invalid-name # noqa: E501 + """get_inventory(['storage-control-unit']) returns empty when none + exist.""" + result = self.client.get_inventory(['storage-control-unit']) + names = [r['name'] for r in result + if r.get('class') == 'storage-control-unit'] + assert names == [] + + def test_inventory_storage_control_unit_two(self): + """get_inventory(['storage-control-unit']) returns both CUs.""" + self._add_cu1() + self._add_cu2() + result = self.client.get_inventory(['storage-control-unit']) + names = {r['name'] for r in result + if r.get('class') == 'storage-control-unit'} + assert names == {CU1_NAME, CU2_NAME} + + def test_inventory_dpm_resources_includes_cu(self): + """get_inventory(['dpm-resources']) includes storage-control-unit + entries.""" + self._add_cu1() + result = self.client.get_inventory(['dpm-resources']) + classes = {r.get('class') for r in result} + assert 'storage-control-unit' in classes diff --git a/tests/unit/zhmcclient/test_storage_path.py b/tests/unit/zhmcclient/test_storage_path.py new file mode 100644 index 00000000..cb4726fd --- /dev/null +++ b/tests/unit/zhmcclient/test_storage_path.py @@ -0,0 +1,533 @@ +# Copyright 2026 IBM Corp. 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. + +""" +Unit tests for the StoragePath and StoragePathManager classes using the +faked HMC. +""" + +import pytest + +import zhmcclient +from zhmcclient.mock import FakedSession +from zhmcclient import Client, HTTPError + +# ── Fixture constants ───────────────────────────────────────────────────────── + +CPC_OID = 'fake-cpc1-oid' + +SSITE1_OID = 'site1-oid' +SSITE1_URI = f'/api/storage-sites/{SSITE1_OID}' + +SUBSYS1_OID = 'subsys1-oid' +SUBSYS1_URI = f'/api/storage-subsystems/{SUBSYS1_OID}' +SUBSYS1_NAME = 'DS8886 A' + +ADAPTER_OID = 'adap1-oid' +ADAPTER_PORT_OID = 'port1-oid' +ADAPTER_PORT_URI = ( + f'/api/adapters/{ADAPTER_OID}/storage-ports/{ADAPTER_PORT_OID}') + +ADAPTER2_OID = 'adap2-oid' +ADAPTER2_PORT_OID = 'port2-oid' +ADAPTER2_PORT_URI = ( + f'/api/adapters/{ADAPTER2_OID}/storage-ports/{ADAPTER2_PORT_OID}') + +SWITCH1_OID = 'sw1-oid' +SWITCH1_URI = f'/api/storage-switches/{SWITCH1_OID}' + +CU1_OID = 'cu1-oid' +CU1_NAME = 'Control unit 50' +CU1_ADDR = '50' + + +class TestStoragePath: + """Unit tests for StoragePath and StoragePathManager.""" + + def setup_method(self): + # pylint: disable=attribute-defined-outside-init + """Set up faked session, HMC resources, and a single control unit.""" + self.session = FakedSession('fake-host', 'fake-hmc', '2.16.0', '4.10') + self.client = Client(self.session) + + # CPC + self.session.hmc.cpcs.add({ + 'object-id': CPC_OID, + 'parent': None, + 'class': 'cpc', + 'name': 'CPC1', + 'dpm-enabled': True, + }) + + # Console + self.faked_console = self.session.hmc.consoles.add({'name': 'HMC1'}) + self.console = self.client.consoles.console + + # Storage infrastructure + self.faked_console.storage_sites.add({ + 'object-id': SSITE1_OID, + 'name': 'Primary Site', + }) + self.faked_console.storage_subsystems.add({ + 'object-id': SUBSYS1_OID, + 'name': SUBSYS1_NAME, + 'storage-site-uri': SSITE1_URI, + }) + + # Primary adapter with one storage port + faked_cpc = self.session.hmc.cpcs.lookup_by_oid(CPC_OID) + faked_adapter = faked_cpc.adapters.add({ + 'object-id': ADAPTER_OID, + 'name': 'Adapter1', + 'adapter-family': 'ficon', + 'type': 'fcp', + 'storage-port-uris': [], + }) + faked_adapter.ports.add({ + 'element-id': ADAPTER_PORT_OID, + 'name': 'Port0', + }) + + # Second adapter/port for multi-path tests + faked_adapter2 = faked_cpc.adapters.add({ + 'object-id': ADAPTER2_OID, + 'name': 'Adapter2', + 'adapter-family': 'ficon', + 'type': 'fcp', + 'storage-port-uris': [], + }) + faked_adapter2.ports.add({ + 'element-id': ADAPTER2_PORT_OID, + 'name': 'Port0', + }) + + # Storage switch + self.faked_console.storage_switches.add({ + 'object-id': SWITCH1_OID, + 'name': 'SW1', + 'domain-id': '1', + 'storage-site-uri': SSITE1_URI, + 'storage-fabric-uri': '/api/storage-fabrics/fab1', + }) + + # Control unit + self.faked_cu = self.faked_console.storage_control_units.add({ + 'object-id': CU1_OID, + 'name': CU1_NAME, + 'logical-address': CU1_ADDR, + 'parent': SUBSYS1_URI, + }) + + self.cu = self.console.storage_control_units.find(name=CU1_NAME) + + # ── StoragePathManager attributes ───────────────────────────────────────── + + def test_manager_type(self): + """storage_paths attribute returns a StoragePathManager.""" + assert isinstance(self.cu.storage_paths, + zhmcclient.StoragePathManager) + + def test_manager_storage_control_unit(self): + """StoragePathManager.storage_control_unit points to the parent CU.""" + assert self.cu.storage_paths.storage_control_unit is self.cu + + def test_manager_session(self): + """StoragePathManager.session is the same session used by the client.""" + assert self.cu.storage_paths.session is self.session + + # ── list() ──────────────────────────────────────────────────────────────── + + def test_list_empty(self): + """list() returns [] when no paths exist.""" + assert self.cu.storage_paths.list() == [] + + def test_list_one(self): + """list() returns one StoragePath after creating one.""" + self.faked_cu.storage_paths.add({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + }) + result = self.cu.storage_paths.list() + assert len(result) == 1 + assert isinstance(result[0], zhmcclient.StoragePath) + + def test_list_two(self): + """list() returns both paths after adding two.""" + self.faked_cu.storage_paths.add({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + }) + self.faked_cu.storage_paths.add({ + 'adapter-port-uri': ADAPTER2_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + }) + result = self.cu.storage_paths.list() + assert len(result) == 2 + + def test_list_full_properties(self): + """list(full_properties=True) returns paths with all properties.""" + self.faked_cu.storage_paths.add({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + }) + result = self.cu.storage_paths.list(full_properties=True) + assert len(result) == 1 + assert 'adapter-port-uri' in result[0].properties + + # ── create() ────────────────────────────────────────────────────────────── + + def test_create_returns_storage_path(self): + """create() returns a StoragePath object.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + assert isinstance(path, zhmcclient.StoragePath) + + def test_create_uri_is_under_cu(self): + """Created path URI is prefixed with the CU URI.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + assert path.uri.startswith(self.cu.uri + '/storage-paths/') + + def test_create_registers_in_cu_path_uris(self): + """create() causes path URI to appear in parent CU's + storage-path-uris.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + self.cu.pull_full_properties() + assert path.uri in self.cu.properties['storage-path-uris'] + + def test_create_with_switch(self): + """create() with exit-switch-uri and exit-port succeeds.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': SWITCH1_URI, + 'exit-port': '00', + }) + assert isinstance(path, zhmcclient.StoragePath) + + def test_create_two_paths_different_ports(self): + """Two paths with different adapter-port-uris can coexist.""" + p1 = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + p2 = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER2_PORT_URI, + }) + assert p1.uri != p2.uri + assert len(self.cu.storage_paths.list()) == 2 + + def test_create_missing_required_field_raises(self): # pylint: disable=invalid-name # noqa: E501 + """create() without adapter-port-uri raises HTTPError 400.""" + with pytest.raises(HTTPError) as exc_info: + self.cu.storage_paths.create({'exit-port': '00'}) + assert exc_info.value.http_status == 400 + + def test_create_unknown_adapter_port_raises(self): + """create() with unknown adapter-port-uri raises HTTPError 404.""" + with pytest.raises(HTTPError) as exc_info: + self.cu.storage_paths.create({ + 'adapter-port-uri': '/api/adapters/no/storage-ports/x', + }) + assert exc_info.value.http_status == 404 + assert exc_info.value.reason == 2 + + def test_create_switch_without_port_raises(self): + """exit-switch-uri without exit-port raises HTTPError 400/442.""" + with pytest.raises(HTTPError) as exc_info: + self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': SWITCH1_URI, + }) + assert exc_info.value.http_status == 400 + assert exc_info.value.reason == 442 + + def test_create_port_without_switch_raises(self): + """exit-port without exit-switch-uri raises HTTPError 400/442.""" + with pytest.raises(HTTPError) as exc_info: + self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-port': '00', + }) + assert exc_info.value.http_status == 400 + assert exc_info.value.reason == 442 + + def test_create_duplicate_path_raises(self): + """Creating an identical path twice raises HTTPError 400/8.""" + self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + with pytest.raises(HTTPError) as exc_info: + self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + assert exc_info.value.http_status == 400 + assert exc_info.value.reason == 8 + + def test_create_max_paths_exceeded_raises(self): + """Creating a 9th path raises HTTPError 409/486.""" + for i in range(8): + self.faked_cu.storage_paths.add({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + '_idx': i, + }) + with pytest.raises(HTTPError) as exc_info: + self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + assert exc_info.value.http_status == 409 + assert exc_info.value.reason == 486 + + # ── StoragePathManager.delete() ─────────────────────────────────────────── + + def test_manager_delete(self): + """StoragePathManager.delete() removes the path by element-id.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + element_id = path.uri.split('/')[-1] + self.cu.storage_paths.delete(element_id) + assert self.cu.storage_paths.list() == [] + + def test_manager_delete_removes_from_path_uris(self): # pylint: disable=invalid-name # noqa: E501 + """StoragePathManager.delete() deregisters URI from parent CU.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + element_id = path.uri.split('/')[-1] + self.cu.storage_paths.delete(element_id) + self.cu.pull_full_properties() + assert path.uri not in self.cu.properties['storage-path-uris'] + + def test_manager_delete_not_found_raises(self): + """StoragePathManager.delete() with non-existent id raises + HTTPError 404.""" + with pytest.raises(HTTPError) as exc_info: + self.cu.storage_paths.delete('no-such-element-id') + assert exc_info.value.http_status == 404 + + # ── StoragePath.update_properties() ────────────────────────────────────── + + def test_update_properties(self): + """update_properties() changes properties on the HMC and locally.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + path.update_properties({ + 'exit-switch-uri': SWITCH1_URI, + 'exit-port': '01', + }) + assert path.properties['exit-switch-uri'] == SWITCH1_URI + assert path.properties['exit-port'] == '01' + # Verify persisted on the HMC + path.pull_full_properties() + assert path.properties['exit-switch-uri'] == SWITCH1_URI + assert path.properties['exit-port'] == '01' + + def test_update_properties_adapter_port_uri(self): + """update_properties() can change adapter-port-uri.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + path.update_properties({'adapter-port-uri': ADAPTER2_PORT_URI}) + assert path.properties['adapter-port-uri'] == ADAPTER2_PORT_URI + + # ── StoragePath.delete() ────────────────────────────────────────────────── + + def test_delete(self): + """StoragePath.delete() removes the resource and it is no longer + listed.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + path.delete() + assert self.cu.storage_paths.list() == [] + + def test_delete_removes_from_cu_path_uris(self): + """StoragePath.delete() deregisters path URI from parent CU.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + path_uri = path.uri + path.delete() + self.cu.pull_full_properties() + assert path_uri not in self.cu.properties['storage-path-uris'] + + def test_delete_not_found_raises(self): + """DELETE on a non-existent path URI raises HTTPError 404.""" + with pytest.raises(HTTPError) as exc_info: + self.session.delete(self.cu.uri + '/storage-paths/no-such') + assert exc_info.value.http_status == 404 + + # ── StoragePath.pull_full_properties() ─────────────────────────────────── + + def test_pull_full_properties(self): + """pull_full_properties() populates all known path properties.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + path.pull_full_properties() + assert 'adapter-port-uri' in path.properties + assert 'element-uri' in path.properties + assert 'class' in path.properties + + def test_class_property_value(self): + """Path 'class' property is 'storage-path'.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + path.pull_full_properties() + assert path.properties['class'] == 'storage-path' + + # ── StoragePath.dump() ─────────────────────────────────────────────────── + + def test_dump_returns_dict_with_properties(self): + """dump() returns a dict containing a 'properties' key.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + path.pull_full_properties() + result = path.dump() + assert isinstance(result, dict) + assert 'properties' in result + + def test_dump_properties_contain_adapter_port_uri(self): # pylint: disable=invalid-name # noqa: E501 + """dump() properties include adapter-port-uri.""" + path = self.cu.storage_paths.create({ + 'adapter-port-uri': ADAPTER_PORT_URI, + }) + path.pull_full_properties() + result = path.dump() + assert result['properties'].get('adapter-port-uri') == ADAPTER_PORT_URI + + # ── Faked-layer direct add/remove ───────────────────────────────────────── + + def test_faked_add_registers_uri_in_cu(self): + """FakedStoragePathManager.add() registers path URI in parent CU.""" + faked_path = self.faked_cu.storage_paths.add({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + }) + path_uris = self.faked_cu.properties.get('storage-path-uris', []) + assert faked_path.uri in path_uris + + def test_faked_remove_deregisters_uri_from_cu(self): # pylint: disable=invalid-name # noqa: E501 + """FakedStoragePathManager.remove() deregisters path URI from + parent CU.""" + faked_path = self.faked_cu.storage_paths.add({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + }) + path_uri = faked_path.uri + self.faked_cu.storage_paths.remove(faked_path.oid) + path_uris = self.faked_cu.properties.get('storage-path-uris', []) + assert path_uri not in path_uris + + # ── GET handler ────────────────────────────────────────────────────────── + + def test_get_path_properties_via_session(self): + """GET on a single path URI returns its properties.""" + result = self.session.post( + self.faked_cu.uri + '/storage-paths', + body={'adapter-port-uri': ADAPTER_PORT_URI}) + path_uri = result['element-uri'] + props = self.session.get(path_uri) + assert props['adapter-port-uri'] == ADAPTER_PORT_URI + + def test_list_handler_returns_element_uris(self): + """GET /storage-paths returns list with element-uri entries.""" + self.faked_cu.storage_paths.add({ + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + }) + result = self.session.get(self.faked_cu.uri + '/storage-paths') + assert 'storage-paths' in result + assert len(result['storage-paths']) == 1 + assert 'element-uri' in result['storage-paths'][0] + + def test_list_handler_cu_not_found_raises(self): + """GET /storage-paths for non-existent CU raises HTTPError 404.""" + with pytest.raises(HTTPError) as exc_info: + self.session.get( + '/api/storage-control-units/no-such/storage-paths') + assert exc_info.value.http_status == 404 + + # ── add_resources() schema wiring ───────────────────────────────────────── + + def test_add_resources_with_storage_paths(self): + """add_resources() accepts storage_paths as children of a CU.""" + self.faked_console.add_resources({ + 'storage_control_units': [ + { + 'properties': { + 'name': 'CU via add_resources', + 'logical-address': 'aa', + 'parent': SUBSYS1_URI, + }, + 'storage_paths': [ + { + 'properties': { + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + } + } + ], + } + ] + }) + cu = self.console.storage_control_units.find( + name='CU via add_resources') + paths = cu.storage_paths.list() + assert len(paths) == 1 + + def test_add_resources_path_registered_in_cu(self): + """add_resources() wired path URI appears in CU's storage-path-uris.""" + self.faked_console.add_resources({ + 'storage_control_units': [ + { + 'properties': { + 'name': 'CU wiring test', + 'logical-address': 'bb', + 'parent': SUBSYS1_URI, + }, + 'storage_paths': [ + { + 'properties': { + 'adapter-port-uri': ADAPTER_PORT_URI, + 'exit-switch-uri': None, + 'exit-port': None, + } + } + ], + } + ] + }) + cu = self.console.storage_control_units.find(name='CU wiring test') + cu.pull_full_properties() + path_uris = cu.properties.get('storage-path-uris', []) + assert len(path_uris) == 1 + paths = cu.storage_paths.list(full_properties=True) + assert paths[0].uri == path_uris[0] diff --git a/tests/unit/zhmcclient/test_storage_subsystem.py b/tests/unit/zhmcclient/test_storage_subsystem.py index 592a5702..1c18a936 100644 --- a/tests/unit/zhmcclient/test_storage_subsystem.py +++ b/tests/unit/zhmcclient/test_storage_subsystem.py @@ -1105,3 +1105,30 @@ def test_ss_add_sw_ep_dup_port_conflict(self): ) assert exc_info.value.http_status == 409 assert exc_info.value.reason == 443 + + # ── Inventory ────────────────────────────────────────────────────────── + + def test_inventory_storage_subsystem_empty(self): + """get_inventory(['storage-subsystem']) returns empty when none + exist.""" + result = self.client.get_inventory(['storage-subsystem']) + names = [r['name'] for r in result + if r.get('class') == 'storage-subsystem'] + assert names == [] + + def test_inventory_storage_subsystem_two(self): + """get_inventory(['storage-subsystem']) returns both subsystems.""" + self.add_subsystem1() + self.add_subsystem2() + result = self.client.get_inventory(['storage-subsystem']) + names = {r['name'] for r in result + if r.get('class') == 'storage-subsystem'} + assert names == {SUBSYS1_NAME, SUBSYS2_NAME} + + def test_inventory_dpm_resources_includes_subsystem(self): # pylint: disable=invalid-name # noqa: E501 + """get_inventory(['dpm-resources']) includes storage-subsystem + entries.""" + self.add_subsystem1() + result = self.client.get_inventory(['dpm-resources']) + classes = {r.get('class') for r in result} + assert 'storage-subsystem' in classes diff --git a/zhmcclient/__init__.py b/zhmcclient/__init__.py index 44acf41a..3f7c9367 100644 --- a/zhmcclient/__init__.py +++ b/zhmcclient/__init__.py @@ -66,6 +66,8 @@ from ._storage_site import * # noqa: F401 from ._storage_switch import * # noqa: F401 from ._storage_subsystem import * # noqa: F401 +from ._storage_control_unit import * # noqa: F401 +from ._storage_path import * # noqa: F401 from ._partition_link import * # noqa: F401 from ._capacity_group import * # noqa: F401 from ._certificates import * # noqa: F401 diff --git a/zhmcclient/_console.py b/zhmcclient/_console.py index df920ba1..af01ed34 100644 --- a/zhmcclient/_console.py +++ b/zhmcclient/_console.py @@ -36,6 +36,7 @@ from ._storage_site import StorageSiteManager from ._storage_switch import StorageSwitchManager from ._storage_subsystem import StorageSubsystemManager +from ._storage_control_unit import StorageControlUnitManager from ._user import UserManager from ._user_role import UserRoleManager from ._user_pattern import UserPatternManager @@ -222,6 +223,7 @@ def __init__(self, manager, uri, name=None, properties=None): self._storage_fabrics = None self._storage_switches = None self._storage_subsystems = None + self._storage_control_units = None self._partition_links = None self._storage_sites = None self._users = None @@ -327,6 +329,18 @@ def storage_subsystems(self): self._storage_subsystems = StorageSubsystemManager(self) return self._storage_subsystems + @property + def storage_control_units(self): + """ + :class:`~zhmcclient.StorageControlUnitManager`: + Manager object for the Storage Control Units in scope of this + Console. + """ + # We do here some lazy loading. + if not self._storage_control_units: + self._storage_control_units = StorageControlUnitManager(self) + return self._storage_control_units + @property def partition_links(self): """ diff --git a/zhmcclient/_storage_control_unit.py b/zhmcclient/_storage_control_unit.py new file mode 100644 index 00000000..9f402dae --- /dev/null +++ b/zhmcclient/_storage_control_unit.py @@ -0,0 +1,382 @@ +# Copyright 2026 IBM Corp. 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. + +""" +A :term:`Storage Control Unit` represents a single storage control unit in +the FICON storage configuration associated with a DPM-enabled CPC. + +A storage control unit belongs to exactly one +:term:`storage subsystem ` and is identified within that +subsystem by its ``logical-address`` property. Each storage control unit +can have up to 8 :term:`storage paths ` and a set of volume +ranges that describe the storage volumes it manages. + +The :term:`Storage Control Unit` resources are accessible via the +:attr:`~zhmcclient.Console.storage_control_units` property of the +:class:`~zhmcclient.Console` object. +""" + + +import copy + +from ._manager import BaseManager +from ._resource import BaseResource +from ._logging import logged_api_call +from ._utils import RC_STORAGE_CONTROL_UNIT +from ._storage_path import StoragePathManager + + +__all__ = ['StorageControlUnitManager', 'StorageControlUnit'] + + +class StorageControlUnitManager(BaseManager): + """ + Manager providing access to the + :term:`storage control units ` of the HMC. + + Derived from :class:`~zhmcclient.BaseManager`; see there for common + methods and attributes. + + Objects of this class are not directly created by the user; they are + accessible via the following instance variable: + + * :attr:`~zhmcclient.Console.storage_control_units` of a + :class:`~zhmcclient.Console` object. + """ + + def __init__(self, console): + # This function should not go into the docs. + # Parameters: + # console (:class:`~zhmcclient.Console`): + # Console defining the scope for this manager. + + # Resource properties that are supported as filter query parameters. + query_props = [ + 'name', + 'logical-address', + ] + + super().__init__( + resource_class=StorageControlUnit, + class_name=RC_STORAGE_CONTROL_UNIT, + session=console.manager.session, + parent=console, + base_uri='/api/storage-control-units', + oid_prop='object-id', + uri_prop='object-uri', + name_prop='name', + query_props=query_props, + ) + self._console = console + + @property + def console(self): + """ + :class:`~zhmcclient.Console`: The Console object representing the HMC. + """ + return self._console + + @logged_api_call + def list(self, full_properties=False, filter_args=None): + """ + List the storage control units defined in the HMC. + + Storage control units for which the authenticated user does not have + task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks are not included. + + Any resource property may be specified in a filter argument. For + details about filter arguments, see :ref:`Filtering`. + + The listing of resources is handled in an optimized way: + + * If this manager is enabled for :ref:`auto-updating`, a locally + maintained resource list is used (which is automatically updated via + inventory notifications from the HMC) and the provided filter + arguments are applied. + + * Otherwise, if the filter arguments specify the resource name as a + single filter argument with a straight match string (i.e. without + regular expressions), an optimized lookup is performed based on a + locally maintained name-URI cache. + + * Otherwise, the HMC List operation is performed with the subset of the + provided filter arguments that can be handled on the HMC side and the + remaining filter arguments are applied on the client side on the list + result. + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Parameters: + + full_properties (bool): + Controls that the full set of resource properties for each returned + storage control unit is being retrieved, vs. only the following + short set: ``object-uri``, ``name``, and ``logical-address``. + + filter_args (dict): + Filter arguments that narrow the list of returned resources to + those that match the specified filter arguments. For details, see + :ref:`Filtering`. + + `None` causes no filtering to happen. + + Returns: + + : A list of :class:`~zhmcclient.StorageControlUnit` objects. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + :exc:`~zhmcclient.FilterConversionError` + """ + result_prop = 'storage-control-units' + list_uri = self._base_uri + return self._list_with_operation( + list_uri, result_prop, full_properties, filter_args, None + ) + + +class StorageControlUnit(BaseResource): + """ + Representation of a :term:`storage control unit`. + + Derived from :class:`~zhmcclient.BaseResource`; see there for common + methods and attributes. + + Objects of this class are not directly created by the user; they are + returned from creation or list functions on their manager object + (in this case, :class:`~zhmcclient.StorageControlUnitManager`). + """ + + def __init__(self, manager, uri, name=None, properties=None): + # This function should not go into the docs. + # manager (:class:`~zhmcclient.StorageControlUnitManager`): + # Manager object for this resource object. + # uri (string): + # Canonical URI path of the resource. + # name (string): + # Name of the resource. + # properties (dict): + # Properties to be set for this resource object. May be `None` or + # empty. + assert isinstance(manager, StorageControlUnitManager), ( + "StorageControlUnit init: Expected manager type " + f"{StorageControlUnitManager}, got {type(manager)}" + ) + super().__init__(manager, uri, name, properties) + self._storage_paths = None + + @property + def storage_paths(self): + """ + :class:`~zhmcclient.StoragePathManager`: Manager for the storage paths + of this storage control unit. + """ + if self._storage_paths is None: + self._storage_paths = StoragePathManager(self) + return self._storage_paths + + @logged_api_call + def update_properties(self, properties): + """ + Update writeable properties of this storage control unit. + + This calls the "Update Storage Control Unit Properties" operation + (``POST /api/storage-control-units/{id}``). + + This method serializes with other methods that access or change + properties on the same Python object. + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Parameters: + + properties (dict): New values for the properties to be updated. + Properties not to be updated are omitted. + Allowable properties are the writeable properties of the storage + control unit resource defined in the :term:`HMC API` book. + + Writeable properties include: ``name``, ``description``, + ``logical-address``. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + self.manager.session.post(self.uri, resource=self, body=properties) + # pylint: disable=protected-access + is_rename = self.manager._name_prop in properties + if is_rename: + self.manager._name_uri_cache.delete(self.name) + self.update_properties_local(copy.deepcopy(properties)) + if is_rename: + self.manager._name_uri_cache.update(self.name, self.uri) + + @logged_api_call + def undefine(self): + """ + Undefine (delete) this storage control unit. + + This calls the "Undefine Storage Control Unit" operation + (``POST /api/storage-control-units/{id}/operations/undefine``). + + If the storage control unit contains storage paths or volume ranges, + they are removed as well. The control unit's URI is removed from the + parent storage subsystem's ``storage-control-unit-uris`` list property. + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + self.manager.session.post( + uri=self.uri + '/operations/undefine', + resource=self, + body=None, + ) + # pylint: disable=protected-access + self.manager._name_uri_cache.delete( + self.get_properties_local(self.manager._name_prop, None) + ) + self.cease_existence_local() + + @logged_api_call + def add_volume_range(self, starting_volume, ending_volume=None, + volume_type='base'): + """ + Add a volume range to this storage control unit. + + This calls the "Add Volume Range" operation + (``POST /api/storage-control-units/{id}/operations/add-volume-range``). + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Parameters: + + starting_volume (str): A two-character lowercase hexadecimal number + representing the first unit address in the volume range. + + ending_volume (str or None): A two-character lowercase hexadecimal + number representing the last unit address. Defaults to + ``starting_volume`` (a range of one volume). + + volume_type (str): The volume type: ``"base"`` or ``"alias"``. + Default: ``"base"``. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + body = { + 'starting-volume': starting_volume, + 'type': volume_type, + } + if ending_volume is not None: + body['ending-volume'] = ending_volume + self.manager.session.post( + uri=self.uri + '/operations/add-volume-range', + resource=self, + body=body, + ) + + @logged_api_call + def remove_volume_range(self, starting_volume, ending_volume=None, + volume_type='base'): + """ + Remove a volume range from this storage control unit. + + This calls the "Remove Volume Range" operation + (``POST /api/storage-control-units/{id}/operations/ + remove-volume-range``). + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Parameters: + + starting_volume (str): A two-character lowercase hexadecimal number + representing the first unit address of the range to remove. + + ending_volume (str or None): A two-character lowercase hexadecimal + number representing the last unit address. Defaults to + ``starting_volume``. + + volume_type (str): The volume type: ``"base"`` or ``"alias"``. + Default: ``"base"``. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + body = { + 'starting-volume': starting_volume, + 'type': volume_type, + } + if ending_volume is not None: + body['ending-volume'] = ending_volume + self.manager.session.post( + uri=self.uri + '/operations/remove-volume-range', + resource=self, + body=body, + ) + + def dump(self): + """ + Dump this StorageControlUnit resource with its properties as a + resource definition. + + The returned resource definition has the following format:: + + { + # Resource properties: + "properties": {...}, + } + + Returns: + + dict: Resource definition of this resource. + """ + resource_dict = super().dump() + return resource_dict diff --git a/zhmcclient/_storage_path.py b/zhmcclient/_storage_path.py new file mode 100644 index 00000000..7c004e2a --- /dev/null +++ b/zhmcclient/_storage_path.py @@ -0,0 +1,296 @@ +# Copyright 2026 IBM Corp. 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. + +""" +A :term:`Storage Path` is an element object nested inside a +:term:`Storage Control Unit`. It defines a communication path from the +storage control unit to a storage adapter, optionally through one or two +storage switches. + +The :term:`Storage Path` resources are accessible via the +:attr:`~zhmcclient.StorageControlUnit.storage_paths` property of a +:class:`~zhmcclient.StorageControlUnit` object. +""" + + +import copy + +from ._manager import BaseManager +from ._resource import BaseResource +from ._logging import logged_api_call +from ._utils import RC_STORAGE_PATH + + +__all__ = ['StoragePathManager', 'StoragePath'] + + +class StoragePathManager(BaseManager): + """ + Manager providing access to the + :term:`storage paths ` of a storage control unit. + + Derived from :class:`~zhmcclient.BaseManager`; see there for common + methods and attributes. + + Objects of this class are not directly created by the user; they are + accessible via the following instance variable: + + * :attr:`~zhmcclient.StorageControlUnit.storage_paths` of a + :class:`~zhmcclient.StorageControlUnit` object. + """ + + def __init__(self, storage_control_unit): + # This function should not go into the docs. + # Parameters: + # storage_control_unit (:class:`~zhmcclient.StorageControlUnit`): + # Storage control unit defining the scope for this manager. + + super().__init__( + resource_class=StoragePath, + class_name=RC_STORAGE_PATH, + session=storage_control_unit.manager.session, + parent=storage_control_unit, + base_uri=storage_control_unit.uri + '/storage-paths', + oid_prop='element-id', + uri_prop='element-uri', + name_prop='element-uri', + query_props=[], + ) + self._storage_control_unit = storage_control_unit + + @property + def storage_control_unit(self): + """ + :class:`~zhmcclient.StorageControlUnit`: The storage control unit + this manager belongs to. + """ + return self._storage_control_unit + + @logged_api_call + def list(self, full_properties=False, filter_args=None): + """ + List the storage paths of this storage control unit. + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Parameters: + + full_properties (bool): + Controls that the full set of resource properties for each returned + storage path is being retrieved, vs. only the following short set: + ``element-uri``. + + filter_args (dict): + Filter arguments that narrow the list of returned resources to + those that match the specified filter arguments. For details, see + :ref:`Filtering`. + + `None` causes no filtering to happen. + + Returns: + + : A list of :class:`~zhmcclient.StoragePath` objects. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + :exc:`~zhmcclient.FilterConversionError` + """ + result_prop = 'storage-paths' + list_uri = self._base_uri + return self._list_with_operation( + list_uri, result_prop, full_properties, filter_args, None + ) + + @logged_api_call + def create(self, properties): + """ + Create a new storage path in this storage control unit. + + This calls the "Create Storage Path" operation + (``POST /api/storage-control-units/{id}/storage-paths``). + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Parameters: + + properties (dict): Initial property values. + Allowable properties are defined in section 'Request body + contents' in section 'Create Storage Path' in the + :term:`HMC API` book. + + Required fields: ``adapter-port-uri``. + Optional fields: ``exit-switch-uri``, ``exit-port``. + + Returns: + + :class:`~zhmcclient.StoragePath`: The new storage path. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + result = self.session.post( + uri=self._base_uri, + body=properties, + ) + uri = result['element-uri'] + props = copy.deepcopy(properties) + props[self._uri_prop] = uri + storage_path = StoragePath(self, uri, uri, props) + return storage_path + + @logged_api_call + def delete(self, element_id): + """ + Delete a storage path from this storage control unit. + + This calls the "Delete Storage Path" operation + (``DELETE /api/storage-control-units/{id}/storage-paths/{path-id}``). + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Parameters: + + element_id (str): The element ID of the storage path to delete. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + path_uri = self._base_uri + '/' + element_id + self.session.delete(uri=path_uri) + + +class StoragePath(BaseResource): + """ + Representation of a :term:`storage path`. + + A storage path is an element object nested inside a + :term:`storage control unit`. It defines a communication path from the + control unit to an adapter, optionally through one or two storage switches. + + Derived from :class:`~zhmcclient.BaseResource`; see there for common + methods and attributes. + + Objects of this class are not directly created by the user; they are + returned from creation or list functions on their manager object + (in this case, :class:`~zhmcclient.StoragePathManager`). + """ + + def __init__(self, manager, uri, name=None, properties=None): + # This function should not go into the docs. + # manager (:class:`~zhmcclient.StoragePathManager`): + # Manager object for this resource object. + # uri (string): + # Canonical URI path of the resource. + # name (string): + # Name of the resource (same as URI for storage paths). + # properties (dict): + # Properties to be set for this resource object. May be `None` or + # empty. + assert isinstance(manager, StoragePathManager), ( + "StoragePath init: Expected manager type " + f"{StoragePathManager}, got {type(manager)}" + ) + super().__init__(manager, uri, name, properties) + + @logged_api_call + def update_properties(self, properties): + """ + Update writeable properties of this storage path. + + This calls the "Update Storage Path Properties" operation + (``POST /api/storage-control-units/{cu-id}/storage-paths/{path-id}``). + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Parameters: + + properties (dict): New values for the properties to be updated. + Properties not to be updated are omitted. Writeable properties + include: ``adapter-port-uri``, ``exit-switch-uri``, ``exit-port``. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + self.manager.session.post(self.uri, resource=self, body=properties) + self.update_properties_local(copy.deepcopy(properties)) + + @logged_api_call + def delete(self): + """ + Delete this storage path. + + This calls the "Delete Storage Path" operation + (``DELETE /api/storage-control-units/{cu-id}/storage-paths/{path-id}``). + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + self.manager.session.delete(uri=self.uri, resource=self) + self.cease_existence_local() + + def dump(self): + """ + Dump this StoragePath resource with its properties as a resource + definition. + + The returned resource definition has the following format:: + + { + # Resource properties: + "properties": {...}, + } + + Returns: + + dict: Resource definition of this resource. + """ + resource_dict = super().dump() + return resource_dict diff --git a/zhmcclient/_utils.py b/zhmcclient/_utils.py index f9f7602d..3bf8104a 100644 --- a/zhmcclient/_utils.py +++ b/zhmcclient/_utils.py @@ -108,6 +108,10 @@ RC_CPC, # For unmanaged CPCs RC_PARTITION_LINK, RC_STORAGE_SITE, + RC_STORAGE_FABRIC, + RC_STORAGE_SWITCH, + RC_STORAGE_SUBSYSTEM, + RC_STORAGE_CONTROL_UNIT, ) # Resource classes that are children of zhmcclient.Client (= top level) RC_CHILDREN_CLIENT = ( diff --git a/zhmcclient/mock/_hmc.py b/zhmcclient/mock/_hmc.py index b89e24a4..f99996f7 100644 --- a/zhmcclient/mock/_hmc.py +++ b/zhmcclient/mock/_hmc.py @@ -66,6 +66,8 @@ 'FakedStorageFabricManager', 'FakedStorageFabric', 'FakedStorageSwitchManager', 'FakedStorageSwitch', 'FakedStorageSubsystemManager', 'FakedStorageSubsystem', + 'FakedStorageControlUnitManager', 'FakedStorageControlUnit', + 'FakedStoragePathManager', 'FakedStoragePath', ] # All currently defined metric groups with their metrics. @@ -1136,6 +1138,7 @@ def add(self, properties): class FakedConsole(FakedBaseResource): + # pylint: disable=too-many-instance-attributes """ A faked Console resource within a faked HMC (see :class:`zhmcclient.mock.FakedHmc`). @@ -1168,6 +1171,8 @@ def __init__(self, manager, properties): hmc=manager.hmc, console=self) self._storage_subsystems = FakedStorageSubsystemManager( hmc=manager.hmc, console=self) + self._storage_control_units = FakedStorageControlUnitManager( + hmc=manager.hmc, console=self) self._users = FakedUserManager(hmc=manager.hmc, console=self) self._user_roles = FakedUserRoleManager(hmc=manager.hmc, console=self) self._user_patterns = FakedUserPatternManager( @@ -1292,6 +1297,14 @@ def storage_subsystems(self): """ return self._storage_subsystems + @property + def storage_control_units(self): + """ + :class:`~zhmcclient.mock.FakedStorageControlUnitManager`: Access to + the faked Storage Control Unit resources of this Console. + """ + return self._storage_control_units + @property def users(self): """ @@ -4895,3 +4908,186 @@ def __init__(self, manager, properties): super().__init__( manager=manager, properties=properties) + + +class FakedStorageControlUnitManager(FakedBaseManager): + """ + A manager for faked Storage Control Unit resources within a faked HMC + (see :class:`zhmcclient.mock.FakedHmc`). + + Derived from :class:`zhmcclient.mock.FakedBaseManager`, see there for + common methods and attributes. + """ + + def __init__(self, hmc, console): + super().__init__( + hmc=hmc, + parent=console, + resource_class=FakedStorageControlUnit, + base_uri='/api/storage-control-units', + oid_prop='object-id', + uri_prop='object-uri', + class_value='storage-control-unit', + name_prop='name') + + def add(self, properties): + # pylint: disable=useless-super-delegation + """ + Add a faked Storage Control Unit resource. + + Parameters: + + properties (dict): + Resource properties. + + Special handling and requirements for certain properties: + + * 'object-id' will be auto-generated with a unique value across + all instances of this resource type, if not specified. + * 'object-uri' will be auto-generated based upon the object ID, + if not specified. + * 'class' will be auto-generated to 'storage-control-unit', + if not specified. + + Returns: + + :class:`~zhmcclient.mock.FakedStorageControlUnit`: The faked + StorageControlUnit resource. + """ + new_cu = super().add(properties) + + # Resource type specific default values + new_cu.properties.setdefault('description', '') + new_cu.properties.setdefault('storage-path-uris', []) + new_cu.properties.setdefault('volume-ranges', []) + + # Register this control unit URI in the parent subsystem's + # storage-control-unit-uris array, keeping both sides consistent. + subsystem_uri = new_cu.properties.get('parent') + if subsystem_uri: + try: + subsystem = self._hmc.lookup_by_uri(subsystem_uri) + cu_uris = list( + subsystem.properties.get( + 'storage-control-unit-uris', [])) + if new_cu.uri not in cu_uris: + cu_uris.append(new_cu.uri) + subsystem.update({'storage-control-unit-uris': cu_uris}) + except KeyError: + pass # subsystem not yet registered — caller's responsibility + + return new_cu + + +class FakedStorageControlUnit(FakedBaseResource): + """ + A faked Storage Control Unit resource within a faked HMC (see + :class:`zhmcclient.mock.FakedHmc`). + + Derived from :class:`zhmcclient.mock.FakedBaseResource`, see there for + common methods and attributes. + """ + + def __init__(self, manager, properties): + super().__init__( + manager=manager, + properties=properties) + self._storage_paths = FakedStoragePathManager( + hmc=manager.hmc, storage_control_unit=self) + + @property + def storage_paths(self): + """ + :class:`~zhmcclient.mock.FakedStoragePathManager`: Access to the + faked Storage Path resources of this Storage Control Unit. + """ + return self._storage_paths + + +class FakedStoragePathManager(FakedBaseManager): + """ + A manager for faked Storage Path resources within a faked HMC + (see :class:`zhmcclient.mock.FakedHmc`). + + Derived from :class:`zhmcclient.mock.FakedBaseManager`, see there for + common methods and attributes. + """ + + def __init__(self, hmc, storage_control_unit): + super().__init__( + hmc=hmc, + parent=storage_control_unit, + resource_class=FakedStoragePath, + base_uri=storage_control_unit.uri + '/storage-paths', + oid_prop='element-id', + uri_prop='element-uri', + class_value='storage-path', + name_prop='element-uri') + + def add(self, properties): + # pylint: disable=useless-super-delegation + """ + Add a faked Storage Path resource. + + Parameters: + + properties (dict): + Resource properties. + + Special handling and requirements for certain properties: + + * 'element-id' will be auto-generated with a unique value across + all instances of this resource type, if not specified. + * 'element-uri' will be auto-generated based upon the element ID, + if not specified. + * 'class' will be auto-generated to 'storage-path', if not + specified. + + Returns: + + :class:`~zhmcclient.mock.FakedStoragePath`: The faked + StoragePath resource. + """ + new_path = super().add(properties) + + # Register this path URI in the parent control unit's storage-path-uris + cu = self.parent + path_uris = list(cu.properties.get('storage-path-uris', [])) + if new_path.uri not in path_uris: + path_uris.append(new_path.uri) + cu.update({'storage-path-uris': path_uris}) + + return new_path + + def remove(self, oid): + """ + Remove a faked Storage Path resource and deregister it from the + parent control unit's storage-path-uris. + + Parameters: + + oid (string): + The element ID of the faked Storage Path resource. + """ + path = self.lookup_by_oid(oid) + cu = self.parent + path_uris = list(cu.properties.get('storage-path-uris', [])) + if path.uri in path_uris: + path_uris.remove(path.uri) + cu.update({'storage-path-uris': path_uris}) + super().remove(oid) + + +class FakedStoragePath(FakedBaseResource): + """ + A faked Storage Path resource within a faked HMC (see + :class:`zhmcclient.mock.FakedHmc`). + + Derived from :class:`zhmcclient.mock.FakedBaseResource`, see there for + common methods and attributes. + """ + + def __init__(self, manager, properties): + super().__init__( + manager=manager, + properties=properties) diff --git a/zhmcclient/mock/_session.py b/zhmcclient/mock/_session.py index 9d3dd673..d89659c8 100644 --- a/zhmcclient/mock/_session.py +++ b/zhmcclient/mock/_session.py @@ -267,6 +267,14 @@ "$ref": "#/definitions/StorageSubsystem" }, }, + "storage_control_units": { + "description": + "The Storage Control Units defined on this HMC", + "type": "array", + "items": { + "$ref": "#/definitions/StorageControlUnit" + }, + }, "hw_messages": { "description": "The hardware mesages for this Console", "type": "array", @@ -539,6 +547,40 @@ }, }, }, + "StorageControlUnit": { + "description": "A storage control unit defined on an HMC", + "type": "object", + "additionalProperties": False, + "required": [ + "properties", + ], + "properties": { + "properties": { + "$ref": "#/definitions/Properties" + }, + "storage_paths": { + "description": "The storage paths of this storage control " + "unit", + "type": "array", + "items": { + "$ref": "#/definitions/StoragePath" + }, + }, + }, + }, + "StoragePath": { + "description": "A storage path of a storage control unit", + "type": "object", + "additionalProperties": False, + "required": [ + "properties", + ], + "properties": { + "properties": { + "$ref": "#/definitions/Properties" + }, + }, + }, "HwMessage": { "description": "A hardware message (for Console or CPC)", "type": "object", diff --git a/zhmcclient/mock/_urihandler.py b/zhmcclient/mock/_urihandler.py index 9384795e..211b4ce1 100644 --- a/zhmcclient/mock/_urihandler.py +++ b/zhmcclient/mock/_urihandler.py @@ -39,7 +39,14 @@ 'StorageSubsystemsHandler', 'StorageSubsystemHandler', 'StorageSubsystemMoveSiteHandler', 'StorageSubsystemAddEndpointHandler', - 'StorageSubsystemRemoveEndpointHandler'] + 'StorageSubsystemRemoveEndpointHandler', + 'StorageControlUnitsHandler', 'StorageControlUnitHandler', + 'StorageSubsystemControlUnitsHandler', + 'StorageControlUnitDefineHandler', + 'StorageControlUnitUndefineHandler', + 'StorageControlUnitAddVolumeRangeHandler', + 'SCURemoveVolumeRangeHandler', + 'StoragePathsHandler', 'StoragePathHandler'] # CPC status values CPC_ACTIVE_STATUSES = ( @@ -3249,30 +3256,20 @@ def get_inventory_for_storage_switch(hmc): def get_inventory_for_storage_subsystem(hmc): - # pylint: disable=unused-argument """Get inventory data for resource class 'storage-subsystem'""" result = [] - # TODO: Implement mock support for this resource class; then enable: - # stosites = hmc.consoles.console.storage_sites.list() - # for stosite in stosites: - # stosubsystems = stosite.storage_subsystems.list() - # for stosubsystem in stosubsystems: - # result.append(properties_copy(stosubsystem.properties)) + stosubsystems = hmc.consoles.console.storage_subsystems.list() + for stosubsystem in stosubsystems: + result.append(properties_copy(stosubsystem.properties)) return result def get_inventory_for_storage_control_unit(hmc): - # pylint: disable=unused-argument """Get inventory data for resource class 'storage-control-unit'""" result = [] - # TODO: Implement mock support for this resource class; then enable: - # stosites = hmc.consoles.console.storage_sites.list() - # for stosite in stosites: - # stosubsystems = stosite.storage_subsystems.list() - # for stosubsystem in stosubsystems: - # stocus = stosubsystem.storage_control_units.list() - # for stocu in stocus: - # result.append(properties_copy(stocu.properties)) + stocus = hmc.consoles.console.storage_control_units.list() + for stocu in stocus: + result.append(properties_copy(stocu.properties)) return result @@ -5668,7 +5665,6 @@ def get(cls, method, hmc, uri, uri_parms, logon_required): uri, query_parms = parse_query_parms(method, uri) check_invalid_query_parms( method, uri, query_parms, cls.valid_query_parms_get) - # Map the 'cpc-uri' query parameter to the 'cpc-uris' resource # property name used for local filtering in the faked HMC. filter_args = {} @@ -6286,6 +6282,382 @@ def get(cls, method, hmc, uri, uri_parms, logon_required): return {'storage-subsystems': result_subsystems} +class StorageControlUnitsHandler: + """ + Handler class for HTTP methods on the global set of StorageControlUnit + resources. + GET /api/storage-control-units + """ + + valid_query_parms_get = ['name', 'logical-address'] + + returned_props = ['object-uri', 'name', 'logical-address'] + + @classmethod + def get(cls, method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: List Storage Control Units (global with filters).""" + uri, query_parms = parse_query_parms(method, uri) + check_invalid_query_parms( + method, uri, query_parms, cls.valid_query_parms_get) + filter_args = query_parms + + result_cus = [] + for cu in hmc.consoles.console.storage_control_units.list( + filter_args): + result_cu = {} + for prop in cls.returned_props: + result_cu[prop] = prop_copy(cu.properties.get(prop)) + result_cus.append(result_cu) + return {'storage-control-units': result_cus} + + +class StorageControlUnitHandler(GenericGetPropertiesHandler, + GenericUpdatePropertiesHandler): # noqa: E127 + """ + Handler class for HTTP methods on a single StorageControlUnit resource + (Get Properties and Update Properties). + POST /api/storage-control-units/{storage-control-unit-id} + """ + pass + + +class StorageSubsystemControlUnitsHandler: + """ + Handler class for listing storage control units of a storage subsystem. + GET /api/storage-subsystems/{id}/storage-control-units + """ + + valid_query_parms_get = ['name', 'logical-address'] + + returned_props = ['object-uri', 'name', 'logical-address'] + + @classmethod + def get(cls, method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: List Storage Control Units of a Storage Subsystem.""" + uri, query_parms = parse_query_parms(method, uri) + check_invalid_query_parms( + method, uri, query_parms, cls.valid_query_parms_get) + + ss_oid = uri_parms[0] + ss_uri = '/api/storage-subsystems/' + ss_oid + try: + hmc.lookup_by_uri(ss_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + + filter_args = dict(query_parms) + filter_args['parent'] = ss_uri + + result_cus = [] + for cu in hmc.consoles.console.storage_control_units.list( + filter_args): + result_cu = {} + for prop in cls.returned_props: + result_cu[prop] = prop_copy(cu.properties.get(prop)) + result_cus.append(result_cu) + return {'storage-control-units': result_cus} + + +class StorageControlUnitDefineHandler: + """ + Handler class for the "Define Storage Control Unit" operation. + POST /api/storage-subsystems/{id}/operations/define-storage-control-unit + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Define Storage Control Unit.""" + assert wait_for_completion is True # always synchronous + check_required_fields(method, uri, body, ['logical-address']) + + ss_oid = uri_parms[0] + ss_uri = '/api/storage-subsystems/' + ss_oid + try: + hmc.lookup_by_uri(ss_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + + logical_addr = body['logical-address'] + + # Check for duplicate logical-address within this subsystem (409/447) + existing = hmc.consoles.console.storage_control_units.list( + {'parent': ss_uri}) + for cu in existing: + if cu.properties.get('logical-address') == logical_addr: + raise ConflictError( + method, uri, reason=447, + message="A storage control unit with logical-address " + f"{logical_addr!r} already exists in subsystem " + f"{ss_uri}") + + # Check for duplicate name within this subsystem (400/8) + cu_name = body.get('name') + if cu_name: + for cu in existing: + if cu.properties.get('name') == cu_name: + raise BadRequestError( + method, uri, reason=8, + message="A storage control unit with name " + f"{cu_name!r} already exists in subsystem " + f"{ss_uri}") + + props = dict(body) + props['parent'] = ss_uri + # Default name to "Control unit {logical-address}" per spec + props.setdefault('name', f'Control unit {logical_addr}') + + new_cu = hmc.consoles.console.storage_control_units.add(props) + return {'object-uri': new_cu.uri} + + +class StorageControlUnitUndefineHandler: + """ + Handler class for the "Undefine Storage Control Unit" operation. + POST /api/storage-control-units/{id}/operations/undefine + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Undefine Storage Control Unit.""" + assert wait_for_completion is True # always synchronous + cu_oid = uri_parms[0] + cu_uri = '/api/storage-control-units/' + cu_oid + try: + cu = hmc.lookup_by_uri(cu_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + + # Remove all child storage paths + for path in list(cu.storage_paths.list(None)): + cu.storage_paths.remove(path.oid) + + # Remove this CU's URI from the parent subsystem's + # storage-control-unit-uris + ss_uri = cu.properties.get('parent') + if ss_uri: + try: + ss = hmc.lookup_by_uri(ss_uri) + cu_uris = list( + ss.properties.get('storage-control-unit-uris', [])) + if cu_uri in cu_uris: + cu_uris.remove(cu_uri) + ss.update({'storage-control-unit-uris': cu_uris}) + except KeyError: + pass + + cu.manager.remove(cu.oid) # 204 No Content + + +class StorageControlUnitAddVolumeRangeHandler: + """ + Handler class for the "Add Volume Range" operation. + POST /api/storage-control-units/{id}/operations/add-volume-range + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Add Volume Range.""" + assert wait_for_completion is True # always synchronous + check_required_fields(method, uri, body, ['starting-volume']) + cu_oid = uri_parms[0] + cu_uri = '/api/storage-control-units/' + cu_oid + try: + cu = hmc.lookup_by_uri(cu_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + + starting = body['starting-volume'] + ending = body.get('ending-volume', starting) + vol_type = body.get('type', 'base') + + new_range = { + 'starting-volume': starting, + 'ending-volume': ending, + 'type': vol_type, + } + volume_ranges = list(cu.properties.get('volume-ranges', [])) + volume_ranges.append(new_range) + cu.update({'volume-ranges': volume_ranges}) # 204 No Content + + +class SCURemoveVolumeRangeHandler: + """ + Handler class for the "Remove Volume Range" operation. + POST /api/storage-control-units/{id}/operations/remove-volume-range + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Remove Volume Range.""" + assert wait_for_completion is True # always synchronous + check_required_fields(method, uri, body, ['starting-volume']) + cu_oid = uri_parms[0] + cu_uri = '/api/storage-control-units/' + cu_oid + try: + cu = hmc.lookup_by_uri(cu_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + + starting = body['starting-volume'] + ending = body.get('ending-volume', starting) + vol_type = body.get('type', 'base') + + volume_ranges = list(cu.properties.get('volume-ranges', [])) + found_idx = None + for idx, vr in enumerate(volume_ranges): + if (vr.get('starting-volume') == starting + and vr.get('ending-volume') == ending + and vr.get('type') == vol_type): + found_idx = idx + break + + if found_idx is None: + raise ConflictError( + method, uri, reason=444, + message=f"Volume range starting at {starting!r} not found " + f"on storage control unit {cu_uri}") + + volume_ranges.pop(found_idx) + cu.update({'volume-ranges': volume_ranges}) # 204 No Content + + +class StoragePathsHandler: + """ + Handler class for HTTP methods on the set of StoragePath resources. + POST /api/storage-control-units/{id}/storage-paths (Create) + GET /api/storage-control-units/{id}/storage-paths (List) + """ + + returned_props = ['element-uri'] + + @classmethod + def get(cls, method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: List Storage Paths of a Storage Control Unit.""" + uri, _ = parse_query_parms(method, uri) + cu_oid = uri_parms[0] + cu_uri = '/api/storage-control-units/' + cu_oid + try: + cu = hmc.lookup_by_uri(cu_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + + result_paths = [] + for path in cu.storage_paths.list(None): + result_path = {} + for prop in cls.returned_props: + result_path[prop] = prop_copy(path.properties.get(prop)) + result_paths.append(result_path) + return {'storage-paths': result_paths} + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Create Storage Path.""" + assert wait_for_completion is True # always synchronous + check_required_fields(method, uri, body, ['adapter-port-uri']) + cu_oid = uri_parms[0] + cu_uri = '/api/storage-control-units/' + cu_oid + try: + cu = hmc.lookup_by_uri(cu_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + + # Validate max 8 paths + existing_paths = cu.storage_paths.list(None) + if len(existing_paths) >= 8: + raise ConflictError( + method, uri, reason=486, + message="Cannot create storage path: maximum of 8 paths " + f"already defined for storage control unit {cu_uri}") + + # Validate adapter-port-uri resolves + adapter_port_uri = body['adapter-port-uri'] + try: + hmc.lookup_by_uri(adapter_port_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri, reason=2, + resource_uri=adapter_port_uri) + new_exc.__cause__ = None + raise new_exc + + # Validate exit-switch-uri / exit-port corequisite (400/442): + # exit-port requires exit-switch-uri; exit-switch-uri requires exit-port + exit_switch_uri = body.get('exit-switch-uri') + exit_port = body.get('exit-port') + if (exit_switch_uri is None) != (exit_port is None): + raise BadRequestError( + method, uri, reason=442, + message="'exit-switch-uri' and 'exit-port' must both be " + "present or both be absent") + + # Check for duplicate path (400/8) + for ep in existing_paths: + if (ep.properties.get('adapter-port-uri') == adapter_port_uri + and ep.properties.get('exit-switch-uri') == exit_switch_uri + and ep.properties.get('exit-port') == exit_port): + raise BadRequestError( + method, uri, reason=8, + message="A storage path with the same properties " + f"already exists in storage control unit {cu_uri}") + + props = dict(body) + props['parent'] = cu_uri + props.setdefault('exit-switch-uri', None) + props.setdefault('exit-port', None) + + new_path = cu.storage_paths.add(props) + return {'element-uri': new_path.uri} + + +class StoragePathHandler(GenericGetPropertiesHandler, + GenericUpdatePropertiesHandler, # noqa: E127 + GenericDeleteHandler): + """ + Handler class for HTTP methods on a single StoragePath element resource + (Get, Update, Delete). + GET/POST/DELETE /api/storage-control-units/{cu-id}/storage-paths/{path-id} + """ + + @staticmethod + def delete(method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: Delete Storage Path.""" + try: + path = hmc.lookup_by_uri(uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + # Remove from parent control unit's storage-path-uris + path.manager.remove(path.oid) + + class TapeLibrariesHandler: """ Handler class for HTTP methods on set of TapeLibraries resources. @@ -8265,11 +8637,31 @@ def post(method, hmc, uri, uri_parms, body, logon_required, StorageSubsystemAddEndpointHandler), (r'/api/storage-subsystems/([^/]+)/operations/remove-connection-endpoint', StorageSubsystemRemoveEndpointHandler), + (r'/api/storage-subsystems/([^/]+)/operations/' + r'define-storage-control-unit', + StorageControlUnitDefineHandler), + (r'/api/storage-subsystems/([^/]+)/storage-control-units(?:\?(.*))?', + StorageSubsystemControlUnitsHandler), (r'/api/storage-subsystems/([^?/]+)(?:\?(.*))?', StorageSubsystemHandler), (r'/api/storage-subsystems(?:\?(.*))?', StorageSubsystemsHandler), + (r'/api/storage-control-units/([^/]+)/operations/undefine', + StorageControlUnitUndefineHandler), + (r'/api/storage-control-units/([^/]+)/operations/add-volume-range', + StorageControlUnitAddVolumeRangeHandler), + (r'/api/storage-control-units/([^/]+)/operations/remove-volume-range', + SCURemoveVolumeRangeHandler), + (r'/api/storage-control-units/([^/]+)/storage-paths/([^?/]+)(?:\?(.*))?', + StoragePathHandler), + (r'/api/storage-control-units/([^/]+)/storage-paths(?:\?(.*))?', + StoragePathsHandler), + (r'/api/storage-control-units/([^?/]+)(?:\?(.*))?', + StorageControlUnitHandler), + (r'/api/storage-control-units(?:\?(.*))?', + StorageControlUnitsHandler), + (r'/api/tape-libraries(?:\?(.*))?', TapeLibrariesHandler), (r'/api/tape-libraries/([^?/]+)(?:\?(.*))?',