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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user