diff --git a/truenas_migrate/archive.py b/truenas_migrate/archive.py index 03b6430..8031e8b 100644 --- a/truenas_migrate/archive.py +++ b/truenas_migrate/archive.py @@ -42,18 +42,24 @@ _CANDIDATES: dict[str, list[str]] = { "ixdiagnose/plugins/Sharing/sharing.nfs.query.json", "ixdiagnose/NFS/sharing.nfs.query.json", ], + "iscsi": [ + "ixdiagnose/plugins/iscsi/iscsi_config.json", + "ixdiagnose/plugins/ISCSI/iscsi_config.json", + ], } # When a candidate file bundles multiple datasets, pull out the right sub-key. _KEY_WITHIN_FILE: dict[str, str] = { "smb_shares": "sharing_smb_query", "nfs_shares": "sharing_nfs_query", + # "iscsi" intentionally omitted — iscsi_config.json is used as-is } # Keyword fragments for heuristic fallback scan (SCALE archives only) _KEYWORDS: dict[str, list[str]] = { "smb_shares": ["sharing.smb", "smb_share", "sharing/smb", "smb_info"], "nfs_shares": ["sharing.nfs", "nfs_share", "sharing/nfs", "nfs_config"], + "iscsi": ["iscsi_config", "iscsi/iscsi"], } # Presence of this path prefix identifies a TrueNAS CORE archive. @@ -251,13 +257,14 @@ def _open_source_tar(tar_path: str): def parse_archive(tar_path: str) -> dict[str, Any]: """ - Extract SMB shares and NFS shares from the debug archive. - Returns: {"smb_shares": list, "nfs_shares": list} + Extract SMB shares, NFS shares, and iSCSI configuration from the debug archive. + Returns: {"smb_shares": list, "nfs_shares": list, "iscsi": dict} """ log.info("Opening archive: %s", tar_path) result: dict[str, Any] = { "smb_shares": [], "nfs_shares": [], + "iscsi": {}, } try: @@ -288,14 +295,33 @@ def parse_archive(tar_path: str) -> dict[str, Any]: result[key] = v break + # iSCSI — combined dict file, not a bare list + iscsi_raw = _find_data(tf, members, "iscsi") + if iscsi_raw and isinstance(iscsi_raw, dict): + result["iscsi"] = { + "global_config": iscsi_raw.get("global_config", {}), + "portals": iscsi_raw.get("portals", []), + "initiators": iscsi_raw.get("initiators", []), + "targets": iscsi_raw.get("targets", []), + "extents": iscsi_raw.get("extents", []), + "targetextents": iscsi_raw.get("targetextents", []), + } + elif iscsi_raw is not None: + log.warning(" iscsi → unexpected format (expected dict)") + except (tarfile.TarError, OSError) as exc: log.error("Failed to open archive: %s", exc) sys.exit(1) + iscsi = result["iscsi"] log.info( - "Parsed: %d SMB share(s), %d NFS share(s)", + "Parsed: %d SMB share(s), %d NFS share(s), " + "iSCSI: %d target(s) / %d extent(s) / %d portal(s)", len(result["smb_shares"]), len(result["nfs_shares"]), + len(iscsi.get("targets", [])), + len(iscsi.get("extents", [])), + len(iscsi.get("portals", [])), ) return result diff --git a/truenas_migrate/cli.py b/truenas_migrate/cli.py index 61949ee..7278b32 100644 --- a/truenas_migrate/cli.py +++ b/truenas_migrate/cli.py @@ -57,7 +57,7 @@ from .archive import parse_archive, list_archive_and_exit from .client import TrueNASClient, check_dataset_paths, create_missing_datasets 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 +from .migrate import migrate_smb_shares, migrate_nfs_shares, migrate_iscsi from .summary import Summary @@ -103,6 +103,10 @@ async def run( await migrate_nfs_shares( client, archive["nfs_shares"], args.dry_run, summary) + if "iscsi" in migrate_set: + await migrate_iscsi( + client, archive.get("iscsi", {}), args.dry_run, summary) + if args.dry_run and summary.paths_to_create: summary.missing_datasets = await check_dataset_paths( client, summary.paths_to_create, @@ -156,6 +160,40 @@ def _prompt_csv_path(share_type: str) -> Optional[str]: print(f" {_bold_red('File not found:')} {raw}") + +def _prompt_iscsi_portals(iscsi: dict) -> None: + """Walk each portal and prompt for destination IPs in-place.""" + portals = iscsi.get("portals", []) + if not portals: + return + + print(f"\n {_bold('iSCSI Portal Configuration')}") + print(f" {_dim('Portal IP addresses are unique per system and must be updated.')}") + print(f" {_dim('For MPIO, enter multiple IPs separated by spaces.')}") + + for portal in portals: + comment = portal.get("comment", "") + listen = portal.get("listen", []) + src_ips = " ".join(f"{l['ip']}:{l['port']}" for l in listen) + default_port = listen[0]["port"] if listen else 3260 + + label = f"Portal {portal['id']}" + (f" ({comment!r})" if comment else "") + print(f"\n {_bold(label)}") + print(f" {_dim('Source IP(s):')} {src_ips}") + + raw = _prompt(" Destination IP(s)").strip() + if not raw: + print(f" {_yellow('⚠')} No IPs entered — keeping source IPs.") + continue + + port_raw = _prompt(" Port", default=str(default_port)) + port = int(port_raw) if port_raw.isdigit() else default_port + dest_ips = raw.split() + portal["listen"] = [{"ip": ip, "port": port} for ip in dest_ips] + print(f" {_green('✓')} Portal: {', '.join(f'{ip}:{port}' for ip in dest_ips)}") + print() + + def _select_shares(shares: list[dict], share_type: str) -> list[dict]: """ Display a numbered list of *shares* and return only those the user selects. @@ -298,22 +336,27 @@ def interactive_mode() -> None: print(f"\n {_bold('What to migrate?')}") print(f" {_cyan('1.')} SMB shares") print(f" {_cyan('2.')} NFS shares") + print(f" {_cyan('3.')} iSCSI (targets, extents, portals, initiator groups)") sel_raw = _prompt( - "Selection (space-separated numbers, Enter for all)", default="1 2" + "Selection (space-separated numbers, Enter for all)", default="1 2 3" ) - _sel_map = {"1": "smb", "2": "nfs"} + _sel_map = {"1": "smb", "2": "nfs", "3": "iscsi"} migrate = [] for tok in sel_raw.split(): if tok in _sel_map and _sel_map[tok] not in migrate: migrate.append(_sel_map[tok]) if not migrate: - migrate = ["smb", "nfs"] + migrate = ["smb", "nfs", "iscsi"] # ── Parse archive ─────────────────────────────────────────────────────── print() archive_data = parse_archive(str(chosen)) extra_ns = {"debug_tar": str(chosen)} + # ── iSCSI portal IP remapping ──────────────────────────────────────── + if "iscsi" in migrate and archive_data.get("iscsi", {}).get("portals"): + _prompt_iscsi_portals(archive_data["iscsi"]) + # ── Select individual shares (common) ────────────────────────────────────── if "smb" in migrate and archive_data["smb_shares"]: archive_data["smb_shares"] = _select_shares(archive_data["smb_shares"], "SMB") @@ -447,11 +490,11 @@ def main() -> None: p.add_argument( "--migrate", nargs="+", - choices=["smb", "nfs"], - default=["smb", "nfs"], + choices=["smb", "nfs", "iscsi"], + default=["smb", "nfs", "iscsi"], metavar="TYPE", help=( - "What to migrate. Choices: smb nfs " + "What to migrate. Choices: smb nfs iscsi " "(default: both). Example: --migrate smb" ), ) diff --git a/truenas_migrate/migrate.py b/truenas_migrate/migrate.py index 9a7fbcc..c1c8a52 100644 --- a/truenas_migrate/migrate.py +++ b/truenas_migrate/migrate.py @@ -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", "") + 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", "") + 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) diff --git a/truenas_migrate/summary.py b/truenas_migrate/summary.py index 5a30185..efd7f3f 100644 --- a/truenas_migrate/summary.py +++ b/truenas_migrate/summary.py @@ -21,12 +21,43 @@ class Summary: nfs_skipped: int = 0 nfs_failed: int = 0 + iscsi_extents_found: int = 0 + iscsi_extents_created: int = 0 + iscsi_extents_skipped: int = 0 + iscsi_extents_failed: int = 0 + + iscsi_initiators_found: int = 0 + iscsi_initiators_created: int = 0 + iscsi_initiators_skipped: int = 0 + iscsi_initiators_failed: int = 0 + + iscsi_portals_found: int = 0 + iscsi_portals_created: int = 0 + iscsi_portals_skipped: int = 0 + iscsi_portals_failed: int = 0 + + iscsi_targets_found: int = 0 + iscsi_targets_created: int = 0 + iscsi_targets_skipped: int = 0 + iscsi_targets_failed: int = 0 + + iscsi_targetextents_found: int = 0 + iscsi_targetextents_created: int = 0 + iscsi_targetextents_skipped: int = 0 + iscsi_targetextents_failed: int = 0 + errors: list[str] = field(default_factory=list) # Populated during dry-run dataset safety checks paths_to_create: list[str] = field(default_factory=list) missing_datasets: list[str] = field(default_factory=list) + @property + def _has_iscsi(self) -> bool: + return (self.iscsi_extents_found + self.iscsi_initiators_found + + self.iscsi_portals_found + self.iscsi_targets_found + + self.iscsi_targetextents_found) > 0 + def report(self) -> str: w = 60 @@ -34,6 +65,14 @@ class Summary: s = f"{label}={n}" return color_fn(s) if n > 0 else _dim(s) + def _iscsi_val(found, created, skipped, failed) -> str: + return ( + f"{_dim('found=' + str(found))} " + f"{_stat('created', created, _bold_green)} " + f"{_stat('skipped', skipped, _yellow)} " + f"{_stat('failed', failed, _bold_red)}" + ) + smb_val = ( f"{_dim('found=' + str(self.smb_found))} " f"{_stat('created', self.smb_created, _bold_green)} " @@ -67,11 +106,30 @@ class Summary: f"{tl}{hr}{tr}", title_row, f"{ml}{hr}{mr}", - row("SMB shares : ", smb_val), - row("NFS shares : ", nfs_val), - f"{bl}{hr}{br}", + row("SMB shares : ", smb_val), + row("NFS shares : ", nfs_val), ] + if self._has_iscsi: + lines.append(f"{ml}{hr}{mr}") + lines.append(row("iSCSI extents : ", _iscsi_val( + self.iscsi_extents_found, self.iscsi_extents_created, + self.iscsi_extents_skipped, self.iscsi_extents_failed))) + lines.append(row("iSCSI initiators: ", _iscsi_val( + self.iscsi_initiators_found, self.iscsi_initiators_created, + self.iscsi_initiators_skipped, self.iscsi_initiators_failed))) + lines.append(row("iSCSI portals : ", _iscsi_val( + self.iscsi_portals_found, self.iscsi_portals_created, + self.iscsi_portals_skipped, self.iscsi_portals_failed))) + lines.append(row("iSCSI targets : ", _iscsi_val( + self.iscsi_targets_found, self.iscsi_targets_created, + self.iscsi_targets_skipped, self.iscsi_targets_failed))) + lines.append(row("iSCSI tgt↔ext : ", _iscsi_val( + self.iscsi_targetextents_found, self.iscsi_targetextents_created, + self.iscsi_targetextents_skipped, self.iscsi_targetextents_failed))) + + lines.append(f"{bl}{hr}{br}") + if self.errors: lines.append(f"\n {_bold_red(str(len(self.errors)) + ' error(s):')} ") for e in self.errors: