From 5886622004a38c6e324646ff15476a82d35275f3 Mon Sep 17 00:00:00 2001 From: scott Date: Thu, 5 Mar 2026 15:13:41 -0500 Subject: [PATCH] Add zvol existence check and creation for iSCSI extents client.py: - check_iscsi_zvols(): queries pool.dataset.query for VOLUME type, returns list of missing zvol names - create_zvol(): creates a single zvol via pool.dataset.create - create_missing_zvols(): opens a fresh connection and creates a batch of zvols from a {name: volsize_bytes} dict summary.py: - Add zvols_to_check and missing_zvols list fields - Report shows a WARNING block listing missing zvols when present migrate.py: - _migrate_iscsi_extents() populates summary.zvols_to_check with the dataset name for each DISK-type extent during dry run cli.py: - Add _parse_size() to parse human-friendly size strings (100G, 500GiB, 1T, etc.) to bytes - run() calls check_iscsi_zvols() during dry run and stores results in summary.missing_zvols - Wizard prompts for size and creates missing zvols after the dry run report, before asking the user to confirm the live run Co-Authored-By: Claude Sonnet 4.6 --- truenas_migrate/cli.py | 48 +++++++++++++++++++++++- truenas_migrate/client.py | 76 ++++++++++++++++++++++++++++++++++++++ truenas_migrate/migrate.py | 2 + truenas_migrate/summary.py | 15 ++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) diff --git a/truenas_migrate/cli.py b/truenas_migrate/cli.py index 7278b32..7695982 100644 --- a/truenas_migrate/cli.py +++ b/truenas_migrate/cli.py @@ -54,7 +54,7 @@ from pathlib import Path from typing import Optional from .archive import parse_archive, list_archive_and_exit -from .client import TrueNASClient, check_dataset_paths, create_missing_datasets +from .client import TrueNASClient, check_dataset_paths, create_missing_datasets, check_iscsi_zvols, create_missing_zvols from .colors import log, _bold, _bold_cyan, _bold_red, _bold_yellow, _cyan, _dim, _green, _yellow from .csv_source import parse_csv_sources from .migrate import migrate_smb_shares, migrate_nfs_shares, migrate_iscsi @@ -112,6 +112,11 @@ async def run( client, summary.paths_to_create, ) + if args.dry_run and summary.zvols_to_check: + summary.missing_zvols = await check_iscsi_zvols( + client, summary.zvols_to_check, + ) + return summary @@ -119,6 +124,24 @@ async def run( # Interactive wizard helpers # ───────────────────────────────────────────────────────────────────────────── +def _parse_size(s: str) -> int: + """Parse a human-friendly size string to bytes. E.g. '100G', '500GiB', '1T'.""" + s = s.strip().upper() + for suffix, mult in [ + ("PIB", 1 << 50), ("PB", 1 << 50), ("P", 1 << 50), + ("TIB", 1 << 40), ("TB", 1 << 40), ("T", 1 << 40), + ("GIB", 1 << 30), ("GB", 1 << 30), ("G", 1 << 30), + ("MIB", 1 << 20), ("MB", 1 << 20), ("M", 1 << 20), + ("KIB", 1 << 10), ("KB", 1 << 10), ("K", 1 << 10), + ]: + if s.endswith(suffix): + try: + return int(float(s[:-len(suffix)]) * mult) + except ValueError: + pass + return int(s) # plain bytes + + def _find_debug_archives(directory: str = ".") -> list[Path]: """Return sorted list of TrueNAS debug archives found in *directory*.""" patterns = ("*.tgz", "*.tar.gz", "*.tar", "*.txz", "*.tar.xz") @@ -405,6 +428,29 @@ def interactive_mode() -> None: )) print() + if dry_summary.missing_zvols: + print(f"\n {len(dry_summary.missing_zvols)} zvol(s) need to be created for iSCSI extents:") + for z in dry_summary.missing_zvols: + print(f" • {z}") + print() + if _confirm(f"Create these {len(dry_summary.missing_zvols)} zvol(s) on {host} now?"): + zvol_sizes: dict[str, int] = {} + for zvol in dry_summary.missing_zvols: + while True: + raw = _prompt(f" Size for {zvol} (e.g. 100G, 500GiB, 1T)").strip() + if not raw: + print(" Size is required.") + continue + try: + zvol_sizes[zvol] = _parse_size(raw) + break + except ValueError: + print(f" Cannot parse {raw!r} — try a format like 100G or 500GiB.") + asyncio.run(create_missing_zvols( + host=host, port=port, api_key=api_key, zvols=zvol_sizes, + )) + print() + if not _confirm(f"Apply these changes to {host}?"): print("Aborted – no changes made.") sys.exit(0) diff --git a/truenas_migrate/client.py b/truenas_migrate/client.py index 6992321..a6a27ff 100644 --- a/truenas_migrate/client.py +++ b/truenas_migrate/client.py @@ -306,3 +306,79 @@ async def create_missing_datasets( ) as client: for path in paths: await create_dataset(client, path) + + +# ───────────────────────────────────────────────────────────────────────────── +# iSCSI zvol utilities +# ───────────────────────────────────────────────────────────────────────────── + +async def check_iscsi_zvols( + client: TrueNASClient, + zvol_names: list[str], +) -> list[str]: + """ + Return the subset of *zvol_names* that do not exist on the destination. + Names are the dataset path without the leading 'zvol/' prefix + (e.g. 'tank/VMWARE001'). Returns [] when the query itself fails. + """ + if not zvol_names: + return [] + + unique = sorted(set(zvol_names)) + log.info("Checking %d zvol(s) against destination datasets …", len(unique)) + try: + datasets = await client.call( + "pool.dataset.query", [[["type", "=", "VOLUME"]]] + ) or [] + except RuntimeError as exc: + log.warning("Could not query zvols (skipping check): %s", exc) + return [] + + existing = {d["name"] for d in datasets} + missing = [n for n in unique if n not in existing] + if missing: + for n in missing: + log.warning(" MISSING zvol: %s", n) + else: + log.info(" All iSCSI zvols exist on destination.") + return missing + + +async def create_zvol( + client: TrueNASClient, + name: str, + volsize: int, +) -> bool: + """ + Create a ZFS volume (zvol) on the destination. + *name* is the dataset path (e.g. 'tank/VMWARE001'). + *volsize* is the size in bytes. + Returns True on success, False on failure. + """ + log.info("Creating zvol %r (%d bytes) …", name, volsize) + try: + await client.call("pool.dataset.create", [{ + "name": name, + "type": "VOLUME", + "volsize": volsize, + }]) + log.info(" Created: %s", name) + return True + except RuntimeError as exc: + log.error(" Failed to create zvol %r: %s", name, exc) + return False + + +async def create_missing_zvols( + host: str, + port: int, + api_key: str, + zvols: dict[str, int], + verify_ssl: bool = False, +) -> None: + """Open a fresh connection and create zvols from {name: volsize_bytes}.""" + async with TrueNASClient( + host=host, port=port, api_key=api_key, verify_ssl=verify_ssl, + ) as client: + for name, volsize in zvols.items(): + await create_zvol(client, name, volsize) diff --git a/truenas_migrate/migrate.py b/truenas_migrate/migrate.py index c1c8a52..3e9a260 100644 --- a/truenas_migrate/migrate.py +++ b/truenas_migrate/migrate.py @@ -236,6 +236,8 @@ async def _migrate_iscsi_extents( _cyan("[DRY RUN]"), _bold_cyan(repr(name)), ext.get("disk") or ext.get("path")) summary.iscsi_extents_created += 1 + if ext.get("type") == "DISK" and ext.get("disk"): + summary.zvols_to_check.append(ext["disk"].removeprefix("zvol/")) continue try: diff --git a/truenas_migrate/summary.py b/truenas_migrate/summary.py index efd7f3f..68a3dfc 100644 --- a/truenas_migrate/summary.py +++ b/truenas_migrate/summary.py @@ -52,6 +52,10 @@ class Summary: paths_to_create: list[str] = field(default_factory=list) missing_datasets: list[str] = field(default_factory=list) + # Populated during iSCSI dry-run zvol safety checks + zvols_to_check: list[str] = field(default_factory=list) + missing_zvols: list[str] = field(default_factory=list) + @property def _has_iscsi(self) -> bool: return (self.iscsi_extents_found + self.iscsi_initiators_found + @@ -147,5 +151,16 @@ class Summary: " These paths must exist before shares can be created.\n" " Use interactive mode or answer 'y' at the dataset prompt to create them." ) + if self.missing_zvols: + lines.append( + f"\n {_bold_yellow('WARNING:')} " + f"{len(self.missing_zvols)} zvol(s) do not exist on the destination:" + ) + for z in self.missing_zvols: + lines.append(f" {_yellow('•')} {z}") + lines.append( + " These zvols must exist before iSCSI extents can be created.\n" + " Use interactive mode to be prompted for size and auto-create them." + ) lines.append("") return "\n".join(lines)