Source code for nexusLIMS.cli.support_bundle

"""Create local NexusLIMS diagnostic support bundles."""

from __future__ import annotations

import csv
import html
import json
import locale
import platform
import re
import shutil
import sqlite3
import sys
import time
import zipfile
from dataclasses import asdict, dataclass, is_dataclass
from datetime import UTC, datetime
from importlib import metadata
from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
from typing import Any

import click

from nexusLIMS import __version__
from nexusLIMS.builder.preflight import run_preflight_checks
from nexusLIMS.cli import _format_version
from nexusLIMS.cli.config import _build_config_dict, _sanitize_config
from nexusLIMS.config import settings

SCHEMA_VERSION = 1
REDACTED = "***"
SUPPORT_EMAIL = "support@datasophos.co"
MIN_SECRET_LENGTH = 4
PROBLEM_STATUSES = {"ERROR", "NO_RESERVATION", "NO_CONSENT"}
SECRET_PATTERN = re.compile(
    r"(?i)\b(token|password|api[_-]?key|authorization)\b\s*[:=]\s*([^\s,;]+)"
)
BEARER_PATTERN = re.compile(r"(?i)(Authorization:\s*Bearer\s+)[^\s,;]+")


[docs] @dataclass class ArtifactMeta: """Manifest metadata for one artifact.""" media_type: str schema_version: int | None = None record_count: int | None = None status: str = "collected" error: str | None = None
[docs] @dataclass class BundleContext: """State shared across support-bundle collectors.""" report_dir: Path output: Path log_days: int max_log_files: int explicit_logs: tuple[Path, ...] no_default_logs: bool include_all_logs: bool generated_at: datetime errors: list[dict[str, str]] included_logs: list[Path] artifact_metadata: dict[str, ArtifactMeta] @property def options(self) -> dict[str, Any]: """Return command options for ``manifest.json``.""" return { "output": str(self.output), "log_days": self.log_days, "max_log_files": self.max_log_files, "log": [str(p) for p in self.explicit_logs], "no_default_logs": self.no_default_logs, "include_all_logs": self.include_all_logs, }
def _json_default(value: Any) -> str | int | float | bool | None: """Convert common non-JSON values to strings.""" if isinstance(value, Path): return str(value) if isinstance(value, datetime): return value.isoformat() if hasattr(value, "value"): return value.value return str(value) def _write_json( report_dir: Path, relative_path: str, data: Any, *, artifact_metadata: dict[str, Any] | None = None, ) -> None: path = report_dir / relative_path path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps(data, indent=2, sort_keys=True, default=_json_default) + "\n", ) if artifact_metadata is not None: artifact_metadata.setdefault("media_type", "application/json") def _record_artifact( ctx: BundleContext, relative_path: str, meta: ArtifactMeta, ) -> None: ctx.artifact_metadata[relative_path] = meta def _write_text( ctx: BundleContext, relative_path: str, text: str, *, media_type: str = "text/plain", record_count: int | None = None, ) -> None: path = ctx.report_dir / relative_path path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text) _record_artifact( ctx, relative_path, ArtifactMeta(media_type=media_type, record_count=record_count), ) def _write_json_artifact( ctx: BundleContext, relative_path: str, data: Any, *, schema_version: int | None = SCHEMA_VERSION, record_count: int | None = None, ) -> None: _write_json(ctx.report_dir, relative_path, data) _record_artifact( ctx, relative_path, ArtifactMeta( media_type="application/json", schema_version=schema_version, record_count=record_count, ), ) def _sqlite_rows(query: str) -> list[dict[str, Any]]: with sqlite3.connect(settings.NX_DB_PATH) as conn: conn.row_factory = sqlite3.Row return [dict(row) for row in conn.execute(query)] def _table_exists(table_name: str) -> bool: with sqlite3.connect(settings.NX_DB_PATH) as conn: row = conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,), ).fetchone() return row is not None def _write_table_csv(ctx: BundleContext, table_name: str, relative_path: str) -> int: rows = _sqlite_rows(f"SELECT * FROM {table_name}") # noqa: S608 path = ctx.report_dir / relative_path path.parent.mkdir(parents=True, exist_ok=True) fieldnames = list(rows[0].keys()) if rows else _table_columns(table_name) with path.open("w", newline="") as file: writer = csv.DictWriter(file, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) _record_artifact( ctx, relative_path, ArtifactMeta(media_type="text/csv", record_count=len(rows)), ) return len(rows) def _table_columns(table_name: str) -> list[str]: with sqlite3.connect(settings.NX_DB_PATH) as conn: return [row[1] for row in conn.execute(f"PRAGMA table_info({table_name})")] def _row_count(table_name: str) -> int | None: if not _table_exists(table_name): return None with sqlite3.connect(settings.NX_DB_PATH) as conn: return int(conn.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()[0]) # noqa: S608 def _collect_environment(ctx: BundleContext) -> None: """Collect Python, platform, and runtime environment details.""" data = { "platform": platform.platform(), "system": platform.system(), "release": platform.release(), "machine": platform.machine(), "python_version": platform.python_version(), "python_executable": sys.executable, "nexuslims_import_path": str(Path(__file__).parents[1]), "current_working_directory": str(Path.cwd()), "timezone": time.tzname, "locale": locale.getlocale(), "source_checkout": (Path(__file__).parents[2] / ".git").exists(), } _write_json_artifact(ctx, "environment.json", data) def _collect_packages(ctx: BundleContext) -> None: """Collect installed Python packages using importlib.metadata.""" packages = sorted( f"{dist.metadata['Name']}=={dist.version}" for dist in metadata.distributions() ) _write_text( ctx, "packages.txt", "\n".join(packages) + "\n", record_count=len(packages) ) def _collect_config(ctx: BundleContext) -> None: """Collect sanitized effective NexusLIMS configuration.""" config_dict = _build_config_dict(settings) sanitized = _sanitize_config(config_dict) _write_json_artifact(ctx, "config.redacted.json", sanitized) def _path_info(path: Path, *, check_write: bool) -> dict[str, Any]: resolved = path.expanduser().resolve(strict=False) info: dict[str, Any] = { "path": str(path), "resolved": str(resolved), "exists": resolved.exists(), "is_file": resolved.is_file(), "is_dir": resolved.is_dir(), "readable": resolved.exists() and _can_read(resolved), "writable": None, "free_bytes": None, } if check_write: info["writable"] = _can_write(resolved) try: usage_target = resolved if resolved.exists() else resolved.parent if usage_target.exists(): info["free_bytes"] = shutil.disk_usage(usage_target).free except OSError as exc: info["free_space_error"] = str(exc) return info def _configured_path_info( path: Path, *, check_write: bool, configured: bool = True, defaulted_from: str | None = None, ) -> dict[str, Any]: info = _path_info(path, check_write=check_write) info["source"] = "configured" if configured else "default" if defaulted_from is not None: info["defaulted_from"] = defaulted_from return info def _can_read(path: Path) -> bool: try: if path.is_dir(): next(path.iterdir(), None) else: with path.open("rb"): pass except OSError: return False return True def _can_write(path: Path) -> bool: target_dir = path if path.is_dir() else path.parent try: with NamedTemporaryFile( prefix=".nexuslims-support-bundle-write-test-", dir=target_dir, ): pass except OSError: return False return True def _collect_paths(ctx: BundleContext) -> None: """Collect health details for configured NexusLIMS paths.""" paths = { "NX_INSTRUMENT_DATA_PATH": _configured_path_info( settings.NX_INSTRUMENT_DATA_PATH, check_write=False, ), "NX_DATA_PATH": _configured_path_info(settings.NX_DATA_PATH, check_write=True), "NX_DB_PATH": _configured_path_info(settings.NX_DB_PATH, check_write=False), "NX_LOG_PATH": _configured_path_info( settings.log_dir_path, check_write=True, configured=settings.NX_LOG_PATH is not None, defaulted_from="NX_DATA_PATH" if settings.NX_LOG_PATH is None else None, ), "NX_RECORDS_PATH": _configured_path_info( settings.records_dir_path, check_write=True, configured=settings.NX_RECORDS_PATH is not None, defaulted_from="NX_DATA_PATH" if settings.NX_RECORDS_PATH is None else None, ), } if settings.NX_LOCAL_PROFILES_PATH is not None: paths["NX_LOCAL_PROFILES_PATH"] = _configured_path_info( settings.NX_LOCAL_PROFILES_PATH, check_write=False, ) _write_json_artifact(ctx, "paths.json", paths) def _collect_preflight(ctx: BundleContext) -> None: """Run preflight checks and serialize structured and text output.""" checks = run_preflight_checks(dry_run=False) serialized = [_serialize_preflight_result(check) for check in checks] _write_json_artifact( ctx, "preflight.json", {"checks": serialized}, record_count=len(serialized), ) lines = [ ( f"[{'PASS' if c['passed'] else 'FAIL'}] " f"{c['severity']} {c['name']}: {c['message']}" ) for c in serialized ] _write_text(ctx, "preflight.txt", "\n".join(lines) + ("\n" if lines else "")) def _serialize_preflight_result(result: Any) -> dict[str, Any]: if is_dataclass(result): return asdict(result) return { "name": result.name, "passed": result.passed, "severity": result.severity, "message": result.message, } def _collect_database(ctx: BundleContext) -> None: """Export database schema details and support-relevant tables.""" schema = _database_schema() _write_json_artifact(ctx, "database/schema.json", schema) if _table_exists("session_log"): _write_table_csv(ctx, "session_log", "database/session_log.csv") summary = _session_log_summary() _write_json_artifact(ctx, "database/session_log_summary.json", summary) _write_json_artifact( ctx, "recent_sessions.json", _recent_sessions(), record_count=len(_recent_sessions()), ) if _table_exists("instruments"): _write_table_csv(ctx, "instruments", "database/instruments.csv") for table in ("upload_log", "external_user_identifiers"): if _table_exists(table): _write_table_csv(ctx, table, f"database/{table}.csv") def _database_schema() -> dict[str, Any]: db_path = Path(settings.NX_DB_PATH) with sqlite3.connect(db_path) as conn: tables = [ row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" ) ] alembic_revision = None if "alembic_version" in tables: row = conn.execute("SELECT version_num FROM alembic_version").fetchone() alembic_revision = row[0] if row else None key_tables = [ "session_log", "instruments", "upload_log", "external_user_identifiers", ] return { "database_path": str(db_path), "file_size_bytes": db_path.stat().st_size if db_path.exists() else None, "tables": tables, "alembic_revision": alembic_revision, "row_counts": {table: _row_count(table) for table in key_tables}, "optional_tables": { table: {"present": _table_exists(table)} for table in ("upload_log", "external_user_identifiers") }, } def _session_log_summary() -> dict[str, Any]: rows = _sqlite_rows("SELECT * FROM session_log") summary: dict[str, Any] = { "record_status": {}, "event_type": {}, "instrument": {}, "timestamp_min": None, "timestamp_max": None, } timestamps = [] for row in rows: for key, field in ( ("record_status", "record_status"), ("event_type", "event_type"), ("instrument", "instrument"), ): value = row[field] summary[key][value] = summary[key].get(value, 0) + 1 if row.get("timestamp"): timestamps.append(row["timestamp"]) if timestamps: summary["timestamp_min"] = min(timestamps) summary["timestamp_max"] = max(timestamps) return summary def _recent_sessions(limit: int = 100) -> list[dict[str, Any]]: rows = _sqlite_rows( "SELECT * FROM session_log ORDER BY timestamp DESC, id_session_log DESC" ) problem = [row for row in rows if row.get("record_status") in PROBLEM_STATUSES] ordered = problem + [row for row in rows if row not in problem] return ordered[:limit] def _collect_extractors(ctx: BundleContext) -> None: """Collect discovered extractor plugin details.""" from nexusLIMS.extractors.registry import get_registry # noqa: PLC0415 registry = get_registry() extractors = [] for extractor in registry.all_extractors: supported_extensions = extractor.supported_extensions or [] extractors.append( { "name": extractor.name, "class": extractor.__class__.__name__, "priority": extractor.priority, "supported_extensions": sorted(supported_extensions), } ) profile_paths = [ str(settings.NX_LOCAL_PROFILES_PATH) if settings.NX_LOCAL_PROFILES_PATH else None, str(Path(__file__).parents[1] / "extractors" / "plugins" / "profiles"), ] _write_json_artifact( ctx, "extractors.json", { "extractors": extractors, "profile_search_paths": [p for p in profile_paths if p], }, record_count=len(extractors), ) def _collect_exporters(ctx: BundleContext) -> None: """Collect export destination configuration diagnostics.""" from nexusLIMS.exporters.registry import get_registry # noqa: PLC0415 registry = get_registry() registry.discover_plugins() destinations = [] for destination in sorted( registry._destinations.values(), # noqa: SLF001 key=lambda d: d.priority, reverse=True, ): destinations.append( { "name": destination.name, "priority": destination.priority, "enabled": destination.enabled, "validation_status": "not_run", "validation_note": "Preflight runs live connectivity checks.", } ) upload_counts = [] if _table_exists("upload_log"): upload_counts = _sqlite_rows( "SELECT destination_name, success, COUNT(*) AS count " "FROM upload_log GROUP BY destination_name, success" ) _write_json_artifact( ctx, "exporters.json", { "export_strategy": settings.NX_EXPORT_STRATEGY, "destinations": destinations, "upload_counts": upload_counts, }, record_count=len(destinations), ) def _collect_logs(ctx: BundleContext) -> None: """Copy selected logs into the archive after line-oriented redaction.""" logs_dir = ctx.report_dir / "logs" logs_dir.mkdir(parents=True, exist_ok=True) selected_logs = _select_logs(ctx) summary = {"files": {}, "totals": {"warnings": 0, "errors": 0, "tracebacks": 0}} used_artifact_paths: set[str] = set() for source in selected_logs: artifact_path = _log_artifact_path(source, used_artifact_paths) target = ctx.report_dir / artifact_path target.parent.mkdir(parents=True, exist_ok=True) text = _redact_text(source.read_text(errors="replace")) target.write_text(text) stats = _summarize_log(text) summary["files"][artifact_path] = stats for key in summary["totals"]: summary["totals"][key] += stats[key] _record_artifact(ctx, artifact_path, ArtifactMeta(media_type="text/plain")) ctx.included_logs = selected_logs _write_json_artifact( ctx, "log_summary.json", summary, record_count=len(selected_logs) ) def _select_logs(ctx: BundleContext) -> list[Path]: selected: dict[Path, Path] = {} if not ctx.no_default_logs and settings.log_dir_path.exists(): candidates = [p for p in settings.log_dir_path.rglob("*") if p.is_file()] if not ctx.include_all_logs: cutoff = time.time() - (ctx.log_days * 24 * 60 * 60) candidates = [p for p in candidates if p.stat().st_mtime >= cutoff] candidates = sorted( candidates, key=lambda p: p.stat().st_mtime, reverse=True, )[: ctx.max_log_files] for path in candidates: selected[path.resolve()] = path for path in ctx.explicit_logs: if path.exists() and path.is_file(): selected[path.resolve()] = path return list(selected.values()) def _log_artifact_path(source: Path, used_artifact_paths: set[str]) -> str: try: relative = source.resolve().relative_to(settings.log_dir_path.resolve()) candidate = Path("logs") / relative except ValueError: candidate = Path("logs") / source.name candidate = Path(*_safe_path_parts(candidate.parts)) artifact_path = candidate.as_posix() counter = 1 while artifact_path in used_artifact_paths: suffix = candidate.suffix stem = candidate.stem parent = candidate.parent artifact_path = (parent / f"{stem}-{counter}{suffix}").as_posix() counter += 1 used_artifact_paths.add(artifact_path) return artifact_path def _safe_path_parts(parts: tuple[str, ...]) -> list[str]: return [ re.sub(r"[^A-Za-z0-9._-]", "_", part) for part in parts if part not in {"", ".", ".."} ] def _redact_text(text: str) -> str: text = BEARER_PATTERN.sub(r"\1***", text) text = SECRET_PATTERN.sub(lambda m: f"{m.group(1)}={REDACTED}", text) for secret in _configured_secret_values(): text = text.replace(secret, REDACTED) return text def _configured_secret_values() -> set[str]: raw_config = _build_config_dict(settings) sanitized_config = _sanitize_config(raw_config) return { value for value in _walk_redacted_values(raw_config, sanitized_config) if len(value) >= MIN_SECRET_LENGTH } def _walk_redacted_values(raw_value: Any, sanitized_value: Any) -> set[str]: if sanitized_value == REDACTED and raw_value not in {None, ""}: return {str(raw_value)} if isinstance(raw_value, dict) and isinstance(sanitized_value, dict): values: set[str] = set() for key, nested_raw_value in raw_value.items(): values.update( _walk_redacted_values( nested_raw_value, sanitized_value.get(key), ) ) return values return set() def _summarize_log(text: str) -> dict[str, int]: lines = text.splitlines() return { "warnings": sum(1 for line in lines if "WARNING" in line.upper()), "errors": sum(1 for line in lines if "ERROR" in line.upper()), "tracebacks": sum(1 for line in lines if "TRACEBACK" in line.upper()), } def _collect_summary_html(ctx: BundleContext) -> None: """Write a compact static HTML summary.""" errors = json.loads((ctx.report_dir / "errors.json").read_text()) preflight_path = ctx.report_dir / "preflight.json" preflight = ( json.loads(preflight_path.read_text()) if preflight_path.exists() else {} ) paths = _load_artifact_json(ctx, "paths.json", {}) schema = _load_artifact_json(ctx, "database/schema.json", {}) recent_sessions = _load_artifact_json(ctx, "recent_sessions.json", []) log_summary = _load_artifact_json(ctx, "log_summary.json", {}) failed_checks = [ check for check in preflight.get("checks", []) if not check.get("passed", True) ] unhealthy_paths = _unhealthy_paths(paths) problematic_sessions = [ session for session in recent_sessions if session.get("record_status") in PROBLEM_STATUSES ] artifacts = sorted(ctx.artifact_metadata) content = "\n".join( [ "<!doctype html>", ( "<html><head><meta charset='utf-8'>" "<title>NexusLIMS Support Bundle</title>" "<style>" ":root{color-scheme:light;--bg:#f6f8fa;--panel:#fff;" "--text:#17202a;--muted:#5f6b7a;--border:#d8dee4;" "--accent:#0b5cad;--warn:#8a4b00;}" "body{margin:0;background:var(--bg);color:var(--text);" "font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI'," "sans-serif;}" "main{max-width:980px;margin:0 auto;padding:32px 24px 48px;}" "header{border-bottom:1px solid var(--border);" "margin-bottom:24px;padding-bottom:16px;}" "h1{font-size:28px;margin:0 0 8px;}" "h2{font-size:17px;margin:24px 0 10px;color:var(--accent);}" "p{margin:4px 0;color:var(--muted);}" "section{background:var(--panel);border:1px solid var(--border);" "border-radius:8px;margin:14px 0;padding:16px 18px;}" "ul{margin:0;padding-left:20px;}" "li{margin:4px 0;}" "a{color:var(--accent);text-decoration:none;}" "a:hover{text-decoration:underline;}" ".mono,code,pre{font-family:ui-monospace,SFMono-Regular," "Menlo,Consolas,'Liberation Mono',monospace;}" ".empty{color:var(--muted);}" ".warn{color:var(--warn);font-weight:600;}" "</style></head>" ), "<body>", "<main>", "<header>", "<h1>NexusLIMS Support Bundle</h1>", f"<p>Generated: <span class='mono'>" f"{html.escape(ctx.generated_at.isoformat())}</span></p>", f"<p>NexusLIMS version: <span class='mono'>" f"{html.escape(__version__)}</span></p>", "</header>", "<section>", f"<h2>Failed Preflight Checks ({len(failed_checks)})</h2>", "<ul>", *[ ( f"<li>{html.escape(check['name'])}: " f"{html.escape(check['message'])}</li>" ) for check in failed_checks ], "<li class='empty'>None</li>" if not failed_checks else "", "</ul>", "</section>", "<section>", f"<h2>Unhealthy Paths ({len(unhealthy_paths)})</h2>", "<ul>", *[ (f"<li>{html.escape(name)}: {html.escape(', '.join(problems))}</li>") for name, problems in unhealthy_paths ], "<li class='empty'>None</li>" if not unhealthy_paths else "", "</ul>", "</section>", "<section>", f"<h2>Problematic Recent Sessions ({len(problematic_sessions)})</h2>", "<ul>", *[ ( f"<li><span class='mono'>" f"{html.escape(str(session.get('session_identifier')))}</span>: " f"{html.escape(str(session.get('record_status')))} " f"on {html.escape(str(session.get('instrument')))}</li>" ) for session in problematic_sessions[:20] ], "<li class='empty'>None</li>" if not problematic_sessions else "", "</ul>", "</section>", "<section>", "<h2>Database Row Counts</h2>", "<ul>", *[ f"<li><span class='mono'>{html.escape(table)}</span>: {count}</li>" for table, count in schema.get("row_counts", {}).items() ], ( "<li class='empty'>No database row counts collected</li>" if not schema.get("row_counts") else "" ), "</ul>", "</section>", "<section>", "<h2>Log Warnings And Errors</h2>", "<ul class='mono'>", f"<li>Warnings: {log_summary.get('totals', {}).get('warnings', 0)}</li>", f"<li>Errors: {log_summary.get('totals', {}).get('errors', 0)}</li>", ( f"<li>Tracebacks: " f"{log_summary.get('totals', {}).get('tracebacks', 0)}</li>" ), "</ul>", "</section>", "<section>", f"<h2>Collector Errors ({len(errors)})</h2>", "<ul>", *[ ( f"<li class='warn'>{html.escape(error['collector'])}: " f"{html.escape(error['error'])}</li>" ) for error in errors ], "<li class='empty'>None</li>" if not errors else "", "</ul>", "</section>", "<section>", "<h2>Artifacts</h2>", "<ul class='mono'>", *[ f"<li><a href='{html.escape(path)}'>{html.escape(path)}</a></li>" for path in artifacts ], "</ul>", "</section>", "</main>", "</body></html>", ] ) _write_text(ctx, "summary.html", content, media_type="text/html") def _load_artifact_json(ctx: BundleContext, relative_path: str, default: Any) -> Any: path = ctx.report_dir / relative_path if not path.exists(): return default try: return json.loads(path.read_text()) except json.JSONDecodeError: return default def _unhealthy_paths(paths: dict[str, dict[str, Any]]) -> list[tuple[str, list[str]]]: unhealthy = [] for name, info in paths.items(): problems = [] if not info.get("exists") and not ( info.get("source") == "default" and info.get("writable") is True ): problems.append("missing") if info.get("exists") and not info.get("readable"): problems.append("not readable") if info.get("writable") is False: problems.append("not writable") if problems: unhealthy.append((name, problems)) return unhealthy def _collect_manifest(ctx: BundleContext) -> None: """Write the stable artifact index.""" ctx.artifact_metadata["manifest.json"] = ArtifactMeta( media_type="application/json", schema_version=SCHEMA_VERSION, ) artifacts = [] for path in sorted(ctx.artifact_metadata): meta = asdict(ctx.artifact_metadata[path]) artifacts.append({"path": path, **meta}) manifest = { "schema_version": SCHEMA_VERSION, "generated_at": ctx.generated_at.isoformat(), "options": ctx.options, "nexuslims_version": __version__, "artifacts": artifacts, } _write_json(ctx.report_dir, "manifest.json", manifest) def _run_collector(ctx: BundleContext, name: str, collector) -> None: try: collector(ctx) except Exception as exc: ctx.errors.append({"collector": name, "error": str(exc)}) def _create_zip(report_dir: Path, output: Path) -> None: output.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as bundle: for path in sorted(p for p in report_dir.rglob("*") if p.is_file()): bundle.write(path, path.relative_to(report_dir).as_posix()) def _default_output_path() -> Path: timestamp = datetime.now(tz=UTC).astimezone().strftime("%Y%m%d-%H%M%S") return Path.cwd() / f"nexuslims-support-bundle-{timestamp}.zip" @click.command() @click.option( "--output", type=click.Path(dir_okay=False, path_type=Path), default=None, help="Zip file path to create.", ) @click.option("--log-days", type=click.IntRange(min=0), default=14, show_default=True) @click.option( "--max-log-files", type=click.IntRange(min=0), default=25, show_default=True, ) @click.option( "--log", "explicit_logs", multiple=True, type=click.Path(exists=True, dir_okay=False), help="Specific log file to include. Can be repeated.", ) @click.option( "--no-default-logs", is_flag=True, help="Disable automatic log discovery from the configured log directory.", ) @click.option( "--include-all-logs", is_flag=True, help="Include all discovered default logs, ignoring recency and count limits.", ) @click.version_option(version=None, message=_format_version("nexuslims support-bundle")) def main(**kwargs: Any) -> None: """Create a local diagnostic archive for Datasophos support.""" output = kwargs["output"] output_path = (output or _default_output_path()).expanduser().resolve() with TemporaryDirectory(prefix="nexuslims-support-bundle-") as temp_dir: ctx = BundleContext( report_dir=Path(temp_dir), output=output_path, log_days=kwargs["log_days"], max_log_files=kwargs["max_log_files"], explicit_logs=tuple(Path(p) for p in kwargs["explicit_logs"]), no_default_logs=kwargs["no_default_logs"], include_all_logs=kwargs["include_all_logs"], generated_at=datetime.now().astimezone(), errors=[], included_logs=[], artifact_metadata={}, ) collectors = [ ("environment", _collect_environment), ("packages", _collect_packages), ("config", _collect_config), ("paths", _collect_paths), ("preflight", _collect_preflight), ("database", _collect_database), ("extractors", _collect_extractors), ("exporters", _collect_exporters), ("logs", _collect_logs), ] for name, collector in collectors: _run_collector(ctx, name, collector) _write_json_artifact( ctx, "errors.json", ctx.errors, record_count=len(ctx.errors), ) if ctx.errors: ctx.artifact_metadata["errors.json"].status = "completed_with_errors" ctx.artifact_metadata["errors.json"].error = ", ".join( error["collector"] for error in ctx.errors ) _collect_summary_html(ctx) _collect_manifest(ctx) try: _create_zip(ctx.report_dir, output_path) except OSError as exc: msg = f"Could not write support bundle: {exc}" raise click.ClickException(msg) from exc click.echo() click.secho("NexusLIMS support bundle created", bold=True) click.echo(f" Archive: {output_path}") if ctx.errors: issue_count = len(ctx.errors) section_word = "section" if issue_count == 1 else "sections" click.secho( f" Issues: {issue_count} diagnostic {section_word} could not be " "included. See errors.json in the archive.", fg="yellow", ) click.echo( " Note: Secrets have been automatically redacted, but please review " "before sending for any sensitive content." ) click.echo(f" Next step: Review the archive and email it to {SUPPORT_EMAIL}.")