diagnostic_snapshot.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. """Aggregate connection, virtual-printer, and log-health diagnostics into a
  2. single snapshot for the support bundle and bug-report submission paths.
  3. Each user-triggered support artifact (the System-page support ZIP and the
  4. bug-report bubble) already exposed these three checks inline in the UI but
  5. omitted them from what landed in the maintainer's hands. This module is the
  6. single entry point both flows call to capture all three at once.
  7. Designed around three constraints:
  8. - **Fail-soft per probe.** A crash inside one printer's check must not nuke the
  9. whole snapshot — that's the whole point of including diagnostics in the
  10. bundle: a partial result is more useful than a 500.
  11. - **Bounded total runtime.** Each probe runs concurrently and is guarded by an
  12. outer wall-clock cap; timeouts emit a marker entry rather than blocking.
  13. - **No mutation.** Connection / VP diagnostics only probe TCP ports and read
  14. state; log-health is a passive scanner. Safe to run on every bundle.
  15. """
  16. from __future__ import annotations
  17. import asyncio
  18. import logging
  19. import re
  20. import time
  21. from typing import Any
  22. from sqlalchemy import select
  23. from sqlalchemy.ext.asyncio import AsyncSession
  24. logger = logging.getLogger(__name__)
  25. # Mirrors the IPv4 pattern in services.log_reader.sanitize_log_content. Kept as
  26. # a literal here (not imported) so a refactor of that module's internals can't
  27. # silently change snapshot sanitization. Skips firmware-version-shaped strings
  28. # (leading-zero octets like "01.09.01.00") via the [1-9]\d|\d alternations.
  29. _IPV4_RE = re.compile(r"\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\b")
  30. # Per-diagnostic wall-clock cap. Each underlying probe carries its own (smaller)
  31. # TCP / HTTP timeouts; this is the outer guard so a hung interface or a wedged
  32. # subprocess can't stall bundle generation past about this many seconds per
  33. # printer/VP. Snapshot total runtime is bounded by max(per-cap) thanks to the
  34. # concurrent gather, not the sum.
  35. _PER_DIAGNOSTIC_TIMEOUT_SECONDS = 15.0
  36. def _serialize(result: Any) -> Any:
  37. """Convert a Pydantic model to a dict; pass through plain dicts/lists."""
  38. if hasattr(result, "model_dump"):
  39. return result.model_dump()
  40. return result
  41. async def _run_connection_for(printer) -> dict:
  42. from backend.app.services.printer_diagnostic import run_connection_diagnostic
  43. base = {"printer_id": printer.id, "printer_name": printer.name}
  44. progress: dict[str, Any] = {}
  45. started = time.monotonic()
  46. try:
  47. result = await asyncio.wait_for(
  48. run_connection_diagnostic(
  49. printer.ip_address,
  50. printer=printer,
  51. serial_number=printer.serial_number,
  52. access_code=printer.access_code,
  53. progress=progress,
  54. ),
  55. timeout=_PER_DIAGNOSTIC_TIMEOUT_SECONDS,
  56. )
  57. return {**base, "result": _serialize(result)}
  58. except asyncio.TimeoutError:
  59. # Name the step that hung and keep the checks that finished before it.
  60. # Without them a bundle from a farm whose every printer overran said
  61. # only "timed_out" fourteen times, and nothing about why (#3164).
  62. return {
  63. **base,
  64. "error": "timed_out",
  65. "stalled_in": progress.get("stage"),
  66. "elapsed_s": round(time.monotonic() - started, 1),
  67. "checks": [_serialize(c) for c in progress.get("checks", [])],
  68. }
  69. except Exception as e:
  70. # Log with traceback so the bundle generation isn't silent about
  71. # a broken probe, but never propagate.
  72. logger.warning("Connection diagnostic failed for printer %s: %s", printer.id, e, exc_info=True)
  73. return {**base, "error": str(e)}
  74. async def _run_vp_for(vp) -> dict:
  75. from backend.app.services.virtual_printer import virtual_printer_manager
  76. from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
  77. base = {"vp_id": vp.id, "name": vp.name}
  78. try:
  79. instance = virtual_printer_manager.get_instance(vp.id)
  80. result = await asyncio.wait_for(
  81. run_vp_diagnostic(vp, instance),
  82. timeout=_PER_DIAGNOSTIC_TIMEOUT_SECONDS,
  83. )
  84. return {**base, "result": _serialize(result)}
  85. except asyncio.TimeoutError:
  86. return {**base, "error": "timed_out"}
  87. except Exception as e:
  88. logger.warning("VP diagnostic failed for VP %s: %s", vp.id, e, exc_info=True)
  89. return {**base, "error": str(e)}
  90. async def _run_log_health() -> Any:
  91. from backend.app.services.log_health import scan_logs
  92. try:
  93. # scan_logs is sync I/O-bound (file read + regex); push off the loop.
  94. result = await asyncio.wait_for(
  95. asyncio.to_thread(scan_logs),
  96. timeout=_PER_DIAGNOSTIC_TIMEOUT_SECONDS,
  97. )
  98. return _serialize(result)
  99. except asyncio.TimeoutError:
  100. return {"error": "timed_out"}
  101. except Exception as e:
  102. logger.warning("Log-health scan failed: %s", e, exc_info=True)
  103. return {"error": str(e)}
  104. async def collect_diagnostic_snapshot(db: AsyncSession) -> dict[str, Any]:
  105. """Return the three-section diagnostic snapshot.
  106. Always returns a dict with keys ``connection_diagnostics`` (list, one entry
  107. per active printer), ``vp_diagnostics`` (list, one entry per enabled VP —
  108. empty if none), and ``log_health`` (the ``scan_logs`` result or an error
  109. marker). Each list entry carries either ``result`` (success) or ``error``
  110. (timeout / exception) so the maintainer can tell at a glance whether a
  111. given probe ran.
  112. """
  113. from backend.app.models.printer import Printer
  114. from backend.app.models.virtual_printer import VirtualPrinter
  115. printers_result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
  116. printers = list(printers_result.scalars().all())
  117. vps_result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.enabled.is_(True)))
  118. vps = list(vps_result.scalars().all())
  119. # Concurrent: total wall-clock ≈ max(per-cap), not sum.
  120. results = await asyncio.gather(
  121. asyncio.gather(*(_run_connection_for(p) for p in printers)) if printers else _noop_list(),
  122. asyncio.gather(*(_run_vp_for(vp) for vp in vps)) if vps else _noop_list(),
  123. _run_log_health(),
  124. return_exceptions=True,
  125. )
  126. connection_results, vp_results, log_health = results
  127. def _coerce_list(r) -> list:
  128. if isinstance(r, BaseException):
  129. logger.warning("Diagnostic snapshot batch failed: %s", r)
  130. return []
  131. return list(r) if r is not None else []
  132. snapshot = {
  133. "connection_diagnostics": _coerce_list(connection_results),
  134. "vp_diagnostics": _coerce_list(vp_results),
  135. "log_health": log_health if not isinstance(log_health, BaseException) else {"error": str(log_health)},
  136. }
  137. # Sanitize before returning. The diagnostic schemas embed printer/host IPs
  138. # (`PrinterDiagnosticResult.ip_address`, network-mode check params, VP
  139. # `bind_ip`) and the snapshot adds printer names — none of which should
  140. # leak into a submitted GitHub issue or a shared support ZIP. Use the
  141. # same `collect_sensitive_strings` table the log sanitizer already
  142. # consults so the replacement labels stay consistent ([PRINTER], [SERIAL],
  143. # [IP], [ACCESS_CODE]); the IPv4 regex fallback in `_mask_string` then
  144. # catches host / bind IPs that aren't in the DB.
  145. try:
  146. from backend.app.services.log_reader import collect_sensitive_strings
  147. sensitive_strings = await collect_sensitive_strings(db)
  148. except Exception:
  149. logger.warning("Could not collect sensitive strings for snapshot sanitization", exc_info=True)
  150. sensitive_strings = {}
  151. return _sanitize_recursive(snapshot, sensitive_strings)
  152. async def _noop_list() -> list:
  153. return []
  154. def _mask_string(value: str, sensitive_strings: dict[str, str]) -> str:
  155. """Apply known-value replacement + IPv4 regex masking to a single string.
  156. Known values are matched first (longest first so "My Printer 1" beats
  157. "My Printer"); the regex pass then catches any IPs the sensitive_strings
  158. table didn't already cover — most importantly the Bambuddy host's own
  159. IP (returned by ``_host_source_ip`` inside the diagnostic, not in the DB)
  160. and any virtual-printer ``bind_ip`` the user picked at setup.
  161. """
  162. if not value:
  163. return value
  164. for raw, label in sorted(sensitive_strings.items(), key=lambda x: len(x[0]), reverse=True):
  165. if len(raw) < 3:
  166. continue
  167. if raw in value:
  168. value = value.replace(raw, label)
  169. value = _IPV4_RE.sub("[IP]", value)
  170. return value
  171. def _sanitize_recursive(node: Any, sensitive_strings: dict[str, str]) -> Any:
  172. """Walk the snapshot and redact strings in place — dicts, lists, scalars.
  173. Non-string scalars (ints, bools, None) pass through; we only need to
  174. mask user-visible values. Keys are NOT renamed (those are structural).
  175. """
  176. if isinstance(node, str):
  177. return _mask_string(node, sensitive_strings)
  178. if isinstance(node, dict):
  179. return {k: _sanitize_recursive(v, sensitive_strings) for k, v in node.items()}
  180. if isinstance(node, list):
  181. return [_sanitize_recursive(item, sensitive_strings) for item in node]
  182. return node