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