Fix dry-run iSCSI ID map cascades; add pre-migration existence check

During dry run, "would create" iSCSI objects now populate id_map with
a source-ID placeholder so downstream objects (targets, target-extents)
can remap references without cascading failures.

Adds query_existing_iscsi() and clear_iscsi_config() to migrate.py, and
_prompt_clear_existing_iscsi() to the wizard: if the destination already
has iSCSI config, the user is shown a summary and offered Keep/Remove
before the dry run begins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 15:46:36 -05:00
parent 32433d6ac8
commit 3fd9e6b6a8
2 changed files with 106 additions and 1 deletions

View File

@@ -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")

View File

@@ -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
# ─────────────────────────────────────────────────────────────────────────────