Add iSCSI migration support (SCALE archive source)

archive.py:
- Add iscsi_config.json to _CANDIDATES and _KEYWORDS
- parse_archive() now extracts portals, initiators, targets, extents,
  targetextents, and global_config into archive["iscsi"]

migrate.py:
- Add payload builders for all five iSCSI object types
  (extents, initiators, portals, targets, target-extents)
- Add migrate_iscsi() which creates objects in dependency order
  (extents+initiators first, then portals, then targets, then
  target-extent associations) and tracks old→new ID mappings at
  each step so downstream references are correctly remapped
- Conflict detection: extents/targets by name, portals by IP set,
  initiators by comment, target-extents by target+LUN combination
- Skipped objects still populate the ID map so dependent objects
  can remap their references correctly

summary.py:
- Add per-sub-type found/created/skipped/failed counters for iSCSI
- iSCSI rows appear in the report only when iSCSI data was processed

cli.py:
- Add _prompt_iscsi_portals() — shows source IPs per portal and
  prompts for destination IPs in-place; supports MPIO (space-separated)
- Wizard scope menu gains option 3 (iSCSI); portal prompt fires
  automatically after archive parse when iSCSI portals are present
- run() wires in migrate_iscsi()
- argparse --migrate now accepts "iscsi" as a valid choice

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 15:04:08 -05:00
parent 40daf20809
commit e81e3f7fbb
4 changed files with 511 additions and 13 deletions

View File

