sp-rand: remove iSCSI exports and targets.

Change-Id: I318bd65080bd1ee644aa034ec2fe0c9f041064f7
diff --git a/tools/sp-rand/src/sp_rand/cleanup.py b/tools/sp-rand/src/sp_rand/cleanup.py
index 3e1a464..80ff052 100644
--- a/tools/sp-rand/src/sp_rand/cleanup.py
+++ b/tools/sp-rand/src/sp_rand/cleanup.py
@@ -4,6 +4,7 @@
 
 import argparse
 import errno
+import itertools
 import pathlib
 
 from typing import NamedTuple, Tuple  # noqa: H301
@@ -44,6 +45,67 @@
     return conffile, prefix
 
 
+def remove_iscsi_targets_and_exports(cfg: Config, api: spapi.Api, prefix: str) -> None:
+    """Unexport volumes, remove the iSCSI target records."""
+    print("Querying the StorPool API for the iSCSI configuration")
+    iscsi = api.iSCSIConfig().iscsi
+
+    print("Looking for target records for our volumes")
+    targets = {
+        tgt.name: tgt.volume
+        for tgt in iscsi.targets.values()
+        if tgt.volume.startswith(prefix)
+    }
+
+    if targets:
+        print(f"Found {len(targets)} target records, looking for exports")
+        exports = list(
+            itertools.chain(
+                *(
+                    [
+                        (idata.name, exp.portalGroup, targets[exp.target])
+                        for exp in idata.exports
+                        if exp.target in targets
+                    ]
+                    for idata in iscsi.initiators.values()
+                )
+            )
+        )
+
+        if exports:
+            to_remove = []
+            for exp_name, exp_pg, exp_vol in exports:
+                print(f"Will remove the export for {exp_vol} to {exp_name} in {exp_pg}")
+                to_remove.append(
+                    sptypes.iSCSIConfigCommand(
+                        exportDelete=sptypes.iSCSICommandExportDelete(
+                            initiator=exp_name, portalGroup=exp_pg, volumeName=exp_vol
+                        )
+                    )
+                )
+
+            print(f"Prepared for removing {len(to_remove)} exports")
+            if not cfg.noop:
+                api.iSCSIConfigChange(sptypes.iSCSIConfigChange(commands=to_remove))
+        else:
+            print("No iSCSI exports to remove")
+
+        to_remove = []
+        for vol in sorted(targets.values()):
+            print(f"Will remove the target record for volume {vol}")
+            to_remove.append(
+                sptypes.iSCSIConfigCommand(
+                    deleteTarget=sptypes.iSCSICommandDeleteTarget(volumeName=vol)
+                )
+            )
+
+        print(f"Prepared for removing {len(to_remove)} targets")
+        if not cfg.noop:
+            api.iSCSIConfigChange(sptypes.iSCSIConfigChange(commands=to_remove))
+    else:
+        print("No iSCSI targets to remove, not looking for exports at all")
+
+
 def remove_volumes(cfg: Config, api: spapi.Api, prefix: str) -> None:
     """Detach and remove any pertinent StorPool volumes and snapshots."""
     print("Querying the StorPool API for attached volumes and snapshots")
@@ -73,6 +135,8 @@
                 sptypes.VolumesReassignWaitDesc(reassign=to_detach)
             )
 
+    remove_iscsi_targets_and_exports(cfg, api, prefix)
+
     print("Querying the StorPool API for existing volumes")
     for name in [vol.name for vol in api.volumesList()]:
         if not name.startswith(prefix):
diff --git a/tools/sp-rand/unit_tests/test_cleanup.py b/tools/sp-rand/unit_tests/test_cleanup.py
index 7c90d08..c54c53e 100644
--- a/tools/sp-rand/unit_tests/test_cleanup.py
+++ b/tools/sp-rand/unit_tests/test_cleanup.py
@@ -5,7 +5,7 @@
 import pathlib
 import tempfile
 
-from typing import DefaultDict, Dict, List, NamedTuple  # noqa: H301
+from typing import DefaultDict, Dict, List, NamedTuple, Tuple  # noqa: H301
 
 import pytest
 
@@ -15,6 +15,39 @@
 from sp_rand import defs as r_defs
 
 
