Phase 10: regression bench, hardening, archive location, native VM kit, go-live docs #55

Merged
spencerm merged 10 commits from phase-10 into main 2026-11-03 21:16:19 +00:00
21 changed files with 1537 additions and 11 deletions
Showing only changes of commit da31b0a87a - Show all commits
+5
View File
@@ -20,3 +20,8 @@
# CLAMAV_HOST= empty: no virus scan. Set it and start the stack with --profile clamav
# CLAMAV_PORT=3310
# CLAMAV_TIMEOUT_SECONDS=30
#
# Archive location (phase 10, Gitea issue 7). See docs/DEPLOY.md for the share:
# ARCHIVE_ROOT= empty: the archive is off. Set it to the mounted archive share, e.g. /archive.
# The Admin picks a folder under it (Admin, File locations); filed packets are
# copied there by the archive-copy job.
+3
View File
@@ -59,6 +59,9 @@ SEAL_KEY_FILE=/run/secrets/seal_key
ASSETS_DIR=/data/assets
# Built frontend. Compose sets it too.
STATIC_DIR=/app/static
# The archive share (Gitea issue 7). compose.ci.yaml mounts the ci-archive volume here, with /archive/packets
# writable by uid 10001. Empty turns the archive off.
ARCHIVE_ROOT=/archive
# ---- LDAP (the throwaway Samba DC from deploy/samba, domain PSTEST.LOCAL; never PESCO's AD) ----
# The DC's alias on the backend network. LDAPS with the CA it generates on first start.
+31
View File
@@ -36,12 +36,36 @@ services:
volumes:
- ci-dc-export:/etc/ssl/pstest:ro # the DC's CA, LDAP_CA_BUNDLE=/etc/ssl/pstest/ca.pem
# The archive share stand-in (Gitea issue 7): a volume at /archive (ARCHIVE_ROOT in ci.env) with the folder
# /archive/packets owned by uid 10001, so the e2e scenario can save "packets" under File locations.
archive-init:
build: *build
image: ${IMAGE:-packetstation:ci}
pull_policy: build
user: "0:0"
# Once per volume: root may not chmod the folder after it belongs to 10001 (no CAP_FOWNER).
entrypoint:
- sh
- -c
- test -d /archive/packets || { mkdir /archive/packets && chmod 0750 /archive/packets && chown 10001:10001 /archive/packets; }
volumes:
- ci-archive:/archive
network_mode: none
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
restart: "no"
app:
build: *build
image: ${IMAGE:-packetstation:ci}
pull_policy: build
volumes:
- ci-dc-export:/etc/ssl/pstest:ro
- ci-archive:/archive
# Playwright reaches the app as ps-app, never as "app": "app" is a top-level domain on
# Chromium's HSTS preload list, so http://app:8000 is forced to TLS and fails.
networks:
@@ -52,6 +76,8 @@ services:
depends_on:
samba-dc:
condition: service_healthy
archive-init:
condition: service_completed_successfully
worker:
build: *build
@@ -60,6 +86,10 @@ services:
volumes:
- ci-dc-export:/etc/ssl/pstest:ro
- ../tests/fixtures/generated:/fixtures:ro # synthetic feed and drawings; the scenario switches state
- ci-archive:/archive
depends_on:
archive-init:
condition: service_completed_successfully
# Throwaway Samba AD DC (PSTEST.LOCAL) so LDAP sign-in runs over real TLS in CI.
# Built from deploy/samba/. Runs as root, as Samba needs. No published port.
@@ -127,6 +157,7 @@ services:
volumes:
ci-dc-data: {}
ci-dc-export: {}
ci-archive: {}
secrets:
samba_admin_password:
+31 -1
View File
@@ -88,8 +88,9 @@ At start the worker runs the same stack guard as the app: the test stack refuses
| `backup-check` | Daily at 07:15 site time | no | The newest dump is there, not empty, under 26 hours old, and not followed by a failure (phase 9). |
| `notify-send` | Every 60 seconds | no | Rule seed, the version check, the watch checks, immediate delivery and the digest queue (phase 9). |
| `notify-digest` | Every 5 minutes | no | Sends each digest that is due, one message per address (phase 9). |
| `archive-copy` | Every 15 minutes, plus a request from every filing, every folder save and "Retry now" | no | Copies each filed packet's data book, files and a manifest to the archive folder (phase 10). |
`job_schedule` holds each job's next run, interval and pause state for the Admin tab. Migration 0004 seeds rows for `visual-feed`, `drawing-lookup` and `housekeeping`; 0010 adds the six phase 9 jobs.
`job_schedule` holds each job's next run, interval and pause state for the Admin tab. Migration 0004 seeds rows for `visual-feed`, `drawing-lookup` and `housekeeping`; 0010 adds the six phase 9 jobs; 0012 adds `archive-copy`.
## Phase 9 jobs
@@ -141,6 +142,34 @@ A failed or retried send makes the run degraded.
For each rule whose digest time (site time) has passed today and that has outbox rows queued before it: recipients resolved per event, then grouped per address across every rule. One message per address lists every due event (`Daily digest: N notifications`), with one `notifications_sent` row per message (event type `digest`). An outbox row is marked sent only when every address got it. A failed address leaves the rows for the next run, which skips addresses that already got an event, and the job backs off 30 minutes (open question 43). Nothing queued: the run ends without touching AD.
## archive-copy (every 15 minutes, phase 10)
Gitea issue 7. Not pausable. Off when `ARCHIVE_ROOT` is unset or blank: the run does nothing (counts `enabled: false`) and FILE asks for nothing. With the root set but no folder saved on Admin, File locations, the run idles (`skipped: no archive folder saved`).
FILE inserts a `job_requests` row for `archive-copy` and NOTIFYs `ps_job_request` inside its own transaction when the archive is on and a folder is saved, so the copy starts right after the filing commits. The request is made in a savepoint: if it fails, the filing still commits and the next scheduled run copies the packet. A failed copy never blocks filing and never touches the packet. Saving a folder and "Retry now" also request a run.
Each run:
1. Checks the saved folder with the same rules and probe as the Admin's Check (below). A failed check raises `archive_failed`, makes the run degraded and copies nothing. The job audits its own check (`archive.checked`, source `job`) only when the result differs from the last check on record.
2. Queues every unbroken seal with a data book that has no `archive_copies` row, so turning the archive on later copies earlier filings too.
3. Copies the pending rows, then the failed rows whose `next_try_at` has passed, up to 200 per run. Each copy is audited (`archive.copied` or `archive.copy_failed`). A failed copy waits 5 minutes, then 15, then 60, then an hour between tries. "Retry now" makes every failed row due at once.
4. Raises `archive_failed` (condition, critical, subject `archive`) when any copy has failed 3 times or more, and resolves it when no copy is failed.
Counts: `enabled`, `archive_dir`, `queued`, `copied`, `failed` (this run), `pending`, `failed_total`, `done_total`, and `check` (the message) when the folder check failed. A failed copy makes the run degraded.
**The folder rules.** `ARCHIVE_ROOT` is a fixed path on the server (the NFS mount). The Admin enters the folder relative to it; an absolute path is accepted only under the root and is stored relative. Refused: empty, a `..` component, an absolute path elsewhere, the root itself, a NUL character, more than 400 characters, a folder that does not exist (nothing creates it), something that is not a folder, and a path whose real path (links followed) leaves the real root. **The check** writes `.packetstation-probe-<random>` in the folder, fsyncs it, reads it back, compares and deletes it, and reports the free space. `packetstation doctor` runs the same check on the saved folder (SKIP when the archive is off or no folder is saved).
**What is copied.** One folder per seal: `<archive folder>/<project>/<unit>/<data book name without .pdf>-seal<seal id>/`, for example `packets/L51234/L51234001/L51234-Lot001_D-5001_Manufacturing_Data_Book-seal7/`. In it:
- the sealed data book PDF (the asset on the seal);
- the files of the packet's drawing pages (live and void, each file once);
- the packet documents that are not removed (NCRs and test reports included);
- the current Q.C. folder documents of the packet's unit and its project (what the data book's section 5 reads);
- the MTRs on the BOM lines;
- `manifest.json`: `packet_id`, `packet_label`, `project`, `unit`, `drawing`, `rev`, `seal_id`, `seal_hash`, `key_id`, `sealed_at`, `sealed_by`, `filed_at`, `filed_by`, `build`, and per file `name`, `kind` (databook, page, doc, qc_doc, mtr), `sha256`, `size`, `asset_id`.
File names are the stored original names made safe (no separators or reserved characters) and unique in the folder (`name (2).pdf`). Each file is written to a temp name in the folder, fsynced, checked against the asset's sha256 and renamed; the manifest goes last. An existing identical file counts as done; a different file is never overwritten (the copy fails and names the file). A manifest already there for the same seal and files means the copy is done. Nothing in the archive is ever deleted. A refile after a reopen is a new seal and a new folder.
### Events from the feed and the drawing job
After each `visual-feed` and `drawing-lookup` run, `worker/runs.py` raises or resolves the feed conditions:
@@ -171,6 +200,7 @@ After each `visual-feed` and `drawing-lookup` run, `worker/runs.py` raises or re
| `qc_reject` | A Q.C. reject on a packet, an ITP line reject | one-shot, department | none |
| `version_deployed` | notify-send, first run on a new build | one-shot | none |
| `assets_quota` | The storage check after every upload and in housekeeping | condition | usage back under the threshold |
| `archive_failed` | archive-copy: a copy failed 3 times, or the folder check failed | condition, subject `archive` | no copy failed |
A condition sends once when it opens and a "Cleared: <title>" message to the same rule when it closes. Raised again while open, it only counts (`raised_count`). The department of an ITP reject is the one department among the unit's live packets that works to the line's page; none or several gives none.
+11 -1
View File
@@ -353,7 +353,7 @@ Phase 3, migration 0004:
- `job_requests`: one row per "Run now". `job`, `requested_at`, `requested_by`, `note`, `claimed_run_id` and `claimed_at` (set by the run that took it). Index on `job` where `claimed_at` is null. The app inserts the row and NOTIFYs `ps_job_request` in the same transaction.
- `job_runs`: one row per run. `job`, `trigger` (schedule, request, chain, cli), `requested_by` uuid[] (the requests claimed), `started_at`, `finished_at`, `status` (running, ok, degraded, failed, skipped, abandoned), `counts` jsonb, `errors` jsonb (a list of redacted strings), `build_version`, `visual_target` (`"<server> / <database>"`), `host`. Index on (`job`, `id DESC`).
- `job_schedule`: one row per job (`job` PK). `next_run_at`, `interval_seconds`, `paused`, `updated_at`. Written by the worker after each run for the Admin tab. Seeded for `visual-feed`, `drawing-lookup` and `housekeeping`; 0010 adds `ad-reconcile`, `session-refresh`, `integrity`, `backup-check`, `notify-send` and `notify-digest`.
- `job_schedule`: one row per job (`job` PK). `next_run_at`, `interval_seconds`, `paused`, `updated_at`. Written by the worker after each run for the Admin tab. Seeded for `visual-feed`, `drawing-lookup` and `housekeeping`; 0010 adds `ad-reconcile`, `session-refresh`, `integrity`, `backup-check`, `notify-send` and `notify-digest`; 0012 adds `archive-copy`.
- `worker_heartbeat`: a single row (`id` = 1 by CHECK). `at`, `build`, `host`, `pid`, `running` text[] (the jobs running now). Upserted every 30 seconds.
## Notifications (phases 4 and 9)
@@ -389,6 +389,16 @@ Migration 0011 serves changes C7 and C8 in [PLAN.md](PLAN.md). The others need n
- `packets.vclosed_at` and `vclosed_by` (no migration; Gitea issue 31, change C5): on a VISUAL packet the feed is now the one writer. It sets `vclosed_at` (with `vclosed_by` null) when every leg is closed or cancelled and clears both when a leg shows open again. On a hand packet Clerical still sets them. See [FEED.md](FEED.md), "The apply".
- `qc_inspections.ncr` (no migration; Gitea issue 24, change C2) is now also written by the accept that carries the NCR number of an earlier reject.
## Archive location (phase 10, migration 0012)
Gitea issue 7. Filed packets are copied to a folder on the archive share (see [FEED.md](FEED.md), "archive-copy").
- `archive_copies` (new): one row per filed seal to copy. `seal_id` (unique, references `packet_seals`), `packet_id`, `status` (pending, done or failed by CHECK), `attempts` (tries so far, the successful one included), `last_error` (the last failure, for the Admin), `next_try_at` (when a failed row is due again), `folder` (the copy's folder relative to `ARCHIVE_ROOT`, for example `packets/L51234/L51234001/L51234-Lot001_D-5001_Manufacturing_Data_Book-seal7`), `manifest_sha256` (sha256 of the `manifest.json` written there), `created_at`, `done_at`. A done row must have `folder` and `done_at` (CHECK). Indexed on (`next_try_at`, `id`) where not done, and on `packet_id`. `ps_app` has SELECT, INSERT and UPDATE; DELETE and TRUNCATE are revoked, so a copy record is never removed. The worker queues a row for every unbroken seal that has a data book and no row yet, so turning the archive on later also copies earlier filings. A refile after a reopen is a new seal and a new row.
- `job_schedule` gains the `archive-copy` row.
- Settings row `files` (no migration): `{"archive_dir": "<folder relative to ARCHIVE_ROOT>"}`, saved by the Admin on File locations with compare and set on `version`. No row, or no `archive_dir`, means no folder is saved and nothing is copied.
- Audit actions: `admin.file_locations_updated` (detail `archive_dir: {from, to}`), `archive.checked` (every check: `source` admin, save, doctor or job, with `archive_dir`, `ok`, `resolved`, `free_bytes`, `message`; the job records its own check only when the result changes), `archive.copied` (`seal_id`, `folder`, `files`, `manifest_sha256`), `archive.copy_failed` (`seal_id`, `attempts`, `error`), `admin.archive_retry` (the "Retry now" job request).
- Notification event type `archive_failed` (seeded by the code like the others; condition, subject `archive`).
## VISUAL and drawing caches (phase 3)
Migration 0004. Every ID part is text (`wo_base`, `lot`, `split`, `sub`, part IDs, order IDs), with `''` in place of null in a key. `seq` and `piece` are int. Closed sets are text with a CHECK. The worker fills these tables. Screens read them, never VISUAL. [FEED.md](FEED.md) says how each is written.
+141
View File
@@ -0,0 +1,141 @@
"""Admin: File locations (Gitea issue 7). The archive folder under ARCHIVE_ROOT, its check, and the archive copies.
Every route needs the admin role and a password session. Writes answer 409 `archive_off` while ARCHIVE_ROOT is unset.
The check and the save are audited (`archive.checked`, `admin.file_locations_updated`); "Retry now" is audited as
`admin.archive_retry` through the job request.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, ConfigDict, Field
from starlette.concurrency import run_in_threadpool
from starlette.responses import JSONResponse
from ..domain import archive
from .deps import Db, Principal, admin_principal, get_db, settings_of
from .errors import ApiError
from .jobs import request_run
from .packets import WRITE
router = APIRouter(prefix="/api/admin/file-locations", tags=["admin"])
class CheckBody(BaseModel):
model_config = ConfigDict(extra="forbid")
archive_dir: str = Field(max_length=archive.DIR_MAX + 100)
class PutBody(BaseModel):
model_config = ConfigDict(extra="forbid")
archive_dir: str = Field(max_length=archive.DIR_MAX + 100)
version: int = Field(ge=0)
def _out(conn: Any, request: Request) -> dict[str, Any]:
root = archive.root_of(settings_of(request))
archive_dir, version, _, _ = archive.load(conn)
resolved = None
if root is not None and archive_dir is not None:
try:
resolved = str(archive.resolve_dir(root, archive_dir)[1])
except archive.PathRefused:
resolved = None
last = archive.last_check(conn)
return {
"enabled": root is not None,
"root": str(root) if root is not None else None,
"archive_dir": archive_dir,
"version": version,
"resolved": resolved,
"last_check": {k: last[k] for k in ("ok", "at", "by", "message")} if last else None,
"copies": archive.copies_status(conn),
}
def _root_or_409(request: Request) -> Any:
root = archive.root_of(settings_of(request))
if root is None:
raise ApiError(409, "archive_off", archive.OFF_MESSAGE)
return root
@router.get("")
def file_locations(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
return _out(d.conn, request)
@router.post("/check", dependencies=WRITE)
async def file_locations_check(
body: CheckBody,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
root = _root_or_409(request)
rel, result = await run_in_threadpool(archive.check, root, body.archive_dir)
await run_in_threadpool(
archive.audit_check, d.conn, result, archive_dir=rel or body.archive_dir, source="admin", who=p
)
if rel is None:
# The path rules refused it (not a failed probe): 422 with the reason, still audited above.
raise ApiError(422, "bad_path", result.message)
return result.as_dict()
@router.put("", dependencies=WRITE)
async def file_locations_put(
body: PutBody,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Any:
root = _root_or_409(request)
conn = d.conn
rel, result = await run_in_threadpool(archive.check, root, body.archive_dir)
await run_in_threadpool(
archive.audit_check, conn, result, archive_dir=rel or body.archive_dir, source="save", who=p
)
if rel is None:
raise ApiError(422, "bad_path", result.message)
if not result.ok:
raise ApiError(422, "check_failed", result.message, extra={"check": result.as_dict()})
def save() -> dict[str, Any]:
try:
archive.save(conn, who=p, archive_dir=rel, version=body.version)
except archive.VersionConflict:
raise ApiError(
409,
"version_conflict",
"Someone else changed the file locations. Reload and try again.",
extra={"current": jsonable_encoder(_out(conn, request))},
) from None
with conn.transaction():
archive.request_copy(conn, p.user_id, "archive folder saved")
return _out(conn, request)
return await run_in_threadpool(save)
@router.post("/retry", dependencies=WRITE)
def file_locations_retry(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> JSONResponse:
_root_or_409(request)
conn = d.conn
with conn.transaction():
reset = archive.reset_backoff(conn)
out = request_run(conn, p, archive.JOB, note="Retry now (File locations)", action="admin.archive_retry")
return JSONResponse(status_code=202, content={**out, "reset": reset})
+1 -1
View File
@@ -213,7 +213,7 @@ def build_parser() -> argparse.ArgumentParser:
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")
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")
+42 -1
View File
@@ -23,7 +23,7 @@ from . import __version__
from .settings import SecretFileError, Settings
PASS, WARN, FAIL, SKIP = "PASS", "WARN", "FAIL", "SKIP"
SKIPPABLE = ("visual", "mfiles", "ldap", "smtp", "db", "volume", "clamav")
SKIPPABLE = ("visual", "mfiles", "ldap", "smtp", "db", "volume", "clamav", "archive")
FREE_WARN_BYTES = 2 * 1024**3
EXPECTED_PS_GROUPS = 50
IN_CHAIN = "1.2.840.113556.1.4.1941"
@@ -261,6 +261,46 @@ def check_volume(s: Settings) -> list[Check]:
return out
# ---------------------------------------------------------------- archive
def check_archive(s: Settings) -> list[Check]:
"""The archive folder check (probe write, read back, delete) against the saved folder, audited like the Admin's."""
import psycopg
from . import db
from .domain import archive
root = archive.root_of(s)
if root is None:
return [Check("archive", SKIP, "ARCHIVE_ROOT not set: filed packets are not copied to an archive")]
try:
with db.connect_as("app", s, autocommit=True) as conn:
archive_dir = archive.load(conn)[0]
except (SecretFileError, psycopg.Error) as e:
return [Check("archive", FAIL, f"can't read the saved archive folder: {_first_line(e)}")]
if archive_dir is None:
return [Check("archive", SKIP, f"ARCHIVE_ROOT={root}, no archive folder saved (Admin, File locations)")]
_, result = archive.check(root, archive_dir)
note = ""
try:
with db.connect_as("app", s, autocommit=True) as conn:
archive.audit_check(conn, result, archive_dir=archive_dir, source="doctor")
except (SecretFileError, psycopg.Error) as e:
note = f" (not audited: {_first_line(e)})"
if not result.ok:
return [
Check(
"archive",
FAIL,
f"{archive_dir} under {root}: {result.message}{note}",
"is the share mounted and writable by uid 10001?",
)
]
free = f", {result.free_bytes / 1024**3:.1f} GB free" if result.free_bytes is not None else ""
return [Check("archive", WARN if note else PASS, f"{result.resolved} writable{free}{note}")]
# ---------------------------------------------------------------- clamav
@@ -439,6 +479,7 @@ def run(
("db", check_db),
("volume", check_volume),
("clamav", check_clamav),
("archive", check_archive),
("ldap", check_ldap),
("smtp", lambda st: check_smtp(st, send_test)),
]
+646
View File
@@ -0,0 +1,646 @@
"""The archive location (Gitea issue 7): filed packets are copied to a folder under ARCHIVE_ROOT.
ARCHIVE_ROOT is a fixed path on the server (an NFS mount). Unset or blank turns the feature off. The Admin picks the
archive folder as a path under the root; the `files` settings row stores it relative (`{"archive_dir": "packets"}`).
A path that leaves the root is refused: empty, `..`, an absolute path elsewhere, NUL, a link that resolves outside
the root, or a folder that does not exist. The check writes a probe file, reads it back and removes it. Nothing here
creates folders or deletes anything in the archive.
FILE inserts a job request for `archive-copy` in its own transaction. The worker job (worker/jobs/archive_copy.py)
queues every unbroken seal with a data book that has no `archive_copies` row, then copies the pending and due failed
rows. A failed copy never blocks filing: it is retried with a backoff and raised to Admin as `archive_failed`.
Folder layout under the archive folder: `<project>/<unit>/<data book name>-seal<seal id>/` with the data book PDF,
the packet's files (drawing page files, packet documents, the Q.C. folder documents of its unit and project, MTRs)
and `manifest.json`. Each file goes to a temp name, is fsynced and renamed. An existing identical file counts as
done; a different one is never overwritten.
"""
from __future__ import annotations
import contextlib
import hashlib
import json
import logging
import os
import re
import secrets
import shutil
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path, PurePath
from typing import Any
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from .. import audit
from ..settings import Settings
log = logging.getLogger(__name__)
KEY = "files"
JOB = "archive-copy"
JOB_CHANNEL = "ps_job_request"
EVENT = "archive_failed"
EVENT_SUBJECT = "archive"
PROBE_PREFIX = ".packetstation-probe-"
MANIFEST = "manifest.json"
DIR_MAX = 400
FAILS_TO_RAISE = 3
# Minutes before the next try after the 1st, 2nd, 3rd failure; hourly after that.
BACKOFF_MINUTES = (5, 15, 60)
RECENT_FAILURES = 20
CHUNK = 1024 * 1024
OFF_MESSAGE = "The archive is off. Set ARCHIVE_ROOT on the server first."
_UNSAFE = re.compile(r'[\x00-\x1f\x7f<>:"/\\|?*]+')
class PathRefused(ValueError):
"""An archive folder the rules refuse. The message is for the Admin."""
class VersionConflict(Exception):
def __init__(self, version: int):
super().__init__(f"version is now {version}")
self.version = version
class CopyError(Exception):
"""One copy failed. The message goes into archive_copies.last_error and the audit row."""
@dataclass(frozen=True)
class CheckResult:
ok: bool
resolved: str | None
free_bytes: int | None
message: str
def as_dict(self) -> dict[str, Any]:
return {"ok": self.ok, "resolved": self.resolved, "free_bytes": self.free_bytes, "message": self.message}
def root_of(settings: Settings) -> Path | None:
"""ARCHIVE_ROOT, or None when the feature is off."""
root = settings.archive_root
if root is None or not str(root).strip():
return None
return Path(root)
# ---------------------------------------------------------------- path rules
def _parts(text: str) -> list[str]:
return [p for p in re.split(r"[\\/]+", text) if p not in ("", ".")]
def _is_absolute(text: str) -> bool:
return text.startswith(("/", "\\")) or PurePath(text).is_absolute()
def relative_dir(root: Path, archive_dir: str) -> str:
"""The folder as a clean relative path under the root ("a/b"). Raises PathRefused. Lexical only."""
text = (archive_dir or "").strip()
if not text:
raise PathRefused("Enter a folder under the archive root.")
if "\x00" in text:
raise PathRefused("The folder name holds a NUL character.")
if len(text) > DIR_MAX:
raise PathRefused(f"The folder path is longer than {DIR_MAX} characters.")
if ".." in re.split(r"[\\/]+", text):
raise PathRefused("The folder may not contain '..'.")
if _is_absolute(text):
root_parts = _parts(os.path.normpath(str(root)))
given = _parts(os.path.normpath(text))
norm = (lambda xs: [x.lower() for x in xs]) if os.name == "nt" else (lambda xs: xs)
if norm(given[: len(root_parts)]) != norm(root_parts):
raise PathRefused(f"An absolute path must lie under the archive root {root}.")
rel = given[len(root_parts) :]
if not rel:
raise PathRefused("Pick a folder under the archive root, not the root itself.")
return "/".join(rel)
return "/".join(_parts(text))
def resolve_dir(root: Path, archive_dir: str) -> tuple[str, Path]:
"""(relative folder, real path) for an archive folder. Raises PathRefused when the rules refuse it."""
rel = relative_dir(root, archive_dir)
try:
real_root = root.resolve(strict=True)
except OSError:
raise PathRefused(f"The archive root {root} is not there. Is the share mounted?") from None
if not real_root.is_dir():
raise PathRefused(f"The archive root {root} is not a folder.")
target = root.joinpath(*rel.split("/"))
try:
real = target.resolve(strict=True)
except OSError:
raise PathRefused(f"The folder {rel} does not exist under {root}. Create it on the share first.") from None
if real != real_root and not real.is_relative_to(real_root):
raise PathRefused(f"The folder {rel} points outside the archive root.")
if real == real_root:
raise PathRefused("Pick a folder under the archive root, not the root itself.")
if not real.is_dir():
raise PathRefused(f"{rel} is not a folder.")
return rel, real
def probe(folder: Path) -> CheckResult:
"""Write a probe file in the folder, fsync, read it back, compare and remove it."""
name = folder / f"{PROBE_PREFIX}{secrets.token_hex(8)}"
data = secrets.token_bytes(64)
try:
with open(name, "xb") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
try:
back = name.read_bytes()
finally:
name.unlink()
except OSError as e:
with contextlib.suppress(OSError):
name.unlink()
return CheckResult(False, str(folder), None, f"Cannot write in {folder}: {e.strerror or type(e).__name__}.")
if back != data:
return CheckResult(False, str(folder), None, f"The probe file read back from {folder} differs.")
try:
free = int(shutil.disk_usage(folder).free)
except OSError:
free = None
return CheckResult(True, str(folder), free, "Write, read back and delete all worked.")
def check(root: Path | None, archive_dir: str) -> tuple[str | None, CheckResult]:
"""(relative folder or None, result) for a candidate folder. Never raises for a refused path."""
if root is None:
return None, CheckResult(False, None, None, OFF_MESSAGE)
try:
rel, real = resolve_dir(root, archive_dir)
except PathRefused as e:
return None, CheckResult(False, None, None, str(e))
return rel, probe(real)
# ---------------------------------------------------------------- the settings row
def load(conn: Any) -> tuple[str | None, int, Any, Any]:
"""(archive_dir or None, version, updated_at, updated_by). Version 0 when the row is missing."""
row = conn.execute("SELECT value, version, updated_at, updated_by FROM settings WHERE key = %s", (KEY,)).fetchone()
if row is None:
return None, 0, None, None
value = row[0] if isinstance(row[0], dict) else {}
d = value.get("archive_dir")
return (str(d) if isinstance(d, str) and d.strip() else None), int(row[1]), row[2], row[3]
def save(conn: Any, *, who: Any, archive_dir: str, version: int) -> int:
"""Compare-and-set the folder (already checked). Returns the new version. Audited with the old and new value."""
with conn.transaction():
row = conn.execute("SELECT value, version FROM settings WHERE key = %s FOR UPDATE", (KEY,)).fetchone()
cur_version = int(row[1]) if row else 0
if int(version) != cur_version:
raise VersionConflict(cur_version)
value = dict(row[0]) if row and isinstance(row[0], dict) else {}
old = value.get("archive_dir")
value["archive_dir"] = archive_dir
new_version = conn.execute(
"INSERT INTO settings (key, value, version, updated_at, updated_by) VALUES (%s, %s, 1, now(), %s)"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1,"
" updated_at = now(), updated_by = EXCLUDED.updated_by RETURNING version",
(KEY, Jsonb(value), who.user_id),
).fetchone()[0]
audit.append(
conn,
action="admin.file_locations_updated",
user_id=who.user_id,
session_id=getattr(who, "session_id", None),
role_snapshot=getattr(who, "role_snapshot", None),
subject_type="settings",
subject_id=KEY,
detail={"archive_dir": {"from": old, "to": archive_dir}},
ip=getattr(who, "ip", None),
)
return int(new_version)
def audit_check(conn: Any, result: CheckResult, *, archive_dir: str | None, source: str, who: Any = None) -> None:
"""One `archive.checked` audit row: source admin (the Check button), save, or job (the worker's own check)."""
audit.append(
conn,
action="archive.checked",
user_id=getattr(who, "user_id", None),
session_id=getattr(who, "session_id", None),
role_snapshot=getattr(who, "role_snapshot", None),
subject_type="archive",
subject_id=(archive_dir or "")[:200] or None,
detail={"source": source, "archive_dir": archive_dir, **result.as_dict()},
ip=getattr(who, "ip", None),
)
def last_check(conn: Any) -> dict[str, Any] | None:
"""The newest check from the audit log: {ok, at, by, message, archive_dir, source}."""
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
"SELECT e.at, e.detail, u.display_name FROM audit_events e LEFT JOIN users u ON u.id = e.user_id"
" WHERE e.action = 'archive.checked' ORDER BY e.id DESC LIMIT 1"
).fetchone()
if r is None:
return None
d = r["detail"] or {}
return {
"ok": bool(d.get("ok")),
"at": r["at"],
"by": r["display_name"] or ("Worker" if d.get("source") == "job" else None),
"message": d.get("message") or "",
"archive_dir": d.get("archive_dir"),
"source": d.get("source"),
}
# ---------------------------------------------------------------- copies: status and requests
def copies_status(conn: Any) -> dict[str, Any]:
"""{pending, failed, done, recent_failures: [...]}."""
counts = {"pending": 0, "failed": 0, "done": 0}
for status, n in conn.execute("SELECT status, count(*) FROM archive_copies GROUP BY status").fetchall():
counts[str(status)] = int(n)
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
"SELECT c.seal_id, c.packet_id, c.attempts, c.last_error, c.next_try_at,"
" pr.number AS project, u.lot, p.drawing"
" FROM archive_copies c JOIN packets p ON p.id = c.packet_id JOIN units u ON u.id = p.unit_id"
" JOIN projects pr ON pr.id = u.project_id"
" WHERE c.status = 'failed' ORDER BY c.next_try_at NULLS FIRST, c.id LIMIT %s",
(RECENT_FAILURES,),
).fetchall()
return {
**counts,
"recent_failures": [
{
"seal_id": r["seal_id"],
"packet_id": r["packet_id"],
"packet_label": packet_label(r["project"], r["lot"], r["drawing"]),
"attempts": r["attempts"],
"last_error": r["last_error"],
"next_try_at": r["next_try_at"],
}
for r in rows
],
}
def stuck_failures(conn: Any) -> list[dict[str, Any]]:
"""The failed copies that have failed FAILS_TO_RAISE times or more, oldest first."""
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
"SELECT c.seal_id, c.packet_id, c.attempts, c.last_error, pr.number AS project, u.lot, p.drawing"
" FROM archive_copies c JOIN packets p ON p.id = c.packet_id JOIN units u ON u.id = p.unit_id"
" JOIN projects pr ON pr.id = u.project_id"
" WHERE c.status = 'failed' AND c.attempts >= %s ORDER BY c.id",
(FAILS_TO_RAISE,),
).fetchall()
return [{**r, "packet_label": packet_label(r["project"], r["lot"], r["drawing"])} for r in rows]
def packet_label(project: Any, lot: Any, drawing: Any) -> str:
return " ".join(x for x in (str(project or ""), f"Lot {lot}" if lot else "", str(drawing or "")) if x)
def request_copy(conn: Any, user_id: Any = None, note: str | None = None) -> None:
"""Ask the worker for an archive-copy run: a job request and a NOTIFY, in the caller's transaction."""
conn.execute("INSERT INTO job_requests (job, requested_by, note) VALUES (%s, %s, %s)", (JOB, user_id, note))
conn.execute("SELECT ps_notify(%s, %s)", (JOB_CHANNEL, json.dumps({"job": JOB}, separators=(",", ":"))))
def on_filed(conn: Any, settings: Settings, user_id: Any, packet_id: int) -> bool:
"""FILE's hook: request a prompt copy when the archive is on and a folder is saved. Never raises; a failure
here only means the copy waits for the next scheduled run. Returns whether a request went in."""
if root_of(settings) is None:
return False
try:
with conn.transaction():
if load(conn)[0] is None:
return False
request_copy(conn, user_id, f"filed packet {packet_id}")
return True
except Exception:
log.exception("archive copy request for packet %s not made; the scheduled run picks it up", packet_id)
return False
def reset_backoff(conn: Any) -> int:
"""Make every failed copy due now. Returns how many."""
return int(conn.execute("UPDATE archive_copies SET next_try_at = now() WHERE status = 'failed'").rowcount or 0)
def queue_missing(conn: Any) -> int:
"""Queue every unbroken seal with a data book and no archive_copies row. Returns how many."""
cur = conn.execute(
"INSERT INTO archive_copies (seal_id, packet_id)"
" SELECT s.id, s.packet_id FROM packet_seals s"
" WHERE s.broken_at IS NULL AND s.databook_asset_id IS NOT NULL"
" AND NOT EXISTS (SELECT 1 FROM archive_copies c WHERE c.seal_id = s.id)"
" ORDER BY s.id ON CONFLICT (seal_id) DO NOTHING"
)
return int(cur.rowcount or 0)
def due_rows(conn: Any, limit: int) -> list[dict[str, Any]]:
with conn.cursor(row_factory=dict_row) as cur:
rows: list[dict[str, Any]] = cur.execute(
"SELECT * FROM archive_copies WHERE status = 'pending'"
" OR (status = 'failed' AND (next_try_at IS NULL OR next_try_at <= now()))"
" ORDER BY (status = 'failed'), id LIMIT %s",
(limit,),
).fetchall()
return rows
def backoff(attempts: int) -> timedelta:
"""The wait after the given number of failed attempts: 5, 15, 60 minutes, then hourly."""
i = max(1, attempts) - 1
return timedelta(minutes=BACKOFF_MINUTES[i] if i < len(BACKOFF_MINUTES) else 60)
def mark_done(conn: Any, row: Mapping[str, Any], folder: str, manifest_sha: str, files: int) -> None:
with conn.transaction():
conn.execute(
"UPDATE archive_copies SET status = 'done', attempts = attempts + 1, last_error = NULL, next_try_at = NULL,"
" folder = %s, manifest_sha256 = %s, done_at = now() WHERE id = %s",
(folder, manifest_sha, row["id"]),
)
audit.append(
conn,
action="archive.copied",
subject_type="seal",
subject_id=str(row["seal_id"]),
packet_id=int(row["packet_id"]),
detail={"seal_id": row["seal_id"], "folder": folder, "files": files, "manifest_sha256": manifest_sha},
)
def mark_failed(conn: Any, row: Mapping[str, Any], error: str) -> int:
"""Record one failed attempt. Returns the attempts so far."""
attempts = int(row["attempts"] or 0) + 1
error = " ".join(error.split())[:500]
with conn.transaction():
conn.execute(
"UPDATE archive_copies SET status = 'failed', attempts = %s, last_error = %s,"
" next_try_at = now() + %s WHERE id = %s",
(attempts, error, backoff(attempts), row["id"]),
)
audit.append(
conn,
action="archive.copy_failed",
subject_type="seal",
subject_id=str(row["seal_id"]),
packet_id=int(row["packet_id"]),
detail={"seal_id": row["seal_id"], "attempts": attempts, "error": error},
)
return attempts
# ---------------------------------------------------------------- one copy
def safe_part(text: Any, fallback: str) -> str:
"""One path component: no separators or reserved characters, no leading dots, at most 120 characters."""
s = _UNSAFE.sub("_", str(text or "")).strip(" .")
return s[:120].strip(" .") or fallback
@dataclass
class Item:
asset_id: str
kind: str # databook, page, doc, qc_doc, mtr
name: str
sha256: str
size: int
path: Path
def _unique(name: str, taken: set[str]) -> str:
stem, dot, ext = name.rpartition(".")
if not dot:
stem, ext = name, ""
out, n = name, 2
while out.lower() in taken or out.lower() == MANIFEST:
out = f"{stem} ({n}).{ext}" if ext else f"{stem} ({n})"
n += 1
taken.add(out.lower())
return out
def _asset_rows(conn: Any, ids: Iterable[Any]) -> dict[str, dict[str, Any]]:
keys = list(dict.fromkeys(str(i) for i in ids if i))
if not keys:
return {}
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute("SELECT * FROM assets WHERE id = ANY(%s::uuid[])", (keys,)).fetchall()
return {str(r["id"]): dict(r) for r in rows}
def packet_files(conn: Any, packet_id: int, unit_id: int, project_id: int) -> list[tuple[str, str]]:
"""(asset id, kind) of the packet's files in a fixed order, each asset once: drawing page files (live and void),
packet documents (not removed), the current Q.C. folder documents of its unit and project, MTRs."""
out: list[tuple[str, str]] = []
q: list[tuple[str, str, tuple[Any, ...]]] = [
(
"page",
"SELECT asset_id FROM packet_pages WHERE packet_id = %s AND asset_id IS NOT NULL"
" GROUP BY asset_id ORDER BY min(n)",
(packet_id,),
),
(
"doc",
"SELECT asset_id FROM packet_docs WHERE packet_id = %s AND removed_at IS NULL ORDER BY added_at, id",
(packet_id,),
),
(
"qc_doc",
"SELECT asset_id FROM qc_docs WHERE superseded_by IS NULL AND (unit_id = %s OR project_id = %s)"
" ORDER BY (unit_id IS NULL), added_at, id",
(unit_id, project_id),
),
(
"mtr",
"SELECT mtr_asset_id FROM bom_lines WHERE packet_id = %s AND mtr_asset_id IS NOT NULL ORDER BY item",
(packet_id,),
),
]
seen: set[str] = set()
for kind, sql, params in q:
for (aid,) in conn.execute(sql, params).fetchall():
key = str(aid)
if key not in seen:
seen.add(key)
out.append((key, kind))
return out
def _sha_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(CHUNK), b""):
h.update(chunk)
return h.hexdigest()
def _fsync_dir(path: Path) -> None:
if os.name != "posix":
return
fd = os.open(str(path), os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
def _write_once(folder: Path, name: str, source: Path | None, data: bytes | None, sha: str) -> None:
"""Put one file in the folder: temp name, fsync, rename. Identical existing file: done. Different: refused."""
target = folder / name
if target.exists():
if target.is_file() and _sha_file(target) == sha:
return
raise CopyError(f"A different file is already at {name} in the archive; it was not overwritten.")
tmp = folder / f".{name[:80]}.tmp-{secrets.token_hex(6)}"
h = hashlib.sha256()
try:
with open(tmp, "xb") as out:
if data is not None:
out.write(data)
h.update(data)
else:
assert source is not None
with open(source, "rb") as src:
for chunk in iter(lambda: src.read(CHUNK), b""):
out.write(chunk)
h.update(chunk)
out.flush()
os.fsync(out.fileno())
if h.hexdigest() != sha:
raise CopyError(f"{name} changed while it was copied (sha256 differs from the asset record).")
if target.exists():
raise CopyError(f"A file appeared at {name} during the copy; it was not overwritten.")
os.rename(tmp, target)
finally:
with contextlib.suppress(OSError):
tmp.unlink()
def copy_seal(conn: Any, settings: Settings, base: Path, rel_base: str, seal_id: int) -> tuple[str, str, int]:
"""Copy one filed seal into the archive folder `base` (its relative path `rel_base`).
Returns (folder relative to ARCHIVE_ROOT, manifest sha256, files). Raises CopyError."""
from . import assets
with conn.cursor(row_factory=dict_row) as cur:
s = cur.execute(
"SELECT s.*, p.drawing, p.rev, p.unit_id, u.lot, u.project_id, pr.number AS project,"
" pr.number || u.lot AS unit_number, sb.display_name AS sealed_by_name,"
" t.by AS filed_by, fb.display_name AS filed_by_name, t.at AS filed_at"
" FROM packet_seals s JOIN packets p ON p.id = s.packet_id JOIN units u ON u.id = p.unit_id"
" JOIN projects pr ON pr.id = u.project_id LEFT JOIN users sb ON sb.id = s.sealed_by"
" LEFT JOIN LATERAL (SELECT x.by, x.at FROM packet_transitions x WHERE x.packet_id = s.packet_id"
" AND x.action = 'file' AND x.at <= s.sealed_at + interval '1 minute' ORDER BY x.id DESC LIMIT 1) t"
" ON true"
" LEFT JOIN users fb ON fb.id = t.by WHERE s.id = %s",
(seal_id,),
).fetchone()
if s is None:
raise CopyError(f"Seal {seal_id} is not there.")
if s["databook_asset_id"] is None:
raise CopyError(f"Seal {seal_id} has no data book.")
rows = _asset_rows(conn, [s["databook_asset_id"]])
book = rows.get(str(s["databook_asset_id"]))
if book is None:
raise CopyError("The data book is not in the asset store.")
book_name = assets.safe_name(book.get("original_name")) or "Data_Book.pdf"
stem = book_name[:-4] if book_name.lower().endswith(".pdf") else book_name
rel_parts = [
safe_part(s["project"], "project"),
safe_part(s["unit_number"], "unit"),
safe_part(f"{stem}-seal{seal_id}", f"seal{seal_id}"),
]
folder = base.joinpath(*rel_parts)
ids = [(str(s["databook_asset_id"]), "databook")]
ids += [
x for x in packet_files(conn, int(s["packet_id"]), int(s["unit_id"]), int(s["project_id"])) if x[0] != ids[0][0]
]
rows.update(_asset_rows(conn, [a for a, _ in ids[1:]]))
taken: set[str] = set()
items: list[Item] = []
for aid, kind in ids:
r = rows.get(aid)
if r is None:
raise CopyError(f"Asset {aid} ({kind}) is not in the asset store.")
try:
path = assets.open_blob(settings, r)
except Exception:
raise CopyError(f"The stored file of asset {aid} ({kind}) is missing.") from None
name = assets.safe_name(r.get("original_name")) or f"{aid}{assets.EXT.get(str(r['mime']), '')}"
if kind == "databook":
name = book_name
items.append(Item(aid, kind, _unique(name, taken), bytes(r["sha256"]).hex(), int(r["size"]), path))
manifest = {
"packet_id": int(s["packet_id"]),
"packet_label": packet_label(s["project"], s["lot"], s["drawing"]),
"project": s["project"],
"unit": s["unit_number"],
"drawing": s["drawing"],
"rev": s["rev"],
"seal_id": int(seal_id),
"seal_hash": bytes(s["hash"]).hex(),
"key_id": s["key_id"],
"sealed_at": _iso(s["sealed_at"]),
"sealed_by": s["sealed_by_name"],
"filed_at": _iso(s["filed_at"] or s["sealed_at"]),
"filed_by": s["filed_by_name"] or s["sealed_by_name"],
"build": settings.ps_build,
"files": [
{"name": i.name, "kind": i.kind, "sha256": i.sha256, "size": i.size, "asset_id": i.asset_id} for i in items
],
}
data = (json.dumps(manifest, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
try:
folder.mkdir(parents=True, exist_ok=True)
existing = folder / MANIFEST
if existing.exists():
same = _same_manifest(existing, manifest)
if same is not None:
return "/".join([rel_base, *rel_parts]), same, len(items)
raise CopyError(f"A different {MANIFEST} is already in {'/'.join(rel_parts)}; it was not overwritten.")
for i in items:
_write_once(folder, i.name, i.path, None, i.sha256)
sha = hashlib.sha256(data).hexdigest()
_write_once(folder, MANIFEST, None, data, sha)
_fsync_dir(folder)
except OSError as e:
raise CopyError(f"Cannot write in the archive: {e.strerror or type(e).__name__}.") from None
return "/".join([rel_base, *rel_parts]), sha, len(items)
def _same_manifest(path: Path, manifest: Mapping[str, Any]) -> str | None:
"""The sha256 of the manifest on disk when it describes the same copy (the build may differ), else None."""
try:
raw = path.read_bytes()
old = json.loads(raw)
except (OSError, ValueError):
return None
keys = ("packet_id", "seal_id", "seal_hash", "files")
if isinstance(old, dict) and all(old.get(k) == manifest.get(k) for k in keys):
return hashlib.sha256(raw).hexdigest()
return None
def _iso(v: Any) -> str | None:
return v.isoformat() if isinstance(v, datetime) else (str(v) if v is not None else None)
+5
View File
@@ -850,6 +850,11 @@ def _file(w: Write) -> None:
w.log_also("Data book filed", f"{pages} pages")
w.detail["seal_id"] = int(seal_id)
w.detail["databook_pages"] = pages
# The archive copy runs in the worker after this commits. A failed copy never blocks filing.
from . import archive
if archive.on_filed(w.conn, _settings(w), w.user_id, w.id):
w.detail["archive_requested"] = True
def _file_databook(w: Write, row: Mapping[str, Any], seal: dict[str, Any], seal_id: int) -> int:
+2
View File
@@ -97,6 +97,7 @@ def create_app(
from .api import (
account,
admin,
admin_files,
admin_org,
admin_security,
assets,
@@ -125,6 +126,7 @@ def create_app(
admin,
admin_org,
admin_security,
admin_files,
departments,
events_api,
projects,
@@ -0,0 +1,32 @@
-- 0012_archive: phase 10. The archive location (Gitea issue 7). Runs as ps_migrate; 0001's default privileges give
-- ps_app SELECT, INSERT, UPDATE, DELETE on the new table.
-- One row per filed seal to copy to the archive folder. The archive-copy job queues every unbroken seal with a data
-- book that has no row yet, then copies the pending rows and the failed rows that are due (next_try_at).
-- folder is the copy's folder relative to ARCHIVE_ROOT, set when the copy is done. manifest_sha256 is the sha256 of
-- the manifest.json written there. A copy is never removed; a refile after a reopen has its own seal and row.
CREATE TABLE archive_copies (
id bigserial PRIMARY KEY,
seal_id bigint NOT NULL UNIQUE REFERENCES packet_seals (id),
packet_id bigint NOT NULL REFERENCES packets (id),
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'done', 'failed')),
attempts int NOT NULL DEFAULT 0,
last_error text,
next_try_at timestamptz,
folder text,
manifest_sha256 text,
created_at timestamptz NOT NULL DEFAULT now(),
done_at timestamptz,
CHECK (status <> 'done' OR (folder IS NOT NULL AND done_at IS NOT NULL))
);
CREATE INDEX archive_copies_due ON archive_copies (next_try_at, id) WHERE status <> 'done';
CREATE INDEX archive_copies_packet ON archive_copies (packet_id);
-- The job, so "Retry now" and FILE's request are accepted before the worker has started once. The notification
-- rule for archive_failed is seeded by the code (notify/seeding.py) like every other event type.
INSERT INTO job_schedule (job) VALUES ('archive-copy') ON CONFLICT (job) DO NOTHING;
-- Explicit, after the default privileges: the app reads, queues and updates copies; it never deletes one.
REVOKE DELETE, TRUNCATE ON archive_copies FROM ps_app;
GRANT SELECT, INSERT, UPDATE ON archive_copies TO ps_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
+6
View File
@@ -72,6 +72,12 @@ _TYPES = (
),
EventType("version_deployed", "Version deployed", "The worker started on a new build."),
EventType("assets_quota", "Assets storage warning", "The assets storage passed its warning threshold."),
EventType(
"archive_failed",
"Archive copy failing",
"A filed packet could not be copied to the archive folder after three tries, or the archive folder check"
" failed.",
),
)
EVENT_TYPES: dict[str, EventType] = {t.key: t for t in _TYPES}
+4 -2
View File
@@ -33,7 +33,7 @@ def read_secret(path: Path | str, what: str = "secret") -> str:
return value
_OPTIONAL_PATHS = ("ldap_ca_bundle", "smtp_ca_bundle")
_OPTIONAL_PATHS = ("ldap_ca_bundle", "smtp_ca_bundle", "archive_root")
class Settings(BaseSettings):
@@ -61,6 +61,8 @@ class Settings(BaseSettings):
assets_dir: Path = Path("/data/assets")
backup_dir: Path = Path("/backups")
# The archive share (Gitea issue 7). Unset or blank turns the archive off. The Admin picks a folder under it.
archive_root: Path | None = None
session_key_file: Path = Path("/run/secrets/session_key")
seal_key_file: Path = Path("/run/secrets/seal_key")
static_dir: Path = Path("/app/static")
@@ -125,7 +127,7 @@ class Settings(BaseSettings):
if isinstance(data, dict):
data = dict(data)
for key in list(data):
if key.lower() in _OPTIONAL_PATHS and data[key] in ("", None):
if key.lower() in _OPTIONAL_PATHS and (data[key] is None or not str(data[key]).strip()):
data[key] = None
for key in list(data):
if key.lower() in ("stack", "feed_source", "drawing_source") and isinstance(data[key], str):
@@ -106,6 +106,7 @@ LAZY = (
"backup_check",
"notify_send",
"notify_digest",
"archive_copy",
)
@@ -0,0 +1,101 @@
"""The archive copy (Gitea issue 7), every 15 minutes and on request (FILE asks for a run when it commits).
Off when ARCHIVE_ROOT is unset, and idle until the Admin saves a folder. Each run checks the folder (the probe), queues
every unbroken seal with a data book that has no `archive_copies` row (so turning the archive on later copies earlier
filings too), then copies the pending rows and the failed rows that are due. A failed copy waits 5, 15, 60 minutes,
then an hour between tries. The `archive_failed` condition (subject `archive`) is raised when a copy has failed three
times or the folder check fails, and cleared when no copy is failed. A failed copy never touches the packet.
"""
from __future__ import annotations
from datetime import datetime
from ...domain import archive
from ...feed.config import FeedSettings
from .. import jobs
from . import Counts, JobContext, JobSpec
NAME = archive.JOB
INTERVAL_SECONDS = 15 * 60
BATCH = 200
TITLE = "Archive copy failing"
def _check_changed(ctx: JobContext, result: archive.CheckResult, archive_dir: str) -> None:
"""Audit the job's own folder check only when its result differs from the last check on record."""
last = archive.last_check(ctx.conn)
if last is None or bool(last["ok"]) != result.ok or last.get("archive_dir") != archive_dir:
archive.audit_check(ctx.conn, result, archive_dir=archive_dir, source="job")
def run(ctx: JobContext) -> Counts:
root = archive.root_of(ctx.settings)
counts: Counts = {"enabled": root is not None, "queued": 0, "copied": 0, "failed": 0}
if root is None:
return counts
archive_dir, _, _, _ = archive.load(ctx.conn)
counts["archive_dir"] = archive_dir
if archive_dir is None:
counts["skipped"] = "no archive folder saved"
return counts
rel, result = archive.check(root, archive_dir)
_check_changed(ctx, result, archive_dir)
if not result.ok or rel is None:
counts["check"] = result.message
ctx.error(f"archive folder check failed: {result.message}")
jobs.raise_event(
ctx.conn,
archive.EVENT,
subject=archive.EVENT_SUBJECT,
title=TITLE,
body=f"The archive folder check failed: {result.message}",
detail={"archive_dir": archive_dir, "message": result.message},
severity="critical",
condition=True,
)
return counts
base = root.joinpath(*rel.split("/"))
with ctx.conn.transaction():
counts["queued"] = archive.queue_missing(ctx.conn)
for row in archive.due_rows(ctx.conn, BATCH):
try:
folder, sha, files = archive.copy_seal(ctx.conn, ctx.settings, base, rel, int(row["seal_id"]))
except archive.CopyError as e:
attempts = archive.mark_failed(ctx.conn, row, str(e))
counts["failed"] += 1
ctx.error(f"seal {row['seal_id']}: {e} (try {attempts})", degrade=True)
continue
except Exception as e: # anything unexpected is one failed copy, never a stopped run
attempts = archive.mark_failed(ctx.conn, row, f"{type(e).__name__}: {e}")
counts["failed"] += 1
ctx.log.exception("archive copy of seal %s failed", row["seal_id"])
ctx.error(f"seal {row['seal_id']}: {type(e).__name__} (try {attempts})", degrade=True)
continue
archive.mark_done(ctx.conn, row, folder, sha, files)
counts["copied"] += 1
st = archive.copies_status(ctx.conn)
counts.update(pending=st["pending"], failed_total=st["failed"], done_total=st["done"])
stuck = archive.stuck_failures(ctx.conn)
if stuck:
lines = [f"{f['packet_label']} (seal {f['seal_id']}): {f['last_error']}" for f in stuck[:10]]
jobs.raise_event(
ctx.conn,
archive.EVENT,
subject=archive.EVENT_SUBJECT,
title=TITLE,
body=f"{len(stuck)} filed packet(s) could not be copied to the archive:\n" + "\n".join(lines),
detail={"failed": st["failed"], "seal_ids": [f["seal_id"] for f in stuck]},
severity="critical",
condition=True,
)
elif st["failed"] == 0:
jobs.resolve_event(ctx.conn, archive.EVENT, subject=archive.EVENT_SUBJECT, title=TITLE)
return counts
def cadence(_: FeedSettings, now_site: datetime) -> int:
return INTERVAL_SECONDS
SPEC = JobSpec(name=NAME, run=run, cadence=cadence, pausable=False)
+453
View File
@@ -0,0 +1,453 @@
"""The archive location (Gitea issue 7): the path rules, the check, the File locations API, the archive-copy job
(copy, idempotence, failure and backoff, the Admin condition, old seals queued) and filing with the archive off or
broken. Synthetic data only; the archive root is a temp folder."""
from __future__ import annotations
import hashlib
import json
import os
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
from closehelpers import file_packet, filed, q1, qall, rich_complete, vclose
from fakedir import FakeDirectory
from pshelpers import make_settings, needs_pg
from shophelpers import Shop, ok, open_shop, refused, run
from packetstation.domain import archive
from packetstation.settings import Settings
from packetstation.worker import jobs, runs
from packetstation.worker.jobs import archive_copy
from packetstation.worker.scheduler import Scheduler
URL = "/api/admin/file-locations"
def share(tmp_path: Path) -> Path:
root = tmp_path / "share"
(root / "packets").mkdir(parents=True, exist_ok=True)
return root
# ---------------------------------------------------------------- settings and path rules
def test_archive_root_blank_is_off(tmp_path: Path) -> None:
assert make_settings(tmp_path).archive_root is None
assert make_settings(tmp_path, archive_root="").archive_root is None
assert make_settings(tmp_path, archive_root=" ").archive_root is None
assert archive.root_of(make_settings(tmp_path, archive_root=" ")) is None
assert make_settings(tmp_path, archive_root="/archive").archive_root == Path("/archive")
@pytest.mark.parametrize(
("given", "why"),
[
("", "Enter a folder"),
(" ", "Enter a folder"),
("..", "'..'"),
("packets/../..", "'..'"),
("a\\..\\b", "'..'"),
("pack\x00ets", "NUL"),
("x" * 401, "longer than"),
],
)
def test_relative_dir_refusals(tmp_path: Path, given: str, why: str) -> None:
with pytest.raises(archive.PathRefused, match=why):
archive.relative_dir(share(tmp_path), given)
def test_relative_dir_clean_and_absolute(tmp_path: Path) -> None:
root = share(tmp_path)
assert archive.relative_dir(root, "packets") == "packets"
assert archive.relative_dir(root, "./packets//2026/") == "packets/2026"
assert archive.relative_dir(root, "packets\\2026") == "packets/2026"
assert archive.relative_dir(root, str(root / "packets" / "2026")) == "packets/2026"
with pytest.raises(archive.PathRefused, match="must lie under"):
archive.relative_dir(root, str(tmp_path / "elsewhere"))
with pytest.raises(archive.PathRefused, match="must lie under"):
archive.relative_dir(root, "/etc")
with pytest.raises(archive.PathRefused, match="not the root"):
archive.relative_dir(root, str(root))
def test_resolve_dir(tmp_path: Path) -> None:
root = share(tmp_path)
rel, real = archive.resolve_dir(root, "packets")
assert rel == "packets" and real == (root / "packets").resolve()
with pytest.raises(archive.PathRefused, match="does not exist"):
archive.resolve_dir(root, "nope")
(root / "afile").write_bytes(b"x")
with pytest.raises(archive.PathRefused, match="not a folder"):
archive.resolve_dir(root, "afile")
with pytest.raises(archive.PathRefused, match="is not there"):
archive.resolve_dir(tmp_path / "unmounted", "packets")
def test_resolve_dir_refuses_a_link_out(tmp_path: Path) -> None:
root = share(tmp_path)
outside = tmp_path / "outside"
outside.mkdir()
try:
os.symlink(outside, root / "escape", target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("this OS or user cannot make symlinks")
with pytest.raises(archive.PathRefused, match="outside the archive root"):
archive.resolve_dir(root, "escape")
(outside / "deep").mkdir()
with pytest.raises(archive.PathRefused, match="outside the archive root"):
archive.resolve_dir(root, "escape/deep")
# a link that stays inside the root is fine
os.symlink(root / "packets", root / "inside", target_is_directory=True)
assert archive.resolve_dir(root, "inside")[1] == (root / "packets").resolve()
def test_check_and_probe(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = share(tmp_path)
rel, res = archive.check(root, "packets")
assert rel == "packets" and res.ok and res.free_bytes and res.resolved == str((root / "packets").resolve())
assert list((root / "packets").iterdir()) == [] # the probe is gone
assert archive.check(None, "packets") == (None, archive.CheckResult(False, None, None, archive.OFF_MESSAGE))
rel, res = archive.check(root, "../x")
assert rel is None and not res.ok and "'..'" in res.message
def broken_fsync(fd: int) -> None:
raise OSError(5, "Input/output error")
monkeypatch.setattr(archive.os, "fsync", broken_fsync)
rel, res = archive.check(root, "packets")
assert rel == "packets" and not res.ok and "Cannot write" in res.message and "Input/output error" in res.message
assert list((root / "packets").iterdir()) == []
def test_backoff() -> None:
assert [archive.backoff(n) for n in (1, 2, 3, 4, 9)] == [
timedelta(minutes=5),
timedelta(minutes=15),
timedelta(minutes=60),
timedelta(minutes=60),
timedelta(minutes=60),
]
def test_safe_names() -> None:
assert archive.safe_part("L5/12:3", "x") == "L5_12_3"
assert archive.safe_part("..", "x") == "x"
taken: set[str] = set()
assert [archive._unique(n, taken) for n in ("a.pdf", "A.pdf", "a.pdf", "manifest.json", "b")] == [
"a.pdf",
"A (2).pdf",
"a (3).pdf",
"manifest (2).json",
"b",
]
# ---------------------------------------------------------------- with PostgreSQL
@pytest.fixture
def on_settings(pg_settings: Settings, tmp_path: Path) -> Settings:
return pg_settings.model_copy(update={"archive_root": share(tmp_path)})
@pytest.fixture
def shop(on_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(on_settings, fake_dir)
@pytest.fixture
def off_shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def run_copy(settings: Settings) -> dict[str, Any]:
sch = Scheduler(settings, build="pytest", jobs={archive.JOB: archive_copy.SPEC})
return runs.run_job(sch, archive.JOB, "schedule")
def save_dir(settings: Settings, archive_dir: str = "packets") -> None:
run(
settings,
"INSERT INTO settings (key, value) VALUES ('files', jsonb_build_object('archive_dir', %s::text))"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1",
(archive_dir,),
)
def copies(settings: Settings) -> list[Any]:
return qall(settings, "SELECT seal_id, status, attempts, last_error, next_try_at, folder FROM archive_copies")
def audits(settings: Settings, action: str) -> list[dict[str, Any]]:
return [r[0] for r in qall(settings, "SELECT detail FROM audit_events WHERE action = %s ORDER BY id", (action,))]
def sha(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
@needs_pg
def test_api_off(off_shop: Shop) -> None:
ann = off_shop("admin.ann")
body = ok(ann.get(URL))
assert body == {
"enabled": False,
"root": None,
"archive_dir": None,
"version": 0,
"resolved": None,
"last_check": None,
"copies": {"pending": 0, "failed": 0, "done": 0, "recent_failures": []},
}
for r in (
ann.post(f"{URL}/check", json={"archive_dir": "packets"}),
ann.put(URL, json={"archive_dir": "packets", "version": 0}),
ann.post(f"{URL}/retry"),
):
refused(r, 409, "archive_off", archive.OFF_MESSAGE)
# Admin only, password session
assert off_shop("clerk.clara").get(URL).status_code == 403
@needs_pg
def test_api_check_save_and_retry(shop: Shop) -> None:
s = shop.settings
root = Path(str(s.archive_root))
ann = shop("admin.ann")
body = ok(ann.get(URL))
assert body["enabled"] is True and body["root"] == str(root) and body["archive_dir"] is None
refused(ann.post(f"{URL}/check", json={"archive_dir": "../etc"}), 422, "bad_path")
refused(ann.post(f"{URL}/check", json={"archive_dir": "missing"}), 422, "bad_path")
good = ok(ann.post(f"{URL}/check", json={"archive_dir": str(root / "packets")}))
assert good["ok"] is True and good["resolved"] == str((root / "packets").resolve()) and good["free_bytes"] > 0
assert ok(ann.get(URL))["archive_dir"] is None # the check saves nothing
checks = audits(s, "archive.checked")
assert [(c["source"], c["ok"]) for c in checks] == [("admin", False), ("admin", False), ("admin", True)]
assert checks[2]["archive_dir"] == "packets"
refused(ann.put(URL, json={"archive_dir": "missing", "version": 0}), 422, "bad_path")
refused(ann.put(URL, json={"archive_dir": "/elsewhere", "version": 0}), 422, "bad_path")
saved = ok(ann.put(URL, json={"archive_dir": str(root / "packets"), "version": 0}))
assert saved["archive_dir"] == "packets" and saved["version"] == 1
assert saved["resolved"] == str((root / "packets").resolve())
assert saved["last_check"]["ok"] is True and saved["last_check"]["by"] == "Ann Admin"
assert set(saved["last_check"]) == {"ok", "at", "by", "message"}
conflict = refused(ann.put(URL, json={"archive_dir": "packets", "version": 0}), 409, "version_conflict")
assert conflict["current"]["version"] == 1
(root / "other").mkdir()
assert ok(ann.put(URL, json={"archive_dir": "other", "version": 1}))["archive_dir"] == "other"
assert [a["archive_dir"] for a in audits(s, "admin.file_locations_updated")] == [
{"from": None, "to": "packets"},
{"from": "packets", "to": "other"},
]
# a check that fails after the path rules (the probe) answers check_failed on save and keeps the old folder
(root / "ro").mkdir()
orig = archive.probe
try:
archive.probe = lambda folder: archive.CheckResult(False, str(folder), None, "Cannot write in it.")
r = refused(ann.put(URL, json={"archive_dir": "ro", "version": 2}), 422, "check_failed", "Cannot write in it.")
assert r["check"]["ok"] is False
finally:
archive.probe = orig
assert ok(ann.get(URL))["archive_dir"] == "other"
# saving asks for a copy run; Retry now resets the backoff and asks again (202)
r = ann.post(f"{URL}/retry")
assert r.status_code == 202 and r.json()["requested"] is True
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 3
assert audits(s, "admin.archive_retry")
assert shop("clerk.clara").post(f"{URL}/retry").status_code == 403
@needs_pg
def test_copy_job(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
s = shop.settings
root = Path(str(s.archive_root))
events: list[tuple[str, str, dict[str, Any]]] = []
monkeypatch.setattr(jobs, "raise_event", lambda conn, t, **kw: events.append(("raise", t, kw)))
monkeypatch.setattr(jobs, "resolve_event", lambda conn, t, **kw: events.append(("resolve", t, kw)))
# no folder saved: the job idles, nothing queued
row = run_copy(s)
assert row["status"] == "ok" and row["counts"]["skipped"] == "no archive folder saved"
save_dir(s)
pkt = rich_complete(shop)
pid = pkt["id"]
# rich_complete keeps one short open (a packet from before change C7); close it so FILE accepts
run(s, "UPDATE bom_shorts SET closed_at = now() WHERE packet_id = %s", (pid,))
out = ok(file_packet(shop, vclose(shop, pkt)))
assert out["status"] == "filed"
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy' AND claimed_at IS NULL")[0] == 1
seal_id, book_id = q1(s, "SELECT id, databook_asset_id FROM packet_seals WHERE packet_id = %s", (pid,))
row = run_copy(s)
assert row["status"] == "ok", row
assert row["trigger"] == "request" and row["counts"]["queued"] == 1 and row["counts"]["copied"] == 1
((sid, status, attempts, err, nxt, folder),) = copies(s)
assert (sid, status, attempts, err, nxt) == (seal_id, "done", 1, None, None)
project, unit = q1(
s,
"SELECT pr.number, pr.number || u.lot FROM packets p JOIN units u ON u.id = p.unit_id"
" JOIN projects pr ON pr.id = u.project_id WHERE p.id = %s",
(pid,),
)
assert folder.startswith(f"packets/{project}/{unit}/") and folder.endswith(f"-seal{seal_id}")
where = root.joinpath(*folder.split("/"))
manifest = json.loads((where / "manifest.json").read_text(encoding="utf-8"))
assert manifest["packet_id"] == pid and manifest["seal_id"] == seal_id and manifest["project"] == project
assert manifest["unit"] == unit and manifest["build"] == s.ps_build and manifest["filed_by"] == "Clara Clerk"
assert manifest["sealed_by"] == "Clara Clerk" and len(manifest["seal_hash"]) == 64 and manifest["key_id"]
kinds = [f["kind"] for f in manifest["files"]]
assert kinds[0] == "databook" and manifest["files"][0]["asset_id"] == str(book_id)
assert {"databook", "page", "doc", "qc_doc", "mtr"} <= set(kinds), kinds
assert kinds.count("mtr") == 2
names = [f["name"] for f in manifest["files"]]
assert len({n.lower() for n in names}) == len(names)
assert sorted(p.name for p in where.iterdir()) == sorted([*names, "manifest.json"])
for f in manifest["files"]:
assert sha(where / f["name"]) == f["sha256"] and (where / f["name"]).stat().st_size == f["size"]
assert q1(s, "SELECT manifest_sha256 FROM archive_copies")[0] == sha(where / "manifest.json")
(done,) = audits(s, "archive.copied")
assert done["seal_id"] == seal_id and done["folder"] == folder and done["files"] == len(names)
assert events == [("resolve", "archive_failed", {"subject": "archive", "title": "Archive copy failing"})]
# idempotent: a second run copies nothing; a row set back to pending finds the identical files and is done
events.clear()
assert run_copy(s)["counts"]["copied"] == 0
run(s, "UPDATE archive_copies SET status = 'pending'")
before = {p.name: p.stat().st_mtime_ns for p in where.iterdir()}
assert run_copy(s)["counts"]["copied"] == 1
assert {p.name: p.stat().st_mtime_ns for p in where.iterdir()} == before
assert copies(s)[0][1] == "done"
# a different file in the way: never overwritten; failed with a 5-minute backoff, then 15, then 60 and raised
run(s, "UPDATE archive_copies SET status = 'pending', folder = NULL, done_at = NULL, attempts = 0")
(where / "manifest.json").unlink()
victim = where / names[1]
victim.write_bytes(b"something else")
events.clear()
t0 = datetime.now(UTC)
row = run_copy(s)
assert row["status"] == "degraded" and row["counts"]["failed"] == 1
_, status, attempts, err, nxt, _ = copies(s)[0]
assert (status, attempts) == ("failed", 1) and "not overwritten" in err and names[1] in err
assert timedelta(minutes=4) < nxt - t0 < timedelta(minutes=6)
assert victim.read_bytes() == b"something else" and not (where / "manifest.json").exists()
assert events == [] # one failure: not raised yet, not cleared either
assert run_copy(s)["counts"]["failed"] == 0 # not due
for n, wait in ((2, 15), (3, 60)):
run(s, "UPDATE archive_copies SET next_try_at = now() - interval '1 second'")
t0 = datetime.now(UTC)
run_copy(s)
_, status, attempts, _, nxt, _ = copies(s)[0]
assert (status, attempts) == ("failed", n)
assert timedelta(minutes=wait - 1) < nxt - t0 < timedelta(minutes=wait + 1)
raised = [e for e in events if e[0] == "raise"]
assert len(raised) == 1 and raised[0][1] == "archive_failed" and raised[0][2]["subject"] == "archive"
assert raised[0][2]["condition"] is True and f"seal {seal_id}" in raised[0][2]["body"]
assert len(audits(s, "archive.copy_failed")) == 3
st = ok(shop("admin.ann").get(URL))["copies"]
assert (st["failed"], st["done"], st["pending"]) == (1, 0, 0)
(f,) = st["recent_failures"]
assert (
f["seal_id"] == seal_id
and f["packet_id"] == pid
and f["attempts"] == 3
and "not overwritten" in f["last_error"]
)
assert f["packet_label"].startswith(project) and f["next_try_at"]
# the Admin removes the stray file and presses Retry now: done, condition cleared
victim.unlink()
events.clear()
assert shop("admin.ann").post(f"{URL}/retry").status_code == 202
row = run_copy(s)
assert row["counts"]["copied"] == 1 and copies(s)[0][1] == "done"
assert sha(victim) == manifest["files"][1]["sha256"]
assert events == [("resolve", "archive_failed", {"subject": "archive", "title": "Archive copy failing"})]
@needs_pg
def test_old_seals_are_queued_when_the_archive_is_turned_on(off_shop: Shop, tmp_path: Path) -> None:
s = off_shop.settings
pkt = filed(off_shop)
# archive off: FILE asks for nothing, and the job does nothing
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 0
assert run_copy(s)["counts"] == {"enabled": False, "queued": 0, "copied": 0, "failed": 0}
assert copies(s) == []
on = s.model_copy(update={"archive_root": share(tmp_path)})
save_dir(on)
row = run_copy(on)
assert row["counts"]["queued"] == 1 and row["counts"]["copied"] == 1
seal = q1(s, "SELECT id FROM packet_seals WHERE packet_id = %s", (pkt["id"],))[0]
assert copies(s)[0][:2] == (seal, "done")
@needs_pg
def test_filing_succeeds_with_the_archive_broken(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
s = shop.settings
root = Path(str(s.archive_root))
events: list[tuple[str, str, dict[str, Any]]] = []
monkeypatch.setattr(jobs, "raise_event", lambda conn, t, **kw: events.append(("raise", t, kw)))
monkeypatch.setattr(jobs, "resolve_event", lambda conn, t, **kw: events.append(("resolve", t, kw)))
save_dir(s, "gone") # saved, then the folder went away
pkt = filed(shop)
assert pkt["status"] == "filed"
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 1
row = run_copy(s)
assert row["status"] == "degraded" and "does not exist" in row["counts"]["check"]
assert copies(s) == []
((kind, etype, kw),) = events
assert (kind, etype, kw["subject"], kw["condition"]) == ("raise", "archive_failed", "archive", True)
checks = audits(s, "archive.checked")
assert [(c["source"], c["ok"]) for c in checks] == [("job", False)]
run_copy(s)
assert len(audits(s, "archive.checked")) == 1 # the same result again is not audited again
# the folder comes back: the copy is made and the condition cleared
(root / "gone").mkdir()
events.clear()
row = run_copy(s)
assert row["status"] == "ok" and row["counts"]["copied"] == 1
assert events == [("resolve", "archive_failed", {"subject": "archive", "title": "Archive copy failing"})]
assert [(c["source"], c["ok"]) for c in audits(s, "archive.checked")] == [("job", False), ("job", True)]
@needs_pg
def test_filing_succeeds_when_the_request_fails(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
s = shop.settings
save_dir(s)
def boom(*a: Any, **k: Any) -> None:
raise RuntimeError("no requests today")
monkeypatch.setattr(archive, "request_copy", boom)
pkt = filed(shop)
assert pkt["status"] == "filed"
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 0
assert run_copy(s)["counts"]["copied"] == 1 # the scheduled run picks it up
@needs_pg
def test_doctor_archive_check(pg_settings: Settings, tmp_path: Path) -> None:
from packetstation import doctor
assert doctor.check_archive(pg_settings)[0].status == "SKIP"
on = pg_settings.model_copy(update={"archive_root": share(tmp_path)})
(c,) = doctor.check_archive(on)
assert c.status == "SKIP" and "no archive folder saved" in c.detail
save_dir(on)
(c,) = doctor.check_archive(on)
assert c.status == "PASS" and "writable" in c.detail
save_dir(on, "gone")
(c,) = doctor.check_archive(on)
assert c.status == "FAIL" and "does not exist" in c.detail
assert [(a["source"], a["ok"]) for a in audits(on, "archive.checked")] == [("doctor", True), ("doctor", False)]
+1 -1
View File
@@ -143,7 +143,7 @@ def test_get_seeds_every_rule(c: TestClient, base: Settings) -> None:
body = c.get(ADMIN).json()
assert body["dry_run"] is True and body["subject_prefix"] == "[Packet Station TEST]"
assert body["smtp"] == {"host": base.smtp_host, "port": base.smtp_port, "from": base.smtp_from, "starttls": True}
assert len(body["rules"]) == 13
assert len(body["rules"]) == 14
r = next(x for x in body["rules"] if x["event_type"] == "drawing_failing")
assert r["label"] == "Drawing lookup failing" and r["param"] == {"name": "hours", "value": 24, "default": 24}
assert r["enabled"] is True and r["delivery"] == "immediate" and r["digest_time"] == "07:00"
+3 -3
View File
@@ -136,12 +136,12 @@ def test_one_shot_dedupe_and_unknown_type(s: Settings, marker: int) -> None:
def test_seed_to_admins_is_idempotent(s: Settings, marker: int) -> None:
with app_conn(s) as conn:
added = seed(conn, s)
assert len(added) == 13
assert len(added) == 14
rows = conn.execute(
"SELECT r.event_type, r.enabled, r.delivery::text, r.digest_time::text, r.param, c.kind::text,"
" c.group_dn, c.group_name FROM notification_rules r JOIN notification_recipients c USING (event_type)"
).fetchall()
assert len(rows) == 13
assert len(rows) == 14
for r in rows:
assert r[1] is True and r[2] == "immediate" and r[3] == "07:00:00" and r[5] == "group"
assert r[6] == f"CN=PS-Admins,{s.ldap_group_base_dn}" and r[7] == "PS-Admins"
@@ -522,7 +522,7 @@ def test_notify_send_job(s: Settings, marker: int, relay: FakeSmtp, ndir: Notify
with app_conn(s) as conn:
ctx = ctx_for(conn, build, ndir)
counts = notify_send.run(ctx)
assert counts["seeded"] == 13 and counts["version_deployed"] == "sha-job" and counts["sent"] == 1
assert counts["seeded"] == 14 and counts["version_deployed"] == "sha-job" and counts["sent"] == 1
assert counts["drawings_failing"] == 0 and counts["co_waiting"] == 0
again = notify_send.run(ctx)
assert "version_deployed" not in again and "drawings_failing" not in again # watch waits 15 minutes
+1 -1
View File
@@ -44,7 +44,7 @@ def mail_settings(tmp: Path, server: FakeSmtp, ca: Path | None, **over: object)
def test_catalog() -> None:
assert len(EVENT_TYPES) == 13
assert len(EVENT_TYPES) == 14
assert SECURITY_TYPES == {"security_alert", "signin_burst", "integrity_failure"}
assert EVENT_TYPES["drawing_failing"].param == "hours" and EVENT_TYPES["drawing_failing"].param_default == 24
assert EVENT_TYPES["co_waiting"].param == "days" and EVENT_TYPES["co_waiting"].param_default == 3
+17
View File
@@ -414,6 +414,7 @@ def test_feed_tables_and_app_grants(pg_settings: Settings) -> None:
jobs = [r[0] for r in conn.execute("SELECT job FROM job_schedule ORDER BY job").fetchall()]
assert jobs == [
"ad-reconcile",
"archive-copy",
"backup-check",
"drawing-lookup",
"housekeeping",
@@ -632,3 +633,19 @@ def test_qc_columns_checks_and_items(pg_settings: Settings) -> None:
"ph_clean",
"ph_qty",
]
def test_archive_copies_grants(pg_settings: Settings) -> None:
"""0012: the app reads, queues and updates archive copies and never deletes one."""
with app_conn(pg_settings) as conn:
for priv, want in (
("SELECT", True),
("INSERT", True),
("UPDATE", True),
("DELETE", False),
("TRUNCATE", False),
):
got = one(conn, "SELECT has_table_privilege('ps_app', 'archive_copies', %s)", (priv,))
assert got is want, priv
with pytest.raises(psycopg.errors.CheckViolation):
conn.execute("INSERT INTO archive_copies (seal_id, packet_id, status) VALUES (1, 1, 'done')")