diff --git a/docs/appendix.rst b/docs/appendix.rst index 030ffb41..9862d28d 100644 --- a/docs/appendix.rst +++ b/docs/appendix.rst @@ -268,7 +268,7 @@ This documentation uses a few special terms: fulfillment The act of satisfying requests for creation, modification, or deletion of - storage volumes in a storage subsystem (i.e. of the actual storage + storage volumes in a :term:`Storage Subsystem` (i.e. of the actual storage backing a :term:`storage volume` object). Storage volume objects have a fulfillment state indicating whether the @@ -527,6 +527,58 @@ Resources scoped to CPCs in DPM mode Storage Port Short term for a :term:`Port` of a :term:`Storage Adapter`. + Storage Site + A location that houses a set of storage switches and storage subsystems + within the FICON storage configuration of a DPM-enabled :term:`CPC`. + A Storage Site object with `type="primary"` always exists for the primary + site and cannot be deleted. Additional Storage Site objects can be created + and deleted. + + For details, see section :ref:`Storage Sites`. + + Storage Fabric + A collection of interconnected storage switches in the FICON storage + configuration of a DPM-enabled :term:`CPC`. A storage fabric can span + multiple :term:`Storage Sites ` and is the logical grouping + to which :term:`Storage Switches ` belong. + + 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 + belongs to exactly one :term:`Storage Site` and can be moved between sites + within the same FICON configuration. Connection endpoints (storage switches + or adapters) can be added and removed from a storage subsystem to describe + how it is physically connected to the storage network. + + For details, see section :ref:`Storage Subsystems`. + + Storage Switch + A physical storage switch in the FICON storage configuration of a + DPM-enabled :term:`CPC`. A storage switch belongs to exactly one + :term:`Storage Site` and one :term:`Storage Fabric`, and can be moved + between sites and fabrics. Storage switches can be defined and undefined + via the :term:`Console`. + + For details, see section :ref:`Storage Switches`. + Storage Volume An FCP or ECKD (=FICON) storage volume defined in context of a :term:`storage group`. The life cycle of a storage volume includes being @@ -539,13 +591,34 @@ Resources scoped to CPCs in DPM mode Storage Volume Template A template for :term:`Storage Volumes `. - - Tape library + Tape Library A Tape Library represents one physical tape storage unit connected to - a CPC.Tape libraries are automatically discovered,but discovery requires - preprocessing,A single Worldwide Port Name (WWPN) must be zoned so the CPC + a CPC. Tape libraries are automatically discovered, but discovery requires + preprocessing. A single Worldwide Port Name (WWPN) must be zoned so the CPC can see the tape library. For details, see section :ref:`Tape Libraries`. + Tape Link + A Tape Link represents a single tape link associated with a DPM-enabled + :term:`CPC`. Tape links define pathways to :term:`Tape Library` storage + that can be attached to :term:`Partitions `. + + When a tape link is attached to a partition, its fulfilled resources are + virtualized and the partition's view of them is represented by + :term:`Virtual Tape Resources `. + + Tape Link resources are top-level resources whose conceptual parent is + the :term:`Console`; a linked tape library is identified by the + ``tape-library-uri`` property of the tape link. + + For details, see section :ref:`Tape Links`. + + Virtual Tape Resource + A representation of a tape-related z/Architecture device in a :term:`Partition`. + A Virtual Tape Resource object represents access to a tape drive through a + :term:`Tape Link`. Each virtual tape resource is associated with a specific + adapter port and provides the partition with a device number for accessing + the tape drive. + For details, see section :ref:`Virtual Tape Resources`. vHBA Synonym for :term:`HBA`. In this resource model, HBAs are always diff --git a/docs/resources.rst b/docs/resources.rst index 12cdfe69..d65f9e8b 100644 --- a/docs/resources.rst +++ b/docs/resources.rst @@ -358,6 +358,46 @@ Tape Libraries :special-members: __str__ +.. _`Tape Links`: + +Tape Links +---------- + +.. automodule:: zhmcclient._tape_link + +.. autoclass:: zhmcclient.TapeLinkManager + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + +.. autoclass:: zhmcclient.TapeLink + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + + +.. _`Virtual Tape Resources`: + +Virtual Tape Resources +---------------------- + +.. automodule:: zhmcclient._virtual_tape_resource + +.. autoclass:: zhmcclient.VirtualTapeResourceManager + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + +.. autoclass:: zhmcclient.VirtualTapeResource + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + + .. _`Virtual Storage Resources`: Virtual Storage Resources @@ -418,6 +458,126 @@ Storage Volume Templates :special-members: __str__ +.. _`Storage Fabrics`: + +Storage Fabrics +--------------- + +.. automodule:: zhmcclient._storage_fabric + +.. autoclass:: zhmcclient.StorageFabricManager + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + +.. autoclass:: zhmcclient.StorageFabric + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + + +.. _`Storage Sites`: + +Storage Sites +------------- + +.. automodule:: zhmcclient._storage_site + +.. autoclass:: zhmcclient.StorageSiteManager + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + +.. autoclass:: zhmcclient.StorageSite + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + + +.. _`Storage Switches`: + +Storage Switches +---------------- + +.. automodule:: zhmcclient._storage_switch + +.. autoclass:: zhmcclient.StorageSwitchManager + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + +.. autoclass:: zhmcclient.StorageSwitch + :members: + :autosummary: + :autosummary-inherited-members: + :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 +------------------ + +.. automodule:: zhmcclient._storage_subsystem + +.. autoclass:: zhmcclient.StorageSubsystemManager + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + +.. autoclass:: zhmcclient.StorageSubsystem + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + + .. _`Partition Links`: Partition Links diff --git a/tests/end2end/mocked_hmc_z14.yaml b/tests/end2end/mocked_hmc_z14.yaml index bcd5ec1f..d7b8f585 100644 --- a/tests/end2end/mocked_hmc_z14.yaml +++ b/tests/end2end/mocked_hmc_z14.yaml @@ -355,6 +355,132 @@ hmc_definition: adapter-port-uri: /api/adapters/fcp1 partition-uri: /api/partitions/part1 + storage_fabrics: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: sfabric1 + name: "Storage Fabric A" + cpc-uri: /api/cpcs/cpc_dpm + description: "Primary storage fabric for CPC DPM" + high-integrity: false + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: sfabric2 + name: "Storage Fabric B" + cpc-uri: /api/cpcs/cpc_dpm + description: "Alternate storage fabric for CPC DPM" + high-integrity: true + + storage_sites: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: ss1 + name: Primary Site + description: Primary storage site local to the CPC + cpc-uris: + - /api/cpcs/cpc_dpm + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: ss2 + name: Alternate Site + description: Alternate storage site at a remote location + cpc-uris: + - /api/cpcs/cpc_dpm + + storage_switches: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: sw1 + name: "Storage switch 11" + domain-id: "11" + storage-fabric-uri: /api/storage-fabrics/sfabric1 + storage-site-uri: /api/storage-sites/ss1 + description: "Storage switch 11 on Fabric A at Primary Site" + port-count: 256 + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: sw2 + name: "Storage switch 21" + domain-id: "21" + storage-fabric-uri: /api/storage-fabrics/sfabric2 + storage-site-uri: /api/storage-sites/ss2 + description: "Storage switch 21 on Fabric B at Alternate Site" + port-count: 256 + + storage_subsystems: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: subsys1 + name: "DS8886 A" + description: "DS8886 A storage subsystem at Primary Site" + storage-site-uri: /api/storage-sites/ss1 + storage-control-unit-uris: + - /api/storage-control-units/cu1 + connection-endpoints: + - endpoint-uri: /api/storage-switches/sw1 + endpoint-class: storage-switch + port-id: "00" + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: subsys2 + name: "DS8886 B" + description: "DS8886 B storage subsystem at Alternate Site" + storage-site-uri: /api/storage-sites/ss2 + 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 ef38c069..fce88f96 100644 --- a/tests/end2end/mocked_hmc_z16.yaml +++ b/tests/end2end/mocked_hmc_z16.yaml @@ -355,7 +355,27 @@ hmc_definition: adapter-port-uri: /api/adapters/fcp1 partition-uri: /api/partitions/part1 - tape_library: + storage_fabrics: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: sfabric1 + name: "Storage Fabric A" + cpc-uri: /api/cpcs/cpc_dpm + description: "Primary storage fabric for CPC DPM" + high-integrity: false + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: sfabric2 + name: "Storage Fabric B" + cpc-uri: /api/cpcs/cpc_dpm + description: "Alternate storage fabric for CPC DPM" + high-integrity: true + + tape_libraries: - properties: # class : created automatically # parent : created automatically @@ -365,6 +385,176 @@ hmc_definition: description : Tape Library resource 1 cpc-uri : /api/cpcs/cpc_dpm + tape_links: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: tlink1 + name: "Tape Link 1" + cpc-uri: /api/cpcs/cpc_dpm + description: "Tape link connecting Tape Library to PART1" + tape-library-uri: /api/tape-libraries/tl1 + virtual_tape_resources: + - properties: + # class: created automatically + # parent: created automatically + # element-uri: created automatically + element-id: vtr1 + name: "Virtual Tape Resource 1" + description: "Virtual tape resource 1" + device-number: "0001" + adapter-port-uri: /api/adapters/fcp1/storage-ports/0 + partition-uri: /api/partitions/part1 + world-wide-port-name-info: + status: validated + world-wide-port-name: "c05076ffe8000001" + degraded-reasons: [] + - properties: + # class: created automatically + # parent: created automatically + # element-uri: created automatically + element-id: vtr2 + name: "Virtual Tape Resource 2" + description: "Virtual tape resource 2" + device-number: "0002" + adapter-port-uri: /api/adapters/fcp1/storage-ports/1 + partition-uri: /api/partitions/part1 + world-wide-port-name-info: + status: validated + world-wide-port-name: "c05076ffe8000002" + degraded-reasons: [] + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: tlink2 + name: "Tape Link 2" + cpc-uri: /api/cpcs/cpc_dpm + description: "Tape link for testing" + tape-library-uri: /api/tape-libraries/tl1 + virtual_tape_resources: + - properties: + # class: created automatically + # parent: created automatically + # element-uri: created automatically + element-id: vtr3 + name: "Virtual Tape Resource 3" + description: "Virtual tape resource 3" + device-number: "0003" + adapter-port-uri: /api/adapters/fcp1/storage-ports/0 + partition-uri: /api/partitions/part1 + world-wide-port-name-info: + status: not-validated + world-wide-port-name: "c05076ffe8000003" + degraded-reasons: [] + + storage_sites: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: ss1 + name: Primary Site + description: Primary storage site local to the CPC + cpc-uris: + - /api/cpcs/cpc_dpm + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: ss2 + name: Alternate Site + description: Alternate storage site at a remote location + cpc-uris: + - /api/cpcs/cpc_dpm + + storage_switches: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: sw1 + name: "Storage switch 11" + domain-id: "11" + storage-fabric-uri: /api/storage-fabrics/sfabric1 + storage-site-uri: /api/storage-sites/ss1 + description: "Storage switch 11 on Fabric A at Primary Site" + port-count: 256 + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: sw2 + name: "Storage switch 21" + domain-id: "21" + storage-fabric-uri: /api/storage-fabrics/sfabric2 + storage-site-uri: /api/storage-sites/ss2 + description: "Storage switch 21 on Fabric B at Alternate Site" + port-count: 256 + + storage_subsystems: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: subsys1 + name: "DS8886 A" + description: "DS8886 A storage subsystem at Primary Site" + storage-site-uri: /api/storage-sites/ss1 + storage-control-unit-uris: + - /api/storage-control-units/cu1 + connection-endpoints: + - endpoint-uri: /api/storage-switches/sw1 + endpoint-class: storage-switch + port-id: "00" + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: subsys2 + name: "DS8886 B" + description: "DS8886 B storage subsystem at Alternate Site" + storage-site-uri: /api/storage-sites/ss2 + 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 new file mode 100644 index 00000000..2adea1dd --- /dev/null +++ b/tests/end2end/mocked_hmc_z17.yaml @@ -0,0 +1,1351 @@ +# Definition of a mocked HMC for end2end tests run by GitHub Actions. +# The file must have the format defined in FAKED_HMC_DEFINITION_SCHEMA in +# zhmcclient.mock/_session.py of the python-zhmcclient project. +# +# This mocked HMC manages two CPCs: +# - CPC_CLA: A z17 in classic mode +# - CPC_DPM: A z17 in DPM mode + +hmc_definition: + host: hmc_z17 + api_version: '4.10' + consoles: + - properties: + # class: created automatically + # parent: created automatically + object-uri: /api/console + object-id: hmc1 + name: HMC1 + description: "primary HMC HMC1" + ip-swapping-available: true + is-auto-switch-enabled: false + is-locked: false + paired-role: '' + version: 2.17.0 + machine-info: + machine-type: '2461' + machine-serial: DK67009 + machine-model: TW1 + network-info: + this-hmc: + - domain-name: boeblingen.de.ibm.com + is-private: false + hmc-name: HMCA224 + ipv4-address: + - ip-address: 9.152.151.47 + subnet-mask: 255.255.254.0 + interface-name: eth0 + mac: 00106f0d8409 + ipv6-address: + - prefix-length: 64 + ip-address: fe80:0:0:0:210:6fff:fe0d:8409 + paired-hmc: null + ec-mcl-description: + ec: + - number: P41499 + part-number: 01LL062 + mcl: + - level: '333' + type: retrieved + last-update: 1650524126000 + - level: '333' + type: activated + last-update: 1650526481000 + - level: '327' + type: accepted + last-update: 1642750400000 + - level: '333' + type: installable-concurrent + last-update: null + - level: '328' + type: removable-concurrent + last-update: null + description: Hardware Management Console Framework + type: SYSTEM + - number: P41470 + part-number: 01LL058 + mcl: + - level: '2' + type: retrieved + last-update: 1566551669000 + - level: '2' + type: activated + last-update: 1566551779000 + - level: '2' + type: accepted + last-update: 1566553286000 + - level: '2' + type: installable-concurrent + last-update: null + - level: '000' + type: removable-concurrent + last-update: null + description: Hardware Management Console Platform Firmware + type: HMCBIOS + + users: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: pedebug + name: PEDEBUG + description: "Product engineering level user" + allow-management-interfaces: true + allow-remote-access: true + authentication-type: local + default-group-uri: null + disable-delay: 1 + disabled: false + disruptive-pw-required: true + disruptive-text-required: false + email-address: null + force-password-change: false + force-shared-secret-key-change: null + idle-timeout: 0 + inactivity-timeout: 0 + is-locked: false + ldap-server-definition-uri: null + max-failed-logins: 3 + max-web-services-api-sessions: 1000 + min-pw-change-time: 0 + multi-factor-authentication-required: false + password: bla + password-expires: -1 + password-rule-uri: /api/console/password-rules/passwordrule1 + replication-overwrite-possible: false + session-timeout: 0 + type: system-defined + userid-on-ldap-server: null + verify-timeout: 15 + web-services-api-session-idle-timeout: 360 + user-roles: + - /api/user-roles/hmc-all-system-managed-objects + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: user1 + name: USER1 + description: "Standard user 1" + allow-management-interfaces: true + allow-remote-access: true + authentication-type: local + default-group-uri: null + disable-delay: 1 + disabled: false + disruptive-pw-required: true + disruptive-text-required: false + email-address: null + force-password-change: false + force-shared-secret-key-change: null + idle-timeout: 0 + inactivity-timeout: 0 + is-locked: false + ldap-server-definition-uri: null + max-failed-logins: 3 + max-web-services-api-sessions: 1000 + min-pw-change-time: 0 + multi-factor-authentication-required: false + password: bla + password-expires: -1 + password-rule-uri: /api/console/password-rules/passwordrule1 + replication-overwrite-possible: false + session-timeout: 0 + type: standard + userid-on-ldap-server: null + verify-timeout: 15 + web-services-api-session-idle-timeout: 360 + user-roles: + - /api/user-roles/hmc-all-system-managed-objects + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: usertemplate1 + name: USERTEMPLATE1 + description: "User template 1" + allow-management-interfaces: true + allow-remote-access: true + authentication-type: local + default-group-uri: null + disable-delay: 1 + disabled: false + disruptive-pw-required: true + disruptive-text-required: false + email-address: null + force-password-change: false + force-shared-secret-key-change: null + idle-timeout: 0 + inactivity-timeout: 0 + is-locked: false + ldap-server-definition-uri: null + max-failed-logins: 3 + max-web-services-api-sessions: 1000 + min-pw-change-time: 0 + multi-factor-authentication-required: false + password: bla + password-expires: -1 + password-rule-uri: /api/console/password-rules/passwordrule1 + replication-overwrite-possible: false + session-timeout: 0 + type: template + userid-on-ldap-server: null + verify-timeout: 15 + web-services-api-session-idle-timeout: 360 + user-roles: + - /api/user-roles/hmc-all-system-managed-objects + + user_roles: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: hmc-all-system-managed-objects + name: hmc-all-system-managed-objects + description: "All system managed objects" + associated-system-defined-user-role-uri: null + is-inheritance-enabled: false + is-locked: false + replication-overwrite-possible: false + type: system-defined + permissions: + - permitted-object: partition + permitted-object-type: object-class + - permitted-object: cpc + permitted-object-type: object-class + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: hmc-operator-tasks + name: hmc-operator-tasks + description: "Operator Tasks" + associated-system-defined-user-role-uri: null + is-inheritance-enabled: false + is-locked: false + replication-overwrite-possible: false + type: system-defined + permissions: [] + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: hmc-access-administrator-tasks + name: hmc-access-administrator-tasks + description: "Access Administrator Tasks" + associated-system-defined-user-role-uri: null + is-inheritance-enabled: false + is-locked: false + replication-overwrite-possible: false + type: system-defined + permissions: + - permitted-object: partition + permitted-object-type: object-class + - permitted-object: cpc + permitted-object-type: object-class + + user_patterns: + - properties: + element-id: userpattern1 + name: "User pattern 1" + description: "User pattern 1" + type: regular-expression + pattern: ".*@ibm.com" + domain-name-restrictions: null + domain-name-restrictions-ldap-server-definition-name: null + domain-name-restrictions-ldap-server-definition-uri: null + ldap-group-default-template-name: null + ldap-group-default-template-uri: null + ldap-group-ldap-server-definition-name: null + ldap-group-ldap-server-definition-uri: null + ldap-group-to-template-mappings: null + ldap-server-definition-name: null + ldap-server-definition-uri: null + replication-overwrite-possible: false + retention-time: 1 + search-order-index: 2 + specific-template-uri: null + template-name-override: null + template-name-override-default-template-uri: null + template-name-override-ldap-server-definition-uri: null + user-template-uri: null + + password_rules: + - properties: + element-id: passwordrule1 + name: "Password rule 1" + type: "user-defined" + description: "Password rule 1" + expiration: 186 + min-length: 6 + max-length: 30 + consecutive-characters: 2 + history-count: 4 + similarity-count: 0 + case-sensitive: false + character-rules: [] + replication-overwrite-possible: false + - properties: + element-id: standard + name: Standard + type: "system-defined" + description: "Standard password rule definition" + expiration: 90 + min-length: 16 + max-length: 32 + consecutive-characters: 0 + history-count: 2 + similarity-count: 2 + case-sensitive: true + character-rules: [] + replication-overwrite-possible: true + + tasks: + - properties: + # class: created automatically + # parent: created automatically + # element-uri: created automatically + element-id: new-performance-policy + name: new-performance-policy + description: Create a new performance policy for the workload + view-only-mode-supported: false + + ldap_server_definitions: + - properties: + element-id: lsd1 + name: LDAP server definition 1 + description: LDAP server definition 1 + primary-hostname-ipaddr: 10.11.12.13 + search-distinguished-name: "test{0}" + replication-overwrite-possible: false + connection-port: null + backup-hostname-ipaddr: null + use-ssl: false + tolerate-untrusted-certificates: null + bind-distinguished-name: abc + bind-password: bla + location-method: null + search-scope: null + search-filter: null + + + sso_server_definitions: + - properties: + # class: created automatically + # parent: created automatically + # element-uri: created automatically + element-id: ssosrvdef1 + name: SSO Server Definition 1 + description: Primary SSO server definition for z17 + type: oidc + client-id: ssosrvdef1-client-id + # client-secret is a write-only field and is intentionally omitted: + # the mock GET handler strips it from responses (wo_properties). + authentication-url: https://sso1.example.com/auth + token-url: https://sso1.example.com/token + issuer-url: https://sso1.example.com/issuer + jwks-url: https://sso1.example.com/jwks + logout-url: null + logout-sso-session-on-reauthentication-failure: false + authentication-page-servers: + - hostname-ipaddr: images1.example.com + port: 443 + replication-overwrite-possible: false + + + storage_groups: + - properties: + object-id: sg1 + name: Storage group 1 + description: Storage group 1 attached to CPC_DPM + shared: false + type: fcp + fulfillment-state: complete + cpc-uri: /api/cpcs/cpc_dpm + storage_volumes: + - properties: + element-id: sv1 + name: Storage volume 1 + description: Storage volume 1 + fulfillment-state: complete + size: 10.0 + usage: boot + virtual_storage_resources: + - properties: + element-id: vsr1 + name: Virtual storage resource 1 + description: Virtual storage resource 1 + device-number: '1300' + adapter-port-uri: /api/adapters/fcp1 + partition-uri: /api/partitions/part1 + + tape_libraries: + - properties: + # class : created automatically + # parent : created automatically + # object-uri: created automatically + object-id : tl1 + name : Tape Library + description : Tape Library resource 1 + cpc-uri : /api/cpcs/cpc_dpm + + tape_links: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: tlink1 + name: "Tape Link 1" + cpc-uri: /api/cpcs/cpc_dpm + description: "Tape link connecting Tape Library to PART1" + tape-library-uri: /api/tape-libraries/tl1 + virtual_tape_resources: + - properties: + # class: created automatically + # parent: created automatically + # element-uri: created automatically + element-id: vtr1 + name: "Virtual Tape Resource 1" + description: "Virtual tape resource 1" + device-number: "0001" + adapter-port-uri: /api/adapters/fcp1/storage-ports/0 + partition-uri: /api/partitions/part1 + world-wide-port-name-info: + status: validated + world-wide-port-name: "c05076ffe8000001" + degraded-reasons: [] + - properties: + # class: created automatically + # parent: created automatically + # element-uri: created automatically + element-id: vtr2 + name: "Virtual Tape Resource 2" + description: "Virtual tape resource 2" + device-number: "0002" + adapter-port-uri: /api/adapters/fcp1/storage-ports/1 + partition-uri: /api/partitions/part1 + world-wide-port-name-info: + status: validated + world-wide-port-name: "c05076ffe8000002" + degraded-reasons: [] + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: tlink2 + name: "Tape Link 2" + cpc-uri: /api/cpcs/cpc_dpm + description: "Tape link for testing" + tape-library-uri: /api/tape-libraries/tl1 + virtual_tape_resources: + - properties: + # class: created automatically + # parent: created automatically + # element-uri: created automatically + element-id: vtr3 + name: "Virtual Tape Resource 3" + description: "Virtual tape resource 3" + device-number: "0003" + adapter-port-uri: /api/adapters/fcp1/storage-ports/0 + partition-uri: /api/partitions/part1 + world-wide-port-name-info: + status: not-validated + world-wide-port-name: "c05076ffe8000003" + degraded-reasons: [] + + storage_fabrics: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: sfabric1 + name: "Storage Fabric A" + cpc-uri: /api/cpcs/cpc_dpm + description: "Primary storage fabric for CPC DPM" + high-integrity: false + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: sfabric2 + name: "Storage Fabric B" + cpc-uri: /api/cpcs/cpc_dpm + description: "Alternate storage fabric for CPC DPM" + high-integrity: true + + storage_sites: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: ss1 + name: Primary Site + description: Primary storage site local to the CPC + cpc-uris: + - /api/cpcs/cpc_dpm + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: ss2 + name: Alternate Site + description: Alternate storage site at a remote location + cpc-uris: + - /api/cpcs/cpc_dpm + + storage_switches: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: sw1 + name: "Storage switch 11" + domain-id: "11" + storage-fabric-uri: /api/storage-fabrics/sfabric1 + storage-site-uri: /api/storage-sites/ss1 + description: "Storage switch 11 on Fabric A at Primary Site" + port-count: 256 + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: sw2 + name: "Storage switch 21" + domain-id: "21" + storage-fabric-uri: /api/storage-fabrics/sfabric2 + storage-site-uri: /api/storage-sites/ss2 + description: "Storage switch 21 on Fabric B at Alternate Site" + port-count: 256 + + storage_subsystems: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: subsys1 + name: "DS8886 A" + description: "DS8886 A storage subsystem at Primary Site" + storage-site-uri: /api/storage-sites/ss1 + storage-control-unit-uris: + - /api/storage-control-units/cu1 + connection-endpoints: + - endpoint-uri: /api/storage-switches/sw1 + endpoint-class: storage-switch + port-id: "00" + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: subsys2 + name: "DS8886 B" + description: "DS8886 B storage subsystem at Alternate Site" + storage-site-uri: /api/storage-sites/ss2 + 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 + # parent: created automatically + # element-uri: created automatically + element-id: a + text: foo + timestamp: 1744826146533 + service-supported: true + cpcs: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: cpc_cla + name: "CPC_CLA" + description: "Mocked CPC CPC_CLA (z17 in classic mode)" + acceptable-status: [active] + additional-status: '' + auto-start-list: [] + cbu-activation-date: 0 + cbu-expiration-date: 0 + cbu-number-of-tests-left: 0 + cpc-node-descriptor: '00' + cpc-power-cap-allowed: null + cpc-power-cap-current: null + cpc-power-cap-maximum: null + cpc-power-cap-minimum: null + cpc-power-capping-state: null + cpc-power-consumption: 5818 + cpc-power-rating: 28000 + cpc-power-save-allowed: allowed + cpc-power-saving-state: high-performance + cpc-power-saving: high-performance + cpc-serial-number: "00000000CLA" + degraded-status: [not-degraded] + dpm-enabled: false + has-automatic-se-switch-enabled: false + has-hardware-messages: true + has-temporary-capacity-change-allowed: false + has-unacceptable-status: true + iml-mode: lpar + is-cbu-activated: false + is-cbu-enabled: false + is-cbu-installed: false + is-cpacf-enabled: true + is-ensemble-member: false + is-locked: false + is-on-off-cod-activated: false + is-on-off-cod-enabled: true + is-on-off-cod-installed: false + is-real-cbu-available: false + is-service-required: true + lan-interface1-address: 00106f0d81cb + lan-interface1-type: ethernet + lan-interface2-address: 00106f0d81cc + lan-interface2-type: ethernet + last-energy-advice-time: 1654609202407 + machine-model: 'AGZ' + machine-serial-number: "00000000CLA" + machine-type: '3932' + management-enablement-level: manage + maximum-alternate-storage-sites: 1 + maximum-hipersockets: 32 + maximum-partitions: 85 + msu-permanent-plus-billable: 3068 + msu-permanent-plus-temporary: 3068 + msu-permanent: 3068 + network1-ipv4-alt-ipaddr: null + network1-ipv4-mask: 255.255.254.0 + network1-ipv4-pri-ipaddr: 9.152.151.39 + network2-ipv4-alt-ipaddr: null + network2-ipv4-mask: 0.0.0.0 + network2-ipv4-pri-ipaddr: null + network2-ipv6-info: [] + on-off-cod-activation-date: 0 + processor-count-aap: 0 + processor-count-cbp: 0 + processor-count-defective: 0 + processor-count-general-purpose: 20 + processor-count-icf: 0 + processor-count-ifl: 10 + processor-count-iip: 3 + processor-count-pending-aap: 0 + processor-count-pending-cbp: 0 + processor-count-pending-general-purpose: 0 + processor-count-pending-icf: 0 + processor-count-pending-ifl: 0 + processor-count-pending-iip: 0 + processor-count-pending-service-assist: 0 + processor-count-pending: 0 + processor-count-service-assist: 5 + processor-count-spare: 0 + se-version: 2.14.1 + software-model-permanent-plus-billable: '720' + software-model-permanent-plus-temporary: '720' + software-model-permanent: '720' + status: active + storage-customer-available: 1105920 + storage-customer-central: 16384 + storage-customer-expanded: 0 + storage-customer: 1114112 + storage-hardware-system-area: 196608 + storage-total-installed: 1310720 + storage-vfm-increment-size: 16 + storage-vfm-total: 0 + zcpc-ambient-temperature: 24.0 + zcpc-dew-point: 12.199999809265137 + zcpc-exhaust-temperature: 30.0 + zcpc-heat-load-forced-air: 19865 + zcpc-heat-load-water: 0 + zcpc-heat-load: 19865 + zcpc-humidity: 47 + zcpc-maximum-potential-heat-load: 25714 + zcpc-maximum-potential-power: 7531 + zcpc-power-cap-allowed: null + zcpc-power-cap-current: null + zcpc-power-cap-maximum: null + zcpc-power-cap-minimum: null + zcpc-power-capping-state: null + zcpc-power-consumption: 5818 + zcpc-power-rating: 28000 + zcpc-power-save-allowed: under-group-control + zcpc-power-saving-state: high-performance + zcpc-power-saving: high-performance + available-features-list: + - name: dpm-storage-management + description: The DPM storage management approach in which FCP and FICON storage + resources are defined in Storage Groups, which are attached to Partitions. + state: false + ec-mcl-description: + action: + - pending: false + type: channel-config + activation: current + - pending: false + type: coupling-facility-reactivation + activation: current + - pending: false + type: power-on-reset-tracking + activation: current + - pending: false + type: zhybrid-blades-activation + activation: current + - pending: false + type: channel-config + activation: next + - pending: false + type: coupling-facility-reactivation + activation: next + - pending: false + type: power-on-reset-tracking + activation: next + - pending: false + type: zhybrid-blades-activation + activation: next + ec: + - number: P41414 + part-number: 01LL001 + mcl: + - level: '373' + type: retrieved + last-update: 1650509077817 + - level: '373' + type: activated + last-update: 1650531050817 + - level: '364' + type: accepted + last-update: 1649742886817 + - level: '373' + type: installable-concurrent + last-update: 943916400817 + - level: '365' + type: removable-concurrent + last-update: 943916400817 + description: SE Framework + type: Base EC + - number: P41415 + part-number: 01LL002 + mcl: + - level: '000' + type: retrieved + last-update: null + - level: '000' + type: activated + last-update: null + - level: '000' + type: accepted + last-update: null + - level: '000' + type: installable-concurrent + last-update: null + - level: '000' + type: removable-concurrent + last-update: null + description: Concurrent Upgrade Sync Point + type: Other Optional EC + network1-ipv6-info: + - prefix: 64 + pri-ip-address: fe80::210:6fff:fe0d:81cb%eth0 + type: link-local + alt-ip-address: fe80::210:6fff:fe0d:820b%eth0 + lpars: + - properties: + object-id: lpar1 + name: "LPAR1" + description: "Lpar LPAR1 on CPC_CLA" + partition-number: 0x41 + partition-identifier: 0x41 + status: "operating" + activation-mode: "linux" + next-activation-profile-name: "LPAR1" + last-used-activation-profile: "LPAR1" + has-unacceptable-status: false + has-operating-system-messages: true + - properties: + object-id: lpar2 + name: "LPAR2" + description: "Lpar LPAR2 on CPC_CLA" + partition-number: 0x42 + partition-identifier: 0x42 + status: "operating" + activation-mode: "ssc" + next-activation-profile-name: "LPAR2" + last-used-activation-profile: "LPAR2" + has-unacceptable-status: false + has-operating-system-messages: true + ssc-host-name: lpar2 + ssc-master-userid: sscuser + ssc-master-pw: sscpass + ssc-boot-selection: installer + - properties: + object-id: lpar9 + name: "LPAR9" + description: "Lpar LPAR9 on CPC_CLA" + partition-number: 0x49 + partition-identifier: 0x49 + status: "not-activated" + activation-mode: "not-set" + next-activation-profile-name: "LPAR9" + last-used-activation-profile: "LPAR9" + has-unacceptable-status: true + has-operating-system-messages: false + reset_activation_profiles: + - properties: + name: "CPC_CLA" + description: "Reset activation profile CPC_CLA" + iocds-name: "ABC" + load_activation_profiles: + - properties: + name: "STANDARDLOAD" + description: "Load activation profile STANDARDLOAD" + ipl-type: "ipltype-standard" + ipl-address: "189AB" + - properties: + name: "SCSILOAD" + description: "Load activation profile SCSILOAD" + ipl-type: "ipltype-scsi" + worldwide-port-name: "1234" + logical-unit-number: "1234" + boot-record-lba: "1234" + disk-partition-id: 0 + image_activation_profiles: + - properties: + name: "LPAR1" + description: "Image activation profile LPAR1 for Linux" + operating-mode: "linux-only" + # TODO: Add more properties + - properties: + name: "LPAR2" + description: "Image activation profile LPAR2 for SSC" + operating-mode: "ssc" + ssc-host-name: lpar2 + ssc-master-userid: sscuser + ssc-master-pw: sscpass + ssc-boot-selection: installer + # TODO: Add more properties + - properties: + name: "ZAWARE" + description: "Image activation profile for zAware" + operating-mode: "zaware" + zaware-host-name: zaware + zaware-master-userid: zawareuser + zaware-master-pw: zawarepass + # TODO: Add more properties + hw_messages: + - properties: + # class: created automatically + # parent: created automatically + # element-uri: created automatically + element-id: a + text: foo + timestamp: 1744826146533 + service-supported: true + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: cpc_dpm + name: "CPC_DPM" + description: "Mocked CPC CPC_DPM (z17 in DPM mode)" + acceptable-status: [active] + additional-status: '' + auto-start-list: [] + cbu-activation-date: 0 + cbu-expiration-date: 0 + cbu-number-of-tests-left: 0 + cpc-node-descriptor: '00' + cpc-power-cap-allowed: null + cpc-power-cap-current: null + cpc-power-cap-maximum: null + cpc-power-cap-minimum: null + cpc-power-capping-state: null + cpc-power-consumption: 5818 + cpc-power-rating: 28000 + cpc-power-save-allowed: allowed + cpc-power-saving-state: high-performance + cpc-power-saving: high-performance + cpc-serial-number: "00000000DPM" + degraded-status: [not-degraded] + dpm-enabled: true + has-automatic-se-switch-enabled: false + has-hardware-messages: true + has-temporary-capacity-change-allowed: false + has-unacceptable-status: true + iml-mode: dpm + is-cbu-activated: false + is-cbu-enabled: false + is-cbu-installed: false + is-cpacf-enabled: true + is-ensemble-member: false + is-locked: false + is-on-off-cod-activated: false + is-on-off-cod-enabled: true + is-on-off-cod-installed: false + is-real-cbu-available: false + is-service-required: true + lan-interface1-address: 00106f0d81cb + lan-interface1-type: ethernet + lan-interface2-address: 00106f0d81cc + lan-interface2-type: ethernet + last-energy-advice-time: 1654609202407 + machine-model: 'AGZ' + machine-serial-number: "00000000DPM" + machine-type: '3932' + management-enablement-level: manage + maximum-alternate-storage-sites: 1 + maximum-hipersockets: 32 + maximum-partitions: 85 + msu-permanent-plus-billable: 3068 + msu-permanent-plus-temporary: 3068 + msu-permanent: 3068 + network1-ipv4-alt-ipaddr: null + network1-ipv4-mask: 255.255.254.0 + network1-ipv4-pri-ipaddr: 9.152.151.39 + network2-ipv4-alt-ipaddr: null + network2-ipv4-mask: 0.0.0.0 + network2-ipv4-pri-ipaddr: null + network2-ipv6-info: [] + on-off-cod-activation-date: 0 + processor-count-aap: 0 + processor-count-cbp: 0 + processor-count-defective: 0 + processor-count-general-purpose: 20 + processor-count-icf: 0 + processor-count-ifl: 10 + processor-count-iip: 3 + processor-count-pending-aap: 0 + processor-count-pending-cbp: 0 + processor-count-pending-general-purpose: 0 + processor-count-pending-icf: 0 + processor-count-pending-ifl: 0 + processor-count-pending-iip: 0 + processor-count-pending-service-assist: 0 + processor-count-pending: 0 + processor-count-service-assist: 5 + processor-count-spare: 0 + se-version: 2.14.1 + software-model-permanent-plus-billable: '720' + software-model-permanent-plus-temporary: '720' + software-model-permanent: '720' + status: active + storage-customer-available: 1105920 + storage-customer-central: 16384 + storage-customer-expanded: 0 + storage-customer: 1114112 + storage-hardware-system-area: 196608 + storage-total-installed: 1310720 + storage-vfm-increment-size: 16 + storage-vfm-total: 0 + zcpc-ambient-temperature: 24.0 + zcpc-dew-point: 12.199999809265137 + zcpc-exhaust-temperature: 30.0 + zcpc-heat-load-forced-air: 19865 + zcpc-heat-load-water: 0 + zcpc-heat-load: 19865 + zcpc-humidity: 47 + zcpc-maximum-potential-heat-load: 25714 + zcpc-maximum-potential-power: 7531 + zcpc-power-cap-allowed: null + zcpc-power-cap-current: null + zcpc-power-cap-maximum: null + zcpc-power-cap-minimum: null + zcpc-power-capping-state: null + zcpc-power-consumption: 5818 + zcpc-power-rating: 28000 + zcpc-power-save-allowed: under-group-control + zcpc-power-saving-state: high-performance + zcpc-power-saving: high-performance + available-features-list: + - name: dpm-storage-management + description: The DPM storage management approach in which FCP and FICON storage + resources are defined in Storage Groups, which are attached to Partitions. + state: true + ec-mcl-description: + action: + - pending: false + type: channel-config + activation: current + - pending: false + type: coupling-facility-reactivation + activation: current + - pending: false + type: power-on-reset-tracking + activation: current + - pending: false + type: zhybrid-blades-activation + activation: current + - pending: false + type: channel-config + activation: next + - pending: false + type: coupling-facility-reactivation + activation: next + - pending: false + type: power-on-reset-tracking + activation: next + - pending: false + type: zhybrid-blades-activation + activation: next + ec: + - number: P41414 + part-number: 01LL001 + mcl: + - level: '373' + type: retrieved + last-update: 1650509077817 + - level: '373' + type: activated + last-update: 1650531050817 + - level: '364' + type: accepted + last-update: 1649742886817 + - level: '373' + type: installable-concurrent + last-update: 943916400817 + - level: '365' + type: removable-concurrent + last-update: 943916400817 + description: SE Framework + type: Base EC + - number: P41415 + part-number: 01LL002 + mcl: + - level: '000' + type: retrieved + last-update: null + - level: '000' + type: activated + last-update: null + - level: '000' + type: accepted + last-update: null + - level: '000' + type: installable-concurrent + last-update: null + - level: '000' + type: removable-concurrent + last-update: null + description: Concurrent Upgrade Sync Point + type: Other Optional EC + network1-ipv6-info: + - prefix: 64 + pri-ip-address: fe80::210:6fff:fe0d:81cb%eth0 + type: link-local + alt-ip-address: fe80::210:6fff:fe0d:820b%eth0 + capacity_groups: + - properties: + # class: created automatically + # parent: created automatically + # element-uri: created automatically + element-id: testgroup + name: TESTGROUP + description: 'Capacity group TESTGROUP' + absolute-general-purpose-proc-cap: 5.0 + absolute-ifl-proc-cap: 0.0 + capping-enabled: true + class: capacity-group + short-name: TESTGRPT + partition-uris: + - /api/partitions/0a796b76-e9e6-11eb-80e2-00106f0d81c9 + adapters: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + object-id: osa1 + name: "OSA1" + description: "Mocked OSA adapter OSA1 in CPC_DPM" + adapter-id: "108" + channel-path-id: "010" + port-count: 2 + status: active + state: online + physical-channel-status: operating + type: osd + adapter-family: osa + detected-card-type: "osa-express-6s-1000base-t" + card-location: "A14B-D104J.01-D204J.01" + configured-capacity: 63 + used-capacity: 5 + allowed-capacity: 10 + maximum-total-capacity: 1920 + ports: + - properties: + element-id: port0 + name: "Port 0" + description: "Mocked port 0 of OSA adapter OSA1 in CPC_DPM" + index: 0 + - properties: + element-id: port1 + name: "Port 1" + description: "Mocked port 1 of OSA adapter OSA1 in CPC_DPM" + index: 1 + - properties: + object-id: fcp1 + name: "FCP1" + description: "Mocked FCP adapter FCP1 in CPC_DPM" + adapter-id: "118" + channel-path-id: "020" + port-count: 1 + status: active + state: online + physical-channel-status: operating + type: fcp + adapter-family: ficon + detected-card-type: "ficon-express-16s" + card-location: "A14B-D114J.01" + configured-capacity: 63 + used-capacity: 5 + allowed-capacity: 10 + maximum-total-capacity: 1920 + ports: + - properties: + element-id: port0 + name: "Port 0" + description: "Mocked port 0 of FCP adapter FCP1 in CPC_DPM" + index: 0 + virtual_switches: + - properties: + object-id: vs-osa1 + name: "Virtual Switch for OSA1" + description: "Mocked Virtual Switch for OSA adapter OSA1 in CPC_DPM" + backing-adapter-uri: /api/adapters/osa1 + port: 0 + partitions: + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + # nic-uris: created automatically + # hba-uris: created automatically + # virtual-function-uris: created automatically + # storage-group-uris: created automatically + object-id: part1 + name: "PART1" + description: "Linux partition PART1 on CPC_DPM" + acceptable-status: [active] + access-basic-counter-set: false + access-basic-sampling: false + access-coprocessor-group-set: false + access-crypto-activity-counter-set: false + access-diagnostic-sampling: false + access-extended-counter-set: false + access-global-performance-data: false + access-problem-state-counter-set: false + auto-start: false + autogenerate-partition-id: true + boot-configuration-selector: 0 + boot-device: test-operating-system + boot-ftp-host: null + boot-ftp-insfile: null + boot-ftp-username: null + boot-iso-image-name: null + boot-iso-ins-file: null + boot-logical-unit-number: '' + boot-network-device: null + boot-os-specific-parameters: '' + boot-load-parameters: '' + boot-record-lba: '0' + boot-removable-media-type: null + boot-removable-media: null + boot-storage-device: null + boot-storage-volume: null + boot-timeout: 60 + boot-world-wide-port-name: '' + cp-absolute-processor-capping-value: 1.0 + cp-absolute-processor-capping: false + cp-processing-weight-capped: false + cp-processors: 1 + crypto-configuration: null + current-cp-processing-weight: 1 + current-ifl-processing-weight: 1 + degraded-adapters: [] + has-unacceptable-status: true + ifl-absolute-processor-capping-value: 1.0 + ifl-absolute-processor-capping: false + ifl-processing-weight-capped: false + ifl-processors: 0 + initial-cp-processing-weight: 100 + initial-ifl-processing-weight: 100 + initial-memory: 4096 + ipl-load-parameter: '' + is-locked: false + maximum-cp-processing-weight: 999 + maximum-ifl-processing-weight: 999 + maximum-memory: 4096 + minimum-cp-processing-weight: 1 + minimum-ifl-processing-weight: 1 + os-name: '' + os-type: '' + os-version: '' + partition-id: null + permit-aes-key-import-functions: true + permit-cross-partition-commands: false + permit-des-key-import-functions: true + processor-management-enabled: false + processor-mode: shared + reserve-resources: false + reserved-memory: 0 + short-name: PAR2 + status: stopped + threads-per-processor: 1 + type: linux + ssc-ipv4-gateway: null + ssc-ipv6-gateway: null + available-features-list: + - name: dpm-storage-management + description: The DPM storage management approach in which FCP and FICON + storage resources are defined in Storage Groups, which are attached to + Partitions. + state: true + nics: + - properties: + element-id: nic1 + name: "OSA1-NIC1" + description: "NIC1 backed by OSA1" + type: osd + device-number: '0010' + virtual-switch-uri: /api/virtual-switches/vs-osa1 + - properties: + # class: created automatically + # parent: created automatically + # object-uri: created automatically + # nic-uris: created automatically + # hba-uris: created automatically + # virtual-function-uris: created automatically + # storage-group-uris: created automatically + object-id: part2 + name: "PART2" + description: "SSC partition PART2 on CPC_DPM" + acceptable-status: [active] + access-basic-counter-set: false + access-basic-sampling: false + access-coprocessor-group-set: false + access-crypto-activity-counter-set: false + access-diagnostic-sampling: false + access-extended-counter-set: false + access-global-performance-data: false + access-problem-state-counter-set: false + auto-start: false + autogenerate-partition-id: true + boot-configuration-selector: 0 + boot-device: test-operating-system + boot-ftp-host: null + boot-ftp-insfile: null + boot-ftp-username: null + boot-iso-image-name: null + boot-iso-ins-file: null + boot-logical-unit-number: '' + boot-network-device: null + boot-os-specific-parameters: '' + boot-load-parameters: '' + boot-record-lba: '0' + boot-removable-media-type: null + boot-removable-media: null + boot-storage-device: null + boot-storage-volume: null + boot-timeout: 60 + boot-world-wide-port-name: '' + cp-absolute-processor-capping-value: 1.0 + cp-absolute-processor-capping: false + cp-processing-weight-capped: false + cp-processors: 1 + crypto-configuration: null + current-cp-processing-weight: 1 + current-ifl-processing-weight: 1 + degraded-adapters: [] + has-unacceptable-status: true + ifl-absolute-processor-capping-value: 1.0 + ifl-absolute-processor-capping: false + ifl-processing-weight-capped: false + ifl-processors: 0 + initial-cp-processing-weight: 100 + initial-ifl-processing-weight: 100 + initial-memory: 4096 + ipl-load-parameter: '' + is-locked: false + maximum-cp-processing-weight: 999 + maximum-ifl-processing-weight: 999 + maximum-memory: 4096 + minimum-cp-processing-weight: 1 + minimum-ifl-processing-weight: 1 + os-name: '' + os-type: '' + os-version: '' + partition-id: null + permit-aes-key-import-functions: true + permit-cross-partition-commands: false + permit-des-key-import-functions: true + processor-management-enabled: false + processor-mode: shared + reserve-resources: false + reserved-memory: 0 + short-name: PAR2 + status: stopped + threads-per-processor: 1 + type: ssc + ssc-host-name: part2 + ssc-master-userid: sscuser + ssc-master-pw: sscpass + ssc-boot-selection: installer + ssc-ipv4-gateway: 10.11.12.13 + ssc-ipv6-gateway: "10::11" + ssc-dns-servers: + - 10.11.12.2 + available-features-list: + - name: dpm-storage-management + description: The DPM storage management approach in which FCP and FICON + storage resources are defined in Storage Groups, which are attached to + Partitions. + state: true + nics: + - properties: + element-id: nic1 + name: "OSA1-NIC1" + description: "NIC1 backed by OSA1" + type: osd + device-number: '0010' + virtual-switch-uri: /api/virtual-switches/vs-osa1 + hw_messages: + - properties: + # class: created automatically + # parent: created automatically + # element-uri: created automatically + element-id: a + text: foo + timestamp: 1744826146533 + service-supported: true 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/end2end/test_storage_site.py b/tests/end2end/test_storage_site.py new file mode 100644 index 00000000..3b75e69d --- /dev/null +++ b/tests/end2end/test_storage_site.py @@ -0,0 +1,439 @@ +# 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 Site (on CPCs in DPM mode). + +These tests do not change any existing primary Storage Site, but create, +modify, and delete alternate test Storage Sites. + +A primary site (with default name "Primary Site") exists by default and cannot +be deleted. Alternate sites can be created and deleted. +""" + + +import uuid +import pytest +from requests.packages import urllib3 + +import zhmcclient + +from .utils import skip_log, pick_test_resources, TEST_PREFIX, \ + runtest_find_list, runtest_get_properties + +urllib3.disable_warnings() + +# Properties in minimalistic StorageSite objects (e.g. find_by_name()) +SS_MINIMAL_PROPS = ['object-uri', 'name'] + +# Properties in StorageSite objects returned by list() without full props +SS_LIST_PROPS = ['object-uri', 'name', 'cpc-uris'] + +# Properties whose values can change between retrievals of StorageSite objs +SS_VOLATILE_PROPS = [] + + +def test_ss_find_list(zhmc_logger, hmc_session): + """ + Test list(), find(), findall() on existing storage sites. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + # Pick the storage sites to test with + ss_list = console.storage_sites.list() + if not ss_list: + skip_log(zhmc_logger, + f"No storage sites defined on HMC {hd.host}") + ss_list = pick_test_resources(ss_list) + + for ss in ss_list: + print(f"Testing with Storage Site {ss.name!r}") + runtest_find_list( + hmc_session, console.storage_sites, ss.name, + 'name', 'object-uri', SS_VOLATILE_PROPS, + SS_MINIMAL_PROPS, SS_LIST_PROPS) + + +def test_ss_property(zhmc_logger, hmc_session): + """ + Test property-related methods (pull_full_properties, get_property, etc.) + on existing storage sites. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + # Pick the storage sites to test with + ss_list = console.storage_sites.list() + if not ss_list: + skip_log(zhmc_logger, + f"No storage sites defined on HMC {hd.host}") + ss_list = pick_test_resources(ss_list) + + for ss in ss_list: + print(f"Testing with Storage Site {ss.name!r}") + + # Select a property that is not returned by list() + non_list_prop = 'description' + + runtest_get_properties(ss.manager, non_list_prop) + + +def test_ss_list_filter_by_name(zhmc_logger, hmc_session): + """ + Test list() with a name filter argument. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + # Get all storage sites + all_sites = console.storage_sites.list() + if not all_sites: + skip_log(zhmc_logger, + f"No storage sites defined on HMC {hd.host}") + + # Pick one site to filter by + target_site = all_sites[0] + target_name = target_site.name + + # The code to be tested: list() with name filter + filtered_sites = console.storage_sites.list( + filter_args={'name': target_name}) + + assert len(filtered_sites) >= 1, ( + f"Expected at least 1 site with name {target_name!r}, got 0") + names = [s.name for s in filtered_sites] + assert target_name in names, ( + f"Expected site {target_name!r} in filtered list {names!r}") + + +def test_ss_list_short_and_full_props(zhmc_logger, hmc_session): + """ + Test list() returns correct properties in short and full-properties mode. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + all_sites = console.storage_sites.list() + if not all_sites: + skip_log(zhmc_logger, + f"No storage sites defined on HMC {hd.host}") + + # Verify that list() (short props) includes the expected properties + for ss in all_sites: + for prop_name in SS_LIST_PROPS: + assert prop_name in ss.properties, ( + f"Expected property {prop_name!r} missing in list() result " + f"for storage site {ss.name!r}") + + # Verify that list(full_properties=True) includes at least all short props + # plus 'description' + full_sites = console.storage_sites.list(full_properties=True) + for ss in full_sites: + for prop_name in SS_LIST_PROPS + ['description']: + assert prop_name in ss.properties, ( + f"Expected property {prop_name!r} missing in full list() " + f"result for storage site {ss.name!r}") + + +def test_ss_crud(zhmc_logger, hmc_session): + """ + Test create, read, update and delete an alternate storage site. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + ss_name = f"{TEST_PREFIX}_crud_{uuid.uuid4().hex}" + ss_name_new = ss_name + '_renamed' + + # Find a DPM-mode CPC on this HMC to use as the cpc-uri + cpcs = client.cpcs.list(filter_args={'dpm-enabled': True}) + if not cpcs: + skip_log(zhmc_logger, + f"No DPM-mode CPC on HMC {hd.host} for storage site test") + cpc = cpcs[0] + + ss_input_props = { + 'name': ss_name, + 'description': 'Test storage site for zhmcclient end2end tests', + 'cpc-uris': [cpc.uri], + } + + ss = None + try: + # Test creating a storage site + print(f"Creating storage site {ss_name!r}") + + # The code to be tested + ss = console.storage_sites.create(ss_input_props) + + assert isinstance(ss, zhmcclient.StorageSite), ( + f"Expected StorageSite, got {type(ss)}") + + # Verify the initial properties match what was specified + for pn, exp_value in ss_input_props.items(): + assert ss.properties[pn] == exp_value, ( + f"Unexpected value for property {pn!r} of storage site:\n" + f"{ss.properties!r}") + + # Verify after pulling full properties + ss.pull_full_properties() + for pn, exp_value in ss_input_props.items(): + assert ss.properties[pn] == exp_value, ( + f"Unexpected value for property {pn!r} after pull:\n" + f"{ss.properties!r}") + + # Test that the storage site can be found by name + found_ss = console.storage_sites.find(name=ss_name) + assert found_ss.name == ss_name + + # Test updating the description + new_desc = 'Updated storage site description for zhmcclient tests' + print(f"Updating description of storage site {ss_name!r}") + + # The code to be tested + ss.update_properties({'description': new_desc}) + + assert ss.properties['description'] == new_desc + ss.pull_full_properties() + assert ss.properties['description'] == new_desc + + # Test renaming the storage site + print(f"Renaming storage site {ss_name!r} to {ss_name_new!r}") + + # The code to be tested + ss.update_properties({'name': ss_name_new}) + + assert ss.properties['name'] == ss_name_new + ss.pull_full_properties() + assert ss.properties['name'] == ss_name_new + + # Verify the old name is gone + with pytest.raises(zhmcclient.NotFound): + console.storage_sites.find(name=ss_name) + + # Verify the new name can be found + found_ss = console.storage_sites.find(name=ss_name_new) + assert found_ss.name == ss_name_new + + finally: + # Test deleting the storage site + if ss: + print(f"Deleting storage site {ss.name!r}") + + # The code to be tested + ss.delete() + + with pytest.raises(zhmcclient.NotFound): + console.storage_sites.find(name=ss_name_new) + + +SS_CREATE_DELETE_TESTCASES = [ + # Testcases for test_ss_create_delete(). + # Each item is a tuple: + # - desc (string): Description of the testcase. + # - input_props (dict): Input properties for create(), excluding 'name' + # and 'cpc-uris' which are added by the test function. + # - exp_props (dict): Expected properties in the created storage site, + # beyond what was specified as input (i.e. server-set defaults). + # 'name' and 'cpc-uris' are checked separately. + # - exp_exc_type (class or None): Exception type expected from create(), + # or None if no exception is expected. + ( + "Minimal input properties (name + cpc-uris only)", + {}, + { + 'description': '', # default empty string + 'class': 'storage-site', + }, + None, + ), + ( + "With description", + { + 'description': 'Alternate site with description', + }, + { + 'description': 'Alternate site with description', + 'class': 'storage-site', + }, + None, + ), +] + + +@pytest.mark.parametrize( + "desc, input_props, exp_props, exp_exc_type", + SS_CREATE_DELETE_TESTCASES) +def test_ss_create_delete( + zhmc_logger, hmc_session, + desc, input_props, exp_props, exp_exc_type): + # pylint: disable=unused-argument + """ + Test creation of a storage site with various property combinations, + and verify properties, then delete it. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + # Find a DPM-mode CPC to reference + cpcs = client.cpcs.list(filter_args={'dpm-enabled': True}) + if not cpcs: + skip_log(zhmc_logger, + f"No DPM-mode CPC on HMC {hd.host} for storage site test") + cpc = cpcs[0] + + ss_name = f"{TEST_PREFIX}_cre_del_{uuid.uuid4().hex}" + + ss_input_props = { + 'name': ss_name, + 'cpc-uris': [cpc.uri], + } + ss_input_props.update(input_props) + + ss = None + try: + if exp_exc_type: + with pytest.raises(exp_exc_type): + # The code to be tested + ss = console.storage_sites.create(ss_input_props) + else: + # The code to be tested + ss = console.storage_sites.create(ss_input_props) + + assert isinstance(ss, zhmcclient.StorageSite) + + ss.pull_full_properties() + + # Verify specified input properties + for pn, exp_value in ss_input_props.items(): + assert ss.properties[pn] == exp_value, ( + f"Property {pn!r}: expected {exp_value!r}, " + f"got {ss.properties.get(pn)!r}") + + # Verify expected server-side properties + for pn, exp_value in exp_props.items(): + assert pn in ss.properties, ( + f"Expected property {pn!r} not found in created site") + assert ss.properties[pn] == exp_value, ( + f"Property {pn!r}: expected {exp_value!r}, " + f"got {ss.properties[pn]!r}") + + finally: + if ss: + ss.delete() + with pytest.raises(zhmcclient.NotFound): + console.storage_sites.find(name=ss_name) + + +def test_ss_update_properties(zhmc_logger, hmc_session): + """ + Test update_properties() on an alternate storage site. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + # Find a DPM-mode CPC to reference + cpcs = client.cpcs.list(filter_args={'dpm-enabled': True}) + if not cpcs: + skip_log(zhmc_logger, + f"No DPM-mode CPC on HMC {hd.host} for storage site test") + cpc = cpcs[0] + + ss_name = f"{TEST_PREFIX}_upd_{uuid.uuid4().hex}" + + ss = None + try: + ss = console.storage_sites.create({ + 'name': ss_name, + 'description': 'Original description', + 'cpc-uris': [cpc.uri], + }) + + # Test updating description + new_desc = 'Updated description for end2end test' + ss.update_properties({'description': new_desc}) + assert ss.properties['description'] == new_desc + ss.pull_full_properties() + assert ss.properties['description'] == new_desc + + # Test updating to empty description + ss.update_properties({'description': ''}) + assert ss.properties['description'] == '' + ss.pull_full_properties() + assert ss.properties['description'] == '' + + finally: + if ss: + ss.delete() + + +def test_ss_delete_nonexistent(zhmc_logger, hmc_session): + """ + Test that deleting a storage site that was already deleted raises NotFound + on a subsequent find(). + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + # Find a DPM-mode CPC to reference + cpcs = client.cpcs.list(filter_args={'dpm-enabled': True}) + if not cpcs: + skip_log(zhmc_logger, + f"No DPM-mode CPC on HMC {hd.host} for storage site test") + cpc = cpcs[0] + + ss_name = f"{TEST_PREFIX}_del2_{uuid.uuid4().hex}" + + ss = console.storage_sites.create({ + 'name': ss_name, + 'cpc-uris': [cpc.uri], + }) + + # Delete it once — should succeed + ss.delete() + + # Verify it is gone + with pytest.raises(zhmcclient.NotFound): + console.storage_sites.find(name=ss_name) + + +def test_ss_zzz_cleanup(zhmc_logger, hmc_session): + # pylint: disable=unused-argument + """ + Cleanup any test storage sites that may have been left over from prior + test runs (e.g. due to test failures). + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + + name_pattern = fr'{TEST_PREFIX}.*' + sites = console.storage_sites.findall(name=name_pattern) + for ss in sites: + + print("Deleting leftover test storage site from a prior run: " + f"{ss.name!r} - please open a zhmcclient issue for that") + + try: + ss.delete() + except zhmcclient.HTTPError as exc: + print(f"HTTPError during StorageSite.delete(): {exc}") diff --git a/tests/end2end/test_storage_subsystem.py b/tests/end2end/test_storage_subsystem.py new file mode 100644 index 00000000..f5a715d2 --- /dev/null +++ b/tests/end2end/test_storage_subsystem.py @@ -0,0 +1,362 @@ +# 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 Subsystems (on CPCs in DPM mode). + +These tests exercise the list, get-properties, update-properties, +move-to-storage-site, add-connection-endpoint, and remove-connection-endpoint +operations. They do not permanently modify existing storage subsystems but +may temporarily create or update test resources where permitted. +""" + + +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 StorageSubsystem objects (e.g. find_by_name()) +SSUB_MINIMAL_PROPS = ['object-uri', 'name'] + +# Properties in StorageSubsystem objects returned by list() without full props +SSUB_LIST_PROPS = ['object-uri', 'name', 'storage-site-uri'] + +# Properties whose values can change between retrievals +SSUB_VOLATILE_PROPS = [] + + +def test_ssub_find_list(hmc_session): + """ + Test list(), find(), findall() for Storage Subsystems. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + subsystem_list = console.storage_subsystems.list() + if not subsystem_list: + skip_warn(f"No Storage Subsystems defined on HMC {hd.host}") + + subsystem_list = pick_test_resources(subsystem_list) + + for subsystem in subsystem_list: + print(f"Testing with Storage Subsystem {subsystem.name!r}") + runtest_find_list( + hmc_session, console.storage_subsystems, subsystem.name, + 'name', 'object-uri', SSUB_VOLATILE_PROPS, + SSUB_MINIMAL_PROPS, SSUB_LIST_PROPS) + + +def test_ssub_property(hmc_session): + """ + Test property related methods for Storage Subsystems. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + subsystem_list = console.storage_subsystems.list() + if not subsystem_list: + skip_warn(f"No Storage Subsystems defined on HMC {hd.host}") + + subsystem_list = pick_test_resources(subsystem_list) + + for subsystem in subsystem_list: + print(f"Testing with Storage Subsystem {subsystem.name!r}") + + # 'class' is not returned by list(), so use it as the non_list_prop + non_list_prop = 'class' + + runtest_get_properties(subsystem.manager, non_list_prop) + + +def test_ssub_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 + + subsystem_list = console.storage_subsystems.list() + if not subsystem_list: + skip_warn(f"No Storage Subsystems defined on HMC {hd.host}") + + subsystem_list = pick_test_resources(subsystem_list) + + for subsystem in subsystem_list: + print(f"Testing with Storage Subsystem {subsystem.name!r}") + + subsystem.pull_full_properties() + + # Mandatory properties from the data model + for prop in ('object-uri', 'object-id', 'class', 'parent', + 'name', 'storage-site-uri', + 'connection-endpoints', 'storage-control-unit-uris'): + assert prop in subsystem.properties, ( + f"Expected property {prop!r} missing from " + f"Storage Subsystem {subsystem.name!r}") + + assert subsystem.properties['class'] == 'storage-subsystem' + assert isinstance(subsystem.properties['connection-endpoints'], list) + + +def test_ssub_list_filter_name(hmc_session): + """ + Test list() with name filter for Storage Subsystems. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + subsystem_list = console.storage_subsystems.list() + if not subsystem_list: + skip_warn(f"No Storage Subsystems defined on HMC {hd.host}") + + subsystem = subsystem_list[0] + subsystem_name = subsystem.name + + filtered = console.storage_subsystems.list( + filter_args={'name': subsystem_name}) + + assert len(filtered) >= 1 + names = [s.name for s in filtered] + assert subsystem_name in names + print(f"name filter returned {len(filtered)} subsystem(s) " + f"for name {subsystem_name!r}") + + +def test_ssub_list_filter_storage_site_uri(hmc_session): + """ + Test list() with storage-site-uri filter for Storage Subsystems. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + subsystem_list = console.storage_subsystems.list() + if not subsystem_list: + skip_warn(f"No Storage Subsystems defined on HMC {hd.host}") + + subsystem = subsystem_list[0] + subsystem.pull_full_properties() + site_uri = subsystem.properties.get('storage-site-uri') + if not site_uri: + skip_warn( + f"Storage Subsystem {subsystem.name!r} has no storage-site-uri") + + filtered = console.storage_subsystems.list( + filter_args={'storage-site-uri': site_uri}) + + assert len(filtered) >= 1 + for s in filtered: + s.pull_full_properties() + assert s.properties['storage-site-uri'] == site_uri + print(f"storage-site-uri filter returned {len(filtered)} subsystem(s)") + + +def test_ssub_list_short_and_full_props(hmc_session): + """ + Test that list() returns correct properties in short and full-props mode. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + all_subsystems = console.storage_subsystems.list() + if not all_subsystems: + skip_warn(f"No Storage Subsystems defined on HMC {hd.host}") + + # Verify short list props are present + for ss in all_subsystems: + for prop_name in SSUB_LIST_PROPS: + assert prop_name in ss.properties, ( + f"Expected property {prop_name!r} missing in list() result " + f"for storage subsystem {ss.name!r}") + + # Verify full list props include at least short props + 'description' + full_subsystems = console.storage_subsystems.list(full_properties=True) + for ss in full_subsystems: + for prop_name in SSUB_LIST_PROPS + ['description']: + assert prop_name in ss.properties, ( + f"Expected property {prop_name!r} missing in full list() " + f"result for storage subsystem {ss.name!r}") + + +def test_ssub_list_by_site(hmc_session): + """ + Test listing storage subsystems associated with a storage site via the + storage-sites sub-resource endpoint. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + subsystem_list = console.storage_subsystems.list() + if not subsystem_list: + skip_warn(f"No Storage Subsystems defined on HMC {hd.host}") + + # Pull full properties to find a site URI + subsystem = subsystem_list[0] + subsystem.pull_full_properties() + site_uri = subsystem.properties.get('storage-site-uri') + if not site_uri: + skip_warn( + f"Storage Subsystem {subsystem.name!r} has no storage-site-uri") + + site_id = site_uri.split('/')[-1] + + result = hmc_session.get( + f'/api/storage-sites/{site_id}/storage-subsystems') + + assert 'storage-subsystems' in result + returned_uris = {ss['object-uri'] for ss in result['storage-subsystems']} + assert subsystem.uri in returned_uris + print(f"Site {site_uri!r} has " + f"{len(result['storage-subsystems'])} subsystem(s)") + + +def test_ssub_update_description(hmc_session): + """ + Test update_properties() – update the description of an existing + storage subsystem and verify the change, then restore the original value. + + This test modifies an existing storage subsystem temporarily. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + subsystem_list = console.storage_subsystems.list() + if not subsystem_list: + skip_warn(f"No Storage Subsystems defined on HMC {hd.host}") + + subsystem = pick_test_resources(subsystem_list)[0] + subsystem.pull_full_properties() + original_desc = subsystem.properties.get('description', '') + + new_desc = f"{original_desc}__zhmcclient_e2e_test" + + try: + # The code to be tested: Update description + subsystem.update_properties({'description': new_desc}) + + assert subsystem.properties['description'] == new_desc + subsystem.pull_full_properties() + assert subsystem.properties['description'] == new_desc + print(f"Updated description of Storage Subsystem " + f"{subsystem.name!r}") + + finally: + # Restore original description + try: + subsystem.update_properties({'description': original_desc}) + print(f"Restored description of Storage Subsystem " + f"{subsystem.name!r}") + except zhmcclient.HTTPError as exc: + print(f"Failed to restore description of storage subsystem " + f"{subsystem.name!r}: {exc}") + + +def test_ssub_connection_endpoints(hmc_session): + """ + Test that connection-endpoints property is accessible and has the + correct structure. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + subsystem_list = console.storage_subsystems.list() + if not subsystem_list: + skip_warn(f"No Storage Subsystems defined on HMC {hd.host}") + + for subsystem in pick_test_resources(subsystem_list): + subsystem.pull_full_properties() + endpoints = subsystem.properties.get('connection-endpoints', []) + + print(f"Storage Subsystem {subsystem.name!r} has " + f"{len(endpoints)} connection endpoint(s)") + + for ep in endpoints: + assert 'endpoint-uri' in ep, ( + f"Missing 'endpoint-uri' in connection endpoint of " + f"subsystem {subsystem.name!r}: {ep!r}") + assert 'endpoint-class' in ep, ( + f"Missing 'endpoint-class' in connection endpoint of " + f"subsystem {subsystem.name!r}: {ep!r}") + assert ep['endpoint-class'] in ('storage-switch', 'adapter'), ( + f"Unexpected endpoint-class {ep['endpoint-class']!r} in " + f"subsystem {subsystem.name!r}") + + +def test_ssub_move_to_storage_site(hmc_session): + """ + Test the move-to-storage-site operation for a Storage Subsystem. + + Requires at least two Storage Sites and at least one Storage Subsystem. + This test moves the subsystem to the second site and then moves it back + to the original site so no persistent change is made. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + subsystem_list = console.storage_subsystems.list() + if not subsystem_list: + skip_warn(f"No Storage Subsystems defined on HMC {hd.host}") + + site_list = console.storage_sites.list() + if len(site_list) < 2: + skip_warn( + f"Need at least 2 Storage Sites on HMC {hd.host} " + f"(found {len(site_list)})") + + subsystem = pick_test_resources(subsystem_list)[0] + subsystem.pull_full_properties() + original_site_uri = subsystem.properties['storage-site-uri'] + + # Find a different target site + target_site = next( + (s for s in site_list if s.uri != original_site_uri), None) + if target_site is None: + skip_warn( + f"Could not find a site different from current one " + f"({original_site_uri}) on HMC {hd.host}") + + try: + # The code to be tested: Move to target site + subsystem.move_to_storage_site(target_site.uri) + + subsystem.pull_full_properties() + assert subsystem.properties['storage-site-uri'] == target_site.uri, ( + f"Expected storage-site-uri {target_site.uri!r}, got " + f"{subsystem.properties['storage-site-uri']!r}") + print(f"Moved Storage Subsystem {subsystem.name!r} to " + f"{target_site.name!r}") + + finally: + # Move it back to the original site + try: + subsystem.move_to_storage_site(original_site_uri) + print(f"Moved Storage Subsystem {subsystem.name!r} back to " + f"original site") + except zhmcclient.HTTPError as exc: + print(f"Failed to move storage subsystem back to original site: " + f"{exc}") diff --git a/tests/end2end/test_storage_switch.py b/tests/end2end/test_storage_switch.py new file mode 100644 index 00000000..9a03f6b5 --- /dev/null +++ b/tests/end2end/test_storage_switch.py @@ -0,0 +1,405 @@ +# 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 Switches (on CPCs in DPM mode). + +These tests create, modify, and delete test Storage Switches, and exercise +the move-to-site and move-to-fabric operations. +""" + +import pytest +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 StorageSwitch objects (e.g. find_by_name()) +SSWITCH_MINIMAL_PROPS = ['object-uri', 'name'] + +# Properties in StorageSwitch objects returned by list() without full props +SSWITCH_LIST_PROPS = ['object-uri', 'name', 'domain-id', 'storage-fabric-uri'] + +# Properties whose values can change between retrievals +SSWITCH_VOLATILE_PROPS = [] + + +def test_sswitch_find_list(hmc_session): + """ + Test list(), find(), findall() for Storage Switches. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + switch_list = console.storage_switches.list() + if not switch_list: + skip_warn(f"No Storage Switches defined on HMC {hd.host}") + + switch_list = pick_test_resources(switch_list) + + for switch in switch_list: + print(f"Testing with Storage Switch {switch.name!r}") + runtest_find_list( + hmc_session, console.storage_switches, switch.name, + 'name', 'object-uri', SSWITCH_VOLATILE_PROPS, + SSWITCH_MINIMAL_PROPS, SSWITCH_LIST_PROPS) + + +def test_sswitch_property(hmc_session): + """ + Test property related methods for Storage Switches. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + switch_list = console.storage_switches.list() + if not switch_list: + skip_warn(f"No Storage Switches defined on HMC {hd.host}") + + switch_list = pick_test_resources(switch_list) + + for switch in switch_list: + print(f"Testing with Storage Switch {switch.name!r}") + + # 'class' is not returned by list(), so use it as the non_list_prop + non_list_prop = 'class' + + runtest_get_properties(switch.manager, non_list_prop) + + +def test_sswitch_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 + + switch_list = console.storage_switches.list() + if not switch_list: + skip_warn(f"No Storage Switches defined on HMC {hd.host}") + + switch_list = pick_test_resources(switch_list) + + for switch in switch_list: + print(f"Testing with Storage Switch {switch.name!r}") + + switch.pull_full_properties() + + # Mandatory properties from the data model + for prop in ('object-uri', 'object-id', 'class', 'parent', + 'name', 'domain-id', + 'storage-fabric-uri', 'storage-site-uri'): + assert prop in switch.properties, ( + f"Expected property {prop!r} missing from " + f"Storage Switch {switch.name!r}") + + assert switch.properties['class'] == 'storage-switch' + assert isinstance(switch.properties['domain-id'], str) + + +def test_sswitch_list_filter_name(hmc_session): + """ + Test list() with name filter for Storage Switches. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + switch_list = console.storage_switches.list() + if not switch_list: + skip_warn(f"No Storage Switches defined on HMC {hd.host}") + + switch = switch_list[0] + switch_name = switch.name + + filtered = console.storage_switches.list( + filter_args={'name': switch_name}) + + assert len(filtered) >= 1 + names = [s.name for s in filtered] + assert switch_name in names + print(f"name filter returned {len(filtered)} switch(es) " + f"for name {switch_name!r}") + + +def test_sswitch_list_filter_domain_id(hmc_session): + """ + Test list() with domain-id filter for Storage Switches. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + switch_list = console.storage_switches.list() + if not switch_list: + skip_warn(f"No Storage Switches defined on HMC {hd.host}") + + switch = switch_list[0] + switch.pull_full_properties() + domain_id = switch.properties.get('domain-id') + if not domain_id: + skip_warn( + f"Storage Switch {switch.name!r} has no domain-id property") + + filtered = console.storage_switches.list( + filter_args={'domain-id': domain_id}) + + assert len(filtered) >= 1 + for s in filtered: + s.pull_full_properties() + assert s.properties['domain-id'] == domain_id + print(f"domain-id filter returned {len(filtered)} switch(es) " + f"for domain-id {domain_id!r}") + + +def test_sswitch_list_by_fabric(hmc_session): + """ + Test listing storage switches associated with a storage fabric. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + switch_list = console.storage_switches.list() + if not switch_list: + skip_warn(f"No Storage Switches defined on HMC {hd.host}") + + # Pull full properties to find a fabric URI + switch = switch_list[0] + switch.pull_full_properties() + fabric_uri = switch.properties.get('storage-fabric-uri') + if not fabric_uri: + skip_warn( + f"Storage Switch {switch.name!r} has no storage-fabric-uri") + + fabric_id = fabric_uri.split('/')[-1] + + result = hmc_session.get( + f'/api/storage-fabrics/{fabric_id}/storage-switches') + + assert 'storage-switches' in result + returned_uris = {s['object-uri'] for s in result['storage-switches']} + assert switch.uri in returned_uris + print(f"Fabric {fabric_uri!r} has " + f"{len(result['storage-switches'])} switch(es)") + + +def test_sswitch_crud(hmc_session): + """ + Test define, update properties, and undefine operations for Storage + Switches. + + Requires at least one Storage Fabric and one Storage Site to be defined. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + # Need a fabric + fabric_list = console.storage_fabrics.list() + if not fabric_list: + skip_warn(f"No Storage Fabrics defined on HMC {hd.host}") + fabric = fabric_list[0] + fabric.pull_full_properties() + fabric_uri = fabric.uri + + # Need a site + site_list = console.storage_sites.list() + if not site_list: + skip_warn(f"No Storage Sites defined on HMC {hd.host}") + site = site_list[0] + site_uri = site.uri + + switch_name = 'zhmcclient-e2e-storage-switch' + switch_props = { + 'name': switch_name, + 'domain-id': '99', + 'storage-fabric-uri': fabric_uri, + 'storage-site-uri': site_uri, + 'description': 'Test storage switch for end-to-end testing', + } + + # Clean up any leftover from a previous run + try: + existing = console.storage_switches.find(name=switch_name) + existing.undefine() + print(f"Cleaned up leftover Storage Switch {switch_name!r}") + except zhmcclient.NotFound: + pass + + # The code to be tested: Define + switch = console.storage_switches.define(switch_props) + + try: + assert switch.properties['name'] == switch_name + assert switch.properties['storage-fabric-uri'] == fabric_uri + print(f"Defined Storage Switch {switch.name!r}") + + # Pull full properties and verify + switch.pull_full_properties() + assert switch.properties['class'] == 'storage-switch' + assert switch.properties['domain-id'] == '99' + assert switch.properties['storage-site-uri'] == site_uri + + # The code to be tested: Update description + new_desc = 'Updated description for e2e storage switch test' + switch.update_properties({'description': new_desc}) + + assert switch.properties['description'] == new_desc + switch.pull_full_properties() + assert switch.properties['description'] == new_desc + print(f"Updated Storage Switch {switch.name!r} description") + + # The code to be tested: Rename + renamed = switch_name + '-renamed' + switch.update_properties({'name': renamed}) + switch.pull_full_properties() + + assert switch.properties['name'] == renamed + with pytest.raises(zhmcclient.NotFound): + console.storage_switches.find(name=switch_name) + print(f"Renamed Storage Switch to {renamed!r}") + + finally: + # Clean up: undefine the test switch + try: + switch.undefine() + print("Undefined test Storage Switch") + except zhmcclient.NotFound: + pass + + +def test_sswitch_move_to_fabric(hmc_session): + """ + Test the move-to-storage-fabric operation for a Storage Switch. + + Requires at least two Storage Fabrics and one Storage Site. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + fabric_list = console.storage_fabrics.list() + if len(fabric_list) < 2: + skip_warn( + f"Need at least 2 Storage Fabrics on HMC {hd.host} " + f"(found {len(fabric_list)})") + + site_list = console.storage_sites.list() + if not site_list: + skip_warn(f"No Storage Sites defined on HMC {hd.host}") + + fabric1 = fabric_list[0] + fabric2 = fabric_list[1] + site = site_list[0] + + switch_name = 'zhmcclient-e2e-storage-switch-move-fabric' + + # Clean up leftover + try: + existing = console.storage_switches.find(name=switch_name) + existing.undefine() + except zhmcclient.NotFound: + pass + + switch = console.storage_switches.define({ + 'name': switch_name, + 'domain-id': '98', + 'storage-fabric-uri': fabric1.uri, + 'storage-site-uri': site.uri, + }) + + try: + switch.pull_full_properties() + assert switch.properties['storage-fabric-uri'] == fabric1.uri + + # The code to be tested: Move to fabric2 + switch.move_to_storage_fabric(fabric2.uri) + + switch.pull_full_properties() + assert switch.properties['storage-fabric-uri'] == fabric2.uri + print(f"Moved Storage Switch {switch.name!r} " + f"from {fabric1.name!r} to {fabric2.name!r}") + + finally: + try: + switch.undefine() + except zhmcclient.NotFound: + pass + + +def test_sswitch_move_to_site(hmc_session): + """ + Test the move-to-storage-site operation for a Storage Switch. + + Requires at least one Storage Fabric and two Storage Sites. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + fabric_list = console.storage_fabrics.list() + if not fabric_list: + skip_warn(f"No Storage Fabrics defined on HMC {hd.host}") + + site_list = console.storage_sites.list() + if len(site_list) < 2: + skip_warn( + f"Need at least 2 Storage Sites on HMC {hd.host} " + f"(found {len(site_list)})") + + fabric = fabric_list[0] + site1 = site_list[0] + site2 = site_list[1] + + switch_name = 'zhmcclient-e2e-storage-switch-move-site' + + # Clean up leftover + try: + existing = console.storage_switches.find(name=switch_name) + existing.undefine() + except zhmcclient.NotFound: + pass + + switch = console.storage_switches.define({ + 'name': switch_name, + 'domain-id': '97', + 'storage-fabric-uri': fabric.uri, + 'storage-site-uri': site1.uri, + }) + + try: + switch.pull_full_properties() + assert switch.properties['storage-site-uri'] == site1.uri + + # The code to be tested: Move to site2 + switch.move_to_storage_site(site2.uri) + + switch.pull_full_properties() + assert switch.properties['storage-site-uri'] == site2.uri + print(f"Moved Storage Switch {switch.name!r} " + f"from {site1.name!r} to {site2.name!r}") + + finally: + try: + switch.undefine() + except zhmcclient.NotFound: + pass 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_fabric.py b/tests/unit/zhmcclient/test_storage_fabric.py new file mode 100644 index 00000000..5fe5f632 --- /dev/null +++ b/tests/unit/zhmcclient/test_storage_fabric.py @@ -0,0 +1,535 @@ +# 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_fabric module. +""" + + +import re +import copy +import pytest + +from zhmcclient import Client, StorageFabric, StorageFabricManager, \ + HTTPError, NotFound +from zhmcclient.mock import FakedSession +from tests.common.utils import assert_resources + + +# Object IDs and names of our faked resources: +CPC_OID = 'fake-cpc1-oid' +CPC_URI = f'/api/cpcs/{CPC_OID}' +SFABRIC1_OID = 'sf1-oid' +SFABRIC1_NAME = 'Fabric A' +SFABRIC2_OID = 'sf2-oid' +SFABRIC2_NAME = 'Fabric B' + + +class TestStorageFabric: + """All tests for the StorageFabric and StorageFabricManager classes.""" + + def setup_method(self): + """ + Setup called by pytest before each test method. + + Creates a faked session with a faked CPC (DPM mode) and a faked + console. + """ + # pylint: disable=attribute-defined-outside-init + + self.session = FakedSession('fake-host', 'fake-hmc', '2.16.0', '4.10') + self.client = Client(self.session) + + # Add a faked CPC in DPM mode + self.faked_cpc = self.session.hmc.cpcs.add({ + 'object-id': CPC_OID, + # object-uri is set up automatically + 'parent': None, + 'class': 'cpc', + 'name': 'fake-cpc1-name', + 'description': 'CPC #1 (DPM mode, storage mgmt feature enabled)', + 'status': 'active', + 'dpm-enabled': True, + 'is-ensemble-member': False, + 'iml-mode': 'dpm', + 'available-features-list': [ + {'name': 'dpm-storage-management', 'state': True}, + ], + }) + assert self.faked_cpc.uri == CPC_URI + self.cpc = self.client.cpcs.find(name='fake-cpc1-name') + + # Add a faked console + self.faked_console = self.session.hmc.consoles.add({ + # object-id is set up automatically + # object-uri is set up automatically + # parent will be automatically set + # class will be automatically set + 'name': 'fake-console-name', + 'description': 'The HMC', + }) + self.console = self.client.consoles.console + + def add_fabric1(self): + """Add storage fabric 1 to the faked console.""" + return self.faked_console.storage_fabrics.add({ + 'object-id': SFABRIC1_OID, + # object-uri will be automatically set + # parent will be automatically set + # class will be automatically set + 'cpc-uri': CPC_URI, + 'name': SFABRIC1_NAME, + 'description': 'Storage Fabric #1', + 'high-integrity': False, + }) + + def add_fabric2(self): + """Add storage fabric 2 to the faked console.""" + return self.faked_console.storage_fabrics.add({ + 'object-id': SFABRIC2_OID, + # object-uri will be automatically set + # parent will be automatically set + # class will be automatically set + 'cpc-uri': CPC_URI, + 'name': SFABRIC2_NAME, + 'description': 'Storage Fabric #2', + 'high-integrity': True, + }) + + # ------------------------------------------------------------------------- + # StorageFabricManager tests + # ------------------------------------------------------------------------- + + def test_sfm_initial_attrs(self): + """Test initial attributes of StorageFabricManager.""" + + fabric_mgr = self.console.storage_fabrics + + assert isinstance(fabric_mgr, StorageFabricManager) + assert fabric_mgr.resource_class == StorageFabric + assert fabric_mgr.session == self.session + assert fabric_mgr.parent == self.console + assert fabric_mgr.console == self.console + + testcases_sfm_list_full_properties = ( + "full_properties_kwargs, prop_names", [ + ({}, + ['object-uri', 'cpc-uri', 'name']), + ({'full_properties': False}, + ['object-uri', 'cpc-uri', 'name']), + ] + ) + + @pytest.mark.parametrize(*testcases_sfm_list_full_properties) + def test_sfm_list_full_properties(self, full_properties_kwargs, prop_names): + """Test StorageFabricManager.list() with full_properties.""" + + faked_fabric1 = self.add_fabric1() + faked_fabric2 = self.add_fabric2() + exp_faked_fabrics = [faked_fabric1, faked_fabric2] + + fabric_mgr = self.console.storage_fabrics + + # Execute the code to be tested + fabrics = fabric_mgr.list(**full_properties_kwargs) + + assert_resources(fabrics, exp_faked_fabrics, prop_names) + + testcases_sfm_list_filter_args = ( + "filter_args, exp_names", [ + ({'object-id': SFABRIC1_OID}, + [SFABRIC1_NAME]), + ({'object-id': SFABRIC2_OID}, + [SFABRIC2_NAME]), + ({'object-id': [SFABRIC1_OID, SFABRIC2_OID]}, + [SFABRIC1_NAME, SFABRIC2_NAME]), + ({'object-id': [SFABRIC1_OID, SFABRIC1_OID]}, + [SFABRIC1_NAME]), + ({'object-id': SFABRIC1_OID + 'foo'}, + []), + ({'object-id': [SFABRIC1_OID, SFABRIC2_OID + 'foo']}, + [SFABRIC1_NAME]), + ({'object-id': [SFABRIC2_OID + 'foo', SFABRIC1_OID]}, + [SFABRIC1_NAME]), + ({'name': SFABRIC1_NAME}, + [SFABRIC1_NAME]), + ({'name': SFABRIC2_NAME}, + [SFABRIC2_NAME]), + ({'name': [SFABRIC1_NAME, SFABRIC2_NAME]}, + [SFABRIC1_NAME, SFABRIC2_NAME]), + ({'name': SFABRIC1_NAME + 'foo'}, + []), + ({'name': [SFABRIC1_NAME, SFABRIC2_NAME + 'foo']}, + [SFABRIC1_NAME]), + ({'name': 'Fabric .*'}, + [SFABRIC1_NAME, SFABRIC2_NAME]), + ({'name': 'Fabric A'}, + [SFABRIC1_NAME]), + ({'name': SFABRIC1_NAME, + 'object-id': SFABRIC1_OID}, + [SFABRIC1_NAME]), + ({'name': SFABRIC1_NAME, + 'object-id': SFABRIC1_OID + 'foo'}, + []), + ({'name': SFABRIC1_NAME + 'foo', + 'object-id': SFABRIC1_OID}, + []), + ] + ) + + @pytest.mark.parametrize(*testcases_sfm_list_filter_args) + def test_sfm_list_filter_args(self, filter_args, exp_names): + """Test StorageFabricManager.list() with filter_args.""" + + self.add_fabric1() + self.add_fabric2() + + fabric_mgr = self.console.storage_fabrics + + # Execute the code to be tested + fabrics = fabric_mgr.list(filter_args=filter_args) + + assert len(fabrics) == len(exp_names) + if exp_names: + names = [f.properties['name'] for f in fabrics] + assert set(names) == set(exp_names) + + testcases_sfm_create = ( + "input_props, exp_prop_names, exp_exc", [ + # Missing required fields → 400 + ({}, + None, + HTTPError({'http-status': 400, 'reason': 5})), + # name only — missing cpc-uri → 400 + ({'name': 'fake-fabric-x'}, + None, + HTTPError({'http-status': 400, 'reason': 5})), + # cpc-uri only — missing name → 400 + ({'cpc-uri': CPC_URI}, + None, + HTTPError({'http-status': 400, 'reason': 5})), + # Both required fields → success + ({'name': 'fake-fabric-x', + 'cpc-uri': CPC_URI}, + ['object-uri', 'name', 'cpc-uri'], + None), + # All fields → success + ({'name': 'fake-fabric-y', + 'cpc-uri': CPC_URI, + 'description': 'Fabric Y description', + 'high-integrity': True}, + ['object-uri', 'name', 'cpc-uri', 'description'], + None), + ] + ) + + @pytest.mark.parametrize(*testcases_sfm_create) + def test_sfm_create(self, input_props, exp_prop_names, exp_exc): + """Test StorageFabricManager.create().""" + + fabric_mgr = self.console.storage_fabrics + + if exp_exc is not None: + + with pytest.raises(exp_exc.__class__) as exc_info: + # Execute the code to be tested + fabric_mgr.create(properties=input_props) + + exc = exc_info.value + if isinstance(exp_exc, HTTPError): + assert exc.http_status == exp_exc.http_status + assert exc.reason == exp_exc.reason + + else: + + # Execute the code to be tested. + # StorageFabric.create() returns the object with input + # properties plus 'object-uri'. + fabric = fabric_mgr.create(properties=input_props) + + # Check the resource for internal consistency + assert isinstance(fabric, StorageFabric) + assert fabric.name == fabric.properties['name'] + assert fabric.uri == fabric.properties['object-uri'] + + # Check that expected property names are present + for prop_name in exp_prop_names: + assert prop_name in fabric.properties + if prop_name in input_props: + assert fabric.properties[prop_name] == \ + input_props[prop_name] + + def test_sfm_resource_object(self): + """Test StorageFabricManager.resource_object().""" + + faked_fabric = self.add_fabric1() + fabric_oid = faked_fabric.oid + + fabric_mgr = self.console.storage_fabrics + + # Execute the code to be tested + fabric = fabric_mgr.resource_object(fabric_oid) + + expected_uri = f'/api/storage-fabrics/{fabric_oid}' + + assert isinstance(fabric, StorageFabric) + assert fabric.properties['object-uri'] == expected_uri + assert fabric.properties['object-id'] == fabric_oid + assert fabric.properties['class'] == 'storage-fabric' + assert fabric.properties['parent'] == self.console.uri + + # ------------------------------------------------------------------------- + # StorageFabric resource tests + # ------------------------------------------------------------------------- + + def test_sf_repr(self): + """Test StorageFabric.__repr__().""" + + faked_fabric = self.add_fabric1() + + fabric_mgr = self.console.storage_fabrics + fabric = fabric_mgr.find(name=faked_fabric.name) + + # Execute the code to be tested + repr_str = repr(fabric) + + repr_str = repr_str.replace('\n', '\\n') + assert re.match( + rf'^{fabric.__class__.__name__}\s+at\s+' + rf'0x{id(fabric):08x}\s+\(\\n.*', + repr_str) + + def test_sf_delete(self): + """Test StorageFabric.delete().""" + + faked_fabric = self.add_fabric1() + self.add_fabric2() + + fabric_mgr = self.console.storage_fabrics + fabric = fabric_mgr.find(name=faked_fabric.name) + + # Execute the code to be tested + fabric.delete() + + # Verify the fabric no longer exists + with pytest.raises(NotFound): + fabric_mgr.find(name=faked_fabric.name) + + def test_sf_delete_create_same(self): + """Test StorageFabric.delete() followed by create() with same name.""" + + faked_fabric = self.add_fabric1() + fabric_name = faked_fabric.name + self.add_fabric2() + + # Build properties for a re-created fabric with the same name + recreate_props = copy.deepcopy(faked_fabric.properties) + recreate_props['description'] = 'Re-created fabric' + + fabric_mgr = self.console.storage_fabrics + fabric = fabric_mgr.find(name=fabric_name) + + # Delete + fabric.delete() + + with pytest.raises(NotFound): + fabric_mgr.find(name=fabric_name) + + # Re-create with same name + fabric_mgr.create(recreate_props) + + # Verify it exists again + fabric3 = fabric_mgr.find(name=fabric_name) + assert fabric3.get_property('description') == 'Re-created fabric' + + # Parameterised update_properties tests + testcases_sf_update_properties_fabrics = ( + "fabric_name", [SFABRIC1_NAME, SFABRIC2_NAME] + ) + + testcases_sf_update_properties_props = ( + "input_props", [ + {}, + {'description': 'Updated description'}, + {'description': 'Updated description', 'high-integrity': True}, + ] + ) + + @pytest.mark.parametrize(*testcases_sf_update_properties_fabrics) + @pytest.mark.parametrize(*testcases_sf_update_properties_props) + def test_sf_update_properties(self, input_props, fabric_name): + """Test StorageFabric.update_properties().""" + + self.add_fabric1() + self.add_fabric2() + + fabric_mgr = self.console.storage_fabrics + fabric = fabric_mgr.find(name=fabric_name) + + fabric.pull_full_properties() + saved_properties = copy.deepcopy(fabric.properties) + + # Execute the code to be tested + fabric.update_properties(properties=input_props) + + # Resource object should immediately reflect updates + for prop_name in saved_properties: + exp_value = (input_props[prop_name] + if prop_name in input_props + else saved_properties[prop_name]) + assert prop_name in fabric.properties + assert fabric.properties[prop_name] == exp_value + + # Verify updates survive a full-properties refresh + fabric.pull_full_properties() + for prop_name in saved_properties: + exp_value = (input_props[prop_name] + if prop_name in input_props + else saved_properties[prop_name]) + assert prop_name in fabric.properties + assert fabric.properties[prop_name] == exp_value + + def test_sf_update_name(self): + """Test StorageFabric.update_properties() with 'name' property.""" + + faked_fabric = self.add_fabric1() + fabric_name = faked_fabric.name + + fabric_mgr = self.console.storage_fabrics + fabric = fabric_mgr.find(name=fabric_name) + + new_name = 'renamed-' + fabric_name + + # Execute the code to be tested + fabric.update_properties(properties={'name': new_name}) + + # Old name: list() should return no results + fabrics_old = fabric_mgr.list(filter_args={'name': fabric_name}) + assert len(fabrics_old) == 0 + + # Old name: find() must raise NotFound + with pytest.raises(NotFound): + fabric_mgr.find(name=fabric_name) + + # Resource object should already reflect the new name + assert fabric.properties['name'] == new_name + + # After a full-properties pull the new name is still there + fabric.pull_full_properties() + assert fabric.properties['name'] == new_name + + # New name: find() must succeed + fabric_found = fabric_mgr.find(name=new_name) + assert fabric_found.properties['name'] == new_name + + # New name: list() must return exactly one result + fabrics_new = fabric_mgr.list(filter_args={'name': new_name}) + assert len(fabrics_new) == 1 + assert fabrics_new[0].properties['name'] == new_name + + def test_sf_default_properties(self): + """Test that FakedStorageFabric has correct default property values.""" + + # Create a fabric without optional properties + faked_fabric = self.faked_console.storage_fabrics.add({ + 'object-id': 'sf-defaults-oid', + 'cpc-uri': CPC_URI, + 'name': 'Fabric Defaults', + }) + + fabric_mgr = self.console.storage_fabrics + fabric = fabric_mgr.find(name='Fabric Defaults') + fabric.pull_full_properties() + + # Verify the defaults set by FakedStorageFabricManager.add() + assert fabric.properties['description'] == '' + assert fabric.properties['storage-switch-uris'] == [] + assert fabric.properties['high-integrity'] is False + + # Clean up + faked_fabric.manager.remove(faked_fabric.oid) + + def test_sf_cpc_uri_property(self): + """Test that cpc-uri is set and accessible.""" + + faked_fabric = self.add_fabric1() + + fabric_mgr = self.console.storage_fabrics + fabric = fabric_mgr.find(name=faked_fabric.name) + fabric.pull_full_properties() + + assert fabric.properties['cpc-uri'] == CPC_URI + + def test_sf_high_integrity_property(self): + """Test that high-integrity property is correctly stored.""" + + # Fabric 1: high-integrity = False + self.add_fabric1() + # Fabric 2: high-integrity = True + self.add_fabric2() + + fabric_mgr = self.console.storage_fabrics + + fabric1 = fabric_mgr.find(name=SFABRIC1_NAME) + fabric1.pull_full_properties() + assert fabric1.properties['high-integrity'] is False + + fabric2 = fabric_mgr.find(name=SFABRIC2_NAME) + fabric2.pull_full_properties() + assert fabric2.properties['high-integrity'] is True + + def test_sf_list_empty(self): + """Test StorageFabricManager.list() with no fabrics defined.""" + + fabric_mgr = self.console.storage_fabrics + + fabrics = fabric_mgr.list() + + assert fabrics == [] + + def test_sf_list_two(self): + """Test StorageFabricManager.list() with two fabrics defined.""" + + self.add_fabric1() + self.add_fabric2() + + fabric_mgr = self.console.storage_fabrics + + fabrics = fabric_mgr.list() + + assert len(fabrics) == 2 + names = {f.properties['name'] for f in fabrics} + assert names == {SFABRIC1_NAME, SFABRIC2_NAME} + + def test_sf_storage_switch_uris_default(self): + """Test that storage-switch-uris defaults to empty list.""" + + self.add_fabric1() + + fabric_mgr = self.console.storage_fabrics + fabric = fabric_mgr.find(name=SFABRIC1_NAME) + fabric.pull_full_properties() + + assert fabric.properties['storage-switch-uris'] == [] + + def test_sf_class_property(self): + """Test that the class property is correctly set to 'storage-fabric'.""" + + self.add_fabric1() + + fabric_mgr = self.console.storage_fabrics + fabric = fabric_mgr.find(name=SFABRIC1_NAME) + fabric.pull_full_properties() + + assert fabric.properties['class'] == 'storage-fabric' 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_site.py b/tests/unit/zhmcclient/test_storage_site.py new file mode 100644 index 00000000..9d990629 --- /dev/null +++ b/tests/unit/zhmcclient/test_storage_site.py @@ -0,0 +1,294 @@ +# 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_site module. +""" + + +import copy +import pytest + +from zhmcclient import Client, StorageSite, HTTPError, NotFound +from zhmcclient.mock import FakedSession +from tests.common.utils import assert_resources + +# Object names and IDs for our faked storage sites: +SS1_NAME = 'Primary Site' +SS2_NAME = 'Alternate Site' +CPC_OID = 'fake-cpc1-oid' +CPC_URI = f'/api/cpcs/{CPC_OID}' + + +class TestStorageSite: + """All tests for the StorageSite and StorageSiteManager classes.""" + + def setup_method(self): + """ + Setup that is called by pytest before each test method. + + Sets up a faked session with a faked Console and a faked CPC. + """ + # pylint: disable=attribute-defined-outside-init + + self.session = FakedSession('fake-host', 'fake-hmc', '2.14.0', '1.8') + self.client = Client(self.session) + self.faked_cpc = self.session.hmc.cpcs.add({ + 'object-id': CPC_OID, + # object-uri is set up automatically + 'parent': None, + 'class': 'cpc', + 'name': 'fake-cpc1-name', + 'description': 'CPC #1 (DPM mode)', + 'status': 'active', + 'dpm-enabled': True, + 'is-ensemble-member': False, + 'iml-mode': 'dpm', + 'available-features-list': [ + {'name': 'dpm-storage-management', 'state': True}, + ], + }) + assert self.faked_cpc.uri == CPC_URI + + self.faked_console = self.session.hmc.consoles.add({ + 'object-id': None, + # object-uri will be automatically set + 'parent': None, + 'class': 'console', + 'name': 'fake-console1', + 'description': 'Console #1', + }) + self.console = self.client.consoles.find(name=self.faked_console.name) + + def add_storage_site(self, name, cpc_uris=None): + """Add a faked storage site to the faked console.""" + if cpc_uris is None: + cpc_uris = [CPC_URI] + faked_storage_site = self.faked_console.storage_sites.add({ + 'object-uri': f'/api/storage-sites/{name}', + 'parent': self.faked_console.uri, + 'class': 'storage-site', + 'name': name, + 'description': f'Storage site {name}', + 'cpc-uris': cpc_uris, + }) + return faked_storage_site + + def test_storage_site_mgr_initial_attrs(self): + """Test initial attributes of StorageSiteManager.""" + + ss_mgr = self.console.storage_sites + + # Verify all public properties of the manager object + assert ss_mgr.resource_class == StorageSite + assert ss_mgr.class_name == 'storage-site' + assert ss_mgr.session == self.session + assert ss_mgr.parent == self.console + assert ss_mgr.console == self.console + + @pytest.mark.parametrize( + "full_properties_kwargs, prop_names", [ + ({'full_properties': False}, + ['object-uri', 'name', 'cpc-uris']), + ({'full_properties': True}, + ['object-uri', 'name', 'cpc-uris', 'description']), + ({}, # test default for full_properties (False) + ['object-uri', 'name', 'cpc-uris']), + ] + ) + @pytest.mark.parametrize( + "filter_args, exp_name", [ + (None, [SS1_NAME, SS2_NAME]), + ({}, [SS1_NAME, SS2_NAME]), + ({'name': SS1_NAME}, [SS1_NAME]), + ({'name': [SS1_NAME, SS2_NAME]}, [SS1_NAME, SS2_NAME]), + ] + ) + def test_storage_site_mgr_list( + self, filter_args, exp_name, full_properties_kwargs, prop_names): + """Test StorageSiteManager.list().""" + + faked_ss1 = self.add_storage_site(name=SS1_NAME) + faked_ss2 = self.add_storage_site(name=SS2_NAME) + faked_sites = [faked_ss1, faked_ss2] + exp_faked_sites = [s for s in faked_sites if s.name in exp_name] + ss_mgr = self.console.storage_sites + + # Execute the code to be tested + sites = ss_mgr.list(filter_args=filter_args, **full_properties_kwargs) + + assert_resources(sites, exp_faked_sites, prop_names) + + @pytest.mark.parametrize( + "input_props, exp_exc", [ + ({'name': SS2_NAME}, + None), + ({'name': SS2_NAME, 'description': 'Alternate site'}, + None), + ({}, # missing required 'name' + HTTPError({'http-status': 400, 'reason': 5})), + ] + ) + def test_storage_site_mgr_create(self, input_props, exp_exc): + """Test StorageSiteManager.create().""" + + ss_mgr = self.console.storage_sites + + if exp_exc is not None: + with pytest.raises(exp_exc.__class__) as exc_info: + # Execute the code to be tested + ss_mgr.create(input_props) + exc = exc_info.value + if isinstance(exp_exc, HTTPError): + assert exc.http_status == exp_exc.http_status + assert exc.reason == exp_exc.reason + else: + # Execute the code to be tested + ss = ss_mgr.create(input_props) + + assert isinstance(ss, StorageSite) + # Verify the site can be found + found_ss = ss_mgr.find(name=input_props['name']) + assert found_ss.name == input_props['name'] + + @pytest.mark.parametrize( + "input_props, exp_exc", [ + ({'name': SS2_NAME}, None), + ] + ) + def test_storage_site_delete(self, input_props, exp_exc): + """Test StorageSite.delete().""" + + faked_ss = self.add_storage_site(name=input_props['name']) + + ss_mgr = self.console.storage_sites + ss = ss_mgr.find(name=faked_ss.name) + + if exp_exc is not None: + with pytest.raises(exp_exc.__class__) as exc_info: + # Execute the code to be tested + ss.delete() + exc = exc_info.value + if isinstance(exp_exc, HTTPError): + assert exc.http_status == exp_exc.http_status + assert exc.reason == exp_exc.reason + # Check that the storage site still exists + ss_mgr.find(name=faked_ss.name) + else: + # Execute the code to be tested + ss.delete() + + # Check that the storage site no longer exists + with pytest.raises(NotFound): + ss_mgr.find(name=ss.name) + + @pytest.mark.parametrize( + "input_props", [ + {}, + {'name': SS2_NAME}, + {'description': 'Updated alternate site description'}, + ] + ) + def test_storage_site_update_properties(self, input_props): + """Test StorageSite.update_properties().""" + + site_name = SS2_NAME + + # Add the storage site to be tested + self.add_storage_site(name=site_name) + + ss_mgr = self.console.storage_sites + ss = ss_mgr.find(name=site_name) + + ss.pull_full_properties() + saved_properties = copy.deepcopy(ss.properties) + + # Execute the code to be tested + ss.update_properties(properties=input_props) + ss.pull_full_properties() + + # Verify that the resource object already reflects the property updates + for prop_name in saved_properties: + if prop_name in input_props: + exp_prop_value = input_props[prop_name] + else: + exp_prop_value = saved_properties[prop_name] + assert prop_name in ss.properties + prop_value = ss.properties[prop_name] + assert prop_value == exp_prop_value, \ + f"Unexpected value for property {prop_name!r}" + + # Refresh the resource object and verify it still reflects updates + ss.pull_full_properties() + for prop_name in saved_properties: + if prop_name in input_props: + exp_prop_value = input_props[prop_name] + else: + exp_prop_value = saved_properties[prop_name] + assert prop_name in ss.properties + prop_value = ss.properties[prop_name] + assert prop_value == exp_prop_value + + def test_storage_site_default_properties(self): + """Test that faked storage site gets correct default property values.""" + + faked_ss = self.faked_console.storage_sites.add({ + 'name': SS1_NAME, + }) + + # Verify default properties are set + assert faked_ss.properties.get('description') == '' + assert faked_ss.properties.get('cpc-uris') == [] + assert faked_ss.properties.get('class') == 'storage-site' + assert faked_ss.properties.get('name') == SS1_NAME + + def test_storage_site_dump(self): + """Test StorageSite.dump().""" + + faked_ss = self.add_storage_site(name=SS1_NAME) + + ss_mgr = self.console.storage_sites + ss = ss_mgr.find(name=faked_ss.name) + ss.pull_full_properties() + + # Execute the code to be tested + resource_dict = ss.dump() + + assert isinstance(resource_dict, dict) + assert 'properties' in resource_dict + assert resource_dict['properties']['name'] == SS1_NAME + + def test_storage_site_list_empty(self): + """Test that listing an empty set of storage sites works.""" + + ss_mgr = self.console.storage_sites + sites = ss_mgr.list() + assert sites == [] + + def test_storage_site_list_cpc_uri_filter(self): + """Test listing storage sites filtered by cpc-uris.""" + + faked_ss1 = self.add_storage_site(name=SS1_NAME, cpc_uris=[CPC_URI]) + self.add_storage_site(name=SS2_NAME, cpc_uris=['/api/cpcs/other-cpc']) + + ss_mgr = self.console.storage_sites + + # List all sites + all_sites = ss_mgr.list() + assert len(all_sites) == 2 + + # Find the site for our CPC by filtering + cpc_sites = ss_mgr.list(filter_args={'name': SS1_NAME}) + assert len(cpc_sites) == 1 + assert cpc_sites[0].name == faked_ss1.name diff --git a/tests/unit/zhmcclient/test_storage_subsystem.py b/tests/unit/zhmcclient/test_storage_subsystem.py new file mode 100644 index 00000000..1c18a936 --- /dev/null +++ b/tests/unit/zhmcclient/test_storage_subsystem.py @@ -0,0 +1,1134 @@ +# 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 _storage_subsystem module. +""" + + +import copy +import pytest + +from zhmcclient import Client, StorageSubsystem, StorageSubsystemManager, \ + HTTPError, NotFound +from zhmcclient.mock import FakedSession +from tests.common.utils import assert_resources + + +# --------------------------------------------------------------------------- +# Object IDs / URIs for faked resources used across the test class +# --------------------------------------------------------------------------- + +CPC_OID = 'fake-cpc1-oid' +CPC_URI = f'/api/cpcs/{CPC_OID}' + +SSITE1_OID = 'ss1-oid' +SSITE1_URI = f'/api/storage-sites/{SSITE1_OID}' +SSITE1_NAME = 'Primary Site' + +SSITE2_OID = 'ss2-oid' +SSITE2_URI = f'/api/storage-sites/{SSITE2_OID}' +SSITE2_NAME = 'Alternate Site' + +SSWITCH1_OID = 'sw1-oid' +SSWITCH1_URI = f'/api/storage-switches/{SSWITCH1_OID}' +SSWITCH1_NAME = 'Storage Switch 11' + +SUBSYS1_OID = 'subsys1-oid' +SUBSYS1_NAME = 'DS8886 A' + +SUBSYS2_OID = 'subsys2-oid' +SUBSYS2_NAME = 'DS8886 B' + + +class TestStorageSubsystem: + """All tests for StorageSubsystem and StorageSubsystemManager classes.""" + + def setup_method(self): + """ + Setup called by pytest before each test method. + + Sets up a faked session with a faked CPC (DPM mode), a faked console, + two faked storage sites, and one faked storage switch. + """ + # pylint: disable=attribute-defined-outside-init + + self.session = FakedSession('fake-host', 'fake-hmc', '2.16.0', '4.10') + self.client = Client(self.session) + + # Add a faked CPC in DPM mode + self.faked_cpc = self.session.hmc.cpcs.add({ + 'object-id': CPC_OID, + 'parent': None, + 'class': 'cpc', + 'name': 'fake-cpc1-name', + 'description': 'CPC #1 (DPM mode)', + 'status': 'active', + 'dpm-enabled': True, + 'is-ensemble-member': False, + 'iml-mode': 'dpm', + 'available-features-list': [ + {'name': 'dpm-storage-management', 'state': True}, + ], + }) + assert self.faked_cpc.uri == CPC_URI + + # Add a faked console + self.faked_console = self.session.hmc.consoles.add({ + 'name': 'fake-console-name', + 'description': 'The HMC', + }) + self.console = self.client.consoles.console + + # Add two faked storage sites + self.faked_site1 = self.faked_console.storage_sites.add({ + 'object-id': SSITE1_OID, + 'name': SSITE1_NAME, + 'description': 'Primary storage site', + 'cpc-uris': [CPC_URI], + 'storage-subsystem-uris': [], + }) + assert self.faked_site1.uri == SSITE1_URI + + self.faked_site2 = self.faked_console.storage_sites.add({ + 'object-id': SSITE2_OID, + 'name': SSITE2_NAME, + 'description': 'Alternate storage site', + 'cpc-uris': [CPC_URI], + 'storage-subsystem-uris': [], + }) + assert self.faked_site2.uri == SSITE2_URI + + # Add a faked storage switch (used as connection endpoint) + self.faked_switch1 = self.faked_console.storage_switches.add({ + 'object-id': SSWITCH1_OID, + 'name': SSWITCH1_NAME, + 'domain-id': '11', + 'storage-fabric-uri': '/api/storage-fabrics/fabric1-oid', + 'storage-site-uri': SSITE1_URI, + }) + assert self.faked_switch1.uri == SSWITCH1_URI + + def add_subsystem1(self): + """Add first faked storage subsystem to site1.""" + faked_ss = self.faked_console.storage_subsystems.add({ + 'object-id': SUBSYS1_OID, + 'name': SUBSYS1_NAME, + 'description': 'Storage subsystem DS8886 A', + 'storage-site-uri': SSITE1_URI, + 'parent': '/api/console', + }) + return faked_ss + + def add_subsystem2(self): + """Add second faked storage subsystem to site1.""" + faked_ss = self.faked_console.storage_subsystems.add({ + 'object-id': SUBSYS2_OID, + 'name': SUBSYS2_NAME, + 'description': 'Storage subsystem DS8886 B', + 'storage-site-uri': SSITE1_URI, + 'parent': '/api/console', + }) + return faked_ss + + # ----------------------------------------------------------------------- + # Manager attribute tests + # ----------------------------------------------------------------------- + + def test_ssm_initial_attrs(self): + """Test initial attributes of StorageSubsystemManager.""" + + ssm = self.console.storage_subsystems + + assert ssm.resource_class == StorageSubsystem + assert ssm.class_name == 'storage-subsystem' + assert ssm.session == self.session + assert ssm.parent == self.console + assert ssm.console == self.console + + # ----------------------------------------------------------------------- + # List tests + # ----------------------------------------------------------------------- + + @pytest.mark.parametrize( + "full_properties_kwargs, prop_names", [ + ({'full_properties': False}, + ['object-uri', 'name', 'storage-site-uri']), + ({'full_properties': True}, + ['object-uri', 'name', 'storage-site-uri', 'description']), + ({}, # default for full_properties is False + ['object-uri', 'name', 'storage-site-uri']), + ] + ) + @pytest.mark.parametrize( + "filter_args, exp_names", [ + (None, [SUBSYS1_NAME, SUBSYS2_NAME]), + ({}, [SUBSYS1_NAME, SUBSYS2_NAME]), + ({'name': SUBSYS1_NAME}, [SUBSYS1_NAME]), + ({'name': [SUBSYS1_NAME, SUBSYS2_NAME]}, + [SUBSYS1_NAME, SUBSYS2_NAME]), + ] + ) + def test_ssm_list(self, filter_args, exp_names, + full_properties_kwargs, prop_names): + """Test StorageSubsystemManager.list().""" + + faked_ss1 = self.add_subsystem1() + faked_ss2 = self.add_subsystem2() + faked_subsystems = [faked_ss1, faked_ss2] + exp_faked = [s for s in faked_subsystems if s.name in exp_names] + + ssm = self.console.storage_subsystems + + # Execute the code to be tested + subsystems = ssm.list(filter_args=filter_args, + **full_properties_kwargs) + + assert_resources(subsystems, exp_faked, prop_names) + + def test_ssm_list_empty(self): + """Test listing an empty set of storage subsystems.""" + + ssm = self.console.storage_subsystems + subsystems = ssm.list() + assert subsystems == [] + + def test_ssm_list_two(self): + """Test listing two storage subsystems.""" + + self.add_subsystem1() + self.add_subsystem2() + + ssm = self.console.storage_subsystems + subsystems = ssm.list() + + assert len(subsystems) == 2 + names = {ss.name for ss in subsystems} + assert names == {SUBSYS1_NAME, SUBSYS2_NAME} + + def test_ssm_list_filter_by_site_uri(self): + """Test filtering subsystems by storage-site-uri.""" + + self.add_subsystem1() + self.add_subsystem2() + + ssm = self.console.storage_subsystems + subsystems = ssm.list(filter_args={'storage-site-uri': SSITE1_URI}) + assert len(subsystems) == 2 + + # ----------------------------------------------------------------------- + # find / findall + # ----------------------------------------------------------------------- + + def test_ssm_resource_object(self): + """Test that find() returns a valid StorageSubsystem object.""" + + self.add_subsystem1() + + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + assert isinstance(ss, StorageSubsystem) + assert ss.name == SUBSYS1_NAME + assert isinstance(ss.manager, StorageSubsystemManager) + + def test_ssm_find_not_found(self): + """Test that find() raises NotFound for unknown name.""" + + ssm = self.console.storage_subsystems + + with pytest.raises(NotFound): + ssm.find(name='nonexistent-subsystem') + + # ----------------------------------------------------------------------- + # repr + # ----------------------------------------------------------------------- + + def test_ss_repr(self): + """Test repr() of StorageSubsystem objects.""" + + faked_ss = self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=faked_ss.name) + ss.pull_full_properties() + + repr_str = repr(ss) + assert 'StorageSubsystem' in repr_str + + # ----------------------------------------------------------------------- + # update_properties tests + # ----------------------------------------------------------------------- + + @pytest.mark.parametrize( + "update_props", [ + {}, + {'description': 'Updated description'}, + {'name': SUBSYS2_NAME, 'description': 'Renamed subsystem'}, + ] + ) + def test_ss_update_properties(self, update_props): + """Test StorageSubsystem.update_properties().""" + + faked_ss = self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=faked_ss.name) + ss.pull_full_properties() + saved_props = copy.deepcopy(ss.properties) + + # Execute the code to be tested + ss.update_properties(properties=update_props) + ss.pull_full_properties() + + for prop_name in saved_props: + if prop_name in update_props: + exp_value = update_props[prop_name] + else: + exp_value = saved_props[prop_name] + assert ss.properties[prop_name] == exp_value, ( + f"Unexpected value for property {prop_name!r}") + + def test_ss_update_name(self): + """Test that renaming a subsystem updates the name-URI cache.""" + + self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + new_name = 'DS8886 A Renamed' + ss.update_properties({'name': new_name}) + + # Old name should be gone from cache + with pytest.raises(NotFound): + ssm.find(name=SUBSYS1_NAME) + + # New name should be findable + renamed_ss = ssm.find(name=new_name) + assert renamed_ss.name == new_name + + # ----------------------------------------------------------------------- + # move_to_storage_site tests + # ----------------------------------------------------------------------- + + def test_ss_move_to_storage_site(self): + """Test StorageSubsystem.move_to_storage_site().""" + + self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + # Execute the code to be tested + ss.move_to_storage_site(SSITE2_URI) + + ss.pull_full_properties() + assert ss.properties['storage-site-uri'] == SSITE2_URI + + def test_ss_move_updates_site_uris(self): + """Test that move_to_storage_site() updates storage-subsystem-uris.""" + + faked_ss = self.add_subsystem1() + ss_uri = faked_ss.uri + + # Set up old site with the subsystem + self.faked_site1.update({'storage-subsystem-uris': [ss_uri]}) + + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + # Execute the code to be tested + ss.move_to_storage_site(SSITE2_URI) + + # Verify old site no longer lists this subsystem + old_site_props = self.faked_site1.properties + assert ss_uri not in old_site_props.get('storage-subsystem-uris', []) + + # Verify new site now lists this subsystem + new_site_props = self.faked_site2.properties + assert ss_uri in new_site_props.get('storage-subsystem-uris', []) + + def test_ss_move_to_site_not_found(self): + """Test move_to_storage_site() with unknown subsystem raises 404.""" + + # Construct a subsystem object with a non-existent URI + ssm = self.console.storage_subsystems + fake_ss = StorageSubsystem( + ssm, '/api/storage-subsystems/nonexistent-oid', + 'Nonexistent', + {'object-uri': '/api/storage-subsystems/nonexistent-oid'}) + + with pytest.raises(HTTPError) as exc_info: + fake_ss.move_to_storage_site(SSITE2_URI) + assert exc_info.value.http_status == 404 + + def test_ss_move_to_site_conflict(self): + """Test move_to_storage_site() when already on target site gives 409.""" + + self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + with pytest.raises(HTTPError) as exc_info: + ss.move_to_storage_site(SSITE1_URI) # already on site1 + assert exc_info.value.http_status == 409 + assert exc_info.value.reason == 450 + + def test_ss_move_to_site_unknown_target(self): + """Test move_to_storage_site() with unknown target site returns 404.""" + + self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + with pytest.raises(HTTPError) as exc_info: + ss.move_to_storage_site('/api/storage-sites/nonexistent-site') + assert exc_info.value.http_status == 404 + + # ----------------------------------------------------------------------- + # add_connection_endpoint tests + # ----------------------------------------------------------------------- + + def test_ss_add_connection_endpoint(self): + """Test StorageSubsystem.add_connection_endpoint().""" + + self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + # Execute the code to be tested + ss.add_connection_endpoint( + endpoint_uri=SSWITCH1_URI, + port_id='00', + ) + + ss.pull_full_properties() + endpoints = ss.properties.get('connection-endpoints', []) + assert len(endpoints) == 1 + assert endpoints[0]['endpoint-uri'] == SSWITCH1_URI + assert endpoints[0]['port-id'] == '00' + assert endpoints[0]['endpoint-class'] == 'storage-switch' + + def test_ss_add_connection_endpoint_no_port(self): + """Test adding an adapter endpoint (no port-id).""" + + # Add a faked adapter resource under the faked CPC + faked_adapter = self.faked_cpc.adapters.add({ + 'object-id': 'adapter1-oid', + 'name': 'TestAdapter', + 'type': 'fc', + 'status': 'active', + }) + adapter_uri = faked_adapter.uri + + self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + # Execute the code to be tested + ss.add_connection_endpoint(endpoint_uri=adapter_uri) + + ss.pull_full_properties() + endpoints = ss.properties.get('connection-endpoints', []) + assert len(endpoints) == 1 + assert endpoints[0]['endpoint-uri'] == adapter_uri + assert endpoints[0]['endpoint-class'] == 'adapter' + assert 'port-id' not in endpoints[0] + + def test_ss_add_endpoint_duplicate(self): + """Test that adding a duplicate endpoint returns 409.""" + + self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + ss.add_connection_endpoint(endpoint_uri=SSWITCH1_URI, port_id='00') + + with pytest.raises(HTTPError) as exc_info: + ss.add_connection_endpoint(endpoint_uri=SSWITCH1_URI, port_id='00') + assert exc_info.value.http_status == 409 + assert exc_info.value.reason == 443 + + def test_ss_add_endpoint_missing_field(self): + """Test that missing endpoint-uri returns 400.""" + + self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + # Bypass the Python method and call the session directly to test + # handler validation (missing required field) + with pytest.raises(HTTPError) as exc_info: + self.session.post( + ss.uri + '/operations/add-connection-endpoint', + body={}, # missing 'endpoint-uri' + ) + assert exc_info.value.http_status == 400 + + def test_ss_add_endpoint_unknown(self): + """Test that add with unknown endpoint URI returns 404.""" + + self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + with pytest.raises(HTTPError) as exc_info: + ss.add_connection_endpoint( + endpoint_uri='/api/storage-switches/nonexistent-switch', + port_id='00', + ) + assert exc_info.value.http_status == 404 + + def test_ss_add_endpoint_subsys_not_found(self): + """Test that add endpoint with unknown subsystem URI returns 404.""" + + ssm = self.console.storage_subsystems + fake_ss = StorageSubsystem( + ssm, '/api/storage-subsystems/nonexistent-oid', + 'Nonexistent', + {'object-uri': '/api/storage-subsystems/nonexistent-oid'}) + + with pytest.raises(HTTPError) as exc_info: + fake_ss.add_connection_endpoint( + endpoint_uri=SSWITCH1_URI, port_id='00') + assert exc_info.value.http_status == 404 + + # ----------------------------------------------------------------------- + # remove_connection_endpoint tests + # ----------------------------------------------------------------------- + + def test_ss_remove_connection_endpoint(self): + """Test StorageSubsystem.remove_connection_endpoint().""" + + self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + # First add an endpoint + ss.add_connection_endpoint(endpoint_uri=SSWITCH1_URI, port_id='00') + ss.pull_full_properties() + assert len(ss.properties['connection-endpoints']) == 1 + + # Execute the code to be tested + ss.remove_connection_endpoint( + endpoint_uri=SSWITCH1_URI, port_id='00') + + ss.pull_full_properties() + assert len(ss.properties['connection-endpoints']) == 0 + + def test_ss_remove_endpoint_not_found(self): + """Test that removing a non-existent endpoint returns 409.""" + + self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + with pytest.raises(HTTPError) as exc_info: + ss.remove_connection_endpoint( + endpoint_uri=SSWITCH1_URI, port_id='00') + assert exc_info.value.http_status == 409 + assert exc_info.value.reason == 444 + + def test_ss_remove_endpoint_subsys_not_found(self): + """Test that remove endpoint with unknown subsystem URI returns 404.""" + + ssm = self.console.storage_subsystems + fake_ss = StorageSubsystem( + ssm, '/api/storage-subsystems/nonexistent-oid', + 'Nonexistent', + {'object-uri': '/api/storage-subsystems/nonexistent-oid'}) + + with pytest.raises(HTTPError) as exc_info: + fake_ss.remove_connection_endpoint( + endpoint_uri=SSWITCH1_URI, port_id='00') + assert exc_info.value.http_status == 404 + + def test_ss_remove_endpoint_unknown(self): + """Test that remove with unknown endpoint URI returns 404.""" + + self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + + with pytest.raises(HTTPError) as exc_info: + ss.remove_connection_endpoint( + endpoint_uri='/api/storage-switches/nonexistent-switch', + port_id='00', + ) + assert exc_info.value.http_status == 404 + + # ----------------------------------------------------------------------- + # Default property tests + # ----------------------------------------------------------------------- + + def test_ss_default_properties(self): + """Test that faked subsystem gets correct default property values.""" + + faked_ss = self.faked_console.storage_subsystems.add({ + 'name': SUBSYS1_NAME, + 'storage-site-uri': SSITE1_URI, + }) + + assert faked_ss.properties.get('description') == '' + assert faked_ss.properties.get('connection-endpoints') == [] + assert faked_ss.properties.get('storage-control-unit-uris') == [] + assert faked_ss.properties.get('class') == 'storage-subsystem' + assert faked_ss.properties.get('name') == SUBSYS1_NAME + + def test_ss_class_property(self): + """Test that class property is 'storage-subsystem'.""" + + faked_ss = self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=faked_ss.name) + ss.pull_full_properties() + + assert ss.properties.get('class') == 'storage-subsystem' + + # ----------------------------------------------------------------------- + # dump() test + # ----------------------------------------------------------------------- + + def test_ss_dump(self): + """Test StorageSubsystem.dump().""" + + faked_ss = self.add_subsystem1() + ssm = self.console.storage_subsystems + ss = ssm.find(name=faked_ss.name) + ss.pull_full_properties() + + resource_dict = ss.dump() + + assert isinstance(resource_dict, dict) + assert 'properties' in resource_dict + assert resource_dict['properties']['name'] == SUBSYS1_NAME + + # ----------------------------------------------------------------------- + # URI handler tests — direct session calls + # ----------------------------------------------------------------------- + + def test_get_subsystem_properties(self): + """Test GET /api/storage-subsystems/{id}.""" + + faked_ss = self.add_subsystem1() + ss_uri = faked_ss.uri + + result = self.session.get(ss_uri) + + assert result['name'] == SUBSYS1_NAME + assert result['class'] == 'storage-subsystem' + + def test_get_subsystem_properties_not_found(self): + """Test GET /api/storage-subsystems/{id} for unknown ID raises 404.""" + + with pytest.raises(HTTPError) as exc_info: + self.session.get('/api/storage-subsystems/nonexistent-id') + assert exc_info.value.http_status == 404 + + def test_list_subsystems_global(self): + """Test GET /api/storage-subsystems returns all subsystems.""" + + self.add_subsystem1() + self.add_subsystem2() + + result = self.session.get('/api/storage-subsystems') + + assert 'storage-subsystems' in result + names = {ss['name'] for ss in result['storage-subsystems']} + assert names == {SUBSYS1_NAME, SUBSYS2_NAME} + + def test_list_subsystems_global_filter_name(self): + """Test GET /api/storage-subsystems?name=... filtering.""" + + self.add_subsystem1() + self.add_subsystem2() + + result = self.session.get( + f'/api/storage-subsystems?name={SUBSYS1_NAME}') + + assert 'storage-subsystems' in result + names = [ss['name'] for ss in result['storage-subsystems']] + assert SUBSYS1_NAME in names + assert SUBSYS2_NAME not in names + + def test_update_subsystem_properties_handler(self): + """Test POST /api/storage-subsystems/{id} (Update Properties).""" + + faked_ss = self.add_subsystem1() + ss_uri = faked_ss.uri + + self.session.post(ss_uri, body={'description': 'Updated'}) + + result = self.session.get(ss_uri) + assert result['description'] == 'Updated' + + def test_list_subsystems_by_site(self): + """Test GET /api/storage-sites/{id}/storage-subsystems.""" + + faked_ss1 = self.add_subsystem1() + faked_ss2 = self.add_subsystem2() + + result = self.session.get( + f'/api/storage-sites/{SSITE1_OID}/storage-subsystems') + + assert 'storage-subsystems' in result + uris = {ss['object-uri'] for ss in result['storage-subsystems']} + assert faked_ss1.uri in uris + assert faked_ss2.uri in uris + + def test_list_subsystems_by_site_not_found(self): + """Test GET /api/storage-sites/{id}/storage-subsystems for bad site.""" + + with pytest.raises(HTTPError) as exc_info: + self.session.get( + '/api/storage-sites/nonexistent-site/storage-subsystems') + assert exc_info.value.http_status == 404 + + def test_move_site_handler(self): + """Test POST …/operations/move-storage-site.""" + + faked_ss = self.add_subsystem1() + ss_oid = faked_ss.oid + + self.session.post( + f'/api/storage-subsystems/{ss_oid}/operations/move-storage-site', + body={'storage-site-uri': SSITE2_URI}, + ) + + result = self.session.get(faked_ss.uri) + assert result['storage-site-uri'] == SSITE2_URI + + def test_move_site_handler_not_found(self): + """Test that move-storage-site with bad subsystem ID returns 404.""" + + with pytest.raises(HTTPError) as exc_info: + self.session.post( + '/api/storage-subsystems/nonexistent-id/operations/' + 'move-storage-site', + body={'storage-site-uri': SSITE2_URI}, + ) + assert exc_info.value.http_status == 404 + + def test_add_endpoint_handler(self): + """Test POST …/operations/add-connection-endpoint.""" + + faked_ss = self.add_subsystem1() + ss_oid = faked_ss.oid + + self.session.post( + f'/api/storage-subsystems/{ss_oid}/operations/' + 'add-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': '01'}, + ) + + result = self.session.get(faked_ss.uri) + endpoints = result.get('connection-endpoints', []) + assert len(endpoints) == 1 + assert endpoints[0]['endpoint-uri'] == SSWITCH1_URI + + def test_remove_endpoint_handler(self): + """Test POST …/operations/remove-connection-endpoint.""" + + faked_ss = self.add_subsystem1() + ss_oid = faked_ss.oid + + # First add an endpoint + self.session.post( + f'/api/storage-subsystems/{ss_oid}/operations/' + 'add-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': '01'}, + ) + + # Then remove it + self.session.post( + f'/api/storage-subsystems/{ss_oid}/operations/' + 'remove-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': '01'}, + ) + + result = self.session.get(faked_ss.uri) + endpoints = result.get('connection-endpoints', []) + assert len(endpoints) == 0 + + def test_add_endpoint_handler_missing_field(self): + """Test add-connection-endpoint missing endpoint-uri returns 400.""" + + faked_ss = self.add_subsystem1() + ss_oid = faked_ss.oid + + with pytest.raises(HTTPError) as exc_info: + self.session.post( + f'/api/storage-subsystems/{ss_oid}/operations/' + 'add-connection-endpoint', + body={}, + ) + assert exc_info.value.http_status == 400 + + # ----------------------------------------------------------------------- + # Dependency regression tests + # ----------------------------------------------------------------------- + + # --- Gap 1 + 2: storage-site ↔ storage-subsystem bi-directional linkage -- + + def test_ssm_add_registers_site_uris(self): + """Adding a subsystem populates the site's storage-subsystem-uris.""" + + faked_ss = self.add_subsystem1() + + site_props = self.faked_site1.properties + assert faked_ss.uri in site_props.get('storage-subsystem-uris', []), ( + "subsystem URI not registered in parent site's " + "storage-subsystem-uris after add()") + + def test_site_default_subsystem_uris(self): + """FakedStorageSite.add() initialises storage-subsystem-uris to [].""" + + faked_site = self.faked_console.storage_sites.add({ + 'object-id': 'new-site-oid', + 'name': 'New Site', + }) + + assert 'storage-subsystem-uris' in faked_site.properties, ( + "storage-subsystem-uris not defaulted on FakedStorageSite") + assert faked_site.properties['storage-subsystem-uris'] == [] + + def test_ssm_add_two_both_in_site_uris(self): + """Two subsystems on the same site both appear in subsystem-uris.""" + + faked_ss1 = self.add_subsystem1() + faked_ss2 = self.add_subsystem2() + + site_uris = self.faked_site1.properties.get( + 'storage-subsystem-uris', []) + assert faked_ss1.uri in site_uris + assert faked_ss2.uri in site_uris + + def test_ss_move_site_updates_both_sites(self): + """move_to_storage_site() removes URI from old site, adds to new.""" + + faked_ss = self.add_subsystem1() + ss_uri = faked_ss.uri + + # Confirm it starts registered in site1 + assert ss_uri in self.faked_site1.properties.get( + 'storage-subsystem-uris', []) + + ssm = self.console.storage_subsystems + ss = ssm.find(name=SUBSYS1_NAME) + ss.move_to_storage_site(SSITE2_URI) + + # After move: must be gone from site1, present in site2 + assert ss_uri not in self.faked_site1.properties.get( + 'storage-subsystem-uris', []) + assert ss_uri in self.faked_site2.properties.get( + 'storage-subsystem-uris', []) + + def test_ssm_add_uri_resolvable_from_site(self): + """The site's storage-subsystem-uris resolves back to the subsystem.""" + + faked_ss = self.add_subsystem1() + + site = self.session.hmc.consoles.console.storage_sites.lookup_by_oid( + SSITE1_OID) + site_ss_uris = site.properties.get('storage-subsystem-uris', []) + assert faked_ss.uri in site_ss_uris + + # Verify the URI resolves to the subsystem + resolved = self.session.hmc.lookup_by_uri(faked_ss.uri) + assert resolved.properties['name'] == SUBSYS1_NAME + + # --- Gap 3: undefining a switch clears subsystem connection-endpoints --- + + def test_sw_undefine_clears_endpoints(self): + """Undefining a switch clears it from subsystem connection-endpoints.""" + + faked_ss = self.add_subsystem1() + ss_oid = faked_ss.oid + + # Add the switch as a connection endpoint on the subsystem + self.session.post( + f'/api/storage-subsystems/{ss_oid}/operations/' + 'add-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': '00'}, + ) + + # Confirm endpoint is present + result = self.session.get(faked_ss.uri) + assert len(result['connection-endpoints']) == 1 + + # Now undefine the storage switch + self.session.post( + f'{SSWITCH1_URI}/operations/undefine', + body=None, + ) + + # The endpoint must have been removed from the subsystem + result = self.session.get(faked_ss.uri) + assert len(result['connection-endpoints']) == 0, ( + "connection-endpoints not cleared after storage switch undefine") + + def test_sw_undefine_clears_all_endpoints(self): + """Undefine a switch clears endpoints on all referencing subsystems.""" + + faked_ss1 = self.add_subsystem1() + faked_ss2 = self.add_subsystem2() + + for faked_ss in (faked_ss1, faked_ss2): + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': '00'}, + ) + + # Undefine the switch + self.session.post(f'{SSWITCH1_URI}/operations/undefine', body=None) + + for faked_ss in (faked_ss1, faked_ss2): + result = self.session.get(faked_ss.uri) + assert result['connection-endpoints'] == [], ( + f"connection-endpoints not cleared on {faked_ss.uri} " + "after switch undefine") + + def test_sw_undefine_leaves_other_endpoints(self): + """Undefining a switch only removes that switch's endpoints.""" + + # Add a second switch to use as an unaffected endpoint + faked_switch2 = self.faked_console.storage_switches.add({ + 'object-id': 'sw2-oid', + 'name': 'Storage Switch 22', + 'domain-id': '22', + 'storage-fabric-uri': '/api/storage-fabrics/fabric1-oid', + 'storage-site-uri': SSITE1_URI, + }) + + faked_ss = self.add_subsystem1() + + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': '00'}, + ) + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={'endpoint-uri': faked_switch2.uri, 'port-id': '01'}, + ) + + # Undefine only switch1 + self.session.post(f'{SSWITCH1_URI}/operations/undefine', body=None) + + result = self.session.get(faked_ss.uri) + endpoints = result['connection-endpoints'] + assert len(endpoints) == 1, ( + "Expected exactly 1 endpoint (switch2) to remain") + assert endpoints[0]['endpoint-uri'] == faked_switch2.uri + + # --- Gap 4: port-id required for switch endpoints (add) --- + + def test_ss_add_sw_endpoint_no_port_id(self): + """Adding a switch endpoint without port-id must return 400 reason 5.""" + + faked_ss = self.add_subsystem1() + + with pytest.raises(HTTPError) as exc_info: + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI}, # port-id missing + ) + assert exc_info.value.http_status == 400 + assert exc_info.value.reason == 5 + + def test_ss_add_adapter_ep_with_port_id(self): + """Adding an adapter endpoint WITH port-id must return 400 reason 15.""" + + faked_adapter = self.faked_cpc.adapters.add({ + 'object-id': 'adapter2-oid', + 'name': 'TestAdapter2', + 'type': 'fc', + 'status': 'active', + }) + faked_ss = self.add_subsystem1() + + with pytest.raises(HTTPError) as exc_info: + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={ + 'endpoint-uri': faked_adapter.uri, + 'port-id': '00', # prohibited for adapters + }, + ) + assert exc_info.value.http_status == 400 + assert exc_info.value.reason == 15 + + def test_ss_add_adapter_endpoint_no_port_id(self): + """Adding an adapter endpoint without port-id succeeds.""" + + faked_adapter = self.faked_cpc.adapters.add({ + 'object-id': 'adapter3-oid', + 'name': 'TestAdapter3', + 'type': 'fc', + 'status': 'active', + }) + faked_ss = self.add_subsystem1() + + # Must succeed with no exception + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={'endpoint-uri': faked_adapter.uri}, + ) + + result = self.session.get(faked_ss.uri) + endpoints = result['connection-endpoints'] + assert len(endpoints) == 1 + assert endpoints[0]['endpoint-class'] == 'adapter' + assert 'port-id' not in endpoints[0] + + def test_ss_add_sw_endpoint_with_port_id(self): + """Adding a switch endpoint with port-id succeeds.""" + + faked_ss = self.add_subsystem1() + + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': '0a'}, + ) + + result = self.session.get(faked_ss.uri) + endpoints = result['connection-endpoints'] + assert len(endpoints) == 1 + assert endpoints[0]['port-id'] == '0a' + assert endpoints[0]['endpoint-class'] == 'storage-switch' + + # --- Gap 5: port-id required for switch endpoints (remove) --- + + def test_ss_remove_sw_endpoint_no_port_id(self): + """Removing a switch endpoint without port-id returns 400 reason 5.""" + + faked_ss = self.add_subsystem1() + + # First add a valid endpoint so we have something to remove + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': '00'}, + ) + + with pytest.raises(HTTPError) as exc_info: + self.session.post( + f'{faked_ss.uri}/operations/remove-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI}, # port-id missing + ) + assert exc_info.value.http_status == 400 + assert exc_info.value.reason == 5 + + def test_ss_remove_sw_endpoint_wrong_port_id(self): + """Removing a switch endpoint with wrong port-id returns 409/444.""" + + faked_ss = self.add_subsystem1() + + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': '00'}, + ) + + with pytest.raises(HTTPError) as exc_info: + self.session.post( + f'{faked_ss.uri}/operations/remove-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': 'ff'}, + ) + assert exc_info.value.http_status == 409 + assert exc_info.value.reason == 444 + + def test_ss_remove_adapter_ep_no_port_id(self): + """Removing an adapter endpoint without port-id succeeds.""" + + faked_adapter = self.faked_cpc.adapters.add({ + 'object-id': 'adapter4-oid', + 'name': 'TestAdapter4', + 'type': 'fc', + 'status': 'active', + }) + faked_ss = self.add_subsystem1() + + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={'endpoint-uri': faked_adapter.uri}, + ) + + # Remove without port-id — must succeed for adapters + self.session.post( + f'{faked_ss.uri}/operations/remove-connection-endpoint', + body={'endpoint-uri': faked_adapter.uri}, + ) + + result = self.session.get(faked_ss.uri) + assert result['connection-endpoints'] == [] + + # --- Duplicate-endpoint check uses port-id correctly --- + + def test_ss_add_sw_endpoint_diff_ports_ok(self): + """Adding the same switch with two different port-ids is allowed.""" + + faked_ss = self.add_subsystem1() + + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': '00'}, + ) + # Same switch, different port — should succeed + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': '01'}, + ) + + result = self.session.get(faked_ss.uri) + assert len(result['connection-endpoints']) == 2 + + def test_ss_add_sw_ep_dup_port_conflict(self): + """Adding exactly the same switch+port twice returns 409 reason 443.""" + + faked_ss = self.add_subsystem1() + + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': '00'}, + ) + + with pytest.raises(HTTPError) as exc_info: + self.session.post( + f'{faked_ss.uri}/operations/add-connection-endpoint', + body={'endpoint-uri': SSWITCH1_URI, 'port-id': '00'}, + ) + 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/tests/unit/zhmcclient/test_storage_switch.py b/tests/unit/zhmcclient/test_storage_switch.py new file mode 100644 index 00000000..42950b57 --- /dev/null +++ b/tests/unit/zhmcclient/test_storage_switch.py @@ -0,0 +1,868 @@ +# 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_switch module. +""" + +import re +import copy +import pytest + +from zhmcclient import Client, StorageSwitch, StorageSwitchManager, \ + HTTPError, NotFound +from zhmcclient.mock import FakedSession +from tests.common.utils import assert_resources + + +# --------------------------------------------------------------------------- +# Object IDs and URIs of faked resources used across the test class +# --------------------------------------------------------------------------- + +CPC_OID = 'fake-cpc1-oid' +CPC_URI = f'/api/cpcs/{CPC_OID}' + +SFABRIC1_OID = 'sf1-oid' +SFABRIC1_URI = f'/api/storage-fabrics/{SFABRIC1_OID}' +SFABRIC1_NAME = 'Fabric A' + +SFABRIC2_OID = 'sf2-oid' +SFABRIC2_URI = f'/api/storage-fabrics/{SFABRIC2_OID}' +SFABRIC2_NAME = 'Fabric B' + +SSITE1_OID = 'ss1-oid' +SSITE1_URI = f'/api/storage-sites/{SSITE1_OID}' +SSITE1_NAME = 'Primary Site' + +SSITE2_OID = 'ss2-oid' +SSITE2_URI = f'/api/storage-sites/{SSITE2_OID}' +SSITE2_NAME = 'Alternate Site' + +SSWITCH1_OID = 'sw1-oid' +SSWITCH1_NAME = 'Storage switch 11' +SSWITCH1_DOMAIN_ID = '11' + +SSWITCH2_OID = 'sw2-oid' +SSWITCH2_NAME = 'Storage switch 21' +SSWITCH2_DOMAIN_ID = '21' + + +class TestStorageSwitch: + """All tests for the StorageSwitch and StorageSwitchManager classes.""" + + def setup_method(self): + """ + Setup called by pytest before each test method. + + Creates a faked session with a faked CPC (DPM mode), a faked console, + two faked storage fabrics, and two faked storage sites. + """ + # pylint: disable=attribute-defined-outside-init + + self.session = FakedSession('fake-host', 'fake-hmc', '2.16.0', '4.10') + self.client = Client(self.session) + + # Add a faked CPC in DPM mode + self.faked_cpc = self.session.hmc.cpcs.add({ + 'object-id': CPC_OID, + 'parent': None, + 'class': 'cpc', + 'name': 'fake-cpc1-name', + 'description': 'CPC #1 (DPM mode)', + 'status': 'active', + 'dpm-enabled': True, + 'is-ensemble-member': False, + 'iml-mode': 'dpm', + 'available-features-list': [ + {'name': 'dpm-storage-management', 'state': True}, + ], + }) + self.cpc = self.client.cpcs.find(name='fake-cpc1-name') + + # Add a faked console + self.faked_console = self.session.hmc.consoles.add({ + 'name': 'fake-console-name', + 'description': 'The HMC', + }) + self.console = self.client.consoles.console + + # Add two faked storage fabrics + self.faked_fabric1 = self.faked_console.storage_fabrics.add({ + 'object-id': SFABRIC1_OID, + 'cpc-uri': CPC_URI, + 'name': SFABRIC1_NAME, + 'description': 'Fabric A', + 'high-integrity': False, + }) + self.faked_fabric2 = self.faked_console.storage_fabrics.add({ + 'object-id': SFABRIC2_OID, + 'cpc-uri': CPC_URI, + 'name': SFABRIC2_NAME, + 'description': 'Fabric B', + 'high-integrity': False, + }) + + # Add two faked storage sites + self.faked_site1 = self.faked_console.storage_sites.add({ + 'object-id': SSITE1_OID, + 'name': SSITE1_NAME, + 'description': 'Primary storage site', + 'cpc-uris': [CPC_URI], + }) + self.faked_site2 = self.faked_console.storage_sites.add({ + 'object-id': SSITE2_OID, + 'name': SSITE2_NAME, + 'description': 'Alternate storage site', + 'cpc-uris': [CPC_URI], + }) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def add_switch1(self): + """Add storage switch 1 directly to the faked console.""" + return self.faked_console.storage_switches.add({ + 'object-id': SSWITCH1_OID, + 'name': SSWITCH1_NAME, + 'domain-id': SSWITCH1_DOMAIN_ID, + 'storage-fabric-uri': SFABRIC1_URI, + 'storage-site-uri': SSITE1_URI, + 'description': 'Storage switch 11', + }) + + def add_switch2(self): + """Add storage switch 2 directly to the faked console.""" + return self.faked_console.storage_switches.add({ + 'object-id': SSWITCH2_OID, + 'name': SSWITCH2_NAME, + 'domain-id': SSWITCH2_DOMAIN_ID, + 'storage-fabric-uri': SFABRIC2_URI, + 'storage-site-uri': SSITE1_URI, + 'description': 'Storage switch 21', + }) + + # ================================================================== + # StorageSwitchManager tests + # ================================================================== + + def test_sswm_initial_attrs(self): + """Test initial attributes of StorageSwitchManager.""" + + sw_mgr = self.console.storage_switches + + assert isinstance(sw_mgr, StorageSwitchManager) + assert sw_mgr.resource_class == StorageSwitch + assert sw_mgr.session == self.session + assert sw_mgr.parent == self.console + assert sw_mgr.console == self.console + + # ------------------------------------------------------------------ + # list() — full_properties + # ------------------------------------------------------------------ + + testcases_sswm_list_full_properties = ( + "full_properties_kwargs, prop_names", [ + ({}, + ['object-uri', 'name', 'domain-id', 'storage-fabric-uri']), + ({'full_properties': False}, + ['object-uri', 'name', 'domain-id', 'storage-fabric-uri']), + ] + ) + + @pytest.mark.parametrize(*testcases_sswm_list_full_properties) + def test_sswm_list_full_props(self, full_properties_kwargs, prop_names): + """Test StorageSwitchManager.list() short-props vs full_properties.""" + + faked_sw1 = self.add_switch1() + faked_sw2 = self.add_switch2() + exp_faked_switches = [faked_sw1, faked_sw2] + + sw_mgr = self.console.storage_switches + + switches = sw_mgr.list(**full_properties_kwargs) + + assert_resources(switches, exp_faked_switches, prop_names) + + # ------------------------------------------------------------------ + # list() — filter_args + # ------------------------------------------------------------------ + + testcases_sswm_list_filter_args = ( + "filter_args, exp_names", [ + ({'object-id': SSWITCH1_OID}, + [SSWITCH1_NAME]), + ({'object-id': SSWITCH2_OID}, + [SSWITCH2_NAME]), + ({'object-id': [SSWITCH1_OID, SSWITCH2_OID]}, + [SSWITCH1_NAME, SSWITCH2_NAME]), + ({'object-id': SSWITCH1_OID + 'foo'}, + []), + ({'name': SSWITCH1_NAME}, + [SSWITCH1_NAME]), + ({'name': SSWITCH2_NAME}, + [SSWITCH2_NAME]), + ({'name': [SSWITCH1_NAME, SSWITCH2_NAME]}, + [SSWITCH1_NAME, SSWITCH2_NAME]), + ({'name': SSWITCH1_NAME + 'foo'}, + []), + ({'name': 'Storage switch .*'}, + [SSWITCH1_NAME, SSWITCH2_NAME]), + ({'domain-id': SSWITCH1_DOMAIN_ID}, + [SSWITCH1_NAME]), + ({'domain-id': SSWITCH2_DOMAIN_ID}, + [SSWITCH2_NAME]), + ({'domain-id': '99'}, + []), + ({'name': SSWITCH1_NAME, 'domain-id': SSWITCH1_DOMAIN_ID}, + [SSWITCH1_NAME]), + ({'name': SSWITCH1_NAME, 'domain-id': SSWITCH2_DOMAIN_ID}, + []), + ({'storage-fabric-uri': SFABRIC1_URI}, + [SSWITCH1_NAME]), + ({'storage-fabric-uri': SFABRIC2_URI}, + [SSWITCH2_NAME]), + ] + ) + + @pytest.mark.parametrize(*testcases_sswm_list_filter_args) + def test_sswm_list_filter_args(self, filter_args, exp_names): + """Test StorageSwitchManager.list() with filter_args.""" + + self.add_switch1() + self.add_switch2() + + sw_mgr = self.console.storage_switches + + switches = sw_mgr.list(filter_args=filter_args) + + assert len(switches) == len(exp_names) + if exp_names: + names = [s.properties['name'] for s in switches] + assert set(names) == set(exp_names) + + # ------------------------------------------------------------------ + # list() — empty / two + # ------------------------------------------------------------------ + + def test_sswm_list_empty(self): + """Test StorageSwitchManager.list() with no switches defined.""" + + switches = self.console.storage_switches.list() + + assert switches == [] + + def test_sswm_list_two(self): + """Test StorageSwitchManager.list() with two switches defined.""" + + self.add_switch1() + self.add_switch2() + + switches = self.console.storage_switches.list() + + assert len(switches) == 2 + names = {s.properties['name'] for s in switches} + assert names == {SSWITCH1_NAME, SSWITCH2_NAME} + + # ------------------------------------------------------------------ + # resource_object() + # ------------------------------------------------------------------ + + def test_sswm_resource_object(self): + """Test StorageSwitchManager.resource_object().""" + + faked_sw = self.add_switch1() + sw_oid = faked_sw.oid + + sw_mgr = self.console.storage_switches + sw = sw_mgr.resource_object(sw_oid) + + expected_uri = f'/api/storage-switches/{sw_oid}' + assert isinstance(sw, StorageSwitch) + assert sw.properties['object-uri'] == expected_uri + assert sw.properties['object-id'] == sw_oid + assert sw.properties['class'] == 'storage-switch' + assert sw.properties['parent'] == self.console.uri + + # ================================================================== + # StorageSwitch resource tests + # ================================================================== + + def test_ssw_repr(self): + """Test StorageSwitch.__repr__().""" + + faked_sw = self.add_switch1() + sw = self.console.storage_switches.find(name=faked_sw.name) + + repr_str = repr(sw) + repr_str = repr_str.replace('\n', '\\n') + assert re.match( + rf'^{sw.__class__.__name__}\s+at\s+' + rf'0x{id(sw):08x}\s+\(\\n.*', + repr_str) + + # ------------------------------------------------------------------ + # undefine() + # ------------------------------------------------------------------ + + def test_ssw_undefine(self): + """Test StorageSwitch.undefine() removes the switch.""" + + faked_sw = self.add_switch1() + self.add_switch2() + + sw_mgr = self.console.storage_switches + sw = sw_mgr.find(name=faked_sw.name) + + sw.undefine() + + with pytest.raises(NotFound): + sw_mgr.find(name=faked_sw.name) + + def test_ssw_undefine_updates_fabric_uris(self): + """Test that undefine() removes the switch URI from the fabric.""" + + faked_sw = self.add_switch1() + sw_uri = faked_sw.uri + + # Manually set the switch URI on the fabric's storage-switch-uris + fabric1 = self.session.hmc.lookup_by_uri(SFABRIC1_URI) + fabric1.update({'storage-switch-uris': [sw_uri]}) + + sw = self.console.storage_switches.find(name=faked_sw.name) + sw.undefine() + + fabric1 = self.session.hmc.lookup_by_uri(SFABRIC1_URI) + assert sw_uri not in fabric1.properties.get('storage-switch-uris', []) + + def test_ssw_undefine_create_same(self): + """Test undefine() followed by re-adding with the same name.""" + + faked_sw = self.add_switch1() + sw_name = faked_sw.name + + sw_mgr = self.console.storage_switches + sw = sw_mgr.find(name=sw_name) + sw.undefine() + + with pytest.raises(NotFound): + sw_mgr.find(name=sw_name) + + # Re-add with same name + self.faked_console.storage_switches.add({ + 'object-id': 'sw1-new-oid', + 'name': sw_name, + 'domain-id': SSWITCH1_DOMAIN_ID, + 'storage-fabric-uri': SFABRIC1_URI, + 'storage-site-uri': SSITE1_URI, + 'description': 'Re-added switch', + }) + + sw_found = sw_mgr.find(name=sw_name) + assert sw_found.get_property('description') == 'Re-added switch' + + # ------------------------------------------------------------------ + # update_properties() + # ------------------------------------------------------------------ + + testcases_ssw_update_props = ( + "input_props", [ + {}, + {'description': 'Updated description'}, + {'description': 'Updated description', 'domain-id': '99'}, + ] + ) + + @pytest.mark.parametrize(*testcases_ssw_update_props) + def test_ssw_update_properties(self, input_props): + """Test StorageSwitch.update_properties().""" + + self.add_switch1() + + sw_mgr = self.console.storage_switches + sw = sw_mgr.find(name=SSWITCH1_NAME) + sw.pull_full_properties() + saved = copy.deepcopy(sw.properties) + + sw.update_properties(properties=input_props) + + for prop_name in saved: + exp_value = (input_props[prop_name] + if prop_name in input_props + else saved[prop_name]) + assert prop_name in sw.properties + assert sw.properties[prop_name] == exp_value + + sw.pull_full_properties() + for prop_name in saved: + exp_value = (input_props[prop_name] + if prop_name in input_props + else saved[prop_name]) + assert sw.properties[prop_name] == exp_value + + def test_ssw_update_name(self): + """Test StorageSwitch.update_properties() with 'name' property.""" + + faked_sw = self.add_switch1() + old_name = faked_sw.name + + sw_mgr = self.console.storage_switches + sw = sw_mgr.find(name=old_name) + new_name = 'renamed-' + old_name + + sw.update_properties(properties={'name': new_name}) + + assert len(sw_mgr.list(filter_args={'name': old_name})) == 0 + + with pytest.raises(NotFound): + sw_mgr.find(name=old_name) + + assert sw.properties['name'] == new_name + + sw.pull_full_properties() + assert sw.properties['name'] == new_name + + sw_found = sw_mgr.find(name=new_name) + assert sw_found.properties['name'] == new_name + + assert len(sw_mgr.list(filter_args={'name': new_name})) == 1 + + # ------------------------------------------------------------------ + # move_to_storage_site() + # ------------------------------------------------------------------ + + def test_ssw_move_to_site(self): + """Test StorageSwitch.move_to_storage_site() updates site URI.""" + + self.add_switch1() + + sw_mgr = self.console.storage_switches + sw = sw_mgr.find(name=SSWITCH1_NAME) + sw.pull_full_properties() + + # Move to site2 (already registered in the faked HMC) + sw.move_to_storage_site(SSITE2_URI) + + assert sw.properties['storage-site-uri'] == SSITE2_URI + + sw.pull_full_properties() + assert sw.properties['storage-site-uri'] == SSITE2_URI + + def test_ssw_move_to_storage_site_not_found(self): + """Test move_to_storage_site() raises 404 for unknown site URI.""" + + self.add_switch1() + sw = self.console.storage_switches.find(name=SSWITCH1_NAME) + + with pytest.raises(HTTPError) as exc_info: + sw.move_to_storage_site('/api/storage-sites/nonexistent') + + assert exc_info.value.http_status == 404 + + # ------------------------------------------------------------------ + # move_to_storage_fabric() + # ------------------------------------------------------------------ + + def test_ssw_move_to_storage_fabric(self): + """Test StorageSwitch.move_to_storage_fabric() updates fabric URI.""" + + faked_sw = self.add_switch1() + sw_uri = faked_sw.uri + + # Pre-populate storage-switch-uris on fabric1 + fabric1 = self.session.hmc.lookup_by_uri(SFABRIC1_URI) + fabric1.update({'storage-switch-uris': [sw_uri]}) + + sw = self.console.storage_switches.find(name=SSWITCH1_NAME) + + sw.move_to_storage_fabric(SFABRIC2_URI) + + assert sw.properties['storage-fabric-uri'] == SFABRIC2_URI + + sw.pull_full_properties() + assert sw.properties['storage-fabric-uri'] == SFABRIC2_URI + + def test_ssw_move_to_fabric_not_found(self): + """Test move_to_storage_fabric() raises 404 for unknown fabric URI.""" + + self.add_switch1() + sw = self.console.storage_switches.find(name=SSWITCH1_NAME) + + with pytest.raises(HTTPError) as exc_info: + sw.move_to_storage_fabric('/api/storage-fabrics/nonexistent') + + assert exc_info.value.http_status == 404 + + # ------------------------------------------------------------------ + # Default properties + # ------------------------------------------------------------------ + + def test_ssw_default_properties(self): + """Test that FakedStorageSwitch has correct default property values.""" + + faked_sw = self.faked_console.storage_switches.add({ + 'object-id': 'sw-defaults-oid', + 'name': 'Switch Defaults', + 'domain-id': '50', + 'storage-fabric-uri': SFABRIC1_URI, + 'storage-site-uri': SSITE1_URI, + }) + + sw = self.console.storage_switches.find(name='Switch Defaults') + sw.pull_full_properties() + + assert sw.properties['description'] == '' + assert sw.properties['port-count'] == 256 + + faked_sw.manager.remove(faked_sw.oid) + + def test_ssw_class_property(self): + """Test that the class property is set to 'storage-switch'.""" + + self.add_switch1() + sw = self.console.storage_switches.find(name=SSWITCH1_NAME) + sw.pull_full_properties() + + assert sw.properties['class'] == 'storage-switch' + + def test_ssw_domain_id_property(self): + """Test that domain-id is correctly stored and accessible.""" + + self.add_switch1() + self.add_switch2() + + sw_mgr = self.console.storage_switches + + sw1 = sw_mgr.find(name=SSWITCH1_NAME) + sw1.pull_full_properties() + assert sw1.properties['domain-id'] == SSWITCH1_DOMAIN_ID + + sw2 = sw_mgr.find(name=SSWITCH2_NAME) + sw2.pull_full_properties() + assert sw2.properties['domain-id'] == SSWITCH2_DOMAIN_ID + + def test_ssw_storage_fabric_uri_property(self): + """Test that storage-fabric-uri is correctly stored.""" + + self.add_switch1() + self.add_switch2() + + sw_mgr = self.console.storage_switches + + sw1 = sw_mgr.find(name=SSWITCH1_NAME) + sw1.pull_full_properties() + assert sw1.properties['storage-fabric-uri'] == SFABRIC1_URI + + sw2 = sw_mgr.find(name=SSWITCH2_NAME) + sw2.pull_full_properties() + assert sw2.properties['storage-fabric-uri'] == SFABRIC2_URI + + # ------------------------------------------------------------------ + # URI handler — List by Storage Fabric + # ------------------------------------------------------------------ + + def test_list_switches_by_fabric(self): + """Test GET /api/storage-fabrics/{id}/storage-switches.""" + + self.add_switch1() + self.add_switch2() + + result = self.session.get( + f'/api/storage-fabrics/{SFABRIC1_OID}/storage-switches') + + assert 'storage-switches' in result + names = [s['name'] for s in result['storage-switches']] + assert SSWITCH1_NAME in names + assert SSWITCH2_NAME not in names + + def test_list_switches_by_fabric_empty(self): + """Test GET /api/storage-fabrics/{id}/storage-switches returns empty.""" + + result = self.session.get( + f'/api/storage-fabrics/{SFABRIC1_OID}/storage-switches') + + assert result == {'storage-switches': []} + + def test_list_switches_by_fabric_not_found(self): + """Test GET on unknown fabric raises 404.""" + + with pytest.raises(HTTPError) as exc_info: + self.session.get( + '/api/storage-fabrics/nonexistent/storage-switches') + + assert exc_info.value.http_status == 404 + + # ------------------------------------------------------------------ + # URI handler — List by Storage Site + # ------------------------------------------------------------------ + + def test_list_switches_by_site(self): + """Test GET /api/storage-sites/{id}/storage-switches.""" + + self.add_switch1() + self.add_switch2() + + result = self.session.get( + f'/api/storage-sites/{SSITE1_OID}/storage-switches') + + assert 'storage-switches' in result + # Both switches belong to SSITE1 + names = {s['name'] for s in result['storage-switches']} + assert names == {SSWITCH1_NAME, SSWITCH2_NAME} + + def test_list_switches_by_site_not_found(self): + """Test GET on unknown site raises 404.""" + + with pytest.raises(HTTPError) as exc_info: + self.session.get( + '/api/storage-sites/nonexistent/storage-switches') + + assert exc_info.value.http_status == 404 + + # ------------------------------------------------------------------ + # URI handler — Global list + # ------------------------------------------------------------------ + + def test_list_switches_global(self): + """Test GET /api/storage-switches lists all switches.""" + + self.add_switch1() + self.add_switch2() + + result = self.session.get('/api/storage-switches') + + assert 'storage-switches' in result + names = {s['name'] for s in result['storage-switches']} + assert names == {SSWITCH1_NAME, SSWITCH2_NAME} + + def test_list_switches_global_filter_name(self): + """Test GET /api/storage-switches?name=... filters correctly.""" + + self.add_switch1() + self.add_switch2() + + result = self.session.get( + f'/api/storage-switches?name={SSWITCH1_NAME}') + + names = [s['name'] for s in result['storage-switches']] + assert names == [SSWITCH1_NAME] + + # ------------------------------------------------------------------ + # URI handler — Get Properties + # ------------------------------------------------------------------ + + def test_get_switch_properties(self): + """Test GET /api/storage-switches/{id} returns full properties.""" + + faked_sw = self.add_switch1() + + result = self.session.get(f'/api/storage-switches/{faked_sw.oid}') + + assert result['name'] == SSWITCH1_NAME + assert result['domain-id'] == SSWITCH1_DOMAIN_ID + assert result['storage-fabric-uri'] == SFABRIC1_URI + assert result['class'] == 'storage-switch' + + def test_get_switch_properties_not_found(self): + """Test GET /api/storage-switches/{id} with unknown ID raises 404.""" + + with pytest.raises(HTTPError) as exc_info: + self.session.get('/api/storage-switches/nonexistent-oid') + + assert exc_info.value.http_status == 404 + + # ------------------------------------------------------------------ + # URI handler — Update Properties + # ------------------------------------------------------------------ + + def test_update_switch_properties_handler(self): + """Test POST /api/storage-switches/{id} updates properties.""" + + faked_sw = self.add_switch1() + + self.session.post( + f'/api/storage-switches/{faked_sw.oid}', + body={'description': 'Updated via handler'}) + + result = self.session.get(f'/api/storage-switches/{faked_sw.oid}') + assert result['description'] == 'Updated via handler' + + # ------------------------------------------------------------------ + # URI handler — Undefine + # ------------------------------------------------------------------ + + def test_undefine_handler(self): + """Test POST /api/storage-switches/{id}/operations/undefine.""" + + faked_sw = self.add_switch1() + sw_oid = faked_sw.oid + + self.session.post( + f'/api/storage-switches/{sw_oid}/operations/undefine', + body=None) + + with pytest.raises(HTTPError) as exc_info: + self.session.get(f'/api/storage-switches/{sw_oid}') + assert exc_info.value.http_status == 404 + + def test_undefine_handler_not_found(self): + """Test undefine on unknown switch raises 404.""" + + with pytest.raises(HTTPError) as exc_info: + self.session.post( + '/api/storage-switches/nonexistent/operations/undefine', + body=None) + + assert exc_info.value.http_status == 404 + + # ------------------------------------------------------------------ + # URI handler — Define Storage Switch + # ------------------------------------------------------------------ + + def test_define_switch_handler(self): + """Test POST /api/console/operations/define-storage-switch.""" + + result = self.session.post( + '/api/console/operations/define-storage-switch', + body={ + 'name': 'New Switch', + 'domain-id': '42', + 'storage-fabric-uri': SFABRIC1_URI, + 'storage-site-uri': SSITE1_URI, + }) + + assert 'object-uri' in result + sw_uri = result['object-uri'] + assert sw_uri.startswith('/api/storage-switches/') + + # Verify the switch is retrievable + props = self.session.get(sw_uri) + assert props['name'] == 'New Switch' + assert props['domain-id'] == '42' + + # Verify fabric storage-switch-uris updated + fabric1 = self.session.hmc.lookup_by_uri(SFABRIC1_URI) + assert sw_uri in fabric1.properties.get('storage-switch-uris', []) + + def test_define_switch_missing_req_field(self): + """Test define with missing required fields raises 400.""" + + with pytest.raises(HTTPError) as exc_info: + self.session.post( + '/api/console/operations/define-storage-switch', + body={ + # Missing domain-id and storage-site-uri + 'storage-fabric-uri': SFABRIC1_URI, + }) + + assert exc_info.value.http_status == 400 + + def test_define_switch_unknown_fabric(self): + """Test define with unknown storage-fabric-uri raises 404.""" + + with pytest.raises(HTTPError) as exc_info: + self.session.post( + '/api/console/operations/define-storage-switch', + body={ + 'domain-id': '10', + 'storage-fabric-uri': '/api/storage-fabrics/nonexistent', + 'storage-site-uri': SSITE1_URI, + }) + + assert exc_info.value.http_status == 404 + + def test_define_switch_unknown_site(self): + """Test define with unknown storage-site-uri raises 404.""" + + with pytest.raises(HTTPError) as exc_info: + self.session.post( + '/api/console/operations/define-storage-switch', + body={ + 'domain-id': '10', + 'storage-fabric-uri': SFABRIC1_URI, + 'storage-site-uri': '/api/storage-sites/nonexistent', + }) + + assert exc_info.value.http_status == 404 + + # ------------------------------------------------------------------ + # URI handler — Move Storage Switch to Storage Site + # ------------------------------------------------------------------ + + def test_move_site_handler(self): + """Test POST .../move-storage-site.""" + + faked_sw = self.add_switch1() + + self.session.post( + f'/api/storage-switches/{faked_sw.oid}/' + 'operations/move-storage-site', + body={'storage-site-uri': SSITE2_URI}) + + props = self.session.get( + f'/api/storage-switches/{faked_sw.oid}') + assert props['storage-site-uri'] == SSITE2_URI + + def test_move_site_handler_not_found(self): + """Test move-storage-site on unknown switch raises 404.""" + + with pytest.raises(HTTPError) as exc_info: + self.session.post( + '/api/storage-switches/nonexistent/' + 'operations/move-storage-site', + body={'storage-site-uri': SSITE2_URI}) + + assert exc_info.value.http_status == 404 + + # ------------------------------------------------------------------ + # URI handler — Move Storage Switch to Storage Fabric + # ------------------------------------------------------------------ + + def test_move_fabric_handler(self): + """Test POST .../move-storage-fabric.""" + + faked_sw = self.add_switch1() + sw_uri = faked_sw.uri + + # Pre-populate fabric1 storage-switch-uris + fabric1 = self.session.hmc.lookup_by_uri(SFABRIC1_URI) + fabric1.update({'storage-switch-uris': [sw_uri]}) + + self.session.post( + f'/api/storage-switches/{faked_sw.oid}/' + 'operations/move-storage-fabric', + body={'storage-fabric-uri': SFABRIC2_URI}) + + props = self.session.get(f'/api/storage-switches/{faked_sw.oid}') + assert props['storage-fabric-uri'] == SFABRIC2_URI + + # sw removed from fabric1 and added to fabric2 + fabric1 = self.session.hmc.lookup_by_uri(SFABRIC1_URI) + sw_uris1 = fabric1.properties.get('storage-switch-uris', []) + assert sw_uri not in sw_uris1 + + fabric2 = self.session.hmc.lookup_by_uri(SFABRIC2_URI) + sw_uris2 = fabric2.properties.get('storage-switch-uris', []) + assert sw_uri in sw_uris2 + + def test_move_fabric_handler_not_found(self): + """Test move-storage-fabric on unknown switch raises 404.""" + + with pytest.raises(HTTPError) as exc_info: + self.session.post( + '/api/storage-switches/nonexistent/' + 'operations/move-storage-fabric', + body={'storage-fabric-uri': SFABRIC2_URI}) + + assert exc_info.value.http_status == 404 diff --git a/zhmcclient/__init__.py b/zhmcclient/__init__.py index 6e0c787a..3f7c9367 100644 --- a/zhmcclient/__init__.py +++ b/zhmcclient/__init__.py @@ -60,6 +60,14 @@ from ._storage_group_template import * # noqa: F401 from ._storage_volume_template import * # noqa: F401 from ._tape_library import * # noqa: F401 +from ._tape_link import * # noqa: F401 +from ._virtual_tape_resource import * # noqa: F401 +from ._storage_fabric import * # noqa: F401 +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 363c3ffa..af01ed34 100644 --- a/zhmcclient/_console.py +++ b/zhmcclient/_console.py @@ -31,6 +31,12 @@ from ._storage_group import StorageGroupManager from ._storage_group_template import StorageGroupTemplateManager from ._tape_library import TapeLibraryManager +from ._tape_link import TapeLinkManager +from ._storage_fabric import StorageFabricManager +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 @@ -179,7 +185,7 @@ def list(self, full_properties=False, filter_args=None): return [resource_obj] -class Console(BaseResource): +class Console(BaseResource): # pylint: disable=too-many-instance-attributes """ Representation of a :term:`Console`. @@ -213,7 +219,13 @@ def __init__(self, manager, uri, name=None, properties=None): self._storage_groups = None self._storage_group_templates = None self._tape_library = None + self._tape_links = 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 self._user_roles = None self._user_patterns = None @@ -262,6 +274,73 @@ def tape_library(self): self._tape_library = TapeLibraryManager(self) return self._tape_library + @property + def tape_links(self): + """ + :class:`~zhmcclient.StorageGroupManager`: + Manager object for the Storage Groups in scope of this Console. + """ + # We do here some lazy loading. + if not self._tape_links: + self._tape_links = TapeLinkManager(self) + return self._tape_links + + @property + def storage_fabrics(self): + """ + :class:`~zhmcclient.StorageFabricManager`: + Manager object for the Storage Fabrics in scope of this Console. + """ + # We do here some lazy loading. + if not self._storage_fabrics: + self._storage_fabrics = StorageFabricManager(self) + return self._storage_fabrics + + @property + def storage_sites(self): + """ + :class:`~zhmcclient.StorageSiteManager`: + Manager object for the Storage Sites in scope of this Console. + """ + # We do here some lazy loading. + if not self._storage_sites: + self._storage_sites = StorageSiteManager(self) + return self._storage_sites + + @property + def storage_switches(self): + """ + :class:`~zhmcclient.StorageSwitchManager`: + Manager object for the Storage Switches in scope of this Console. + """ + # We do here some lazy loading. + if not self._storage_switches: + self._storage_switches = StorageSwitchManager(self) + return self._storage_switches + + @property + def storage_subsystems(self): + """ + :class:`~zhmcclient.StorageSubsystemManager`: + Manager object for the Storage Subsystems in scope of this Console. + """ + # We do here some lazy loading. + if not self._storage_subsystems: + 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): """ @@ -1642,6 +1721,8 @@ def dump(self): "sso_server_definitions": [...], "unmanaged_cpcs": [...], "storage_groups": [...], + "tape_libraries": [...], + "tape_links": [...], } Returns: @@ -1680,6 +1761,12 @@ def dump(self): storage_groups = self.storage_groups.dump() if storage_groups: resource_dict['storage_groups'] = storage_groups + tape_libraries = self.tape_library.dump() + if tape_libraries: + resource_dict['tape_libraries'] = tape_libraries + tape_links = self.tape_links.dump() + if tape_links: + resource_dict['tape_links'] = tape_links # Note: Unmanaged CPCs are not dumped, since their properties cannot # be retrieved. 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/_storage_site.py b/zhmcclient/_storage_site.py new file mode 100644 index 00000000..212132ee --- /dev/null +++ b/zhmcclient/_storage_site.py @@ -0,0 +1,319 @@ +# 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 Site` represents a single storage site in the FICON storage +configuration associated with a DPM-enabled CPC. + +A storage site describes a location that houses a set of storage switches and +storage subsystems. A Storage Site object with `type="primary"` always exists +for the primary site and cannot be deleted. Additional Storage Site objects +can be created and deleted. + +The Storage Site object APIs provide access to the set of storage sites within +the FICON configuration associated with a CPC that is enabled for DPM. APIs +exist to create and delete alternate storage sites, query storage sites, and +update selected properties of storage sites. + +The :term:`Storage Site` resources are accessible via the +:attr:`~zhmcclient.Console.storage_sites` 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_SITE + + +__all__ = ['StorageSiteManager', 'StorageSite'] + + +class StorageSiteManager(BaseManager): + """ + Manager providing access to the :term:`storage sites ` 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_sites` 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 = [ + 'cpc-uris', + 'name', + 'type', + ] + + super().__init__( + resource_class=StorageSite, + class_name=RC_STORAGE_SITE, + session=console.manager.session, + parent=console, + base_uri='/api/storage-sites', + 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 sites defined in the HMC. + + Storage sites for which the authenticated user does not have + object-access permission 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: + + * Object-access permission to any storage sites to be included in the + result. + + Parameters: + + full_properties (bool): + Controls that the full set of resource properties for each returned + storage site is being retrieved, vs. only the following short + set: ``object-uri``, ``name``, and ``cpc-uris``. + + 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.StorageSite` objects. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + :exc:`~zhmcclient.FilterConversionError` + """ + result_prop = 'storage-sites' + 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 storage site with the specified properties. + + Only alternate storage sites can be created. The primary site (with + the default name "Primary Site") exists by default and cannot be + deleted or created via this API. + + Authorization requirements: + + * Object-access permission to the CPC associated with this storage + site. + * Task permission to the "Configure Storage - System Programmer" task. + + Parameters: + + properties (dict): Initial property values. + Allowable properties are defined in section 'Request body + contents' in section 'Create Storage Site' in the + :term:`HMC API` book. + + Returns: + + :class:`~zhmcclient.StorageSite`: The new storage site. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + result = self.session.post( + uri=self._base_uri, + body=properties, + ) + # The "Create Storage Site" operation returns the object-uri of the + # new storage site in the response body. + uri = result['object-uri'] + name = properties.get(self._name_prop) + props = copy.deepcopy(properties) + props[self._uri_prop] = uri + storage_site = StorageSite(self, uri, name, props) + self._name_uri_cache.update(name, uri) + return storage_site + + +class StorageSite(BaseResource): + """ + Representation of a :term:`storage site`. + + 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.StorageSiteManager`). + """ + + def __init__(self, manager, uri, name=None, properties=None): + # This function should not go into the docs. + # manager (:class:`~zhmcclient.StorageSiteManager`): + # 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, StorageSiteManager), ( + "StorageSite init: Expected manager type " + f"{StorageSiteManager}, got {type(manager)}" + ) + super().__init__(manager, uri, name, properties) + + @logged_api_call + def delete(self): + """ + Delete this storage site. + + Only alternate storage sites can be deleted. The primary site cannot + be deleted. + + Authorization requirements: + + * Object-access permission to this storage site. + * Task permission to the "Configure Storage - System Programmer" task. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + self.manager.session.delete(uri=self.uri, resource=self) + # 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 update_properties(self, properties): + """ + Update writeable properties of this storage site. + + This method serializes with other methods that access or change + properties on the same Python object. + + Authorization requirements: + + * Object-access permission to this storage site. + * Task permission to the "Configure Storage - System Programmer" task. + + 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 + site resource defined in the :term:`HMC API` book. + + 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: + # Delete the old name from the cache + self.manager._name_uri_cache.delete(self.name) + self.update_properties_local(copy.deepcopy(properties)) + if is_rename: + # Add the new name to the cache + self.manager._name_uri_cache.update(self.name, self.uri) + + def dump(self): + """ + Dump this StorageSite 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. + """ + # Dump the resource properties + resource_dict = super().dump() + return resource_dict diff --git a/zhmcclient/_storage_subsystem.py b/zhmcclient/_storage_subsystem.py new file mode 100644 index 00000000..78af983e --- /dev/null +++ b/zhmcclient/_storage_subsystem.py @@ -0,0 +1,371 @@ +# 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 Subsystem` represents a single storage subsystem in the +FICON storage configuration associated with a DPM-enabled CPC. + +A storage subsystem describes a physical storage device (e.g. DS8000 series) +that is connected to a storage site via one or more connection endpoints +(storage switches or adapters). It belongs to exactly one storage site. + +The Storage Subsystem object APIs provide access to the set of storage +subsystems within the FICON configuration. APIs exist to list and query +storage subsystems, update selected properties, move a subsystem to a +different storage site, and add or remove connection endpoints. + +The :term:`Storage Subsystem` resources are accessible via the +:attr:`~zhmcclient.Console.storage_subsystems` 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_SUBSYSTEM + + +__all__ = ['StorageSubsystemManager', 'StorageSubsystem'] + + +class StorageSubsystemManager(BaseManager): + """ + Manager providing access to the + :term:`storage subsystems ` 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_subsystems` 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', + 'storage-site-uri', + ] + + super().__init__( + resource_class=StorageSubsystem, + class_name=RC_STORAGE_SUBSYSTEM, + session=console.manager.session, + parent=console, + base_uri='/api/storage-subsystems', + 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 subsystems defined in the HMC. + + Storage subsystems 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 subsystem is being retrieved, vs. only the following short + set: ``object-uri``, ``name``, and ``storage-site-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.StorageSubsystem` objects. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + :exc:`~zhmcclient.FilterConversionError` + """ + result_prop = 'storage-subsystems' + list_uri = self._base_uri + return self._list_with_operation( + list_uri, result_prop, full_properties, filter_args, None + ) + + +class StorageSubsystem(BaseResource): + """ + Representation of a :term:`storage subsystem`. + + 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.StorageSubsystemManager`). + """ + + def __init__(self, manager, uri, name=None, properties=None): + # This function should not go into the docs. + # manager (:class:`~zhmcclient.StorageSubsystemManager`): + # 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, StorageSubsystemManager), ( + "StorageSubsystem init: Expected manager type " + f"{StorageSubsystemManager}, got {type(manager)}" + ) + super().__init__(manager, uri, name, properties) + + @logged_api_call + def update_properties(self, properties): + """ + Update writeable properties of this storage subsystem. + + This calls the "Update Storage Subsystem Properties" operation + (``POST /api/storage-subsystems/{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 + subsystem resource defined in the :term:`HMC API` book. + + Writeable properties include: ``name``, ``description``. + + 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 move_to_storage_site(self, storage_site_uri): + """ + Move this storage subsystem to a different storage site. + + This calls the "Move Storage Subsystem to Storage Site" operation + (``POST /api/storage-subsystems/{id}/operations/move-storage-site``). + + The storage subsystem is moved from its current storage site to the + specified storage site within the same FICON configuration. The + ``storage-site-uri`` property of this subsystem is updated, and + the ``storage-subsystem-uris`` arrays on both the old and new + storage site objects are updated accordingly. + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Parameters: + + storage_site_uri (str): The canonical URI path of the target + storage site. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + body = {'storage-site-uri': storage_site_uri} + self.manager.session.post( + uri=self.uri + '/operations/move-storage-site', + resource=self, + body=body, + ) + self.update_properties_local({'storage-site-uri': storage_site_uri}) + + @logged_api_call + def add_connection_endpoint(self, endpoint_uri, port_id=None): + """ + Add a connection endpoint to this storage subsystem. + + This calls the "Add Connection Endpoint" operation + (``POST /api/storage-subsystems/{id}/operations/ + add-connection-endpoint``). + + The new connection endpoint is added to the ``connection-endpoints`` + property of this storage subsystem. + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Parameters: + + endpoint_uri (str): The canonical URI path for the Storage Switch or + Adapter object to which this subsystem is connected. + + port_id (str or None): A two-character lowercase hexadecimal number + that represents the switch port. This value is required if + ``endpoint_uri`` references a Storage Switch object and is + prohibited if ``endpoint_uri`` references an Adapter object. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + body = {'endpoint-uri': endpoint_uri} + if port_id is not None: + body['port-id'] = port_id + self.manager.session.post( + uri=self.uri + '/operations/add-connection-endpoint', + resource=self, + body=body, + ) + + @logged_api_call + def remove_connection_endpoint(self, endpoint_uri, port_id=None): + """ + Remove a connection endpoint from this storage subsystem. + + This calls the "Remove Connection Endpoint" operation + (``POST /api/storage-subsystems/{id}/operations/ + remove-connection-endpoint``). + + The specified connection endpoint is removed from the + ``connection-endpoints`` property of this storage subsystem. + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Parameters: + + endpoint_uri (str): The canonical URI path for the Storage Switch or + Adapter object from which this subsystem is to be disconnected. + + port_id (str or None): A two-character lowercase hexadecimal number + that represents the switch port. This value is required if + ``endpoint_uri`` references a Storage Switch object and is + ignored if ``endpoint_uri`` references an Adapter object. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + body = {'endpoint-uri': endpoint_uri} + if port_id is not None: + body['port-id'] = port_id + self.manager.session.post( + uri=self.uri + '/operations/remove-connection-endpoint', + resource=self, + body=body, + ) + + def dump(self): + """ + Dump this StorageSubsystem 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_switch.py b/zhmcclient/_storage_switch.py new file mode 100644 index 00000000..f6497a1d --- /dev/null +++ b/zhmcclient/_storage_switch.py @@ -0,0 +1,395 @@ +# 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 Switch` represents a single physical storage switch in the +FICON storage configuration associated with a DPM-enabled CPC. + +A storage switch belongs to exactly one storage site and one storage fabric. +It can be moved between sites and fabrics using dedicated operations. + +The Storage Switch object APIs provide access to the set of storage switches +within the FICON configuration associated with a CPC that is enabled for DPM. +APIs exist to define and undefine storage switches, list storage switches, +query storage switch properties, update selected properties, and move a switch +to a different storage site or storage fabric. + +The :term:`Storage Switch` resources are accessible via the +:attr:`~zhmcclient.Console.storage_switches` 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_SWITCH + + +__all__ = ['StorageSwitchManager', 'StorageSwitch'] + + +class StorageSwitchManager(BaseManager): + """ + Manager providing access to the :term:`storage switches ` + 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_switches` 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', + 'domain-id', + ] + + super().__init__( + resource_class=StorageSwitch, + class_name=RC_STORAGE_SWITCH, + session=console.manager.session, + parent=console, + base_uri='/api/storage-switches', + 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 switches defined in the HMC. + + Storage switches 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 switch is being retrieved, vs. only the following short + set: ``object-uri``, ``name``, ``domain-id``, and + ``storage-fabric-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.StorageSwitch` objects. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + :exc:`~zhmcclient.FilterConversionError` + """ + result_prop = 'storage-switches' + list_uri = self._base_uri + return self._list_with_operation( + list_uri, result_prop, full_properties, filter_args, None + ) + + @logged_api_call + def define(self, properties): + """ + Define a new storage switch with the specified properties. + + This calls the "Define Storage Switch" operation on the HMC console + (``POST /api/console/operations/define-storage-switch``). + + 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 'Define Storage Switch' in the + :term:`HMC API` book. + + Required fields: ``domain-id``, ``storage-fabric-uri``, + ``storage-site-uri``. + Optional fields: ``name``, ``description``, ``port-count``. + + Returns: + + :class:`~zhmcclient.StorageSwitch`: The new storage switch. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + result = self.session.post( + uri='/api/console/operations/define-storage-switch', + body=properties, + ) + uri = result['object-uri'] + name = properties.get(self._name_prop) + props = copy.deepcopy(properties) + props[self._uri_prop] = uri + storage_switch = StorageSwitch(self, uri, name, props) + self._name_uri_cache.update(name, uri) + return storage_switch + + +class StorageSwitch(BaseResource): + """ + Representation of a :term:`storage switch`. + + 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.StorageSwitchManager`). + """ + + def __init__(self, manager, uri, name=None, properties=None): + # This function should not go into the docs. + # manager (:class:`~zhmcclient.StorageSwitchManager`): + # 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, StorageSwitchManager), ( + "StorageSwitch init: Expected manager type " + f"{StorageSwitchManager}, got {type(manager)}" + ) + super().__init__(manager, uri, name, properties) + + @logged_api_call + def undefine(self): + """ + Undefine (delete) this storage switch. + + This calls the "Undefine Storage Switch" operation + (``POST /api/storage-switches/{id}/operations/undefine``). + + If the storage switch contains switch ports, they are removed as well. + + 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 update_properties(self, properties): + """ + Update writeable properties of this storage switch. + + This calls the "Update Storage Switch Properties" operation + (``POST /api/storage-switches/{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 + switch resource defined in the :term:`HMC API` book. + + Writeable properties: ``name``, ``description``, ``domain-id``, + ``port-count``. + + 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 move_to_storage_site(self, storage_site_uri): + """ + Move this storage switch to a different storage site. + + This calls the "Move Storage Switch to Storage Site" operation + (``POST /api/storage-switches/{id}/operations/move-storage-site``). + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Parameters: + + storage_site_uri (str): The canonical URI path of the target storage + site. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + body = {'storage-site-uri': storage_site_uri} + self.manager.session.post( + uri=self.uri + '/operations/move-storage-site', + resource=self, + body=body, + ) + self.update_properties_local({'storage-site-uri': storage_site_uri}) + + @logged_api_call + def move_to_storage_fabric(self, storage_fabric_uri): + """ + Move this storage switch to a different storage fabric. + + This calls the "Move Storage Switch to Storage Fabric" operation + (``POST /api/storage-switches/{id}/operations/move-storage-fabric``). + + Authorization requirements: + + * Task permission to the "Configure Storage – System Programmer" or + "Configure Storage – Storage Administrator" tasks. + + Parameters: + + storage_fabric_uri (str): The canonical URI path of the target + storage fabric. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + body = {'storage-fabric-uri': storage_fabric_uri} + self.manager.session.post( + uri=self.uri + '/operations/move-storage-fabric', + resource=self, + body=body, + ) + self.update_properties_local( + {'storage-fabric-uri': storage_fabric_uri}) + + def dump(self): + """ + Dump this StorageSwitch 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 8fcb3efe..ec299e74 100644 --- a/zhmcclient/_utils.py +++ b/zhmcclient/_utils.py @@ -107,6 +107,11 @@ RC_SSO_SERVER_DEFINITION, 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 = ( @@ -208,7 +213,7 @@ def repr_dict(dct, indent): raise TypeError(f"Object must be a mapping, but is a {type(dct)}") dct_lines = [] dct_lines.append('{') - for key in dct.keys(): + for key in dct: value = dct[key] dct_lines.append(_indent(f'{key!r}: {value!r},', 2)) dct_lines.append('}') @@ -694,7 +699,7 @@ def get_api_features(obj, name=None): # turn into "no features available at all". if e.http_status == 404: return [] - raise e + raise def get_firmware_features(obj, pull=False): @@ -806,7 +811,7 @@ def get_stomp_rt_kwargs(rt_config): dict: Additional kwargs for StompConnection from the config. """ - rt_kwargs = dict() + rt_kwargs = {} if rt_config: if rt_config.connect_timeout == 0: rt_kwargs['timeout'] = None # No timeout diff --git a/zhmcclient/mock/_hmc.py b/zhmcclient/mock/_hmc.py index 83ae0530..0f1c68de 100644 --- a/zhmcclient/mock/_hmc.py +++ b/zhmcclient/mock/_hmc.py @@ -55,10 +55,18 @@ 'FakedVirtualStorageResourceManager', 'FakedVirtualStorageResource', 'FakedStorageGroupTemplateManager', 'FakedStorageGroupTemplate', 'FakedTapeLibraryManager', 'FakedTapeLibrary', + 'FakedTapeLinkManager', 'FakedTapeLink', + 'FakedVirtualTapeResourceManager', 'FakedVirtualTapeResource', + 'FakedStorageSiteManager', 'FakedStorageSite', 'FakedMetricsContextManager', 'FakedMetricsContext', 'FakedMetricGroupDefinition', 'FakedMetricObjectValues', 'FakedCapacityGroupManager', 'FakedCapacityGroup', 'FakedHwMessageManager', 'FakedHwMessage', + 'FakedStorageFabricManager', 'FakedStorageFabric', + 'FakedStorageSwitchManager', 'FakedStorageSwitch', + 'FakedStorageSubsystemManager', 'FakedStorageSubsystem', + 'FakedStorageControlUnitManager', 'FakedStorageControlUnit', + 'FakedStoragePathManager', 'FakedStoragePath', ] # All currently defined metric groups with their metrics. @@ -70,21 +78,21 @@ # False=classic, None=any or N/A) in which the metric group is available. # - metrics: List of metrics, as tuple(metric-name, metric-type). METRIC_GROUPS = [ - dict( - name='channel-usage', - resource_class='cpc', - cpc_dpm_enabled=False, - metrics=[ + { + 'name': 'channel-usage', + 'resource_class': 'cpc', + 'cpc_dpm_enabled': False, + 'metrics': [ ('channel-name', 'string-metric'), ('shared-channel', 'boolean-metric'), ('logical-partition-name', 'string-metric'), ('channel-usage', 'integer-metric'), - ]), - dict( - name='cpc-usage-overview', - resource_class='cpc', - cpc_dpm_enabled=False, - metrics=[ + ]}, + { + 'name': 'cpc-usage-overview', + 'resource_class': 'cpc', + 'cpc_dpm_enabled': False, + 'metrics': [ ('cpc-processor-usage', 'integer-metric'), ('channel-usage', 'integer-metric'), ('power-consumption-watts', 'integer-metric'), @@ -108,12 +116,12 @@ ('cbp-shared-processor-usage', 'integer-metric'), ('cbp-dedicated-processor-usage', 'integer-metric'), ('cbp-all-processor-usage', 'integer-metric'), - ]), - dict( - name='dpm-system-usage-overview', - resource_class='cpc', - cpc_dpm_enabled=True, - metrics=[ + ]}, + { + 'name': 'dpm-system-usage-overview', + 'resource_class': 'cpc', + 'cpc_dpm_enabled': True, + 'metrics': [ ('processor-usage', 'integer-metric'), ('network-usage', 'integer-metric'), ('storage-usage', 'integer-metric'), @@ -125,12 +133,12 @@ ('cp-dedicated-processor-usage', 'integer-metric'), ('ifl-shared-processor-usage', 'integer-metric'), ('ifl-dedicated-processor-usage', 'integer-metric'), - ]), - dict( - name='logical-partition-usage', - resource_class='logical-partition', - cpc_dpm_enabled=False, - metrics=[ + ]}, + { + 'name': 'logical-partition-usage', + 'resource_class': 'logical-partition', + 'cpc_dpm_enabled': False, + 'metrics': [ ('processor-usage', 'integer-metric'), ('zvm-paging-rate', 'integer-metric'), ('cp-processor-usage', 'integer-metric'), @@ -138,23 +146,23 @@ ('icf-processor-usage', 'integer-metric'), ('iip-processor-usage', 'integer-metric'), ('cbp-processor-usage', 'integer-metric'), - ]), - dict( - name='partition-usage', - resource_class='partition', - cpc_dpm_enabled=True, - metrics=[ + ]}, + { + 'name': 'partition-usage', + 'resource_class': 'partition', + 'cpc_dpm_enabled': True, + 'metrics': [ ('processor-usage', 'integer-metric'), ('network-usage', 'integer-metric'), ('storage-usage', 'integer-metric'), ('accelerator-usage', 'integer-metric'), ('crypto-usage', 'integer-metric'), - ]), - dict( - name='zcpc-environmentals-and-power', - resource_class='cpc', - cpc_dpm_enabled=None, - metrics=[ + ]}, + { + 'name': 'zcpc-environmentals-and-power', + 'resource_class': 'cpc', + 'cpc_dpm_enabled': None, + 'metrics': [ ('temperature-celsius', 'double-metric'), ('humidity', 'integer-metric'), ('dew-point-celsius', 'double-metric'), @@ -163,12 +171,12 @@ ('heat-load-forced-air', 'integer-metric'), ('heat-load-water', 'integer-metric'), ('exhaust-temperature-celsius', 'double-metric'), - ]), - dict( - name='"environmental-power-status', - resource_class='cpc', - cpc_dpm_enabled=None, - metrics=[ + ]}, + { + 'name': '"environmental-power-status', + 'resource_class': 'cpc', + 'cpc_dpm_enabled': None, + 'metrics': [ ('linecord-one-name', 'string-metric'), ('linecord-one-power-phase-A', 'integer-metric'), ('linecord-one-power-phase-B', 'integer-metric'), @@ -201,56 +209,56 @@ ('linecord-eight-power-phase-A', 'integer-metric'), ('linecord-eight-power-phase-B', 'integer-metric'), ('linecord-eight-power-phase-C', 'integer-metric'), - ]), - dict( - name='zcpc-processor-usage', - resource_class='cpc', - cpc_dpm_enabled=None, - metrics=[ + ]}, + { + 'name': 'zcpc-processor-usage', + 'resource_class': 'cpc', + 'cpc_dpm_enabled': None, + 'metrics': [ ('processor-name', 'string-metric'), ('processor-type', 'string-metric'), ('processor-usage', 'integer-metric'), ('smt-usage', 'integer-metric'), ('thread0-usage', 'integer-metric'), ('thread1-usage', 'integer-metric'), - ]), - dict( - name='crypto-usage', - resource_class='cpc', - cpc_dpm_enabled=False, - metrics=[ + ]}, + { + 'name': 'crypto-usage', + 'resource_class': 'cpc', + 'cpc_dpm_enabled': False, + 'metrics': [ ('channel-id', 'string-metric'), ('crypto-id', 'string-metric'), ('adapter-usage', 'integer-metric'), - ]), - dict( - name='adapter-usage', - resource_class='adapter', - cpc_dpm_enabled=False, - metrics=[ + ]}, + { + 'name': 'adapter-usage', + 'resource_class': 'adapter', + 'cpc_dpm_enabled': False, + 'metrics': [ ('adapter-usage', 'integer-metric'), - ]), - dict( - name='flash-memory-usage', - resource_class='cpc', - cpc_dpm_enabled=False, - metrics=[ + ]}, + { + 'name': 'flash-memory-usage', + 'resource_class': 'cpc', + 'cpc_dpm_enabled': False, + 'metrics': [ ('channel-id', 'string-metric'), ('adapter-usage', 'integer-metric'), - ]), - dict( - name='roce-usage', - resource_class='cpc', - cpc_dpm_enabled=False, - metrics=[ + ]}, + { + 'name': 'roce-usage', + 'resource_class': 'cpc', + 'cpc_dpm_enabled': False, + 'metrics': [ ('channel-id', 'string-metric'), ('adapter-usage', 'integer-metric'), - ]), - dict( - name='network-physical-adapter-port', - resource_class='adapter', - cpc_dpm_enabled=True, - metrics=[ + ]}, + { + 'name': 'network-physical-adapter-port', + 'resource_class': 'adapter', + 'cpc_dpm_enabled': True, + 'metrics': [ ('network-port-id', 'integer-metric'), ('bytes-sent', 'long-metric'), ('bytes-received', 'long-metric'), @@ -271,12 +279,12 @@ ('utilization', 'long-metric'), ('mac-address', 'string-metric'), ('flags', 'long-metric'), - ]), - dict( - name='partition-attached-network-interface', - resource_class='nic', - cpc_dpm_enabled=True, - metrics=[ + ]}, + { + 'name': 'partition-attached-network-interface', + 'resource_class': 'nic', + 'cpc_dpm_enabled': True, + 'metrics': [ ('partition-id', 'string-metric'), ('bytes-sent', 'long-metric'), ('bytes-received', 'long-metric'), @@ -295,7 +303,7 @@ ('bytes-per-second-sent', 'long-metric'), ('bytes-per-second-received', 'long-metric'), ('flags', 'long-metric'), - ]), + ]}, ] @@ -1123,6 +1131,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`). @@ -1142,6 +1151,18 @@ def __init__(self, manager, properties): hmc=manager.hmc, console=self) self._tape_library = FakedTapeLibraryManager( hmc=manager.hmc, console=self) + self._tape_links = FakedTapeLinkManager( + hmc=manager.hmc, console=self) + self._storage_fabrics = FakedStorageFabricManager( + hmc=manager.hmc, console=self) + self._storage_sites = FakedStorageSiteManager( + hmc=manager.hmc, console=self) + self._storage_switches = FakedStorageSwitchManager( + 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( @@ -1218,6 +1239,54 @@ def tape_library(self): """ return self._tape_library + @property + def tape_links(self): + """ + :class:`~zhmcclient.mock.FakedTapeLibraryManager`: Access to + the faked Storage Group Template resources of this Console. + """ + return self._tape_links + + @property + def storage_fabrics(self): + """ + :class:`~zhmcclient.mock.FakedStorageFabricManager`: Access to + the faked Storage Fabric resources of this Console. + """ + return self._storage_fabrics + + @property + def storage_sites(self): + """ + :class:`~zhmcclient.mock.FakedStorageSiteManager`: Access to + the faked Storage Site resources of this Console. + """ + return self._storage_sites + + @property + def storage_switches(self): + """ + :class:`~zhmcclient.mock.FakedStorageSwitchManager`: Access to + the faked Storage Switch resources of this Console. + """ + return self._storage_switches + + @property + def storage_subsystems(self): + """ + :class:`~zhmcclient.mock.FakedStorageSubsystemManager`: Access to + the faked Storage Subsystem resources of this Console. + """ + 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): """ @@ -1854,18 +1923,11 @@ def add(self, properties): # Resource type specific default values new_lsd.properties.setdefault('description', '') - new_lsd.properties.setdefault('authentication-url', - 'https://sso1.example.com/auth') new_lsd.properties.setdefault('type', 'oidc') + new_lsd.properties.setdefault('logout-url', None) new_lsd.properties.setdefault( - 'logout-sso-session-on-reauthentication-failure', True + 'logout-sso-session-on-reauthentication-failure', False ) - new_lsd.properties.setdefault('logout-url', - 'https://sso1.example.com/logout') - new_lsd.properties.setdefault('issuer-url', - 'https://sso1.example.com/issuer') - new_lsd.properties.setdefault('jwks-url', - 'https://sso1.example.com/jwks') new_lsd.properties.setdefault('replication-overwrite-possible', False) return new_lsd @@ -3834,6 +3896,186 @@ def __init__(self, manager, properties): properties=properties) +class FakedTapeLinkManager(FakedBaseManager): + """ + A manager for faked TapeLink resources within a faked TapeLibrary (see + :class:`zhmcclient.mock.FakedTapeLibrary`). + + 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=FakedTapeLink, + base_uri=self.api_root + '/tape-links', + oid_prop='object-id', + uri_prop='object-uri', + class_value='tape-link', + name_prop='name') + + +class FakedTapeLink(FakedBaseResource): + """ + A faked TapeLink resource within a faked TapeLibrary (see + :class:`zhmcclient.mock.FakedTapeLibrary`). + + 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._virtual_tape_resources = FakedVirtualTapeResourceManager( + hmc=manager.hmc, tape_link=self) + + @property + def virtual_tape_resources(self): + """ + :class:`~zhmcclient.mock.FakedVirtualTapeResourceManager`: Access to + the faked VirtualTapeResource resources of this TapeLink. + """ + return self._virtual_tape_resources + + +class FakedVirtualTapeResourceManager(FakedBaseManager): + """ + A manager for faked VirtualTapeResource resources within a faked TapeLink + (see :class:`zhmcclient.mock.FakedTapeLink`). + + Derived from :class:`zhmcclient.mock.FakedBaseManager`, see there for + common methods and attributes. + """ + + def __init__(self, hmc, tape_link): + super().__init__( + hmc=hmc, + parent=tape_link, + resource_class=None, # Will be set below + base_uri=tape_link.uri + '/virtual-tape-resources', + oid_prop='element-id', + uri_prop='element-uri', + class_value='virtual-tape-resource', + name_prop='name') + # Set resource_class after the class is defined + self._resource_class = FakedVirtualTapeResource + + def add(self, properties): + # pylint: disable=useless-super-delegation + """ + Add a faked VirtualTapeResource 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 ``'virtual-tape-resource'``, + if not specified. + * ``parent`` will be auto-generated to the URI of the parent + TapeLink, if not specified. + + Returns: + :class:`~zhmcclient.mock.FakedVirtualTapeResource`: The faked + VirtualTapeResource resource. + """ + return super().add(properties) + + +class FakedVirtualTapeResource(FakedBaseResource): + """ + A faked VirtualTapeResource resource within a faked TapeLink (see + :class:`zhmcclient.mock.FakedTapeLink`). + + Derived from :class:`zhmcclient.mock.FakedBaseResource`, see there for + common methods and attributes. + """ + + def __init__(self, manager, properties): + super().__init__( + manager=manager, + properties=properties) + + +class FakedStorageSiteManager(FakedBaseManager): + """ + A manager for faked Storage Site 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=FakedStorageSite, + base_uri='/api/storage-sites', + oid_prop='object-id', + uri_prop='object-uri', + class_value='storage-site', + name_prop='name') + + def add(self, properties): + # pylint: disable=useless-super-delegation + """ + Add a faked Storage Site 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-site', + if not specified. + + Returns: + + :class:`~zhmcclient.mock.FakedStorageSite`: The faked + StorageSite resource. + """ + new_site = super().add(properties) + + # Resource type specific default values + new_site.properties.setdefault('description', '') + new_site.properties.setdefault('cpc-uris', []) + new_site.properties.setdefault('storage-subsystem-uris', []) + + return new_site + + +class FakedStorageSite(FakedBaseResource): + """ + A faked Storage Site 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) + + class FakedCapacityGroupManager(FakedBaseManager): """ A manager for faked CapacityGroup resources within a faked Cpc (see @@ -4361,3 +4603,406 @@ def __repr__(self): f" _properties = {repr_dict(self._properties, indent=2)}\n" ")") return ret + + +class FakedStorageFabricManager(FakedBaseManager): + """ + A manager for faked Storage Fabric 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=FakedStorageFabric, + base_uri='/api/storage-fabrics', + oid_prop='object-id', + uri_prop='object-uri', + class_value='storage-fabric', + name_prop='name') + + def add(self, properties): + # pylint: disable=useless-super-delegation + """ + Add a faked Storage Fabric 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-fabric', + if not specified. + + Returns: + + :class:`~zhmcclient.mock.FakedStorageFabric`: The faked + StorageFabric resource. + """ + new_fabric = super().add(properties) + + # Resource type specific default values + new_fabric.properties.setdefault('description', '') + new_fabric.properties.setdefault('storage-switch-uris', []) + new_fabric.properties.setdefault('high-integrity', False) + + return new_fabric + + +class FakedStorageFabric(FakedBaseResource): + """ + A faked Storage Fabric 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) + + +class FakedStorageSwitchManager(FakedBaseManager): + """ + A manager for faked Storage Switch 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=FakedStorageSwitch, + base_uri='/api/storage-switches', + oid_prop='object-id', + uri_prop='object-uri', + class_value='storage-switch', + name_prop='name') + + def add(self, properties): + # pylint: disable=useless-super-delegation + """ + Add a faked Storage Switch 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-switch', + if not specified. + + Returns: + + :class:`~zhmcclient.mock.FakedStorageSwitch`: The faked + StorageSwitch resource. + """ + new_switch = super().add(properties) + + # Resource type specific default values + new_switch.properties.setdefault('description', '') + new_switch.properties.setdefault('port-count', 256) + + return new_switch + + +class FakedStorageSwitch(FakedBaseResource): + """ + A faked Storage Switch 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) + + +class FakedStorageSubsystemManager(FakedBaseManager): + """ + A manager for faked Storage Subsystem 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=FakedStorageSubsystem, + base_uri='/api/storage-subsystems', + oid_prop='object-id', + uri_prop='object-uri', + class_value='storage-subsystem', + name_prop='name') + + def add(self, properties): + # pylint: disable=useless-super-delegation + """ + Add a faked Storage Subsystem 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-subsystem', + if not specified. + + Returns: + + :class:`~zhmcclient.mock.FakedStorageSubsystem`: The faked + StorageSubsystem resource. + """ + new_subsystem = super().add(properties) + + # Resource type specific default values + new_subsystem.properties.setdefault('description', '') + new_subsystem.properties.setdefault('connection-endpoints', []) + new_subsystem.properties.setdefault('storage-control-unit-uris', []) + + # Register this subsystem URI in the parent storage site's + # storage-subsystem-uris array, keeping both sides consistent. + site_uri = new_subsystem.properties.get('storage-site-uri') + if site_uri: + try: + site = self._hmc.lookup_by_uri(site_uri) + site_ss_uris = list( + site.properties.get('storage-subsystem-uris', [])) + if new_subsystem.uri not in site_ss_uris: + site_ss_uris.append(new_subsystem.uri) + site.update({'storage-subsystem-uris': site_ss_uris}) + except KeyError: + pass # site not yet registered — caller's responsibility + + return new_subsystem + + +class FakedStorageSubsystem(FakedBaseResource): + """ + A faked Storage Subsystem 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) + + +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 d0ac19a2..d89659c8 100644 --- a/zhmcclient/mock/_session.py +++ b/zhmcclient/mock/_session.py @@ -225,13 +225,56 @@ "$ref": "#/definitions/StorageGroup" }, }, - "tape_library": { - "description": "The Tape Library defined on this HMC", + "storage_fabrics": { + "description": "The Storage Fabrics defined on this HMC", + "type": "array", + "items": { + "$ref": "#/definitions/StorageFabric" + }, + }, + "tape_libraries": { + "description": "The Tape Libraries defined on this HMC", "type": "array", "items": { "$ref": "#/definitions/TapeLibrary" }, }, + "tape_links": { + "description": "The tape links defined on this HMC", + "type": "array", + "items": { + "$ref": "#/definitions/TapeLink" + }, + }, + "storage_sites": { + "description": "The storage sites defined on this HMC", + "type": "array", + "items": { + "$ref": "#/definitions/StorageSite" + }, + }, + "storage_switches": { + "description": "The Storage Switches defined on this HMC", + "type": "array", + "items": { + "$ref": "#/definitions/StorageSwitch" + }, + }, + "storage_subsystems": { + "description": "The Storage Subsystems defined on this HMC", + "type": "array", + "items": { + "$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", @@ -399,10 +442,136 @@ }, }, }, + "StorageFabric": { + "description": "A Storage Fabric on an HMC", + "type": "object", + "additionalProperties": False, + "required": [ + "properties", + ], + "properties": { + "properties": { + "$ref": "#/definitions/Properties" + }, + }, + }, + "StorageSwitch": { + "description": "A Storage Switch on an HMC", + "type": "object", + "additionalProperties": False, + "required": [ + "properties", + ], + "properties": { + "properties": { + "$ref": "#/definitions/Properties" + }, + }, + }, "TapeLibrary": { - "description": "An Tape Library on an HMC", + "description": "A Tape Library on an HMC", "type": "object", - "additionalProperties": True, + "additionalProperties": False, + "required": [ + "properties", + ], + "properties": { + "properties": { + "$ref": "#/definitions/Properties" + }, + "tape_links": { + "description": "The tape links of this tape library", + "type": "array", + "items": { + "$ref": "#/definitions/TapeLink" + }, + }, + }, + }, + "TapeLink": { + "description": "A tape link of a tape library", + "type": "object", + "additionalProperties": False, + "required": [ + "properties", + ], + "properties": { + "properties": { + "$ref": "#/definitions/Properties" + }, + "virtual_tape_resources": { + "description": "Virtual tape resources of this tape link", + "type": "array", + "items": { + "$ref": "#/definitions/VirtualTapeResource" + }, + }, + }, + }, + "VirtualTapeResource": { + "description": "A virtual tape resource of a tape link", + "type": "object", + "additionalProperties": False, + "required": [ + "properties", + ], + "properties": { + "properties": { + "$ref": "#/definitions/Properties" + }, + }, + }, + "StorageSite": { + "description": "A storage site defined on an HMC", + "type": "object", + "additionalProperties": False, + "required": [ + "properties", + ], + "properties": { + "properties": { + "$ref": "#/definitions/Properties" + }, + }, + }, + "StorageSubsystem": { + "description": "A storage subsystem defined on an HMC", + "type": "object", + "additionalProperties": False, + "required": [ + "properties", + ], + "properties": { + "properties": { + "$ref": "#/definitions/Properties" + }, + }, + }, + "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", ], diff --git a/zhmcclient/mock/_urihandler.py b/zhmcclient/mock/_urihandler.py index e788247b..15d1b284 100644 --- a/zhmcclient/mock/_urihandler.py +++ b/zhmcclient/mock/_urihandler.py @@ -35,7 +35,18 @@ from ._hmc import InputError __all__ = ['UriHandler', 'LparActivateHandler', 'LparDeactivateHandler', - 'LparLoadHandler', 'URIS'] + 'LparLoadHandler', 'URIS', + 'StorageSubsystemsHandler', 'StorageSubsystemHandler', + 'StorageSubsystemMoveSiteHandler', + 'StorageSubsystemAddEndpointHandler', + 'StorageSubsystemRemoveEndpointHandler', + 'StorageControlUnitsHandler', 'StorageControlUnitHandler', + 'StorageSubsystemControlUnitsHandler', + 'StorageControlUnitDefineHandler', + 'StorageControlUnitUndefineHandler', + 'StorageControlUnitAddVolumeRangeHandler', + 'SCURemoveVolumeRangeHandler', + 'StoragePathsHandler', 'StoragePathHandler'] # CPC status values CPC_ACTIVE_STATUSES = ( @@ -1410,7 +1421,7 @@ def post(method, hmc, uri, uri_parms, body, logon_required, if 'associated-system-defined-user-role-uri' not in properties: # Use the default uroles = console.user_roles.list( - filter_args=dict(name='hmc-operator-tasks')) + filter_args={'name': 'hmc-operator-tasks'}) if not uroles: new_exc = ServerError( method, uri, reason=99, @@ -1951,9 +1962,12 @@ class SSOServerDefinitionsHandler: Handler class for HTTP methods on set of SSOServerDefinition resources. """ - valid_query_parms_get = ['name'] + valid_query_parms_get = ['name', 'type', 'additional-properties'] - returned_props = ['element-uri', 'name', 'type'] + returned_props = [ + 'element-uri', 'name', 'type' + # plus additional-properties + ] @classmethod def get(cls, method, hmc, uri, uri_parms, logon_required): @@ -1962,6 +1976,9 @@ 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) + add_props = get_additional_properties( + query_parms, SSOServerDefinitionHandler.wo_properties) + filter_args = query_parms try: @@ -1973,7 +1990,7 @@ def get(cls, method, hmc, uri, uri_parms, logon_required): result_sso_srv_defs = [] for sso_srv_def in console.sso_server_definitions.list(filter_args): result_sso_srv_def = {} - for prop in cls.returned_props: + for prop in cls.returned_props + add_props: result_sso_srv_def[prop] = \ sso_srv_def.properties.get(prop) result_sso_srv_defs.append(result_sso_srv_def) @@ -1995,7 +2012,14 @@ def post(method, hmc, uri, uri_parms, body, logon_required, ['name', 'type', 'client-id', 'client-secret', 'issuer-url', 'authentication-url', 'token-url', 'jwks-url']) - new_sso_srv_def = console.sso_server_definitions.add(body) + # These properties are server-controlled on create: the real HMC ignores + # any caller-supplied value and auto-sets them. Strip them here so the + # setdefault() calls in FakedSSOServerDefinitionManager.add() apply. + auto_props = ('logout-url', + 'logout-sso-session-on-reauthentication-failure') + store_body = {k: v for k, v in body.items() + if k not in auto_props} + new_sso_srv_def = console.sso_server_definitions.add(store_body) return {'element-uri': new_sso_srv_def.uri} @@ -2005,6 +2029,8 @@ class SSOServerDefinitionHandler(GenericGetPropertiesHandler, Handler class for HTTP methods on single SSOServerDefinition resource. """ + valid_query_parms_get = ['properties'] + wo_properties = ["client-secret"] @staticmethod @@ -3205,65 +3231,47 @@ def get_inventory_for_virtual_switch(hmc): def get_inventory_for_storage_site(hmc): - # pylint: disable=unused-argument """Get inventory data for resource class 'storage-site'""" result = [] - # TODO: Implement mock support for this resource class; then enable: - # stosites = hmc.consoles.console.storage_sites.list() - # for stosite in stosites: - # result.append(properties_copy(stosite.properties)) + stosites = hmc.consoles.console.storage_sites.list() + for stosite in stosites: + result.append(properties_copy(stosite.properties)) return result def get_inventory_for_storage_fabric(hmc): - # pylint: disable=unused-argument """Get inventory data for resource class 'storage-fabric'""" result = [] - # TODO: Implement mock support for this resource class; then enable: - # stofabrics = hmc.consoles.console.storage_fabrics.list() - # for stofabric in stofabrics: - # result.append(properties_copy(stofabric.properties)) + stofabrics = hmc.consoles.console.storage_fabrics.list() + for stofabric in stofabrics: + result.append(properties_copy(stofabric.properties)) return result def get_inventory_for_storage_switch(hmc): - # pylint: disable=unused-argument """Get inventory data for resource class 'storage-switch'""" result = [] - # TODO: Implement mock support for this resource class; then enable: - # stosites = hmc.consoles.console.storage_sites.list() - # for stosite in stosites: - # stoswitches = stosite.storage_switches.list() - # for stoswitch in stoswitches: - # result.append(properties_copy(stoswitch.properties)) + stoswitches = hmc.consoles.console.storage_switches.list() + for stoswitch in stoswitches: + result.append(properties_copy(stoswitch.properties)) return result 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 @@ -5312,10 +5320,10 @@ class StorageVolumesHandler: """ valid_query_parms_get = ['name', 'fulfillment-state', 'maximum-size', - 'minimum-size', 'usage'] + 'minimum-size', 'usage', 'additional-properties'] returned_props = ['element-uri', 'name', 'fulfillment-state', 'size', - 'usage'] + 'usage'] # plus additional-properties @classmethod def get(cls, method, hmc, uri, uri_parms, logon_required): @@ -5324,6 +5332,8 @@ 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) + add_props = get_additional_properties( + query_parms, StorageVolumeHandler.wo_properties) filter_args = query_parms sg_uri = re.sub('/storage-volumes$', '', uri) @@ -5336,7 +5346,7 @@ def get(cls, method, hmc, uri, uri_parms, logon_required): result_storage_volumes = [] for sv in sg.storage_volumes.list(filter_args): result_sv = {} - for prop in cls.returned_props: + for prop in cls.returned_props + add_props: result_sv[prop] = prop_copy(sv.properties.get(prop)) result_storage_volumes.append(result_sv) return {'storage-volumes': result_storage_volumes} @@ -5592,271 +5602,1626 @@ class StorageTemplateVolumeHandler(GenericGetPropertiesHandler): pass -class TapeLibrariesHandler: +class StorageFabricsHandler: """ - Handler class for HTTP methods on set of TapeLibraries resources. + Handler class for HTTP methods on the set of StorageFabric resources. """ - valid_query_parms_get = ['cpc-uri', 'name', 'state'] + valid_query_parms_get = ['cpc-uri', 'name'] - returned_props = ['object-uri', 'cpc-uri', 'name', 'state'] + returned_props = ['object-uri', 'name', 'cpc-uri'] @classmethod def get(cls, method, hmc, uri, uri_parms, logon_required): # pylint: disable=unused-argument - """Operation: List Tape Library (always global but with filters).""" + """Operation: List Storage Fabrics (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_tape_libraries = [] - for tl in hmc.consoles.console.tape_library.list(filter_args): - result_tl = {} + result_storage_fabrics = [] + for sf in hmc.consoles.console.storage_fabrics.list(filter_args): + result_sf = {} for prop in cls.returned_props: - result_tl[prop] = prop_copy(tl.properties.get(prop)) - result_tape_libraries.append(result_tl) - return {'tape-libraries': result_tape_libraries} + result_sf[prop] = prop_copy(sf.properties.get(prop)) + result_storage_fabrics.append(result_sf) + return {'storage-fabrics': result_storage_fabrics} + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Create Storage Fabric.""" + assert wait_for_completion is True # always synchronous + check_required_fields(method, uri, body, ['cpc-uri', 'name']) -class TapeLibraryHandler(GenericGetPropertiesHandler, - GenericUpdatePropertiesHandler): + new_storage_fabric = hmc.consoles.console.storage_fabrics.add(body) + return {'object-uri': new_storage_fabric.uri} + + +class StorageFabricHandler(GenericGetPropertiesHandler, + GenericUpdatePropertiesHandler, + GenericDeleteHandler): """ - Handler class for HTTP methods on single TapeLibrary resource. + Handler class for HTTP methods on a single StorageFabric resource. """ pass -class TapeLibraryUndefineHandler(): +class StorageSitesHandler: """ - Handler class for operation: Undefine Tape Library. + Handler class for HTTP methods on the set of StorageSite resources. """ - @staticmethod - def post(method, hmc, uri, uri_parms, body, logon_required, - wait_for_completion): - # pylint: disable=unused-argument - """Operation: Request Tape Library Undefine""" - try: - tape_library_id = uri_parms[0] # Extract from regex capture group - tape_library_uri = f'/api/tape-libraries/{tape_library_id}' - tape_library = hmc.lookup_by_uri(tape_library_uri) - except KeyError: - new_exc = InvalidResourceError(method, uri) - new_exc.__cause__ = None - raise new_exc # zhmcclient.mock.InvalidResourceError - print("resource object " + str(tape_library)) - tape_library.manager.remove(tape_library.oid) + valid_query_parms_get = ['cpc-uris', 'name'] + returned_props = ['object-uri', 'name', 'cpc-uris'] -class TapeLibraryRequestZoningHandler: - """ - Handler class for HTTP methods on set of TapeLibrary resources. - """ + @classmethod + def get(cls, method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: List Storage Sites (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_storage_sites = [] + for ss in hmc.consoles.console.storage_sites.list(filter_args): + result_ss = {} + for prop in cls.returned_props: + result_ss[prop] = prop_copy(ss.properties.get(prop)) + result_storage_sites.append(result_ss) + return {'storage-sites': result_storage_sites} @staticmethod def post(method, hmc, uri, uri_parms, body, logon_required, wait_for_completion): # pylint: disable=unused-argument - """Operation: Request Tape Library Zoning""" + """Operation: Create Storage Site.""" assert wait_for_completion is True # always synchronous - check_required_fields(method, uri, body, - ['cpc-uri']) - cpc_uri = body['cpc-uri'] - try: - cpc = hmc.lookup_by_uri(cpc_uri) - except KeyError: - new_exc = InvalidResourceError(method, uri) - new_exc.__cause__ = None - raise new_exc # zhmcclient.mock.InvalidResourceError - if not cpc.dpm_enabled: - raise CpcNotInDpmError(method, uri, cpc) - if cpc.adapters.list(): - print('This is CPC Adapters', str(cpc.adapters.list())) - cpc.properties['management-world-wide-port-name'] =\ - 'a1b2c3d4e5f60002' - return {} - raise ConflictError( - method, uri, reason=487, - message=f"The CPC with the {cpc_uri} does not have any " - "adapters configured as FCP") + check_required_fields(method, uri, body, ['name']) + new_storage_site = hmc.consoles.console.storage_sites.add(body) + return {'object-uri': new_storage_site.uri} -class TapeLibraryDiscoverHandler: + +class StorageSiteHandler(GenericGetPropertiesHandler, + GenericUpdatePropertiesHandler, + GenericDeleteHandler): """ - Handler class for HTTP methods on set of TapeLibrary resources. + Handler class for HTTP methods on a single StorageSite resource. """ - - @staticmethod - def post(method, hmc, uri, uri_parms, body, logon_required, - wait_for_completion): - # pylint: disable=unused-argument - """Operation: Request Tape Library Zoning""" - assert wait_for_completion is True # async not yet supported - check_required_fields(method, uri, body, ['cpc-uri']) - cpc_uri = body['cpc-uri'] - try: - cpc = hmc.lookup_by_uri(cpc_uri) - except KeyError: - new_exc = InvalidResourceError(method, uri) - new_exc.__cause__ = None - raise new_exc # zhmcclient.mock.InvalidResourceError - if not cpc.dpm_enabled: - raise CpcNotInDpmError(method, uri, cpc) - check_valid_cpc_status(method, uri, cpc) - if cpc.properties['management-world-wide-port-name']: - return {} - - raise ConflictError( - method, uri, reason=501, - message=f"The CPC with the {cpc_uri} has not been zoned.") + pass -class CapacityGroupsHandler: +class StorageSwitchesHandler: """ - Handler class for HTTP methods on set of CapacityGroup resources. + Handler class for HTTP methods on the global set of StorageSwitch resources. """ - valid_query_parms_get = ['name'] + valid_query_parms_get = ['name', 'domain-id'] - returned_props = ['element-uri', 'name'] + returned_props = ['object-uri', 'name', 'domain-id', 'storage-fabric-uri'] @classmethod def get(cls, method, hmc, uri, uri_parms, logon_required): # pylint: disable=unused-argument - """Operation: List Capacity Groups (always global but with filters).""" + """Operation: List Storage Switches (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 - cpc_oid = uri_parms[0] - cpc_uri = '/api/cpcs/' + cpc_oid - try: - cpc = hmc.lookup_by_uri(cpc_uri) - except KeyError: - new_exc = InvalidResourceError(method, uri) - new_exc.__cause__ = None - raise new_exc # zhmcclient.mock.InvalidResourceError - result_capacity_groups = [] - for cg in cpc.capacity_groups.list(filter_args): - result_cg = {} + result_storage_switches = [] + for sw in hmc.consoles.console.storage_switches.list(filter_args): + result_sw = {} for prop in cls.returned_props: - result_cg[prop] = prop_copy(cg.properties.get(prop)) - result_capacity_groups.append(result_cg) - return {'capacity-groups': result_capacity_groups} + result_sw[prop] = prop_copy(sw.properties.get(prop)) + result_storage_switches.append(result_sw) + return {'storage-switches': result_storage_switches} + + +class StorageSwitchHandler(GenericGetPropertiesHandler, + GenericUpdatePropertiesHandler): + """ + Handler class for HTTP methods on a single StorageSwitch resource + (Get Properties and Update Properties). + """ + pass + + +class StorageSwitchDefineHandler: + """ + Handler class for the "Define Storage Switch" operation. + POST /api/console/operations/define-storage-switch + """ @staticmethod def post(method, hmc, uri, uri_parms, body, logon_required, wait_for_completion): # pylint: disable=unused-argument - """Operation: Create Capacity Group.""" - assert wait_for_completion is True # async not supported yet - check_required_fields(method, uri, body, ['name']) - cpc_oid = uri_parms[0] - cpc_uri = '/api/cpcs/' + cpc_oid + """Operation: Define Storage Switch.""" + assert wait_for_completion is True # always synchronous + check_required_fields( + method, uri, body, + ['domain-id', 'storage-fabric-uri', 'storage-site-uri']) + + # Validate storage-fabric-uri references a known fabric + fabric_uri = body['storage-fabric-uri'] try: - cpc = hmc.lookup_by_uri(cpc_uri) + hmc.lookup_by_uri(fabric_uri) except KeyError: - new_exc = InvalidResourceError(method, uri) + new_exc = InvalidResourceError(method, uri, reason=440, + resource_uri=fabric_uri) new_exc.__cause__ = None - raise new_exc # zhmcclient.mock.InvalidResourceError - if not cpc.dpm_enabled: - raise CpcNotInDpmError(method, uri, cpc) - check_valid_cpc_status(method, uri, cpc) + raise new_exc - # Reflect the result of creating the capacity group - new_capacity_group = cpc.capacity_groups.add(body) + # Validate storage-site-uri references a known site + site_uri = body['storage-site-uri'] + try: + hmc.lookup_by_uri(site_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri, reason=2, + resource_uri=site_uri) + new_exc.__cause__ = None + raise new_exc - return { - 'element-uri': new_capacity_group.uri - } + new_sw = hmc.consoles.console.storage_switches.add(body) + # Update storage-switch-uris on the parent fabric + try: + fabric = hmc.lookup_by_uri(fabric_uri) + sw_uris = list(fabric.properties.get('storage-switch-uris', [])) + if new_sw.uri not in sw_uris: + sw_uris.append(new_sw.uri) + fabric.update({'storage-switch-uris': sw_uris}) + except KeyError: + pass -class CapacityGroupHandler(GenericGetPropertiesHandler, - GenericUpdatePropertiesHandler, - GenericDeleteHandler): + return {'object-uri': new_sw.uri} + + +class StorageSwitchUndefineHandler: """ - Handler class for HTTP methods on single CapacityGroup resource. + Handler class for the "Undefine Storage Switch" operation. + POST /api/storage-switches/{id}/operations/undefine """ @staticmethod - def delete(method, hmc, uri, uri_parms, logon_required): + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): # pylint: disable=unused-argument - """Operation: Delete Capacity Group.""" + """Operation: Undefine Storage Switch.""" + assert wait_for_completion is True # always synchronous + sw_oid = uri_parms[0] + sw_uri = '/api/storage-switches/' + sw_oid try: - capacity_group = hmc.lookup_by_uri(uri) + sw = hmc.lookup_by_uri(sw_uri) except KeyError: new_exc = InvalidResourceError(method, uri) new_exc.__cause__ = None - raise new_exc # zhmcclient.mock.InvalidResourceError + raise new_exc - # Check that Capacity Group is empty - partition_uris = capacity_group.properties['partition-uris'] - if partition_uris: - raise ConflictError( - method, uri, reason=110, - message=f"Capacity group {capacity_group.name!r} is not empty " - f"and contains partitions with URIs {partition_uris!r}") + # Remove the switch URI from the parent fabric's storage-switch-uris + fabric_uri = sw.properties.get('storage-fabric-uri') + if fabric_uri: + try: + fabric = hmc.lookup_by_uri(fabric_uri) + sw_uris = list(fabric.properties.get('storage-switch-uris', [])) + if sw_uri in sw_uris: + sw_uris.remove(sw_uri) + fabric.update({'storage-switch-uris': sw_uris}) + except KeyError: + pass - # Delete the mocked resource - capacity_group.manager.remove(capacity_group.oid) + # Remove any connection-endpoints on storage subsystems that reference + # this switch, so subsystem state stays consistent after an undefine. + for subsystem in hmc.consoles.console.storage_subsystems.list(None): + endpoints = list( + subsystem.properties.get('connection-endpoints', [])) + new_endpoints = [ + ep for ep in endpoints if ep.get('endpoint-uri') != sw_uri] + if len(new_endpoints) != len(endpoints): + subsystem.update({'connection-endpoints': new_endpoints}) + sw.manager.remove(sw.oid) # 204 No Content -class CapacityGroupAddPartitionHandler: + +class StorageSwitchMoveSiteHandler: """ - Handler class for operation: Add Partition to Capacity Group. + Handler class for the "Move Storage Switch to Storage Site" operation. + POST /api/storage-switches/{id}/operations/move-storage-site """ @staticmethod def post(method, hmc, uri, uri_parms, body, logon_required, wait_for_completion): # pylint: disable=unused-argument - """Operation: Add Partition to Capacity Group.""" - assert wait_for_completion is True # async not supported yet - - # The URI is a POST operation, so we need to construct the CG URI - cpc_oid = uri_parms[0] - cpc_uri = '/api/cpcs/' + cpc_oid + """Operation: Move Storage Switch to Storage Site.""" + assert wait_for_completion is True # always synchronous + check_required_fields(method, uri, body, ['storage-site-uri']) + sw_oid = uri_parms[0] + sw_uri = '/api/storage-switches/' + sw_oid try: - cpc = hmc.lookup_by_uri(cpc_uri) + sw = hmc.lookup_by_uri(sw_uri) except KeyError: new_exc = InvalidResourceError(method, uri) new_exc.__cause__ = None - raise new_exc # zhmcclient.mock.InvalidResourceError - cg_oid = uri_parms[1] - cg_uri = cpc_uri + '/capacity-groups/' + cg_oid + raise new_exc + new_site_uri = body['storage-site-uri'] try: - capacity_group = hmc.lookup_by_uri(cg_uri) + hmc.lookup_by_uri(new_site_uri) except KeyError: - new_exc = InvalidResourceError(method, uri, reason=150) + new_exc = InvalidResourceError(method, uri, reason=2, + resource_uri=new_site_uri) new_exc.__cause__ = None - raise new_exc # zhmcclient.mock.InvalidResourceError + raise new_exc - check_required_fields(method, uri, body, ['partition-uri']) + sw.update({'storage-site-uri': new_site_uri}) # 204 No Content - # Check the partition exists - partition_uri = body['partition-uri'] + +class StorageSwitchMoveFabricHandler: + """ + Handler class for the "Move Storage Switch to Storage Fabric" operation. + POST /api/storage-switches/{id}/operations/move-storage-fabric + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Move Storage Switch to Storage Fabric.""" + assert wait_for_completion is True # always synchronous + check_required_fields(method, uri, body, ['storage-fabric-uri']) + sw_oid = uri_parms[0] + sw_uri = '/api/storage-switches/' + sw_oid try: - partition = hmc.lookup_by_uri(partition_uri) + sw = hmc.lookup_by_uri(sw_uri) except KeyError: - new_exc = InvalidResourceError(method, uri, reason=2) + new_exc = InvalidResourceError(method, uri) new_exc.__cause__ = None - raise new_exc # zhmcclient.mock.InvalidResourceError + raise new_exc - # Check the partition is in shared processor mode - processor_mode = partition.properties.get('processor-mode', 'shared') - if processor_mode != 'shared': - raise ConflictError(method, uri, 170, - f"Partition {partition.name} is in " - f"{processor_mode} processor mode") + new_fabric_uri = body['storage-fabric-uri'] + try: + new_fabric = hmc.lookup_by_uri(new_fabric_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri, reason=2, + resource_uri=new_fabric_uri) + new_exc.__cause__ = None + raise new_exc - # Check the partition is not in this capacity group - partition_uris = capacity_group.properties['partition-uris'] - if partition.uri in partition_uris: - raise ConflictError(method, uri, 130, - f"Partition {partition.name} is already a " + # Remove from old fabric's storage-switch-uris + old_fabric_uri = sw.properties.get('storage-fabric-uri') + if old_fabric_uri and old_fabric_uri != new_fabric_uri: + try: + old_fabric = hmc.lookup_by_uri(old_fabric_uri) + old_uris = list( + old_fabric.properties.get('storage-switch-uris', [])) + if sw_uri in old_uris: + old_uris.remove(sw_uri) + old_fabric.update({'storage-switch-uris': old_uris}) + except KeyError: + pass + + # Add to new fabric's storage-switch-uris + new_uris = list(new_fabric.properties.get('storage-switch-uris', [])) + if sw_uri not in new_uris: + new_uris.append(sw_uri) + new_fabric.update({'storage-switch-uris': new_uris}) + + sw.update({'storage-fabric-uri': new_fabric_uri}) # 204 No Content + + +class StorageSiteSwitchesHandler: + """ + Handler class for listing storage switches of a storage site. + GET /api/storage-sites/{storage-site-id}/storage-switches + """ + + valid_query_parms_get = ['name', 'domain-id', 'storage-fabric-uri'] + + returned_props = ['object-uri', 'name', 'domain-id', 'storage-fabric-uri'] + + @classmethod + def get(cls, method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: List Storage Switches of a Storage Site.""" + uri, query_parms = parse_query_parms(method, uri) + check_invalid_query_parms( + method, uri, query_parms, cls.valid_query_parms_get) + + site_oid = uri_parms[0] + site_uri = '/api/storage-sites/' + site_oid + try: + hmc.lookup_by_uri(site_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + + filter_args = dict(query_parms) + filter_args['storage-site-uri'] = site_uri + + result_switches = [] + for sw in hmc.consoles.console.storage_switches.list(filter_args): + result_sw = {} + for prop in cls.returned_props: + result_sw[prop] = prop_copy(sw.properties.get(prop)) + result_switches.append(result_sw) + return {'storage-switches': result_switches} + + +class StorageFabricSwitchesHandler: + """ + Handler class for listing storage switches of a storage fabric. + GET /api/storage-fabrics/{storage-fabric-id}/storage-switches + """ + + valid_query_parms_get = ['name', 'domain-id', 'storage-site-uri'] + + returned_props = ['object-uri', 'name', 'domain-id', 'storage-site-uri'] + + @classmethod + def get(cls, method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: List Storage Switches of a Storage Fabric.""" + uri, query_parms = parse_query_parms(method, uri) + check_invalid_query_parms( + method, uri, query_parms, cls.valid_query_parms_get) + + fabric_oid = uri_parms[0] + fabric_uri = '/api/storage-fabrics/' + fabric_oid + try: + hmc.lookup_by_uri(fabric_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + + filter_args = dict(query_parms) + filter_args['storage-fabric-uri'] = fabric_uri + + result_switches = [] + for sw in hmc.consoles.console.storage_switches.list(filter_args): + result_sw = {} + for prop in cls.returned_props: + result_sw[prop] = prop_copy(sw.properties.get(prop)) + result_switches.append(result_sw) + return {'storage-switches': result_switches} + + +class StorageSubsystemsHandler: + """ + Handler class for HTTP methods on the set of StorageSubsystem resources. + GET /api/storage-subsystems + """ + + valid_query_parms_get = ['name', 'storage-site-uri'] + + returned_props = ['object-uri', 'name', 'storage-site-uri'] + + @classmethod + def get(cls, method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: List Storage Subsystems (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_storage_subsystems = [] + for ss in hmc.consoles.console.storage_subsystems.list(filter_args): + result_ss = {} + for prop in cls.returned_props: + result_ss[prop] = prop_copy(ss.properties.get(prop)) + result_storage_subsystems.append(result_ss) + return {'storage-subsystems': result_storage_subsystems} + + +class StorageSubsystemHandler(GenericGetPropertiesHandler, + GenericUpdatePropertiesHandler): # noqa: E127 + """ + Handler class for HTTP methods on a single StorageSubsystem resource + (Get Properties and Update Properties). + POST /api/storage-subsystems/{storage-subsystem-id} + """ + pass + + +class StorageSubsystemMoveSiteHandler: + """ + Handler class for the "Move Storage Subsystem to Storage Site" operation. + POST /api/storage-subsystems/{id}/operations/move-storage-site + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Move Storage Subsystem to Storage Site.""" + assert wait_for_completion is True # always synchronous + check_required_fields(method, uri, body, ['storage-site-uri']) + ss_oid = uri_parms[0] + ss_uri = '/api/storage-subsystems/' + ss_oid + try: + ss = hmc.lookup_by_uri(ss_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + + new_site_uri = body['storage-site-uri'] + try: + new_site = hmc.lookup_by_uri(new_site_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri, reason=2, + resource_uri=new_site_uri) + new_exc.__cause__ = None + raise new_exc + + # Check if the subsystem is already in the target storage site + current_site_uri = ss.properties.get('storage-site-uri') + if current_site_uri == new_site_uri: + raise ConflictError( + method, uri, reason=450, + message="The storage subsystem is already in the target " + f"storage site: {new_site_uri}") + + # Update storage-subsystem-uris on the old storage site + if current_site_uri: + try: + old_site = hmc.lookup_by_uri(current_site_uri) + old_uris = list( + old_site.properties.get('storage-subsystem-uris', [])) + if ss_uri in old_uris: + old_uris.remove(ss_uri) + old_site.update({'storage-subsystem-uris': old_uris}) + except KeyError: + pass + + # Update storage-subsystem-uris on the new storage site + new_uris = list(new_site.properties.get('storage-subsystem-uris', [])) + if ss_uri not in new_uris: + new_uris.append(ss_uri) + new_site.update({'storage-subsystem-uris': new_uris}) + + ss.update({'storage-site-uri': new_site_uri}) # 204 No Content + + +class StorageSubsystemAddEndpointHandler: + """ + Handler class for the "Add Connection Endpoint" operation. + POST /api/storage-subsystems/{id}/operations/add-connection-endpoint + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Add Connection Endpoint.""" + assert wait_for_completion is True # always synchronous + check_required_fields(method, uri, body, ['endpoint-uri']) + ss_oid = uri_parms[0] + ss_uri = '/api/storage-subsystems/' + ss_oid + try: + ss = hmc.lookup_by_uri(ss_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + + endpoint_uri = body['endpoint-uri'] + try: + hmc.lookup_by_uri(endpoint_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri, reason=2, + resource_uri=endpoint_uri) + new_exc.__cause__ = None + raise new_exc + + port_id = body.get('port-id') + is_switch_endpoint = '/storage-switches/' in endpoint_uri + + # Enforce API spec: port-id required for switch endpoints, + # prohibited for adapter endpoints (reason 15). + if is_switch_endpoint and port_id is None: + raise BadRequestError( + method, uri, reason=5, + message="Field 'port-id' is required when 'endpoint-uri' " + "references a Storage Switch object") + if not is_switch_endpoint and port_id is not None: + raise BadRequestError( + method, uri, reason=15, + message="Field 'port-id' is present in the request body " + "when 'endpoint-uri' references an Adapter object") + + # Check for duplicate endpoint + endpoints = list(ss.properties.get('connection-endpoints', [])) + for ep in endpoints: + if ep.get('endpoint-uri') == endpoint_uri: + if ep.get('port-id') == port_id: + raise ConflictError( + method, uri, reason=443, + message="The specified connection endpoint already " + f"exists on the storage subsystem: {ss_uri}") + + # Check maximum endpoint count (64) + if len(endpoints) >= 64: + raise ConflictError( + method, uri, reason=486, + message="Cannot add endpoint: the maximum number of " + f"endpoints (64) is already defined for {ss_uri}") + + # Determine endpoint class based on whether it references a switch + if '/storage-switches/' in endpoint_uri: + endpoint_class = 'storage-switch' + else: + endpoint_class = 'adapter' + + new_endpoint = { + 'endpoint-uri': endpoint_uri, + 'endpoint-class': endpoint_class, + } + if port_id is not None: + new_endpoint['port-id'] = port_id + + endpoints.append(new_endpoint) + ss.update({'connection-endpoints': endpoints}) # 204 No Content + + +class StorageSubsystemRemoveEndpointHandler: + """ + Handler class for the "Remove Connection Endpoint" operation. + POST /api/storage-subsystems/{id}/operations/remove-connection-endpoint + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Remove Connection Endpoint.""" + assert wait_for_completion is True # always synchronous + check_required_fields(method, uri, body, ['endpoint-uri']) + ss_oid = uri_parms[0] + ss_uri = '/api/storage-subsystems/' + ss_oid + try: + ss = hmc.lookup_by_uri(ss_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + + endpoint_uri = body['endpoint-uri'] + try: + hmc.lookup_by_uri(endpoint_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri, reason=2, + resource_uri=endpoint_uri) + new_exc.__cause__ = None + raise new_exc + + port_id = body.get('port-id') + is_switch_endpoint = '/storage-switches/' in endpoint_uri + + # Enforce API spec: port-id required for switch endpoints. + # (For adapter endpoints port-id is ignored, so we accept either way.) + if is_switch_endpoint and port_id is None: + raise BadRequestError( + method, uri, reason=5, + message="Field 'port-id' is required when 'endpoint-uri' " + "references a Storage Switch object") + + # Find the matching endpoint to remove + endpoints = list(ss.properties.get('connection-endpoints', [])) + found_idx = None + for idx, ep in enumerate(endpoints): + if ep.get('endpoint-uri') == endpoint_uri: + # For switch endpoints match by port-id too; for adapter + # endpoints port-id is irrelevant so match on URI alone. + if not is_switch_endpoint or ep.get('port-id') == port_id: + found_idx = idx + break + + if found_idx is None: + raise ConflictError( + method, uri, reason=444, + message="The specified connection endpoint does not exist " + f"on storage subsystem: {ss_uri}") + + endpoints.pop(found_idx) + ss.update({'connection-endpoints': endpoints}) # 204 No Content + + +class StorageSiteSubsystemsHandler: + """ + Handler class for listing storage subsystems of a storage site. + GET /api/storage-sites/{storage-site-id}/storage-subsystems + """ + + valid_query_parms_get = ['name'] + + returned_props = ['object-uri', 'name', 'storage-site-uri'] + + @classmethod + def get(cls, method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: List Storage Subsystems of a Storage Site.""" + uri, query_parms = parse_query_parms(method, uri) + check_invalid_query_parms( + method, uri, query_parms, cls.valid_query_parms_get) + + site_oid = uri_parms[0] + site_uri = '/api/storage-sites/' + site_oid + try: + hmc.lookup_by_uri(site_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc + + filter_args = dict(query_parms) + filter_args['storage-site-uri'] = site_uri + + result_subsystems = [] + for ss in hmc.consoles.console.storage_subsystems.list(filter_args): + result_ss = {} + for prop in cls.returned_props: + result_ss[prop] = prop_copy(ss.properties.get(prop)) + result_subsystems.append(result_ss) + 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. + """ + + valid_query_parms_get = ['cpc-uri', 'name', 'state'] + + returned_props = ['object-uri', 'cpc-uri', 'name', 'state'] + + @classmethod + def get(cls, method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: List Tape Library (always global but 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_tape_libraries = [] + for tl in hmc.consoles.console.tape_library.list(filter_args): + result_tl = {} + for prop in cls.returned_props: + result_tl[prop] = prop_copy(tl.properties.get(prop)) + result_tape_libraries.append(result_tl) + return {'tape-libraries': result_tape_libraries} + + +class TapeLibraryHandler(GenericGetPropertiesHandler, + GenericUpdatePropertiesHandler): + """ + Handler class for HTTP methods on single TapeLibrary resource. + """ + pass + + +class TapeLibraryUndefineHandler: + """ + Handler class for operation: Undefine Tape Library. + """ + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Request Tape Library Undefine""" + try: + tape_library_id = uri_parms[0] # Extract from regex capture group + tape_library_uri = f'/api/tape-libraries/{tape_library_id}' + tape_library = hmc.lookup_by_uri(tape_library_uri) + except KeyError: + + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + print("resource object " + str(tape_library)) + tape_library.manager.remove(tape_library.oid) + + +class TapeLibraryRequestZoningHandler: + """ + Handler class for HTTP methods on set of TapeLibrary resources. + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Request Tape Library Zoning""" + assert wait_for_completion is True # always synchronous + check_required_fields(method, uri, body, + ['cpc-uri']) + cpc_uri = body['cpc-uri'] + try: + cpc = hmc.lookup_by_uri(cpc_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + if not cpc.dpm_enabled: + raise CpcNotInDpmError(method, uri, cpc) + if cpc.adapters.list(): + print('This is CPC Adapters', str(cpc.adapters.list())) + cpc.properties['management-world-wide-port-name'] =\ + 'a1b2c3d4e5f60002' + return {} + raise ConflictError( + method, uri, reason=487, + message=f"The CPC with the {cpc_uri} does not have any " + "adapters configured as FCP") + + +class TapeLibraryDiscoverHandler: + """ + Handler class for HTTP methods on set of TapeLibrary resources. + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Request Tape Library Zoning""" + assert wait_for_completion is True # async not yet supported + check_required_fields(method, uri, body, ['cpc-uri']) + cpc_uri = body['cpc-uri'] + try: + cpc = hmc.lookup_by_uri(cpc_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + if not cpc.dpm_enabled: + raise CpcNotInDpmError(method, uri, cpc) + check_valid_cpc_status(method, uri, cpc) + if cpc.properties['management-world-wide-port-name']: + return {} + + raise ConflictError( + method, uri, reason=501, + message=f"The CPC with the {cpc_uri} has not been zoned.") + + +class TapeLinksHandler: + """ + Handler class for HTTP methods on set of TapeLink resources. + """ + + valid_query_parms_get = ['cpc-uri', 'name', 'fulfillment-state', + 'tape-library-uri'] + + returned_props = ['object-uri', 'cpc-uri', 'name', 'fulfillment-state', + 'tape-library-uri'] + + @classmethod + def get(cls, method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: List Tape Links.""" + 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_tape_links = [] + for tl in hmc.consoles.console.tape_links.list(filter_args): + result_tl = {} + for prop in cls.returned_props: + result_tl[prop] = prop_copy(tl.properties.get(prop)) + result_tape_links.append(result_tl) + return {'tape-links': result_tape_links} + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Create Tape Link.""" + assert wait_for_completion is True # async not supported yet + check_required_fields(method, uri, body, ['name', 'cpc-uri']) + cpc_uri = body['cpc-uri'] + try: + cpc = hmc.lookup_by_uri(cpc_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + if not cpc.dpm_enabled: + raise CpcNotInDpmError(method, uri, cpc) + check_valid_cpc_status(method, uri, cpc) + + body2 = body.copy() + body2.setdefault('description', '') + body2['fulfillment-state'] = 'pending' + new_tape_link = hmc.consoles.console.tape_links.add(body2) + return { + 'object-uri': new_tape_link.uri + } + + +class TapeLinkHandler(GenericGetPropertiesHandler, + GenericUpdatePropertiesHandler): + """ + Handler class for HTTP methods on single TapeLink resource. + """ + pass + + +class TapeLinkDeleteHandler: + """ + Handler class for operation: Delete Tape Link. + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Delete Tape Link.""" + assert wait_for_completion is True # async not supported yet + + tape_link_oid = uri_parms[0] + tape_link_uri = '/api/tape-links/' + tape_link_oid + try: + tape_link = hmc.lookup_by_uri(tape_link_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + + # TODO: Check that the Tape Link is detached from any partitions + + # Reflect the result of deleting the tape_link + tape_link.manager.remove(tape_link.oid) + + +class TapeLinkUpdateHandler: + """ + Handler class for operation: Delete Tape Link. + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Modify Tape Link.""" + assert wait_for_completion is True # async not supported yet + + tape_link_oid = uri_parms[0] + tape_link_uri = '/api/tape-links/' + tape_link_oid + try: + tape_link = hmc.lookup_by_uri(tape_link_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + + body2 = body.copy() + tape_link.update(body2) + + +class TapeLinkGetHistoriesHandler: + """ + Handler class for operation: Get Tape Link Histories. + """ + + @staticmethod + def get(method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: Get Tape Link Histories.""" + + # Extract tape link OID from URI + tape_link_oid = uri_parms[0] + tape_link_uri = f'/api/tape-links/{tape_link_oid}' + + try: + hmc.lookup_by_uri(tape_link_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + + # Return mock history data + # In a real implementation, this would return actual history records + return { + 'tape-link-histories': [] + } + + +class TapeLinkGetPartitionsHandler: + """ + Handler class for operation: Get Partitions attached to Tape Link. + """ + + @staticmethod + def get(method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: Get Partitions for a Tape Link.""" + + # Extract tape link OID from URI + tape_link_oid = uri_parms[0] + tape_link_uri = f'/api/tape-links/{tape_link_oid}' + + try: + tape_link = hmc.lookup_by_uri(tape_link_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + + # Get the CPC for this tape link + cpc_uri = tape_link.properties.get('cpc-uri') + if not cpc_uri: + # Return empty list if no CPC is associated + return {'partitions': []} + + try: + cpc = hmc.lookup_by_uri(cpc_uri) + except KeyError: + # Return empty list if CPC not found + return {'partitions': []} + + # Return all partitions from the CPC + # In a real implementation, this would filter based on actual + # attachments + result_partitions = [] + for partition in cpc.partitions.list(): + result_partition = { + 'object-uri': partition.uri, + 'name': partition.properties.get('name'), + 'status': partition.properties.get('status', 'stopped') + } + result_partitions.append(result_partition) + + return {'partitions': result_partitions} + + +class TapeLinkGetEnvironmentReportHandler: + """ + Handler class for operation: Get Tape Link Environment Report. + """ + + @staticmethod + def get(method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: Get Tape Link Environment Report.""" + + # Extract tape link OID from URI + tape_link_oid = uri_parms[0] + tape_link_uri = f'/api/tape-links/{tape_link_oid}' + + try: + tape_link = hmc.lookup_by_uri(tape_link_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + + # Return mock environment report + # In a real implementation, this would return actual environment data + report = tape_link.properties.get('environment-report', {}) + if not report: + report = { + 'status': 'ok', + 'last-updated': '2024-01-01T00:00:00Z', + 'details': {} + } + + return report + + +class TapeLinkUpdateEnvironmentReportHandler: + """ + Handler class for operation: Update Tape Link Environment Report. + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Update Tape Link Environment Report.""" + assert wait_for_completion is True # async not supported yet + + # Extract tape link OID from URI + tape_link_oid = uri_parms[0] + tape_link_uri = f'/api/tape-links/{tape_link_oid}' + + try: + tape_link = hmc.lookup_by_uri(tape_link_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + + # Update the environment report in the tape link properties + if body: + tape_link.properties['environment-report'] = body + + return {} + + +class VirtualTapeResourcesHandler: + """ + Handler class for HTTP methods on set of VirtualTapeResource resources. + """ + + valid_query_parms_get = [ + 'name', 'device-number', 'adapter-port-uri', 'partition-uri'] + + returned_props = [ + 'element-uri', 'name', 'device-number', + 'adapter-port-uri', 'partition-uri'] + + @classmethod + def get(cls, method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: List Virtual Tape Resources of a Tape Link.""" + uri, query_parms = parse_query_parms(method, uri) + check_invalid_query_parms( + method, uri, query_parms, cls.valid_query_parms_get) + + # Extract tape link OID from URI + tape_link_oid = uri_parms[0] + tape_link_uri = f'/api/tape-links/{tape_link_oid}' + + try: + tape_link = hmc.lookup_by_uri(tape_link_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + + filter_args = query_parms + + result_vtrs = [] + for vtr in tape_link.virtual_tape_resources.list(filter_args): + result_vtr = {} + for prop in cls.returned_props: + result_vtr[prop] = prop_copy(vtr.properties.get(prop)) + result_vtrs.append(result_vtr) + return {'virtual-tape-resources': result_vtrs} + + +class VirtualTapeResourceHandler(GenericGetPropertiesHandler, + GenericUpdatePropertiesHandler): + """ + Handler class for HTTP methods on single VirtualTapeResource resource. + """ + pass + + +class CapacityGroupsHandler: + """ + Handler class for HTTP methods on set of CapacityGroup resources. + """ + + valid_query_parms_get = ['name'] + + returned_props = ['element-uri', 'name'] + + @classmethod + def get(cls, method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: List Capacity Groups (always global but 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 + + cpc_oid = uri_parms[0] + cpc_uri = '/api/cpcs/' + cpc_oid + try: + cpc = hmc.lookup_by_uri(cpc_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + result_capacity_groups = [] + for cg in cpc.capacity_groups.list(filter_args): + result_cg = {} + for prop in cls.returned_props: + result_cg[prop] = prop_copy(cg.properties.get(prop)) + result_capacity_groups.append(result_cg) + return {'capacity-groups': result_capacity_groups} + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Create Capacity Group.""" + assert wait_for_completion is True # async not supported yet + check_required_fields(method, uri, body, ['name']) + cpc_oid = uri_parms[0] + cpc_uri = '/api/cpcs/' + cpc_oid + try: + cpc = hmc.lookup_by_uri(cpc_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + if not cpc.dpm_enabled: + raise CpcNotInDpmError(method, uri, cpc) + check_valid_cpc_status(method, uri, cpc) + + # Reflect the result of creating the capacity group + new_capacity_group = cpc.capacity_groups.add(body) + + return { + 'element-uri': new_capacity_group.uri + } + + +class CapacityGroupHandler(GenericGetPropertiesHandler, + GenericUpdatePropertiesHandler, + GenericDeleteHandler): + """ + Handler class for HTTP methods on single CapacityGroup resource. + """ + + @staticmethod + def delete(method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: Delete Capacity Group.""" + try: + capacity_group = hmc.lookup_by_uri(uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + + # Check that Capacity Group is empty + partition_uris = capacity_group.properties['partition-uris'] + if partition_uris: + raise ConflictError( + method, uri, reason=110, + message=f"Capacity group {capacity_group.name!r} is not empty " + f"and contains partitions with URIs {partition_uris!r}") + + # Delete the mocked resource + capacity_group.manager.remove(capacity_group.oid) + + +class CapacityGroupAddPartitionHandler: + """ + Handler class for operation: Add Partition to Capacity Group. + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Add Partition to Capacity Group.""" + assert wait_for_completion is True # async not supported yet + + # The URI is a POST operation, so we need to construct the CG URI + cpc_oid = uri_parms[0] + cpc_uri = '/api/cpcs/' + cpc_oid + try: + cpc = hmc.lookup_by_uri(cpc_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + cg_oid = uri_parms[1] + cg_uri = cpc_uri + '/capacity-groups/' + cg_oid + + try: + capacity_group = hmc.lookup_by_uri(cg_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri, reason=150) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + + check_required_fields(method, uri, body, ['partition-uri']) + + # Check the partition exists + partition_uri = body['partition-uri'] + try: + partition = hmc.lookup_by_uri(partition_uri) + except KeyError: + new_exc = InvalidResourceError(method, uri, reason=2) + new_exc.__cause__ = None + raise new_exc # zhmcclient.mock.InvalidResourceError + + # Check the partition is in shared processor mode + processor_mode = partition.properties.get('processor-mode', 'shared') + if processor_mode != 'shared': + raise ConflictError(method, uri, 170, + f"Partition {partition.name} is in " + f"{processor_mode} processor mode") + + # Check the partition is not in this capacity group + partition_uris = capacity_group.properties['partition-uris'] + if partition.uri in partition_uris: + raise ConflictError(method, uri, 130, + f"Partition {partition.name} is already a " "member of this capacity group " f"{capacity_group.name}") @@ -7028,6 +8393,67 @@ def post(method, hmc, uri, uri_parms, body, logon_required, (r'/api/storage-templates/([^/]+)/storage-template-volumes/' r'([^?/]+)(?:\?(.*))?', StorageTemplateVolumeHandler), + (r'/api/storage-fabrics(?:\?(.*))?', + StorageFabricsHandler), + (r'/api/storage-fabrics/([^/]+)/storage-switches(?:\?(.*))?', + StorageFabricSwitchesHandler), + (r'/api/storage-fabrics/([^?/]+)(?:\?(.*))?', + StorageFabricHandler), + + (r'/api/storage-sites(?:\?(.*))?', + StorageSitesHandler), + (r'/api/storage-sites/([^/]+)/storage-switches(?:\?(.*))?', + StorageSiteSwitchesHandler), + (r'/api/storage-sites/([^/]+)/storage-subsystems(?:\?(.*))?', + StorageSiteSubsystemsHandler), + (r'/api/storage-sites/([^?/]+)(?:\?(.*))?', + StorageSiteHandler), + + (r'/api/storage-switches/([^/]+)/operations/undefine', + StorageSwitchUndefineHandler), + (r'/api/storage-switches/([^/]+)/operations/move-storage-site', + StorageSwitchMoveSiteHandler), + (r'/api/storage-switches/([^/]+)/operations/move-storage-fabric', + StorageSwitchMoveFabricHandler), + (r'/api/storage-switches/([^?/]+)(?:\?(.*))?', + StorageSwitchHandler), + (r'/api/storage-switches(?:\?(.*))?', + StorageSwitchesHandler), + + (r'/api/console/operations/define-storage-switch', + StorageSwitchDefineHandler), + + (r'/api/storage-subsystems/([^/]+)/operations/move-storage-site', + StorageSubsystemMoveSiteHandler), + (r'/api/storage-subsystems/([^/]+)/operations/add-connection-endpoint', + 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/([^?/]+)(?:\?(.*))?', @@ -7039,6 +8465,36 @@ def post(method, hmc, uri, uri_parms, body, logon_required, (r'/api/tape-libraries/operations/discover-tape-libraries', TapeLibraryDiscoverHandler), + (r'/api/tape-links(?:\?(.*))?', + TapeLinksHandler), + (r'/api/tape-links/([^?/]+)(?:\?(.*))?', + TapeLinkHandler), + (r'/api/tape-links/([^/]+)/' + r'operations/delete', + TapeLinkDeleteHandler), + (r'/api/tape-links/([^/]+)/' + r'operations/modify', + TapeLinkUpdateHandler), + (r'/api/tape-links/([^/]+)/' + r'operations/get-tape-link-histories(?:\?(.*))?', + TapeLinkGetHistoriesHandler), + (r'/api/tape-links/([^/]+)/' + r'operations/get-partitions(?:\?(.*))?', + TapeLinkGetPartitionsHandler), + (r'/api/tape-links/([^/]+)/' + r'operations/get-tape-link-environment-report(?:\?(.*))?', + TapeLinkGetEnvironmentReportHandler), + (r'/api/tape-links/([^/]+)/' + r'operations/update-tape-link-environment-report(?:\?(.*))?', + TapeLinkUpdateEnvironmentReportHandler), + + (r'/api/tape-links/([^/]+)/' + r'virtual-tape-resources(?:\?(.*))?', + VirtualTapeResourcesHandler), + (r'/api/tape-links/([^/]+)/' + r'virtual-tape-resources/([^?/]+)(?:\?(.*))?', + VirtualTapeResourceHandler), + (r'/api/cpcs/([^/]+)/capacity-groups(?:\?(.*))?', CapacityGroupsHandler), (r'/api/cpcs/([^/]+)/capacity-groups/([^?/]+)(?:\?(.*))?', CapacityGroupHandler),