Add the sp_rand_init and sp_rand_cleanup tools.

These will be used on the worker node to set up a random volume name
prefix for the StorPool volumes and snapshots created during
the OpenStack CI run, and detach and remove them all later.

Change-Id: Ic558e2183db8068545f7454f956dc8bc740959c6
diff --git a/tools/sp-rand/src/sp_rand/__init__.py b/tools/sp-rand/src/sp_rand/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tools/sp-rand/src/sp_rand/__init__.py
diff --git a/tools/sp-rand/src/sp_rand/cleanup.py b/tools/sp-rand/src/sp_rand/cleanup.py
new file mode 100644
index 0000000..3e1a464
--- /dev/null
+++ b/tools/sp-rand/src/sp_rand/cleanup.py
@@ -0,0 +1,147 @@
+"""Clean up: remove volumes, snapshots, and the file itself."""
+
+from __future__ import annotations
+
+import argparse
+import errno
+import pathlib
+
+from typing import NamedTuple, Tuple  # noqa: H301
+
+import confget
+
+from storpool import spapi  # type: ignore
+from storpool import sptypes
+
+from . import defs
+
+
+class Config(NamedTuple):
+    """Runtime configuration for the sp_rand_cleanup tool."""
+
+    confdir: pathlib.Path
+    noop: bool
+    skip_api: bool
+
+    @staticmethod
+    def default() -> Config:
+        """Build a configuration object with default values."""
+        return Config(confdir=defs.DEFAULT_CONFDIR, noop=False, skip_api=False)
+
+
+def parse_file(cfg: Config) -> Tuple[pathlib.Path, str]:
+    """Parse the StorPool configuration file."""
+    conffile = cfg.confdir / defs.FILENAME
+    print(f"Parsing {conffile}")
+    data = confget.read_ini_file(confget.Config([], filename=str(conffile)))
+
+    assert isinstance(data, dict)
+    assert sorted(data) == [""]
+    assert sorted(data[""]) == [defs.PREFIX_VAR]
+    prefix = data[""][defs.PREFIX_VAR]
+    print(f"- got prefix {prefix!r}")
+
+    return conffile, prefix
+
+
+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")
+    to_detach = []
+    for desc in api.attachmentsList():  # pylint: disable=not-callable
+        if not desc.volume.startswith(prefix):
+            continue
+        if desc.snapshot:
+            print(f"Will detach snapshot {desc.volume} from client {desc.client}")
+            to_detach.append(
+                sptypes.SnapshotReassignDesc(
+                    detach=[desc.client], force=True, snapshot=desc.volume
+                )
+            )
+        else:
+            print(f"Will detach volume {desc.volume} from client {desc.client}")
+            to_detach.append(
+                sptypes.VolumeReassignDesc(
+                    detach=[desc.client], force=True, volume=desc.volume
+                )
+            )
+
+    if to_detach:
+        print(f"Detaching {len(to_detach)} volumes/snapshots")
+        if not cfg.noop:
+            api.volumesReassignWait(  # pylint: disable=not-callable
+                sptypes.VolumesReassignWaitDesc(reassign=to_detach)
+            )
+
+    print("Querying the StorPool API for existing volumes")
+    for name in [vol.name for vol in api.volumesList()]:
+        if not name.startswith(prefix):
+            continue
+        print(f"Removing volume {name}")
+        if not cfg.noop:
+            api.volumeDelete(name)
+
+    print("Querying the StorPool API for existing snapshots")
+    for name in [snap.name for snap in api.snapshotsList()]:
+        if not name.startswith(prefix):
+            continue
+        print(f"Removing snapshot {name}")
+        if not cfg.noop:
+            api.snapshotDelete(name)
+
+
+def remove_file(cfg: Config, conffile: pathlib.Path) -> None:
+    """Remove the StorPool configuration file."""
+    print(f"Removing {conffile}")
+    if not cfg.noop:
+        try:
+            conffile.unlink()
+        except OSError as err:
+            if err.errno != errno.ENOENT:
+                raise
+
+
+def parse_args() -> Config:
+    """Parse the command-line arguments."""
+    parser = argparse.ArgumentParser(prog="sp_rand_cleanup")
+    parser.add_argument(
+        "-d",
+        "--confdir",
+        type=pathlib.Path,
+        default=defs.DEFAULT_CONFDIR,
+        help="The directory to look for the configuration file in",
+    )
+    parser.add_argument(
+        "-N",
+        "--noop",
+        action="store_true",
+        help="No-operation mode; display what would have been done",
+    )
+    parser.add_argument(
+        "--skip-api",
+        type=str,
+        help=(
+            "If explicitly given the 'yes' value, do not "
+            "delete any volumes or snapshots"
+        ),
+    )
+
+    args = parser.parse_args()
+
+    return Config(confdir=args.confdir, noop=args.noop, skip_api=args.skip_api == "yes")
+
+
+def main() -> None:
+    """Main program: parse command-line options, handle them."""
+    cfg = parse_args()
+    conffile, prefix = parse_file(cfg)
+
+    if not cfg.skip_api:
+        api = spapi.Api.fromConfig()
+        remove_volumes(cfg, api, prefix)
+
+    remove_file(cfg, conffile)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/sp-rand/src/sp_rand/defs.py b/tools/sp-rand/src/sp_rand/defs.py
new file mode 100644
index 0000000..bf2ad8a
--- /dev/null
+++ b/tools/sp-rand/src/sp_rand/defs.py
@@ -0,0 +1,11 @@
+"""Common definitions for the StorPool random prefix toolset."""
+
+import pathlib
+
+
+VERSION = "0.1.0"
+
+FILENAME = "sp-ostack-random.conf"
+PREFIX_VAR = "SP_OPENSTACK_VOLUME_PREFIX"
+
+DEFAULT_CONFDIR = pathlib.Path("/etc/storpool.conf.d")
diff --git a/tools/sp-rand/src/sp_rand/init.py b/tools/sp-rand/src/sp_rand/init.py
new file mode 100644
index 0000000..60baa88
--- /dev/null
+++ b/tools/sp-rand/src/sp_rand/init.py
@@ -0,0 +1,100 @@
+"""Set up the random volume name prefix."""
+
+from __future__ import annotations
+
+import argparse
+import datetime
+import pathlib
+import random
+
+from typing import NamedTuple, Tuple  # noqa: H301
+
+from . import defs
+
+DEFAULT_TAG = "osci-1-date"
+
+
+class Config(NamedTuple):
+    """Runtime configuration for the sp_rand_init tool."""
+
+    confdir: pathlib.Path
+    noop: bool
+    prefix_var: str
+    tag: str
+
+    @staticmethod
+    def default() -> Config:
+        """Build a configuration object with the default values."""
+        return Config(
+            confdir=defs.DEFAULT_CONFDIR,
+            noop=False,
+            prefix_var=defs.PREFIX_VAR,
+            tag=DEFAULT_TAG,
+        )
+
+
+def generate_prefix(cfg: Config) -> str:
+    """Generate a random name prefix."""
+    now = datetime.datetime.now()
+    rval = random.randint(1000, 9999)
+    tdate = f"{now.year:04}{now.month:02}{now.day:02}"
+    ttime = f"{now.hour:02}{now.minute:02}"
+    return f"{cfg.tag}-{tdate}-{ttime}-{rval}-"
+
+
+def store_prefix(cfg: Config) -> Tuple[pathlib.Path, str]:
+    """Generate and store the random name prefix into a file."""
+    prefix = generate_prefix(cfg)
+    conffile = cfg.confdir / defs.FILENAME
+    if not cfg.noop:
+        conffile.write_text(f"{cfg.prefix_var}={prefix}\n", encoding="UTF-8")
+    return conffile, prefix
+
+
+def parse_args() -> Config:
+    """Parse the command-line arguments."""
+    parser = argparse.ArgumentParser(prog="sp_rand_init")
+    parser.add_argument(
+        "-d",
+        "--confdir",
+        type=pathlib.Path,
+        default=defs.DEFAULT_CONFDIR,
+        help="The directory to create the configuration file in",
+    )
+    parser.add_argument(
+        "-N",
+        "--noop",
+        action="store_true",
+        help="No-operation mode; display what would have been done",
+    )
+    parser.add_argument(
+        "-p",
+        "--prefix-var",
+        type=str,
+        default=defs.PREFIX_VAR,
+        help="The name of the variable to store into the configuration file",
+    )
+    parser.add_argument(
+        "-t",
+        "--tag",
+        type=str,
+        default=DEFAULT_TAG,
+        help="The tag that the prefix should start with",
+    )
+
+    args = parser.parse_args()
+
+    return Config(
+        confdir=args.confdir, noop=args.noop, prefix_var=args.prefix_var, tag=args.tag
+    )
+
+
+def main() -> None:
+    """Main program: parse options, generate a prefix, store it."""
+    cfg = parse_args()
+    conffile, prefix = store_prefix(cfg)
+    print(f"{prefix}\n{conffile}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/sp-rand/src/sp_rand/py.typed b/tools/sp-rand/src/sp_rand/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tools/sp-rand/src/sp_rand/py.typed