diff --git a/truenas_migrate/cli.py b/truenas_migrate/cli.py index fa4e9d9..68fa01e 100644 --- a/truenas_migrate/cli.py +++ b/truenas_migrate/cli.py @@ -57,7 +57,7 @@ from .archive import parse_archive, list_archive_and_exit 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 +from .migrate import migrate_smb_shares, migrate_nfs_shares, migrate_iscsi, query_existing_iscsi, clear_iscsi_config from .summary import Summary @@ -214,6 +214,53 @@ def _prompt_iscsi_portals(iscsi: dict) -> None: print() +def _prompt_clear_existing_iscsi(host: str, port: int, api_key: str) -> None: + """ + Check whether the destination already has iSCSI configuration. + If so, summarise what exists and offer to remove it before migration. + """ + async def _check(): + async with TrueNASClient(host=host, port=port, api_key=api_key, verify_ssl=False) as client: + return await query_existing_iscsi(client) + + existing = asyncio.run(_check()) + counts = {k: len(v) for k, v in existing.items()} + total = sum(counts.values()) + if total == 0: + return + + print(f"\n {_bold_yellow('WARNING:')} Destination already has iSCSI configuration:") + labels = [ + ("extents", "extent(s)"), + ("initiators", "initiator group(s)"), + ("portals", "portal(s)"), + ("targets", "target(s)"), + ("targetextents", "target-extent association(s)"), + ] + for key, label in labels: + n = counts[key] + if n: + print(f" • {n} {label}") + print() + print(f" {_dim('Keep existing: new objects will be skipped if conflicts are detected.')}") + print(f" {_dim('Remove existing: ALL iSCSI config will be deleted before migration.')}") + print() + + raw = _prompt(" [K]eep existing / [R]emove all existing iSCSI config", default="K") + if raw.strip().lower().startswith("r"): + if _confirm(f" Remove ALL {total} iSCSI object(s) from {host}?"): + async def _clear(): + async with TrueNASClient(host=host, port=port, api_key=api_key, verify_ssl=False) as client: + await clear_iscsi_config(client) + print() + asyncio.run(_clear()) + print(f" {_bold_cyan('✓')} iSCSI configuration cleared.\n") + else: + print(f" {_yellow('–')} Removal cancelled — keeping existing config.\n") + else: + print(f" {_dim('Keeping existing iSCSI configuration.')}\n") + + def _select_shares(shares: list[dict], share_type: str) -> list[dict]: """ Display a numbered list of *shares* and return only those the user selects. @@ -377,6 +424,10 @@ def interactive_mode() -> None: if "iscsi" in migrate and archive_data.get("iscsi", {}).get("portals"): _prompt_iscsi_portals(archive_data["iscsi"]) + # ── iSCSI pre-migration check ──────────────────────────────────────── + if "iscsi" in migrate: + _prompt_clear_existing_iscsi(host, port, api_key) + # ── Select individual shares (common) ────────────────────────────────────── if "smb" in migrate and archive_data["smb_shares"]: archive_data["smb_shares"] = _select_shares(archive_data["smb_shares"], "SMB") diff --git a/truenas_migrate/migrate.py b/truenas_migrate/migrate.py index 2c774e9..69674ea 100644 --- a/truenas_migrate/migrate.py +++ b/truenas_migrate/migrate.py @@ -239,6 +239,7 @@ async def _migrate_iscsi_extents( _cyan("[DRY RUN]"), _bold_cyan(repr(name)), ext.get("disk") or ext.get("path")) summary.iscsi_extents_created += 1 + id_map[ext["id"]] = ext["id"] # placeholder — enables downstream dry-run remapping if ext.get("type") == "DISK" and ext.get("disk"): summary.zvols_to_check.append(ext["disk"].removeprefix("zvol/")) continue @@ -288,6 +289,7 @@ async def _migrate_iscsi_initiators( log.info(" %s would create initiator group %s", _cyan("[DRY RUN]"), _bold_cyan(repr(comment))) summary.iscsi_initiators_created += 1 + id_map[init["id"]] = init["id"] # placeholder — enables downstream dry-run remapping continue try: @@ -341,6 +343,7 @@ async def _migrate_iscsi_portals( log.info(" %s would create portal %s → %s", _cyan("[DRY RUN]"), _bold_cyan(repr(comment)), ips) summary.iscsi_portals_created += 1 + id_map[portal["id"]] = portal["id"] # placeholder — enables downstream dry-run remapping continue try: @@ -404,6 +407,7 @@ async def _migrate_iscsi_targets( log.info(" %s would create target %s", _cyan("[DRY RUN]"), _bold_cyan(repr(name))) summary.iscsi_targets_created += 1 + id_map[target["id"]] = target["id"] # placeholder — enables downstream dry-run remapping continue try: @@ -480,6 +484,56 @@ async def _migrate_iscsi_targetextents( f"iSCSI target-extent (target={dest_tid}, lun={lunid}): {exc}") +# ───────────────────────────────────────────────────────────────────────────── +# iSCSI pre-migration utilities +# ───────────────────────────────────────────────────────────────────────────── + +async def query_existing_iscsi(client: TrueNASClient) -> dict: + """ + Query all iSCSI object counts from the destination. + Returns a dict with keys: extents, initiators, portals, targets, targetextents + Each value is a list of objects (may be empty). + """ + result = {} + for key, method in [ + ("extents", "iscsi.extent.query"), + ("initiators", "iscsi.initiator.query"), + ("portals", "iscsi.portal.query"), + ("targets", "iscsi.target.query"), + ("targetextents", "iscsi.targetextent.query"), + ]: + try: + result[key] = await client.call(method) or [] + except RuntimeError: + result[key] = [] + return result + + +async def clear_iscsi_config(client: TrueNASClient) -> None: + """ + Delete all iSCSI configuration from the destination in safe dependency order: + target-extents → targets → portals → initiators → extents. + """ + for method_query, method_delete, label in [ + ("iscsi.targetextent.query", "iscsi.targetextent.delete", "target-extent"), + ("iscsi.target.query", "iscsi.target.delete", "target"), + ("iscsi.portal.query", "iscsi.portal.delete", "portal"), + ("iscsi.initiator.query", "iscsi.initiator.delete", "initiator"), + ("iscsi.extent.query", "iscsi.extent.delete", "extent"), + ]: + try: + objects = await client.call(method_query) or [] + except RuntimeError as exc: + log.warning(" Could not query iSCSI %ss: %s", label, exc) + continue + for obj in objects: + try: + await client.call(method_delete, [obj["id"]]) + log.info(" Deleted iSCSI %s id=%s", label, obj["id"]) + except RuntimeError as exc: + log.warning(" Failed to delete iSCSI %s id=%s: %s", label, obj["id"], exc) + + # ───────────────────────────────────────────────────────────────────────────── # Public iSCSI entry point # ─────────────────────────────────────────────────────────────────────────────