diff --git a/truenas_migrate.py b/truenas_migrate.py index dd08ef3..b678f48 100644 --- a/truenas_migrate.py +++ b/truenas_migrate.py @@ -2,10 +2,9 @@ """ truenas_migrate.py – TrueNAS Share Migration Tool ===================================================== -Reads SMB shares, NFS shares, and SMB global config from a TrueNAS debug -archive (.tar / .tgz) produced by the built-in "Save Debug" feature, then -re-creates them on a destination TrueNAS system via the JSON-RPC 2.0 -WebSocket API (TrueNAS 25.04+). +Reads SMB shares and NFS shares from a TrueNAS debug archive (.tar / .tgz) +produced by the built-in "Save Debug" feature, then re-creates them on a +destination TrueNAS system via the JSON-RPC 2.0 WebSocket API (TrueNAS 25.04+). SAFE BY DEFAULT • Existing shares are never overwritten or deleted. @@ -25,13 +24,13 @@ QUICK START --api-key "1-xxxxxxxxxxxx" \\ --dry-run - # 3. Live migration of all three data types: + # 3. Live migration: python truenas_migrate.py \\ --debug-tar debug.tgz \\ --dest 192.168.1.50 \\ --api-key "1-xxxxxxxxxxxx" - # 4. Migrate only SMB shares (skip NFS and global config): + # 4. Migrate only SMB shares (skip NFS): python truenas_migrate.py \\ --debug-tar debug.tgz \\ --dest 192.168.1.50 \\ @@ -42,7 +41,6 @@ CONFLICT POLICY Shares that already exist on the destination are silently skipped: SMB – matched by share name (case-insensitive) NFS – matched by export path (exact match) - SMB global config is always applied unless --migrate excludes "smb-config". """ from __future__ import annotations @@ -137,7 +135,6 @@ class Summary: nfs_skipped: int = 0 nfs_failed: int = 0 - cfg_applied: bool = False errors: list[str] = field(default_factory=list) # Populated during dry-run dataset safety checks @@ -163,7 +160,6 @@ class Summary: f"{_stat('skipped', self.nfs_skipped, _yellow)} " f"{_stat('failed', self.nfs_failed, _bold_red)}" ) - cfg_val = _bold_green("applied") if self.cfg_applied else _dim("not applied") hr = _cyan("─" * w) tl = _cyan("┌"); tr = _cyan("┐") @@ -187,7 +183,6 @@ class Summary: f"{ml}{hr}{mr}", row("SMB shares : ", smb_val), row("NFS shares : ", nfs_val), - row("SMB config : ", cfg_val), f"{bl}{hr}{br}", ] @@ -252,27 +247,18 @@ _CANDIDATES: dict[str, list[str]] = { "ixdiagnose/plugins/Sharing/sharing.nfs.query.json", "ixdiagnose/NFS/sharing.nfs.query.json", ], - "smb_config": [ - # SCALE 24.04+ – combined plugin file; config is under "smb_config" - "ixdiagnose/plugins/smb/smb_info.json", - # Older SCALE – uppercase plugin dirs - "ixdiagnose/plugins/SMB/smb.config.json", - "ixdiagnose/SMB/smb.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", - "smb_config": "smb_config", } # 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"], - "smb_config": ["smb.config", "smb_config", "smb_info"], } # Presence of this path prefix identifies a TrueNAS CORE archive (fndebug / @@ -280,16 +266,6 @@ _KEYWORDS: dict[str, list[str]] = { # dump embeds JSON blocks that we can extract. _CORE_MARKER = "ixdiagnose/fndebug" -# CORE SMB config fields that do not exist in the SCALE API and must be -# stripped before calling smb.update on the destination. -_SMB_CONFIG_CORE_EXTRAS = frozenset({ - "cifs_SID", # renamed to server_sid in SCALE (already stripped) - "loglevel", # removed in SCALE - "netbiosname_b", # HA node-B hostname; not applicable in SCALE - "netbiosname_local",# HA active-node field; not applicable in SCALE - "next_rid", # internal RID counter; not settable via API -}) - def _members_map(tf: tarfile.TarFile) -> dict[str, tarfile.TarInfo]: """Return {normalised_path: TarInfo} for every member.""" @@ -433,13 +409,10 @@ def _parse_core_into( fh = tf.extractfile(members[smb_key]) dump = fh.read().decode("utf-8", errors="replace") # type: ignore[union-attr] vals = _extract_core_dump_json(dump, "Database Dump") - if vals and isinstance(vals[0], dict): - result["smb_config"] = vals[0] - log.info(" smb_config → %s (CORE)", smb_key) if len(vals) >= 2 and isinstance(vals[1], list): result["smb_shares"] = vals[1] log.info(" smb_shares → %s (CORE, %d share(s))", smb_key, len(vals[1])) - elif result["smb_config"] is not None: + elif vals: log.warning(" smb_shares → NOT FOUND in Database Dump") else: log.warning(" SMB dump not found: %s", smb_key) @@ -496,14 +469,13 @@ def _open_source_tar(tar_path: str): def parse_archive(tar_path: str) -> dict[str, Any]: """ - Extract SMB shares, NFS shares, and SMB config from the debug archive. - Returns: {"smb_shares": list, "nfs_shares": list, "smb_config": dict|None} + Extract SMB shares and NFS shares from the debug archive. + Returns: {"smb_shares": list, "nfs_shares": list} """ log.info("Opening archive: %s", tar_path) result: dict[str, Any] = { "smb_shares": [], "nfs_shares": [], - "smb_config": None, } try: @@ -519,33 +491,29 @@ def parse_archive(tar_path: str) -> dict[str, Any]: if is_core: _parse_core_into(tf, members, result) else: - for key in ("smb_shares", "nfs_shares", "smb_config"): + for key in ("smb_shares", "nfs_shares"): data = _find_data(tf, members, key) if data is None: log.warning(" %-12s → NOT FOUND", key) continue - if key in ("smb_shares", "nfs_shares"): - if isinstance(data, list): - result[key] = data - elif isinstance(data, dict): - # Some versions wrap the list: {"result": [...]} - for v in data.values(): - if isinstance(v, list): - result[key] = v - break - else: - result[key] = data if isinstance(data, dict) else None + if isinstance(data, list): + result[key] = data + elif isinstance(data, dict): + # Some versions wrap the list: {"result": [...]} + for v in data.values(): + if isinstance(v, list): + result[key] = v + break except (tarfile.TarError, OSError) as exc: log.error("Failed to open archive: %s", exc) sys.exit(1) log.info( - "Parsed: %d SMB share(s), %d NFS share(s), SMB config=%s", + "Parsed: %d SMB share(s), %d NFS share(s)", len(result["smb_shares"]), len(result["nfs_shares"]), - "found" if result["smb_config"] else "not found", ) return result @@ -610,7 +578,6 @@ def list_archive_and_exit(tar_path: str) -> None: # Read-only / server-generated fields that must NOT be sent on create/update _SMB_SHARE_READONLY = frozenset({"id", "locked"}) -_SMB_CONFIG_READONLY = frozenset({"id", "server_sid"}) # CORE SMB share fields that do not exist in the SCALE API _SMB_SHARE_CORE_EXTRAS = frozenset({ @@ -639,11 +606,6 @@ def _nfs_share_payload(share: dict) -> dict: return payload -def _smb_config_payload(config: dict) -> dict: - exclude = _SMB_CONFIG_READONLY | _SMB_CONFIG_CORE_EXTRAS - return {k: v for k, v in config.items() if k not in exclude} - - # ───────────────────────────────────────────────────────────────────────────── # Minimal WebSocket client (stdlib only, RFC 6455) # ───────────────────────────────────────────────────────────────────────────── @@ -1071,39 +1033,6 @@ async def migrate_nfs_shares( summary.errors.append(f"NFS share {path!r}: {exc}") -async def migrate_smb_config( - client: TrueNASClient, - config: Optional[dict], - dry_run: bool, - summary: Summary, -) -> None: - if not config: - log.info("No SMB global config found in archive – skipping.") - return - - payload = _smb_config_payload(config) - log.info("%s SMB global config", _bold("──")) - log.info( - " netbiosname=%-20s workgroup=%-15s encryption=%s", - repr(payload.get("netbiosname")), - repr(payload.get("workgroup")), - repr(payload.get("encryption")), - ) - - if dry_run: - log.info(" %s would call smb.update", _cyan("[DRY RUN]")) - summary.cfg_applied = True - return - - try: - await client.call("smb.update", [payload]) - log.info(" %s", _bold_green("APPLIED")) - summary.cfg_applied = True - except RuntimeError as exc: - log.error(" %s: %s", _bold_red("FAILED"), exc) - summary.errors.append(f"SMB config: {exc}") - - # ───────────────────────────────────────────────────────────────────────────── # CLI # ───────────────────────────────────────────────────────────────────────────── @@ -1140,10 +1069,6 @@ async def run( await migrate_nfs_shares( client, archive["nfs_shares"], args.dry_run, summary) - if "smb-config" in migrate_set: - await migrate_smb_config( - client, archive["smb_config"], args.dry_run, summary) - # During dry runs, verify that every path we would create a share for # actually exists as a ZFS dataset on the destination system. if args.dry_run and summary.paths_to_create: @@ -1293,17 +1218,16 @@ 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.')} SMB global config") sel_raw = _prompt( - "Selection (space-separated numbers, Enter for all)", default="1 2 3" + "Selection (space-separated numbers, Enter for all)", default="1 2" ) - _sel_map = {"1": "smb", "2": "nfs", "3": "smb-config"} + _sel_map = {"1": "smb", "2": "nfs"} migrate: list[str] = [] 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", "smb-config"] + migrate = ["smb", "nfs"] # 5 ── Parse archive once (reused for dry + live runs) ──────────────────── print() @@ -1429,12 +1353,12 @@ def main() -> None: p.add_argument( "--migrate", nargs="+", - choices=["smb", "nfs", "smb-config"], - default=["smb", "nfs", "smb-config"], + choices=["smb", "nfs"], + default=["smb", "nfs"], metavar="TYPE", help=( - "What to migrate. Choices: smb nfs smb-config " - "(default: all three). Example: --migrate smb nfs" + "What to migrate. Choices: smb nfs " + "(default: both). Example: --migrate smb" ), ) p.add_argument(