Update for Core Debugs

This commit is contained in:
2026-03-03 14:54:03 -05:00
parent 0a42cb572f
commit 9c5612e85a

View File

@@ -136,57 +136,48 @@ class Summary:
# Debug archive parser # Debug archive parser
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# #
# TrueNAS generates debug archives with its built-in "ixdiagnose" tool (SCALE # TrueNAS SCALE generates debug archives with the "ixdiagnose" tool.
# 24.04+) or the older "freenas-debug" tool (CORE / earlier SCALE). # The internal layout has changed across versions:
# Neither has a fully stable internal layout across versions, so we try a
# ranked list of known paths and fall back to a keyword heuristic scan.
# #
# Known ixdiagnose (SCALE) layouts observed in the wild: # SCALE 24.04+ (plugins layout, lowercase dirs, combined JSON files)
# ixdiagnose/plugins/SMB/sharing.smb.query.json SMB shares # ixdiagnose/plugins/smb/smb_info.json SMB shares + config combined
# ixdiagnose/plugins/SMB/smb.config.json SMB global config # ixdiagnose/plugins/nfs/nfs_config.json NFS shares + config combined
# ixdiagnose/plugins/NFS/sharing.nfs.query.json NFS shares #
# Older SCALE (plugins layout, uppercase dirs, per-query JSON files)
# ixdiagnose/plugins/SMB/sharing.smb.query.json
# ixdiagnose/plugins/SMB/smb.config.json
# ixdiagnose/plugins/NFS/sharing.nfs.query.json
# ixdiagnose/plugins/Sharing/sharing.smb.query.json # ixdiagnose/plugins/Sharing/sharing.smb.query.json
# ixdiagnose/plugins/Sharing/sharing.nfs.query.json # ixdiagnose/plugins/Sharing/sharing.nfs.query.json
# #
# Known freenas-debug (CORE) layouts: # TrueNAS CORE uses the "freenas-debug" tool (stored as "fndebug" inside the
# freenas-debug/sharing/smb.json SMB shares # archive). It produces only plain-text dump files there is NO JSON share
# freenas-debug/sharing/nfs.json NFS shares # data in CORE debug archives. The script detects CORE archives early and
# (CORE SMB config is plain text; JSON form may not exist) # exits with a clear message rather than silently returning empty results.
_CANDIDATES: dict[str, list[str]] = { _CANDIDATES: dict[str, list[str]] = {
"smb_shares": [ "smb_shares": [
# SCALE 24.04+ combined plugin file; shares are under "sharing_smb_query" # SCALE 24.04+ combined plugin file; shares are under "sharing_smb_query"
"ixdiagnose/plugins/smb/smb_info.json", "ixdiagnose/plugins/smb/smb_info.json",
# Older SCALE layouts # Older SCALE uppercase plugin dirs, per-query files
"ixdiagnose/plugins/SMB/sharing.smb.query.json", "ixdiagnose/plugins/SMB/sharing.smb.query.json",
"ixdiagnose/plugins/Sharing/sharing.smb.query.json", "ixdiagnose/plugins/Sharing/sharing.smb.query.json",
"ixdiagnose/SMB/sharing.smb.query.json", "ixdiagnose/SMB/sharing.smb.query.json",
# CORE / freenas-debug
"freenas-debug/sharing/smb.json",
"sharing/smb.json",
"middleware/sharing.smb.query.json",
], ],
"nfs_shares": [ "nfs_shares": [
# SCALE 24.04+ combined plugin file; shares are under "sharing_nfs_query" # SCALE 24.04+ combined plugin file; shares are under "sharing_nfs_query"
"ixdiagnose/plugins/nfs/nfs_config.json", "ixdiagnose/plugins/nfs/nfs_config.json",
# Older SCALE layouts # Older SCALE uppercase plugin dirs, per-query files
"ixdiagnose/plugins/NFS/sharing.nfs.query.json", "ixdiagnose/plugins/NFS/sharing.nfs.query.json",
"ixdiagnose/plugins/Sharing/sharing.nfs.query.json", "ixdiagnose/plugins/Sharing/sharing.nfs.query.json",
"ixdiagnose/NFS/sharing.nfs.query.json", "ixdiagnose/NFS/sharing.nfs.query.json",
# CORE / freenas-debug
"freenas-debug/sharing/nfs.json",
"sharing/nfs.json",
"middleware/sharing.nfs.query.json",
], ],
"smb_config": [ "smb_config": [
# SCALE 24.04+ combined plugin file; config is under "smb_config" # SCALE 24.04+ combined plugin file; config is under "smb_config"
"ixdiagnose/plugins/smb/smb_info.json", "ixdiagnose/plugins/smb/smb_info.json",
# Older SCALE layouts # Older SCALE uppercase plugin dirs
"ixdiagnose/plugins/SMB/smb.config.json", "ixdiagnose/plugins/SMB/smb.config.json",
"ixdiagnose/SMB/smb.config.json", "ixdiagnose/SMB/smb.config.json",
# CORE / freenas-debug
"freenas-debug/SMB/smb_config.json",
"middleware/smb.config.json",
], ],
} }
@@ -197,13 +188,28 @@ _KEY_WITHIN_FILE: dict[str, str] = {
"smb_config": "smb_config", "smb_config": "smb_config",
} }
# Keyword fragments for heuristic fallback scan # Keyword fragments for heuristic fallback scan (SCALE archives only)
_KEYWORDS: dict[str, list[str]] = { _KEYWORDS: dict[str, list[str]] = {
"smb_shares": ["sharing.smb", "smb_share", "sharing/smb", "smb_info"], "smb_shares": ["sharing.smb", "smb_share", "sharing/smb", "smb_info"],
"nfs_shares": ["sharing.nfs", "nfs_share", "sharing/nfs", "nfs_config"], "nfs_shares": ["sharing.nfs", "nfs_share", "sharing/nfs", "nfs_config"],
"smb_config": ["smb.config", "smb_config", "smb_info"], "smb_config": ["smb.config", "smb_config", "smb_info"],
} }
# Presence of this path prefix identifies a TrueNAS CORE archive (fndebug /
# freenas-debug). CORE stores diagnostics as plain-text dump files, but each
# 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]: def _members_map(tf: tarfile.TarFile) -> dict[str, tarfile.TarInfo]:
"""Return {normalised_path: TarInfo} for every member.""" """Return {normalised_path: TarInfo} for every member."""
@@ -278,6 +284,106 @@ def _find_data(
return None return None
def _extract_core_dump_json(dump_text: str, title_fragment: str) -> list[Any]:
"""
Extract all top-level JSON values from a named section of a CORE dump.txt.
CORE dump sections look like:
+--------...--------+
+ SECTION TITLE + ← title line (contains the section name)
+--------...--------+
<content may include JSON object(s) and/or array(s)>
debug finished in N seconds for SECTION TITLE
Returns a list of parsed JSON values found in the content block, in order.
An empty list is returned when the section is not found or contains no JSON.
"""
import re as _re
# Split on the horizontal rule lines
parts = _re.split(r'\+[-]{20,}\+', dump_text)
for i, part in enumerate(parts):
if title_fragment.lower() in part.lower() and i + 1 < len(parts):
content = parts[i + 1]
# Trim the "debug finished …" trailer and surrounding whitespace
content = _re.sub(
r'debug finished.*', '', content,
flags=_re.IGNORECASE | _re.DOTALL,
).strip()
# Greedily parse consecutive JSON values from the content
results: list[Any] = []
decoder = json.JSONDecoder()
pos = 0
while pos < len(content):
remaining = content[pos:].lstrip()
if not remaining or remaining[0] not in "{[":
break
pos += len(content[pos:]) - len(remaining) # account for whitespace
try:
val, end = decoder.raw_decode(remaining)
results.append(val)
pos += end
except json.JSONDecodeError:
break
return results
return []
def _parse_core_into(
tf: tarfile.TarFile,
members: dict[str, tarfile.TarInfo],
result: dict[str, Any],
) -> None:
"""
Populate *result* from TrueNAS CORE fndebug dump files.
SMB dump (ixdiagnose/fndebug/SMB/dump.txt)
"Database Dump" section → JSON object (global config) + JSON array (shares)
NFS dump (ixdiagnose/fndebug/NFS/dump.txt)
"Configuration" section → JSON object (global config) + JSON array (shares)
"""
log.info("TrueNAS CORE archive detected; parsing fndebug dump files.")
smb_key = "ixdiagnose/fndebug/SMB/dump.txt"
if smb_key in members:
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:
log.warning(" smb_shares → NOT FOUND in Database Dump")
else:
log.warning(" SMB dump not found: %s", smb_key)
nfs_key = "ixdiagnose/fndebug/NFS/dump.txt"
if nfs_key in members:
fh = tf.extractfile(members[nfs_key])
dump = fh.read().decode("utf-8", errors="replace") # type: ignore[union-attr]
vals = _extract_core_dump_json(dump, "Configuration")
if len(vals) >= 2 and isinstance(vals[1], list):
result["nfs_shares"] = vals[1]
log.info(" nfs_shares → %s (CORE, %d share(s))", nfs_key, len(vals[1]))
else:
log.warning(" nfs_shares → NOT FOUND in Configuration")
else:
log.warning(" NFS dump not found: %s", nfs_key)
if not result["smb_shares"] and not result["nfs_shares"]:
log.warning(
"No share data found in CORE archive. "
"This is expected when SMB/NFS services were disabled on the source system."
)
@contextlib.contextmanager @contextlib.contextmanager
def _open_source_tar(tar_path: str): def _open_source_tar(tar_path: str):
""" """
@@ -325,6 +431,14 @@ def parse_archive(tar_path: str) -> dict[str, Any]:
members = _members_map(tf) members = _members_map(tf)
log.info(" Archive contains %d total entries.", len(members)) log.info(" Archive contains %d total entries.", len(members))
is_core = any(
p == _CORE_MARKER or p.startswith(_CORE_MARKER + "/")
for p in members
)
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", "smb_config"):
data = _find_data(tf, members, key) data = _find_data(tf, members, key)
if data is None: if data is None:
@@ -358,12 +472,37 @@ def parse_archive(tar_path: str) -> dict[str, Any]:
def list_archive_and_exit(tar_path: str) -> None: def list_archive_and_exit(tar_path: str) -> None:
""" """
Print a structured listing of all JSON files in the archive, then exit. Print a structured listing of the archive contents, then exit.
Helps confirm the archive actually contains the data we need. For SCALE archives: lists all .json plugin files.
For CORE archives: lists the fndebug dump files and the JSON sections
that contain share / config data.
""" """
print(f"\nJSON files in archive: {tar_path}\n")
try: try:
with _open_source_tar(tar_path) as tf: with _open_source_tar(tar_path) as tf:
members_map = _members_map(tf)
is_core = any(
p == _CORE_MARKER or p.startswith(_CORE_MARKER + "/")
for p in members_map
)
if is_core:
print(f"\nTrueNAS CORE archive: {tar_path}\n")
print(" fndebug plain-text dump files (JSON is embedded inside):\n")
dump_files = sorted(
p for p in members_map
if p.startswith(_CORE_MARKER + "/") and p.endswith(".txt")
)
for p in dump_files:
size = members_map[p].size / 1024
print(f" {p} ({size:.1f} KB)")
print()
print(" Data this tool will extract:")
print(" SMB config + shares → fndebug/SMB/dump.txt "
"(\"Database Dump\" section)")
print(" NFS shares → fndebug/NFS/dump.txt "
"(\"Configuration\" section)")
else:
print(f"\nJSON plugin files in archive: {tar_path}\n")
json_members = sorted( json_members = sorted(
(m for m in tf.getmembers() if m.name.endswith(".json")), (m for m in tf.getmembers() if m.name.endswith(".json")),
key=lambda m: m.name, key=lambda m: m.name,
@@ -371,7 +510,6 @@ def list_archive_and_exit(tar_path: str) -> None:
if not json_members: if not json_members:
print(" (no .json files found)") print(" (no .json files found)")
else: else:
# Group by top-level directory for readability
current_dir = "" current_dir = ""
for m in json_members: for m in json_members:
parts = m.name.lstrip("./").split("/") parts = m.name.lstrip("./").split("/")
@@ -405,7 +543,8 @@ def _nfs_share_payload(share: dict) -> dict:
def _smb_config_payload(config: dict) -> dict: def _smb_config_payload(config: dict) -> dict:
return {k: v for k, v in config.items() if k not in _SMB_CONFIG_READONLY} exclude = _SMB_CONFIG_READONLY | _SMB_CONFIG_CORE_EXTRAS
return {k: v for k, v in config.items() if k not in exclude}
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────