+T_ATTACHED = {
+    False: ["something-else", "testvol-42"],
+    True: ["testvol-9000", "another.snapshot", "testvol-616", "some-testvol-12"],
+}
+
+T_EXPORTED = {
+    "beleriand": [
+        ("neighbors", "testvol-451"),
+        ("ours", "something-else"),
+        ("ours", "testvol-616"),
+    ],
+    "gondor": [("ours", "testvol-12")],
+}
+
+T_TARGETS = {
+    1: {"name": "ouriqn:target-something-else", "volume": "something-else"},
+    2: {"name": "ouriqn:target-testvol-12", "volume": "testvol-12"},
+    3: {"name": "ouriqn:target-testvol-616", "volume": "testvol-616"},
+    4: {"name": "neiqn:whee-testvol-451", "volume": "testvol-451"},
+}
+
+T_VOLS = {
+    False: ["something-else", "testvol-42", "testvol-451"],
+    True: [
+        "another.snapshot",
+        "testvol-616",
+        "testvol-9000",
+        "some-testvol-12",
+        "yet.another.thing",
+    ],
+}
+
+
 def test_parse_file() -> None:
     """Make sure the name prefix file will be parsed correctly."""
     with tempfile.TemporaryDirectory() as tempd_obj:
@@ -88,14 +121,22 @@
     """Mock the StorPool API."""
 
     attached: Dict[bool, List[str]]
+    exported: Dict[str, List[Tuple[str, str]]]
     invoked: DefaultDict[str, int]
+    targets: Dict[int, Dict[str, str]]
     vols: Dict[bool, List[str]]
 
     def __init__(
-        self, attached: Dict[bool, List[str]], vols: Dict[bool, List[str]]
+        self,
+        attached: Dict[bool, List[str]],
+        exported: Dict[str, List[Tuple[str, str]]],
+        targets: Dict[int, Dict[str, str]],
+        vols: Dict[bool, List[str]],
     ) -> None:
         """Store deep copies of the passed definitions."""
         self.attached = {key: list(value) for key, value in attached.items()}
+        self.exported = {key: list(value) for key, value in exported.items()}
+        self.targets = {key: dict(value) for key, value in targets.items()}
         self.vols = {key: list(value) for key, value in vols.items()}
         self.invoked = collections.defaultdict(lambda: 0)
 
@@ -116,6 +157,64 @@
             for name in self.attached[True]
         ]
 
+    def iSCSIConfig(self) -> sptypes.iSCSIConfig:
+        """Return the current view of the iSCSI configuration."""
+        self.invoked["iSCSIConfig"] += 1
+        target_dict = {tgt["volume"]: tgt["name"] for tgt in self.targets.values()}
+
+        return sptypes.iSCSIConfig(
+            iscsi=sptypes.iSCSIConfigData(
+                baseName="just-us-here",
+                initiators={
+                    idx: sptypes.iSCSIInitiator(
+                        name=name,
+                        username="",
+                        secret="",
+                        nets=[],
+                        exports=[
+                            sptypes.iSCSIExport(
+                                portalGroup=exp[0], target=target_dict[exp[1]]
+                            )
+                            for exp in explist
+                        ],
+                    )
+                    for idx, (name, explist) in enumerate(sorted(self.exported.items()))
+                },
+                portalGroups={},
+                targets={
+                    tid: sptypes.iSCSITarget(
+                        currentControllerId=65535,
+                        name=tgt["name"],
+                        volume=tgt["volume"],
+                    )
+                    for tid, tgt in self.targets.items()
+                },
+            )
+        )
+
+    def iSCSIConfigChange(self, change: sptypes.iSCSIConfigChange) -> None:
+        """Apply the "delete export" and "remove target" commands."""
+        self.invoked["iSCSIConfigChange"] += 1
+
+        for cmd in change.commands:
+            expdel = cmd.exportDelete
+            tgtdel = cmd.deleteTarget
+            if expdel is not None:
+                initiator = self.exported[expdel.initiator]
+                data = (expdel.portalGroup, expdel.volumeName)
+                assert data in initiator
+                initiator.remove(data)
+            elif tgtdel is not None:
+                found = [
+                    tid
+                    for tid, tgt in self.targets.items()
+                    if tgt["volume"] == tgtdel.volumeName
+                ]
+                assert len(found) == 1
+                del self.targets[found[0]]
+            else:
+                raise ValueError(repr(cmd))
+
     def volumesReassignWait(self, req: sptypes.VolumesReassignWaitDesc) -> None:
         """Update the current view of what is attached."""
         self.invoked["volumesReassignWait"] += 1
