A filed packet's sealed data book, documents and a manifest are copied to the archive folder by the worker. A failed copy never blocks filing: it is retried with backoff and raises the archive_failed condition. Co-Authored-By: Claude Opus 5.5 <[email protected]>
252 lines
8.9 KiB
Python
252 lines
8.9 KiB
Python
"""`packetstation` command line: app, worker, job, fixtures, migrate, doctor, version, healthcheck."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from . import __version__
|
|
from .settings import Settings
|
|
|
|
log = logging.getLogger("packetstation")
|
|
|
|
DEFAULT_BEAT_FILE = "/tmp/ps-worker.beat" # noqa: S108 (contract default; containers have a private /tmp)
|
|
HEALTH_URL = "http://127.0.0.1:8000/healthz"
|
|
|
|
|
|
def _settings() -> Settings:
|
|
from .settings import get_settings
|
|
|
|
return get_settings()
|
|
|
|
|
|
def _setup_logging(level: str | None = None) -> None:
|
|
from . import logs
|
|
|
|
logs.setup(level or os.environ.get("LOG_LEVEL", "INFO").upper())
|
|
|
|
|
|
def cmd_app(_: argparse.Namespace) -> int:
|
|
import uvicorn
|
|
|
|
s = _settings()
|
|
_setup_logging(s.log_level)
|
|
uvicorn.run(
|
|
"packetstation.main:app",
|
|
host="0.0.0.0",
|
|
port=8000,
|
|
proxy_headers=False,
|
|
forwarded_allow_ips="",
|
|
server_header=False,
|
|
access_log=False,
|
|
log_config=None,
|
|
)
|
|
return 0
|
|
|
|
|
|
def cmd_worker(_: argparse.Namespace) -> int:
|
|
from . import guard, worker
|
|
|
|
s = _settings()
|
|
_setup_logging(s.log_level)
|
|
guard.startup_checks(s)
|
|
beat = Path(os.environ.get("WORKER_BEAT_FILE") or s.worker_beat_file)
|
|
return worker.run(beat, s.ps_build, settings=s)
|
|
|
|
|
|
def cmd_job_run(a: argparse.Namespace) -> int:
|
|
"""Run one job once in the foreground (trigger `cli`) and print its job_runs row as JSON."""
|
|
from . import guard
|
|
from .worker.jobs import load_jobs
|
|
from .worker.runs import run_job
|
|
from .worker.scheduler import Scheduler
|
|
|
|
s = _settings()
|
|
_setup_logging(s.log_level)
|
|
guard.startup_checks(s)
|
|
jobs = load_jobs()
|
|
if a.name not in jobs:
|
|
print(f"packetstation job run: no job {a.name!r}; jobs: {', '.join(sorted(jobs))}", file=sys.stderr)
|
|
return 2
|
|
sched = Scheduler(s, jobs=jobs)
|
|
try:
|
|
row = run_job(sched, a.name, "cli")
|
|
except Exception as e:
|
|
from .worker.redaction import redact
|
|
|
|
print(f"packetstation job run: {redact(f'{type(e).__name__}: {e}')}", file=sys.stderr)
|
|
return 1
|
|
print(json.dumps(row, indent=2, default=str))
|
|
return 1 if row.get("status") == "failed" else 0
|
|
|
|
|
|
def cmd_fixtures_use(a: argparse.Namespace) -> int:
|
|
"""Point the fixture sources at state N: writes DIR/state. Never on STACK=prod."""
|
|
if os.environ.get("STACK", "").strip().lower() == "prod":
|
|
print("packetstation fixtures: refused on STACK=prod", file=sys.stderr)
|
|
return 2
|
|
if a.state < 1:
|
|
print("packetstation fixtures use: the state is a number from 1", file=sys.stderr)
|
|
return 2
|
|
d = Path(a.dir or os.environ.get("FIXTURE_DIR") or "/fixtures")
|
|
if not (d / f"feed.{a.state}.json").is_file():
|
|
print(f"packetstation fixtures use: {d / f'feed.{a.state}.json'} not found", file=sys.stderr)
|
|
return 1
|
|
tmp = d / "state.tmp"
|
|
tmp.write_text(str(a.state) + "\n", encoding="utf-8")
|
|
tmp.replace(d / "state")
|
|
print(f"fixture state {a.state} in {d}")
|
|
return 0
|
|
|
|
|
|
def cmd_migrate(a: argparse.Namespace) -> int:
|
|
from . import db, migrate
|
|
|
|
s = _settings()
|
|
_setup_logging(s.log_level)
|
|
try:
|
|
if a.bootstrap_roles:
|
|
for item in migrate.bootstrap_roles(s):
|
|
log.info("bootstrap created %s", item)
|
|
with db.connect_as("migrate", s, autocommit=True) as conn:
|
|
done = migrate.run(conn, db.MIGRATIONS_DIR)
|
|
except migrate.MigrationError as e:
|
|
log.error("migrate refused: %s", e)
|
|
return 1
|
|
log.info("migrations applied: %s", ", ".join(done) if done else "none (at head)")
|
|
return 0
|
|
|
|
|
|
def cmd_doctor(a: argparse.Namespace) -> int:
|
|
from . import doctor
|
|
|
|
_setup_logging("WARNING")
|
|
checks = doctor.run(skip=doctor.parse_skip(a.skip), send_test=a.send_test)
|
|
if a.json:
|
|
print(json.dumps(doctor.as_json(checks), indent=2))
|
|
else:
|
|
print(doctor.report(checks))
|
|
return 1 if doctor.failed(checks) else 0
|
|
|
|
|
|
def cmd_ad_groups(a: argparse.Namespace) -> int:
|
|
"""Print the 50 mapped group names, one per line, in map order (deploy/samba/groups.txt is a copy)."""
|
|
from .auth import groups
|
|
|
|
if a.list:
|
|
for name in groups.group_names():
|
|
print(name)
|
|
return 0
|
|
print("packetstation ad-groups: say what to do (--list)", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
def cmd_ad_refresh(_: argparse.Namespace) -> int:
|
|
"""Enumerate the mapped AD groups with the service account and refresh the users table."""
|
|
from . import db
|
|
from .auth import groups
|
|
from .auth.directory import refresh_from_ad
|
|
from .auth.ldap import Directory, LdapUnavailable
|
|
|
|
s = _settings()
|
|
_setup_logging(s.log_level)
|
|
try:
|
|
with db.connect_as("app", s, autocommit=True) as conn:
|
|
deps = groups.load_departments(conn)
|
|
if deps:
|
|
groups.use_departments(deps)
|
|
res = refresh_from_ad(conn, Directory(s))
|
|
except LdapUnavailable as e:
|
|
log.error("ad-refresh: AD unavailable: %s", e)
|
|
return 1
|
|
print(json.dumps(res.as_dict(), indent=2))
|
|
return 0
|
|
|
|
|
|
def cmd_version(_: argparse.Namespace) -> int:
|
|
print(f"packetstation {__version__} build {os.environ.get('PS_BUILD', 'dev')}")
|
|
return 0
|
|
|
|
|
|
def cmd_healthcheck(a: argparse.Namespace) -> int:
|
|
"""Exit 0 when healthy. Reads the environment directly so it works without full settings."""
|
|
if a.target == "app":
|
|
import httpx
|
|
|
|
try:
|
|
r = httpx.get(HEALTH_URL, timeout=3.0)
|
|
return 0 if r.status_code == 200 and r.json().get("ok") is True else 1
|
|
except (httpx.HTTPError, ValueError):
|
|
return 1
|
|
from .worker import STALE_SECONDS, beat_age
|
|
|
|
age = beat_age(Path(os.environ.get("WORKER_BEAT_FILE") or DEFAULT_BEAT_FILE))
|
|
return 0 if age is not None and age < STALE_SECONDS else 1
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
ap = argparse.ArgumentParser(prog="packetstation", description="Packet Station server")
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
sub.add_parser("app", help="Run the web app (uvicorn on 0.0.0.0:8000)").set_defaults(func=cmd_app)
|
|
sub.add_parser("worker", help="Run the background worker").set_defaults(func=cmd_worker)
|
|
j = sub.add_parser("job", help="Worker jobs")
|
|
jsub = j.add_subparsers(dest="job_cmd", required=True)
|
|
jr = jsub.add_parser("run", help="Run one job once now and print its run as JSON (exit 1 when it failed)")
|
|
jr.add_argument("name", help="visual-feed, drawing-lookup or housekeeping")
|
|
jr.set_defaults(func=cmd_job_run)
|
|
f = sub.add_parser("fixtures", help="Synthetic feed fixtures (CI and tests)")
|
|
fsub = f.add_subparsers(dest="fixtures_cmd", required=True)
|
|
fu = fsub.add_parser("use", help="Point the fixture sources at state N (writes DIR/state)")
|
|
fu.add_argument("state", type=int)
|
|
fu.add_argument("--dir", help="The fixture directory (default FIXTURE_DIR or /fixtures)")
|
|
fu.set_defaults(func=cmd_fixtures_use)
|
|
m = sub.add_parser("migrate", help="Apply pending SQL migrations as the migrate role")
|
|
m.add_argument(
|
|
"--bootstrap-roles",
|
|
action="store_true",
|
|
help="dev/test/ci: create roles and database first, as PG_SUPERUSER_URL",
|
|
)
|
|
m.set_defaults(func=cmd_migrate)
|
|
d = sub.add_parser("doctor", help="Check configuration and every dependency")
|
|
d.add_argument("--json", action="store_true", help="Print the checks as a JSON list")
|
|
d.add_argument("--skip", metavar="LIST", help="Comma list of: visual,mfiles,ldap,smtp,db,volume,clamav,archive")
|
|
d.add_argument("--send-test", metavar="ADDR", help="Also send one test message to ADDR")
|
|
d.set_defaults(func=cmd_doctor)
|
|
g = sub.add_parser("ad-groups", help="The AD groups Packet Station maps to roles")
|
|
g.add_argument("--list", action="store_true", help="Print the 50 group names, one per line")
|
|
g.set_defaults(func=cmd_ad_groups)
|
|
sub.add_parser("ad-refresh", help="Refresh users and roles from AD now; prints the counts as JSON").set_defaults(
|
|
func=cmd_ad_refresh
|
|
)
|
|
sub.add_parser("version", help="Print the version and build").set_defaults(func=cmd_version)
|
|
h = sub.add_parser("healthcheck", help="Container healthcheck (exit 0 healthy, 1 not)")
|
|
h.add_argument("target", choices=["app", "worker"])
|
|
h.set_defaults(func=cmd_healthcheck)
|
|
return ap
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
try:
|
|
return int(args.func(args))
|
|
except KeyboardInterrupt:
|
|
return 130
|
|
except Exception as e:
|
|
from pydantic import ValidationError
|
|
|
|
from .guard import StackGuardError
|
|
from .settings import SecretFileError
|
|
|
|
if isinstance(e, (ValidationError, StackGuardError, SecretFileError)):
|
|
print(f"packetstation {args.cmd}: {e}", file=sys.stderr)
|
|
return 2
|
|
raise
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|