@@ -152,3 +152,374 @@ async def migrate_nfs_shares(
log.error(" %s: %s", _bold_red("FAILED"), exc)
summary.nfs_failed += 1
summary.errors.append(f"NFS share {path!r}: {exc}")
# ─────────────────────────────────────────────────────────────────────────────
# iSCSI payload builders
# ─────────────────────────────────────────────────────────────────────────────
_ISCSI_EXTENT_READONLY = frozenset({"id", "serial", "naa", "vendor", "locked"})
_ISCSI_INITIATOR_READONLY = frozenset({"id"})
_ISCSI_PORTAL_READONLY = frozenset({"id", "tag"})
_ISCSI_TARGET_READONLY = frozenset({"id", "rel_tgt_id", "iscsi_parameters"})
def _iscsi_extent_payload(extent: dict) -> dict:
payload = {k: v for k, v in extent.items() if k not in _ISCSI_EXTENT_READONLY}
if extent.get("type") == "DISK":
payload.pop("path", None) # derived from disk on DISK extents
payload.pop("filesize", None) # only meaningful for FILE extents
else:
payload.pop("disk", None)
return payload
def _iscsi_initiator_payload(initiator: dict) -> dict:
return {k: v for k, v in initiator.items() if k not in _ISCSI_INITIATOR_READONLY}
def _iscsi_portal_payload(portal: dict) -> dict:
return {k: v for k, v in portal.items() if k not in _ISCSI_PORTAL_READONLY}
def _iscsi_target_payload(
target: dict,
portal_id_map: dict[int, int],
initiator_id_map: dict[int, int],
) -> dict:
payload = {k: v for k, v in target.items() if k not in _ISCSI_TARGET_READONLY}
payload["groups"] = [
{**g,
"portal": portal_id_map.get(g["portal"], g["portal"]),
"initiator": initiator_id_map.get(g.get("initiator"), g.get("initiator"))}
for g in target.get("groups", [])
]
return payload
# ─────────────────────────────────────────────────────────────────────────────
# iSCSI migration sub-routines
# ─────────────────────────────────────────────────────────────────────────────
async def _migrate_iscsi_extents(
client: TrueNASClient,
extents: list[dict],
dry_run: bool,
summary: Summary,
id_map: dict[int, int],
) -> None:
log.info("Querying existing iSCSI extents on destination …")
try:
existing = await client.call("iscsi.extent.query") or []
except RuntimeError as exc:
msg = f"Could not query iSCSI extents: {exc}"
log.error(msg); summary.errors.append(msg); return
existing_by_name = {e["name"].lower(): e for e in existing}
log.info(" Destination has %d existing extent(s).", len(existing_by_name))
for ext in extents:
name = ext.get("name", "<unnamed>")
log.info("%s iSCSI extent %s", _bold("──"), _bold_cyan(repr(name)))
if name.lower() in existing_by_name:
log.info(" %s already exists on destination.", _yellow("SKIP"))
id_map[ext["id"]] = existing_by_name[name.lower()]["id"]
summary.iscsi_extents_skipped += 1
continue
payload = _iscsi_extent_payload(ext)
log.debug(" payload: %s", json.dumps(payload))
if dry_run:
log.info(" %s would create extent %s%s",
_cyan("[DRY RUN]"), _bold_cyan(repr(name)),
ext.get("disk") or ext.get("path"))
summary.iscsi_extents_created += 1
continue
try:
r = await client.call("iscsi.extent.create", [payload])
log.info(" %s id=%s", _bold_green("CREATED"), r.get("id"))
id_map[ext["id"]] = r["id"]
summary.iscsi_extents_created += 1
except RuntimeError as exc:
log.error(" %s: %s", _bold_red("FAILED"), exc)
summary.iscsi_extents_failed += 1
summary.errors.append(f"iSCSI extent {name!r}: {exc}")
async def _migrate_iscsi_initiators(
client: TrueNASClient,
initiators: list[dict],
dry_run: bool,
summary: Summary,
id_map: dict[int, int],
) -> None:
log.info("Querying existing iSCSI initiator groups on destination …")
try:
existing = await client.call("iscsi.initiator.query") or []
except RuntimeError as exc:
msg = f"Could not query iSCSI initiators: {exc}"
log.error(msg); summary.errors.append(msg); return
existing_by_comment = {e["comment"].lower(): e for e in existing if e.get("comment")}
log.info(" Destination has %d existing initiator group(s).", len(existing))
for init in initiators:
comment = init.get("comment", "")
log.info("%s iSCSI initiator group %s", _bold("──"), _bold_cyan(repr(comment)))
if comment and comment.lower() in existing_by_comment:
log.info(" %s comment already exists on destination.", _yellow("SKIP"))
id_map[init["id"]] = existing_by_comment[comment.lower()]["id"]
summary.iscsi_initiators_skipped += 1
continue
payload = _iscsi_initiator_payload(init)
log.debug(" payload: %s", json.dumps(payload))
if dry_run:
log.info(" %s would create initiator group %s",
_cyan("[DRY RUN]"), _bold_cyan(repr(comment)))
summary.iscsi_initiators_created += 1
continue
try:
r = await client.call("iscsi.initiator.create", [payload])
log.info(" %s id=%s", _bold_green("CREATED"), r.get("id"))
id_map[init["id"]] = r["id"]
summary.iscsi_initiators_created += 1
except RuntimeError as exc:
log.error(" %s: %s", _bold_red("FAILED"), exc)
summary.iscsi_initiators_failed += 1
summary.errors.append(f"iSCSI initiator {comment!r}: {exc}")
async def _migrate_iscsi_portals(
client: TrueNASClient,
portals: list[dict],
dry_run: bool,
summary: Summary,
id_map: dict[int, int],
) -> None:
log.info("Querying existing iSCSI portals on destination …")
try:
existing = await client.call("iscsi.portal.query") or []
except RuntimeError as exc:
msg = f"Could not query iSCSI portals: {exc}"
log.error(msg); summary.errors.append(msg); return
def _ip_set(p: dict) -> frozenset:
return frozenset((l["ip"], l["port"]) for l in p.get("listen", []))
existing_ip_sets = [(_ip_set(p), p["id"]) for p in existing]
log.info(" Destination has %d existing portal(s).", len(existing))
for portal in portals:
comment = portal.get("comment", "")
ips = ", ".join(f"{l['ip']}:{l['port']}" for l in portal.get("listen", []))
log.info("%s iSCSI portal %s [%s]", _bold("──"), _bold_cyan(repr(comment)), ips)
my_ips = _ip_set(portal)
match = next((eid for eips, eid in existing_ip_sets if eips == my_ips), None)
if match is not None:
log.info(" %s IP set already exists on destination.", _yellow("SKIP"))
id_map[portal["id"]] = match
summary.iscsi_portals_skipped += 1
continue
payload = _iscsi_portal_payload(portal)
log.debug(" payload: %s", json.dumps(payload))
if dry_run:
log.info(" %s would create portal %s%s",
_cyan("[DRY RUN]"), _bold_cyan(repr(comment)), ips)
summary.iscsi_portals_created += 1
continue
try:
r = await client.call("iscsi.portal.create", [payload])
log.info(" %s id=%s", _bold_green("CREATED"), r.get("id"))
id_map[portal["id"]] = r["id"]
summary.iscsi_portals_created += 1
except RuntimeError as exc:
log.error(" %s: %s", _bold_red("FAILED"), exc)
summary.iscsi_portals_failed += 1
summary.errors.append(f"iSCSI portal {comment!r}: {exc}")
async def _migrate_iscsi_targets(
client: TrueNASClient,
targets: list[dict],
dry_run: bool,
summary: Summary,
id_map: dict[int, int],
portal_id_map: dict[int, int],
initiator_id_map: dict[int, int],
) -> None:
log.info("Querying existing iSCSI targets on destination …")
try:
existing = await client.call("iscsi.target.query") or []
except RuntimeError as exc:
msg = f"Could not query iSCSI targets: {exc}"
log.error(msg); summary.errors.append(msg); return
existing_by_name = {t["name"].lower(): t for t in existing}
log.info(" Destination has %d existing target(s).", len(existing_by_name))
for target in targets:
name = target.get("name", "<unnamed>")
log.info("%s iSCSI target %s", _bold("──"), _bold_cyan(repr(name)))
if name.lower() in existing_by_name:
log.info(" %s already exists on destination.", _yellow("SKIP"))
id_map[target["id"]] = existing_by_name[name.lower()]["id"]
summary.iscsi_targets_skipped += 1
continue
# Verify all referenced portals and initiators were successfully mapped
missing = []
for g in target.get("groups", []):
if g.get("portal") not in portal_id_map:
missing.append(f"portal id={g['portal']}")
if g.get("initiator") not in initiator_id_map:
missing.append(f"initiator id={g['initiator']}")
if missing:
msg = f"iSCSI target {name!r}: cannot remap {', '.join(missing)}"
log.error(" %s: %s", _bold_red("SKIP"), msg)
summary.iscsi_targets_failed += 1
summary.errors.append(msg)
continue
payload = _iscsi_target_payload(target, portal_id_map, initiator_id_map)
log.debug(" payload: %s", json.dumps(payload))
if dry_run:
log.info(" %s would create target %s",
_cyan("[DRY RUN]"), _bold_cyan(repr(name)))
summary.iscsi_targets_created += 1
continue
try:
r = await client.call("iscsi.target.create", [payload])
log.info(" %s id=%s", _bold_green("CREATED"), r.get("id"))
id_map[target["id"]] = r["id"]
summary.iscsi_targets_created += 1
except RuntimeError as exc:
log.error(" %s: %s", _bold_red("FAILED"), exc)
summary.iscsi_targets_failed += 1
summary.errors.append(f"iSCSI target {name!r}: {exc}")
async def _migrate_iscsi_targetextents(
client: TrueNASClient,
targetextents: list[dict],
dry_run: bool,
summary: Summary,
target_id_map: dict[int, int],
extent_id_map: dict[int, int],
) -> None:
log.info("Querying existing iSCSI target-extent associations on destination …")
try:
existing = await client.call("iscsi.targetextent.query") or []
except RuntimeError as exc:
msg = f"Could not query iSCSI target-extents: {exc}"
log.error(msg); summary.errors.append(msg); return
existing_keys = {(te["target"], te["lunid"]) for te in existing}
log.info(" Destination has %d existing association(s).", len(existing))
for te in targetextents:
src_tid = te["target"]
src_eid = te["extent"]
lunid = te["lunid"]
dest_tid = target_id_map.get(src_tid)
dest_eid = extent_id_map.get(src_eid)
if dest_tid is None or dest_eid is None:
missing = []
if dest_tid is None: missing.append(f"target id={src_tid}")
if dest_eid is None: missing.append(f"extent id={src_eid}")
msg = f"iSCSI target-extent (lun {lunid}): cannot remap {', '.join(missing)}"
log.error(" %s", msg)
summary.iscsi_targetextents_failed += 1
summary.errors.append(msg)
continue
log.info("%s iSCSI target↔extent target=%s lun=%s extent=%s",
_bold("──"), dest_tid, lunid, dest_eid)
if (dest_tid, lunid) in existing_keys:
log.info(" %s target+LUN already assigned on destination.", _yellow("SKIP"))
summary.iscsi_targetextents_skipped += 1
continue
payload = {"target": dest_tid, "lunid": lunid, "extent": dest_eid}
log.debug(" payload: %s", json.dumps(payload))
if dry_run:
log.info(" %s would associate target=%s lun=%s extent=%s",
_cyan("[DRY RUN]"), dest_tid, lunid, dest_eid)
summary.iscsi_targetextents_created += 1
continue
try:
r = await client.call("iscsi.targetextent.create", [payload])
log.info(" %s id=%s", _bold_green("CREATED"), r.get("id"))
summary.iscsi_targetextents_created += 1
except RuntimeError as exc:
log.error(" %s: %s", _bold_red("FAILED"), exc)
summary.iscsi_targetextents_failed += 1
summary.errors.append(
f"iSCSI target-extent (target={dest_tid}, lun={lunid}): {exc}")
# ─────────────────────────────────────────────────────────────────────────────
# Public iSCSI entry point
# ─────────────────────────────────────────────────────────────────────────────
async def migrate_iscsi(
client: TrueNASClient,
iscsi: dict,
dry_run: bool,
summary: Summary,
) -> None:
if not iscsi:
log.info("No iSCSI configuration found in archive.")
return
portals = iscsi.get("portals", [])
initiators = iscsi.get("initiators", [])
targets = iscsi.get("targets", [])
extents = iscsi.get("extents", [])
targetextents = iscsi.get("targetextents", [])
summary.iscsi_extents_found = len(extents)
summary.iscsi_initiators_found = len(initiators)
summary.iscsi_portals_found = len(portals)
summary.iscsi_targets_found = len(targets)
summary.iscsi_targetextents_found = len(targetextents)
gc = iscsi.get("global_config", {})
if gc.get("basename"):
log.info(" Source iSCSI basename: %s (destination keeps its own)", gc["basename"])
if not any([portals, initiators, targets, extents, targetextents]):
log.info("iSCSI configuration is empty nothing to migrate.")
return
extent_id_map: dict[int, int] = {}
initiator_id_map: dict[int, int] = {}
portal_id_map: dict[int, int] = {}
target_id_map: dict[int, int] = {}
# Dependency order: extents and initiators first (no deps), then portals,
# then targets (need portal + initiator maps), then target-extent links.
await _migrate_iscsi_extents(client, extents, dry_run, summary, extent_id_map)
await _migrate_iscsi_initiators(client, initiators, dry_run, summary, initiator_id_map)
await _migrate_iscsi_portals(client, portals, dry_run, summary, portal_id_map)
await _migrate_iscsi_targets(
client, targets, dry_run, summary, target_id_map, portal_id_map, initiator_id_map)
await _migrate_iscsi_targetextents(
client, targetextents, dry_run, summary, target_id_map, extent_id_map)