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 .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 .colors import log, _bold, _bold_cyan, _bold_red, _bold_yellow, _cyan, _dim, _green, _yellow
|
||||||
from .csv_source import parse_csv_sources
|
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
|
from .summary import Summary
|
||||||
|
|
||||||
|
|
||||||
@@ -214,6 +214,53 @@ def _prompt_iscsi_portals(iscsi: dict) -> None:
|
|||||||
print()
|
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]:
|
def _select_shares(shares: list[dict], share_type: str) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Display a numbered list of *shares* and return only those the user selects.
|
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"):
|
if "iscsi" in migrate and archive_data.get("iscsi", {}).get("portals"):
|
||||||
_prompt_iscsi_portals(archive_data["iscsi"])
|
_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) ──────────────────────────────────────
|
# ── Select individual shares (common) ──────────────────────────────────────
|
||||||
if "smb" in migrate and archive_data["smb_shares"]:
|
if "smb" in migrate and archive_data["smb_shares"]:
|
||||||
archive_data["smb_shares"] = _select_shares(archive_data["smb_shares"], "SMB")
|
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)),
|
_cyan("[DRY RUN]"), _bold_cyan(repr(name)),
|
||||||
ext.get("disk") or ext.get("path"))
|
ext.get("disk") or ext.get("path"))
|
||||||
summary.iscsi_extents_created += 1
|
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"):
|
if ext.get("type") == "DISK" and ext.get("disk"):
|
||||||
summary.zvols_to_check.append(ext["disk"].removeprefix("zvol/"))
|
summary.zvols_to_check.append(ext["disk"].removeprefix("zvol/"))
|
||||||
continue
|
continue
|
||||||
@@ -288,6 +289,7 @@ async def _migrate_iscsi_initiators(
|
|||||||
log.info(" %s would create initiator group %s",
|
log.info(" %s would create initiator group %s",
|
||||||
_cyan("[DRY RUN]"), _bold_cyan(repr(comment)))
|
_cyan("[DRY RUN]"), _bold_cyan(repr(comment)))
|
||||||
summary.iscsi_initiators_created += 1
|
summary.iscsi_initiators_created += 1
|
||||||
|
id_map[init["id"]] = init["id"] # placeholder — enables downstream dry-run remapping
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -341,6 +343,7 @@ async def _migrate_iscsi_portals(
|
|||||||
log.info(" %s would create portal %s → %s",
|
log.info(" %s would create portal %s → %s",
|
||||||
_cyan("[DRY RUN]"), _bold_cyan(repr(comment)), ips)
|
_cyan("[DRY RUN]"), _bold_cyan(repr(comment)), ips)
|
||||||
summary.iscsi_portals_created += 1
|
summary.iscsi_portals_created += 1
|
||||||
|
id_map[portal["id"]] = portal["id"] # placeholder — enables downstream dry-run remapping
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -404,6 +407,7 @@ async def _migrate_iscsi_targets(
|
|||||||
log.info(" %s would create target %s",
|
log.info(" %s would create target %s",
|
||||||
_cyan("[DRY RUN]"), _bold_cyan(repr(name)))
|
_cyan("[DRY RUN]"), _bold_cyan(repr(name)))
|
||||||
summary.iscsi_targets_created += 1
|
summary.iscsi_targets_created += 1
|
||||||
|
id_map[target["id"]] = target["id"] # placeholder — enables downstream dry-run remapping
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -480,6 +484,56 @@ async def _migrate_iscsi_targetextents(
|
|||||||
f"iSCSI target-extent (target={dest_tid}, lun={lunid}): {exc}")
|
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
|
# Public iSCSI entry point
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user