@@ -160,43 +259,42 @@
 def test_remove_volumes() -> None:
     """Make sure the StorPool volumes and snapshots will be removed."""
     cfg = r_cleanup.Config.default()
-    attached = {
-        False: ["something-else", "testvol-42"],
-        True: ["testvol-9000", "another.snapshot", "testvol-616", "some-testvol-12"],
-    }
-    vols = {
-        False: ["something-else", "testvol-42", "testvol-451"],
-        True: [
-            "another.snapshot",
-            "testvol-616",
-            "testvol-9000",
-            "some-testvol-12",
-            "yet.another.thing",
-        ],
-    }
-    mock_api = MockApi(attached=attached, vols=vols)
+    mock_api = MockApi(
+        attached=T_ATTACHED, exported=T_EXPORTED, targets=T_TARGETS, vols=T_VOLS
+    )
 
     r_cleanup.remove_volumes(cfg, mock_api, "testvol-")
 
     assert mock_api.attached == {
         key: [name for name in value if not name.startswith("testvol-")]
-        for key, value in attached.items()
+        for key, value in T_ATTACHED.items()
+    }
+    assert mock_api.exported == {
+        initiator: [item for item in exports if not item[1].startswith("testvol-")]
+        for initiator, exports in T_EXPORTED.items()
+    }
+    assert mock_api.targets == {
+        tid: tgt
+        for tid, tgt in T_TARGETS.items()
+        if not tgt["volume"].startswith("testvol-")
     }
     assert mock_api.vols == {
         key: [name for name in value if not name.startswith("testvol-")]
-        for key, value in vols.items()
+        for key, value in T_VOLS.items()
     }
 
     assert mock_api.invoked == {
         "attachmentsList": 1,
+        "iSCSIConfig": 1,
+        "iSCSIConfigChange": 2,
         "volumesReassignWait": 1,
         "volumesList": 1,
         "snapshotsList": 1,
         "volumeDelete": len(
-            [name for name in vols[False] if name.startswith("testvol-")]
+            [name for name in T_VOLS[False] if name.startswith("testvol-")]
         ),
         "snapshotDelete": len(
-            [name for name in vols[True] if name.startswith("testvol-")]
+            [name for name in T_VOLS[True] if name.startswith("testvol-")]
         ),
     }
 
@@ -204,29 +302,20 @@
 def test_remove_volumes_noop() -> None:
     """Make sure the StorPool volumes and snapshots will be removed."""
     cfg = r_cleanup.Config.default()._replace(noop=True)
-    attached = {
-        False: ["something-else", "testvol-42"],
-        True: ["testvol-9000", "another.snapshot", "testvol-616", "some-testvol-12"],
-    }
-    vols = {
-        False: ["something-else", "testvol-42", "testvol-451"],
-        True: [
-            "another.snapshot",
-            "testvol-616",
-            "testvol-9000",
-            "some-testvol-12",
-            "yet.another.thing",
-        ],
-    }
-    mock_api = MockApi(attached=attached, vols=vols)
+    mock_api = MockApi(
+        attached=T_ATTACHED, exported=T_EXPORTED, targets=T_TARGETS, vols=T_VOLS
+    )
 
     r_cleanup.remove_volumes(cfg, mock_api, "testvol-")
 
-    assert mock_api.attached == attached
-    assert mock_api.vols == vols
+    assert mock_api.attached == T_ATTACHED
+    assert mock_api.exported == T_EXPORTED
+    assert mock_api.targets == T_TARGETS
+    assert mock_api.vols == T_VOLS
 
     assert mock_api.invoked == {
         "attachmentsList": 1,
+        "iSCSIConfig": 1,
         "volumesList": 1,
         "snapshotsList": 1,
     }