The Warehouse home with the numbers, the Open shorts table, the find bar and the five-column task board (waiting, being pulled, on hold, pulled, released in the last 3 days) with buttons, hold reasons and drag. Every role sees the warehouse stage on a packet as the WH pill and the line under it. The pull table on the Materials tab: Pulled with auto-start, issued, location, heat with Enter to the next line, shorts with receipts, N/A, MTR per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays blocked until every line is pulled or short and every pulled line has a heat. Server: the heats_recorded precondition on WH_RELEASE, receive by custody, server-validated hold and short reasons, the old packet history texts, GET /api/warehouse/board, cards with the warehouse stage and pull counts. Uploads typed by content per kind (drawing, mtr, document, bom with XLSX and CSV), limits per kind, optional ClamAV behind the clamav compose profile, a storage quota alert on the new notification_events table (migration 0005) shown on the Admin Storage tab. Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat pattern, upload sniffing and limits, the ClamAV path with a fake scanner, the quota alert; frontend board and pull table tests; Playwright scenarios for the board and the pull flow. Verified on docker-1 in the CI stack: 3557 server tests, 106 frontend tests, 17 Playwright scenarios. Docs updated; D5 closed. Co-Authored-By: Claude Fable 5.1 <[email protected]>
490 lines
18 KiB
Python
490 lines
18 KiB
Python
"""`packetstation doctor`: check config, VISUAL, M-Files, database, volumes, LDAP, SMTP and tzdata."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import secrets
|
|
import shutil
|
|
import smtplib
|
|
import ssl
|
|
import subprocess
|
|
import sys
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from dataclasses import asdict, dataclass
|
|
from email.message import EmailMessage
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
from pydantic import ValidationError
|
|
|
|
from . import __version__
|
|
from .settings import SecretFileError, Settings
|
|
|
|
PASS, WARN, FAIL, SKIP = "PASS", "WARN", "FAIL", "SKIP"
|
|
SKIPPABLE = ("visual", "mfiles", "ldap", "smtp", "db", "volume", "clamav")
|
|
FREE_WARN_BYTES = 2 * 1024**3
|
|
EXPECTED_PS_GROUPS = 50
|
|
IN_CHAIN = "1.2.840.113556.1.4.1941"
|
|
LDAP_ATTRS = ("memberOf", "employeeID", "userAccountControl")
|
|
|
|
|
|
@dataclass
|
|
class Check:
|
|
name: str
|
|
status: str
|
|
detail: str
|
|
hint: str = ""
|
|
|
|
|
|
# ---------------------------------------------------------------- config
|
|
|
|
|
|
def check_config(load: Callable[[], Settings] = Settings) -> tuple[list[Check], Settings | None]:
|
|
try:
|
|
s = load()
|
|
except ValidationError as e:
|
|
bad = "; ".join(
|
|
f"{'.'.join(str(x) for x in err['loc']).upper() or 'settings'}: {err['msg']}" for err in e.errors()
|
|
)
|
|
return [Check("config", FAIL, f"settings invalid: {bad}", "fix the environment (see .env.example)")], None
|
|
out = [
|
|
Check("config", PASS, f"STACK={s.stack} SITE_ID={s.site_id} build {s.ps_build}, packetstation {__version__}")
|
|
]
|
|
from . import guard
|
|
|
|
try:
|
|
g = guard.check_stack(s)
|
|
except guard.StackGuardError as e:
|
|
out.append(Check("config: stack guard", FAIL, str(e), "VISUAL_* must point at DEV unless STACK=prod"))
|
|
except Exception as e:
|
|
out.append(Check("config: stack guard", FAIL, f"can't read VisualKit settings: {e}"))
|
|
else:
|
|
out.append(Check("config: stack guard", PASS, f"VISUAL target {g.target}"))
|
|
for w in g.warnings:
|
|
out.append(Check("config: warning", WARN, w))
|
|
return out, s
|
|
|
|
|
|
# ---------------------------------------------------------------- visual / mfiles
|
|
|
|
|
|
def _lib_checks(prefix: str, items: list[Any]) -> list[Check]:
|
|
return [
|
|
Check(f"{prefix}: {c.name}", str(c.status), str(c.detail), str(getattr(c, "hint", "") or "")) for c in items
|
|
]
|
|
|
|
|
|
_LINE = re.compile(r"^\[(PASS|WARN|FAIL)\]\s+(.+?)\s{2,}(.*)$")
|
|
|
|
|
|
def parse_report(prefix: str, text: str) -> list[Check]:
|
|
"""Parse `[STATUS] name detail` lines (and `-> hint` lines) from a library's doctor output."""
|
|
out: list[Check] = []
|
|
for line in text.splitlines():
|
|
m = _LINE.match(line.rstrip())
|
|
if m:
|
|
out.append(Check(f"{prefix}: {m.group(2).strip()}", m.group(1), m.group(3).strip()))
|
|
elif line.strip().startswith("->") and out:
|
|
out[-1].hint = line.strip()[2:].strip()
|
|
return out
|
|
|
|
|
|
def _subprocess_doctor(prefix: str, argv: list[str]) -> list[Check]:
|
|
exe = shutil.which(argv[0])
|
|
if exe is None:
|
|
return [Check(prefix, FAIL, f"{argv[0]} is not installed")]
|
|
try:
|
|
r = subprocess.run([exe, *argv[1:]], capture_output=True, text=True, timeout=180, check=False)
|
|
except subprocess.TimeoutExpired:
|
|
return [Check(prefix, FAIL, f"{' '.join(argv)} timed out")]
|
|
checks = parse_report(prefix, r.stdout)
|
|
if not checks:
|
|
return [
|
|
Check(
|
|
prefix, FAIL, f"{' '.join(argv)} exited {r.returncode} with no checks", (r.stderr or "").strip()[-300:]
|
|
)
|
|
]
|
|
return checks
|
|
|
|
|
|
def check_visual(s: Settings) -> list[Check]:
|
|
from . import guard
|
|
|
|
try:
|
|
vs = guard.load_visual_settings()
|
|
except Exception as e:
|
|
return [Check("visual", FAIL, f"VISUAL_* settings invalid: {e}")]
|
|
if vs.is_production and s.stack != "prod":
|
|
return [
|
|
Check(
|
|
"visual",
|
|
FAIL,
|
|
f"refusing PRODUCTION target {vs.server} / {vs.database} on STACK={s.stack}",
|
|
"point VISUAL_* at DEV",
|
|
)
|
|
]
|
|
allow = s.stack == "prod"
|
|
try:
|
|
from visualkit import doctor as vdoctor
|
|
except Exception:
|
|
argv = ["visualkit", "doctor"] + (["--allow-production"] if allow else [])
|
|
return _subprocess_doctor("visual", argv)
|
|
try:
|
|
return _lib_checks("visual", vdoctor.run(allow_production=allow, prompt_password=None))
|
|
except Exception as e:
|
|
return [Check("visual", FAIL, f"visualkit doctor raised {type(e).__name__}: {e}")]
|
|
|
|
|
|
def check_mfiles(s: Settings) -> list[Check]:
|
|
try:
|
|
from mfileskit import doctor as mdoctor
|
|
except Exception:
|
|
return _subprocess_doctor("mfiles", ["mfileskit", "doctor"])
|
|
try:
|
|
return _lib_checks("mfiles", mdoctor.run(prompt_password=None))
|
|
except Exception as e:
|
|
return [Check("mfiles", FAIL, f"mfileskit doctor raised {type(e).__name__}: {e}")]
|
|
|
|
|
|
# ---------------------------------------------------------------- database
|
|
|
|
|
|
def _first_line(e: Exception) -> str:
|
|
text = str(e).strip()
|
|
return text.splitlines()[0] if text else type(e).__name__
|
|
|
|
|
|
def check_db(s: Settings) -> list[Check]:
|
|
import psycopg
|
|
|
|
from . import db, migrate
|
|
|
|
out: list[Check] = []
|
|
where = f"{s.pg_app_user}@{s.pg_host}:{s.pg_port}/{s.pg_database}"
|
|
try:
|
|
conn = db.connect_as("app", s, autocommit=True)
|
|
except SecretFileError as e:
|
|
return [Check("db: connect", FAIL, str(e), "mount the postgres_app_password secret")]
|
|
except psycopg.Error as e:
|
|
return [
|
|
Check(
|
|
"db: connect",
|
|
FAIL,
|
|
f"{where}: {type(e).__name__}: {_first_line(e)}",
|
|
"is postgres up, and do the roles exist (initdb or migrate --bootstrap-roles)?",
|
|
)
|
|
]
|
|
with conn:
|
|
ver = conn.execute("SHOW server_version").fetchone()
|
|
out.append(Check("db: connect", PASS, f"{where}, PostgreSQL {ver[0] if ver else '?'}"))
|
|
try:
|
|
ok, detail = migrate.status(conn, db.MIGRATIONS_DIR)
|
|
except psycopg.Error as e:
|
|
ok, detail = False, f"can't read schema_migrations: {type(e).__name__}"
|
|
out.append(Check("db: migrations", PASS if ok else FAIL, detail, "" if ok else "run: packetstation migrate"))
|
|
|
|
try:
|
|
with conn.transaction(force_rollback=True):
|
|
conn.execute("UPDATE audit_events SET action = action WHERE id = -1")
|
|
out.append(
|
|
Check(
|
|
"db: audit append-only",
|
|
FAIL,
|
|
f"{s.pg_app_user} may UPDATE audit_events",
|
|
"re-apply the 0001 grants (REVOKE UPDATE, DELETE ON audit_events FROM ps_app)",
|
|
)
|
|
)
|
|
except psycopg.errors.InsufficientPrivilege:
|
|
out.append(Check("db: audit append-only", PASS, "UPDATE on audit_events is denied for the app role"))
|
|
except psycopg.Error as e:
|
|
out.append(Check("db: audit append-only", FAIL, f"unexpected {type(e).__name__}: {_first_line(e)}"))
|
|
|
|
channel = f"ps_doctor_{secrets.token_hex(4)}"
|
|
payload = str(uuid.uuid4())
|
|
try:
|
|
conn.execute(f'LISTEN "{channel}"')
|
|
conn.execute("SELECT ps_notify(%s, %s)", (channel, payload))
|
|
got = [n.payload for n in conn.notifies(timeout=5, stop_after=1)]
|
|
conn.execute(f'UNLISTEN "{channel}"')
|
|
if payload in got:
|
|
out.append(Check("db: listen/notify", PASS, "round trip through ps_notify"))
|
|
else:
|
|
out.append(Check("db: listen/notify", FAIL, "no notification within 5 s"))
|
|
except psycopg.Error as e:
|
|
out.append(Check("db: listen/notify", FAIL, f"{type(e).__name__}: {_first_line(e)}"))
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------- volumes
|
|
|
|
|
|
def check_volume(s: Settings) -> list[Check]:
|
|
out: list[Check] = []
|
|
a = s.assets_dir
|
|
if not a.is_dir():
|
|
out.append(Check("volume: assets", FAIL, f"ASSETS_DIR {a} does not exist", "mount the assets volume"))
|
|
else:
|
|
probe = a / f".doctor-{secrets.token_hex(4)}"
|
|
try:
|
|
with open(probe, "wb") as f:
|
|
f.write(b"ok")
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
probe.unlink()
|
|
except OSError as e:
|
|
out.append(
|
|
Check(
|
|
"volume: assets",
|
|
FAIL,
|
|
f"ASSETS_DIR {a} not writable: {e.strerror}",
|
|
"the volume must be writable by uid 10001",
|
|
)
|
|
)
|
|
else:
|
|
free = shutil.disk_usage(a).free
|
|
gb = free / 1024**3
|
|
if free < FREE_WARN_BYTES:
|
|
out.append(Check("volume: assets", WARN, f"{a} writable, only {gb:.1f} GB free", "free some space"))
|
|
else:
|
|
out.append(Check("volume: assets", PASS, f"{a} writable, {gb:.1f} GB free"))
|
|
b = s.backup_dir
|
|
if not b.exists():
|
|
out.append(Check("volume: backups", SKIP, f"BACKUP_DIR {b} not mounted"))
|
|
else:
|
|
try:
|
|
n = len(os.listdir(b))
|
|
out.append(Check("volume: backups", PASS, f"{b} readable, {n} entries"))
|
|
except OSError as e:
|
|
out.append(Check("volume: backups", FAIL, f"{b} not readable: {e.strerror}"))
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------- clamav
|
|
|
|
|
|
def check_clamav(s: Settings) -> list[Check]:
|
|
from .domain import scan
|
|
|
|
scanner = scan.scanner_for(s)
|
|
if scanner is None:
|
|
return [Check("clamav", SKIP, "CLAMAV_HOST not set: uploads are not virus scanned")]
|
|
where = f"{scanner.host}:{scanner.port}"
|
|
if scanner.ping(timeout=min(scanner.timeout, 5.0)):
|
|
return [Check("clamav", PASS, f"clamd at {where} answers PING")]
|
|
return [
|
|
Check(
|
|
"clamav",
|
|
FAIL,
|
|
f"clamd at {where} does not answer; uploads are refused until it does",
|
|
"start the stack with --profile clamav, or clear CLAMAV_HOST",
|
|
)
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------- ldap
|
|
|
|
|
|
def _ldap_one(s: Settings, url: str, password: str) -> Check:
|
|
import ldap3
|
|
from ldap3.core.exceptions import LDAPException
|
|
from ldap3.utils.conv import escape_filter_chars
|
|
|
|
name = f"ldap: {urlparse(url).hostname or url}"
|
|
ldaps = url.lower().startswith("ldaps://")
|
|
if not (ldaps or s.ldap_starttls) and not s.ldap_allow_insecure:
|
|
return Check(
|
|
name,
|
|
FAIL,
|
|
f"{url} is plain LDAP without STARTTLS",
|
|
"use ldaps:// or LDAP_STARTTLS=true (LDAP_ALLOW_INSECURE only off prod)",
|
|
)
|
|
tls = ldap3.Tls(
|
|
validate=ssl.CERT_REQUIRED,
|
|
version=ssl.PROTOCOL_TLS_CLIENT,
|
|
ca_certs_file=str(s.ldap_ca_bundle) if s.ldap_ca_bundle else None,
|
|
)
|
|
server = ldap3.Server(url, use_ssl=ldaps, tls=tls, get_info=ldap3.SCHEMA, connect_timeout=10)
|
|
try:
|
|
conn = ldap3.Connection(
|
|
server, user=s.ldap_bind_dn, password=password, receive_timeout=15, raise_exceptions=False
|
|
)
|
|
conn.open()
|
|
if s.ldap_starttls and not ldaps and not conn.start_tls():
|
|
return Check(name, FAIL, f"STARTTLS failed: {conn.result.get('description')}")
|
|
if not conn.bind():
|
|
return Check(
|
|
name,
|
|
FAIL,
|
|
f"bind as {s.ldap_bind_dn} failed: {conn.result.get('description')}",
|
|
"check LDAP_BIND_DN and the ldap_bind_password secret",
|
|
)
|
|
try:
|
|
schema = server.schema
|
|
missing = [a for a in LDAP_ATTRS if not schema or a not in schema.attribute_types]
|
|
if missing:
|
|
return Check(name, FAIL, f"schema lacks {', '.join(missing)}")
|
|
conn.search(s.ldap_bind_dn, "(objectClass=*)", ldap3.BASE, attributes=list(LDAP_ATTRS))
|
|
if not conn.entries:
|
|
return Check(name, FAIL, "can't read the service account's own entry")
|
|
parts = ["bind ok, service entry readable"]
|
|
status = PASS
|
|
if s.ldap_group_base_dn:
|
|
groups = conn.extend.standard.paged_search(
|
|
s.ldap_group_base_dn,
|
|
"(&(objectClass=group)(cn=PS-*))",
|
|
ldap3.SUBTREE,
|
|
attributes=["cn"],
|
|
paged_size=200,
|
|
generator=False,
|
|
)
|
|
count = sum(1 for g in groups if g.get("type") == "searchResEntry")
|
|
if count == EXPECTED_PS_GROUPS:
|
|
parts.append(f"{count} PS-* groups")
|
|
else:
|
|
status = WARN
|
|
parts.append(f"{count} PS-* groups (expected {EXPECTED_PS_GROUPS})")
|
|
base = s.ldap_group_base_dn
|
|
else:
|
|
status = WARN
|
|
parts.append("LDAP_GROUP_BASE_DN not set")
|
|
base = s.ldap_base_dn
|
|
if base:
|
|
ok = conn.search(
|
|
base,
|
|
f"(member:{IN_CHAIN}:={escape_filter_chars(s.ldap_bind_dn)})",
|
|
ldap3.SUBTREE,
|
|
attributes=["cn"],
|
|
size_limit=500,
|
|
)
|
|
desc = conn.result.get("description")
|
|
if ok or desc == "success":
|
|
parts.append(f"IN_CHAIN query ok ({len(conn.entries)} groups hold the service account)")
|
|
else:
|
|
status = FAIL
|
|
parts.append(f"IN_CHAIN query failed: {desc}")
|
|
return Check(name, status, "; ".join(parts))
|
|
finally:
|
|
conn.unbind()
|
|
except LDAPException as e:
|
|
return Check(name, FAIL, f"{type(e).__name__}: {e}", "check LDAP_CA_BUNDLE and that the port is reachable")
|
|
|
|
|
|
def check_ldap(s: Settings) -> list[Check]:
|
|
urls = s.ldap_urls
|
|
if not urls or not s.ldap_bind_dn:
|
|
return [Check("ldap", FAIL, "LDAP_URL or LDAP_BIND_DN not set", "set them, or --skip ldap")]
|
|
try:
|
|
password = s.ldap_password()
|
|
except SecretFileError as e:
|
|
return [Check("ldap", FAIL, str(e), "mount the ldap_bind_password secret")]
|
|
return [_ldap_one(s, url, password) for url in urls]
|
|
|
|
|
|
# ---------------------------------------------------------------- smtp
|
|
|
|
|
|
def check_smtp(s: Settings, send_test: str | None = None) -> list[Check]:
|
|
name = f"smtp: {s.smtp_host}:{s.smtp_port}"
|
|
try:
|
|
with smtplib.SMTP(s.smtp_host, s.smtp_port, timeout=15) as smtp:
|
|
smtp.ehlo()
|
|
if s.smtp_starttls:
|
|
ctx = ssl.create_default_context(cafile=str(s.smtp_ca_bundle) if s.smtp_ca_bundle else None)
|
|
smtp.starttls(context=ctx)
|
|
smtp.ehlo()
|
|
out = [Check(name, PASS, "EHLO and STARTTLS with a verified certificate")]
|
|
else:
|
|
out = [Check(name, WARN, "EHLO ok; STARTTLS is off (SMTP_STARTTLS=false)")]
|
|
if send_test:
|
|
msg = EmailMessage()
|
|
msg["From"] = s.smtp_from
|
|
msg["To"] = send_test
|
|
msg["Subject"] = f"Packet Station doctor test ({s.stack})"
|
|
msg.set_content(f"Test message from packetstation doctor, build {s.ps_build}.")
|
|
smtp.send_message(msg)
|
|
out.append(Check("smtp: send test", PASS, f"sent to {send_test}"))
|
|
return out
|
|
except ssl.SSLError as e:
|
|
return [Check(name, FAIL, f"TLS: {e}", "set SMTP_CA_BUNDLE to the CA that signed the relay certificate")]
|
|
except (OSError, smtplib.SMTPException) as e:
|
|
return [Check(name, FAIL, f"{type(e).__name__}: {e}")]
|
|
|
|
|
|
# ---------------------------------------------------------------- tz
|
|
|
|
|
|
def check_tz(tz: str) -> Check:
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
try:
|
|
ZoneInfo(tz)
|
|
except (ZoneInfoNotFoundError, ValueError) as e:
|
|
return Check("tz", FAIL, f"SITE_TZ {tz} does not load: {e}", "install tzdata")
|
|
return Check("tz", PASS, f"{tz} loads")
|
|
|
|
|
|
# ---------------------------------------------------------------- runner
|
|
|
|
|
|
def run(
|
|
skip: set[str] | None = None, send_test: str | None = None, load: Callable[[], Settings] = Settings
|
|
) -> list[Check]:
|
|
skip = skip or set()
|
|
checks, s = check_config(load)
|
|
steps: list[tuple[str, Callable[[Settings], list[Check]]]] = [
|
|
("visual", check_visual),
|
|
("mfiles", check_mfiles),
|
|
("db", check_db),
|
|
("volume", check_volume),
|
|
("clamav", check_clamav),
|
|
("ldap", check_ldap),
|
|
("smtp", lambda st: check_smtp(st, send_test)),
|
|
]
|
|
for key, fn in steps:
|
|
if key in skip:
|
|
checks.append(Check(key, SKIP, "skipped (--skip)"))
|
|
elif s is None:
|
|
checks.append(Check(key, SKIP, "settings did not load"))
|
|
else:
|
|
try:
|
|
checks.extend(fn(s))
|
|
except Exception as e:
|
|
checks.append(Check(key, FAIL, f"{type(e).__name__}: {e}"))
|
|
checks.append(check_tz(s.site_tz if s else "America/Denver"))
|
|
return checks
|
|
|
|
|
|
def failed(checks: list[Check]) -> bool:
|
|
return any(c.status == FAIL for c in checks)
|
|
|
|
|
|
def as_json(checks: list[Check]) -> list[dict[str, str]]:
|
|
return [asdict(c) for c in checks]
|
|
|
|
|
|
def report(checks: list[Check]) -> str:
|
|
width = max((len(c.name) for c in checks), default=0)
|
|
lines = []
|
|
for c in checks:
|
|
lines.append(f"[{c.status}] {c.name:<{width}} {c.detail}")
|
|
if c.hint and c.status not in (PASS, SKIP):
|
|
lines.append(f" {'':<{width}} -> {c.hint}")
|
|
fails = sum(c.status == FAIL for c in checks)
|
|
warns = sum(c.status == WARN for c in checks)
|
|
lines.append("")
|
|
lines.append("All checks passed" if not fails and not warns else f"{fails} failed, {warns} warnings")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def parse_skip(text: str | None) -> set[str]:
|
|
if not text:
|
|
return set()
|
|
items = {x.strip().lower() for x in text.split(",") if x.strip()}
|
|
bad = items - set(SKIPPABLE)
|
|
if bad:
|
|
print(f"unknown --skip value(s): {', '.join(sorted(bad))}; choose from {', '.join(SKIPPABLE)}", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
return items
|