papra: mount the volume where the app actually writes (R-156, last leg)
gates / gates (push) Successful in 1s
gates / gates (push) Successful in 1s
papra mounted papra_data:/app/data while the application writes to /app/app-data, so its database sat in the container's writable layer: lost on redeploy, and tarred nightly as an empty directory while the healthcheck stayed green. Last of the three apps R-156 convicted. Decided from the IMAGE, not the README. docker inspect of ghcr.io/papra-hq/papra:26.6.1-rootless gives WORKDIR=/app and all three data paths under ./app-data (DATABASE_URL, DOCUMENT_STORAGE_FILESYSTEM_ROOT, PAPRA_CONFIG_DIR) — and /app/data does not exist in the image at all. Reconfiguring the app to write to /app/data was available and deliberately not taken: it enumerates data paths, so a fourth added upstream would escape to the writable layer again, silently — this defect re-armed. Mounting the app's own data ROOT captures every current and future path by construction. Precondition checked rather than inherited: docker ps -a (including stopped) on BOTH demo guests, plus the hub fleet view (two enrolled hosts, zero papra) — both boxes were wiped and rebuilt today, so the 2 August evidence was re-measured. Proven by the runtime gate in both directions: CLEAN with the self-test passing, and BROKEN when the mount is reverted, with the exact R-156 evidence. Full catalog_gates.py papra: all three gates OK.
This commit is contained in:
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
### papra — the volume is mounted where the app actually writes (2026-08-03, R-156, last leg)
|
||||
|
||||
**The third and last of the three apps that kept their data where backups never looked.** papra
|
||||
mounted `papra_data:/app/data` while the application writes to `/app/app-data`, so its database sat
|
||||
in the container's **writable layer**: lost on redeploy, and tarred nightly as an empty directory
|
||||
while the healthcheck stayed green.
|
||||
|
||||
**Decided from the IMAGE, not the README.** `docker inspect ghcr.io/papra-hq/papra:26.6.1-rootless`
|
||||
gives `WORKDIR=/app` and all three data paths under `./app-data` — `DATABASE_URL=file:./app-data/db/db.sqlite`,
|
||||
`DOCUMENT_STORAGE_FILESYSTEM_ROOT=./app-data/documents`, `PAPRA_CONFIG_DIR=./app-data` — and
|
||||
**`/app/data` does not exist in the image at all**.
|
||||
|
||||
**Why the mount moved rather than the app being reconfigured.** Pointing all three env vars at
|
||||
`/app/data` would have worked, but it enumerates data paths: a fourth one added upstream escapes to
|
||||
the writable layer again, silently, which is this defect re-armed. Mounting the app's own data ROOT
|
||||
captures every current and future path by construction.
|
||||
|
||||
**Precondition checked, not inherited:** papra is deployed nowhere — `docker ps -a` (including
|
||||
stopped) on both demo guests, plus the hub fleet view showing two enrolled hosts and zero papra
|
||||
references. Both boxes were wiped and rebuilt on 3 August, so the 2 August evidence was re-measured.
|
||||
|
||||
**Proven by the runtime gate, in both directions.** `check-volume-persistence.py papra` → **CLEAN**,
|
||||
with its self-test passing on the same run. Red-proof: reverting the mount to `/app/data` → **BROKEN**
|
||||
with the exact R-156 evidence (`DATA in the writable layer at /app/app-data/db`,
|
||||
`declared volume /app/data is EMPTY`). Full `catalog_gates.py papra`: all three gates OK.
|
||||
|
||||
**Note for the next run of that gate:** it needs **root** (it reads `/var/lib/docker/volumes`, mode
|
||||
`drwx--x---`; as a normal user its own canary fails UNDETERMINED and it correctly refuses a verdict),
|
||||
and it should be scoped to the app touched — unscoped it deploys all 53 templates.
|
||||
|
||||
## CI — the static catalog gate runs on every push (2026-08-02, R-168)
|
||||
|
||||
**No version bump, no build, no deploy** — this adds a workflow file only. Stated explicitly so the
|
||||
|
||||
@@ -1,43 +1,70 @@
|
||||
# REPORT — CI runs the static catalog gate on every push (R-168, 2026-08-02)
|
||||
# REPORT — papra volume persistence (R-156, last leg)
|
||||
|
||||
**Overwritten** per the standing rule. The prior contents (`catalog_gates --fast` + the pre-push hook, same day) have their durable record in `CHANGELOG.md`.
|
||||
|
||||
**No version bump, no build, no deploy.** This adds `.gitea/workflows/gates.yml` and a CHANGELOG
|
||||
entry. Nothing compiled changed.
|
||||
**Date:** 2026-08-03 · **Repo:** `app-catalog-felhom.eu` · **No version** (catalog templates are unversioned)
|
||||
|
||||
## What changed
|
||||
|
||||
`.gitea/workflows/gates.yml` — on every push, a Gitea Actions runner obtains this repo at the
|
||||
**exact pushed SHA** (shallow `git fetch`, no `uses:` step anywhere) and runs
|
||||
`python3 scripts/catalog_gates.py --fast` and nothing else. The exit code is the job's result: no `|| true`, no
|
||||
pipe that could swallow it.
|
||||
`templates/papra/docker-compose.yml` — the volume mount moved from `/app/data` to `/app/app-data`.
|
||||
One line, plus a Hungarian comment recording why, so the next reader does not "fix" it back.
|
||||
|
||||
**It REPORTS, it cannot REFUSE**, and the workflow header says so in the pre-push hook's voice: this
|
||||
repo pushes straight to `main` with no pull request, so there is no merge for a status check to stand
|
||||
at. The refusing half is `.githooks/pre-push` (per-clone, `--no-verify`-able); this half notices when
|
||||
that was skipped. Making CI blocking needs branch protection plus a PR workflow — an operator
|
||||
decision, tracked as `felhom.eu` `OPEN-ITEMS.md` **R-169**.
|
||||
## Why
|
||||
|
||||
**A failed run emails the operator.** Probe P5 measured that Gitea itself sends **nothing** on a
|
||||
failed run — no mail, no notification row, no log line — so the workflow sends its own via Resend and
|
||||
prints the provider's accepted id, making "a message left the machine" an observable. Demonstrated on
|
||||
a real red run in `felhom.eu`: `RESEND-ACCEPTED id=5ff34766-c5f8-4588-8104-08296aeb45ab`.
|
||||
papra mounted `papra_data:/app/data` while the application writes to `/app/app-data`. Its database
|
||||
therefore lived in the container's writable layer: lost on redeploy, and tarred nightly as an empty
|
||||
directory while the healthcheck stayed green. Last of the three apps R-156 convicted.
|
||||
|
||||
**`--fast` only, and that is the point.** `check-image-pins.py` runs; `check-image-resolvable.py`
|
||||
(network) and `check-volume-persistence.py` (Docker, minutes per app) do **not**. CI that pulls 53
|
||||
images on every push gets disabled, and the bypass becomes the habit. No sibling clone is needed
|
||||
here — unlike the controller and the agent, `catalog_gates --fast` does not invoke the shared reuse
|
||||
checker.
|
||||
## Precondition — checked, not inherited
|
||||
|
||||
## Verification
|
||||
The register's "deployed nowhere" evidence was from 2026-08-02 and covered one guest; both demo boxes
|
||||
were wiped and rebuilt on 2026-08-03, so it was re-measured three ways:
|
||||
|
||||
First run: run #1, id=10, conclusion **success**, sha `aa57588f`.
|
||||
- `docker ps -a` (**including stopped containers**) on demo-hp guest 9201 → no papra
|
||||
- `docker ps -a` on demo-felhom guest 9201 → no papra
|
||||
- hub `/hosts` fleet view → exactly two enrolled hosts (`demo-felhom-8363b5`, `demo-hp-bb76ea`), **zero** papra references
|
||||
|
||||
**Scenario F proven from the run log:** `image-pin gate OK — 53 templates, 0 unpinned images`, the
|
||||
skip announced (`--fast SKIPPED: image-resolvable, volume-persistence …`), and **zero** lines of
|
||||
resolvability output, volume-persistence output, canary output or image pulls.
|
||||
Deployed nowhere ⇒ the template fix strands no live data.
|
||||
|
||||
The runner is unprivileged host-mode, shared with the other three repos on a single owner-scoped
|
||||
registration (measured: all four repos' tasks claimed by `felhom-gates-runner`). Full probe detail,
|
||||
the security posture and the teardown: `felhom.eu/documentation/audits/SPIKE-ci-runner-2026-08-02.md`
|
||||
and `felhom.eu/REPORT.md`.
|
||||
## How the fix was chosen
|
||||
|
||||
From the **image**, not the README:
|
||||
|
||||
```
|
||||
WORKDIR=/app
|
||||
DATABASE_URL=file:./app-data/db/db.sqlite
|
||||
DOCUMENT_STORAGE_FILESYSTEM_ROOT=./app-data/documents
|
||||
PAPRA_CONFIG_DIR=./app-data
|
||||
```
|
||||
|
||||
and `/app/data` does not exist in the image at all — the old mount pointed at a path nothing could
|
||||
ever write.
|
||||
|
||||
**Reconfiguring the app to write to `/app/data` was available and was deliberately not taken.** All
|
||||
three paths are env-settable, so option (1) of the task's preference order was open. It enumerates
|
||||
data paths: a fourth added upstream would escape to the writable layer again, silently — this exact
|
||||
defect, re-armed and invisible. Mounting the app's own data ROOT captures every current and future
|
||||
path by construction, in one line rather than three env vars coupled to upstream.
|
||||
|
||||
## Proof — the runtime gate, in both directions
|
||||
|
||||
| Run | Verdict |
|
||||
|---|---|
|
||||
| `check-volume-persistence.py papra` (fixed) | **CLEAN**, self-test passed: *"prober flags the R-156 signature and clears a correct template — trustworthy"* |
|
||||
| same gate, mount reverted to `/app/data` (red-proof) | **BROKEN** — `mount /app/data is NOT writable by the app's own uid=999`; `DATA in the writable layer at /app/app-data/db (db_signature=True, e.g. ['db.sqlite'])`; `declared volume /app/data is EMPTY` |
|
||||
| `catalog_gates.py papra` (full, not `--fast`) | **rc=0** — image-pins OK (53 templates, 0 unpinned) · image-resolvable OK · volume-persistence OK |
|
||||
|
||||
The red-proof was run against the **real template**, not only the built-in canary, so the gate is
|
||||
shown to discriminate on the artifact actually being shipped.
|
||||
|
||||
## Two operational notes for the next run of that gate
|
||||
|
||||
- **It needs root.** It reads volume contents under `/var/lib/docker/volumes` (mode `drwx--x---`). As
|
||||
a normal user its own canary self-test fails UNDETERMINED and it correctly refuses to report — the
|
||||
fail-closed behaviour worked exactly as designed.
|
||||
- **Scope it to the app you touched.** Unscoped it deploys all 53 templates; that run exceeded ten
|
||||
minutes and was aborted. Its scratch containers were cleaned up afterwards (`volgate-*` projects).
|
||||
- It hardcodes a scratch path `/srv/felhom-gate`, which had to be created on DooPlex.
|
||||
|
||||
## Teardown
|
||||
|
||||
`volgate-*` scratch compose projects removed with their volumes. The pre-existing `jarr-*` containers
|
||||
(9 days old, unrelated) were left untouched.
|
||||
|
||||
@@ -16,7 +16,21 @@ services:
|
||||
- APP_BASE_URL=https://${SUBDOMAIN}.${DOMAIN}
|
||||
- AUTH_SECRET=${AUTH_SECRET}
|
||||
volumes:
|
||||
- papra_data:/app/data
|
||||
# R-156: a csatolási pont /app/app-data, NEM /app/data. A képfájl WORKDIR-je
|
||||
# /app, és MINDHÁROM adatútvonal az ./app-data alá mutat (a képfájlból
|
||||
# kiolvasva, nem a README-ből): DATABASE_URL=file:./app-data/db/db.sqlite,
|
||||
# DOCUMENT_STORAGE_FILESYSTEM_ROOT=./app-data/documents, PAPRA_CONFIG_DIR=./app-data.
|
||||
# A /app/data könyvtár a képfájlban nem is létezik — a régi csatolás olyan
|
||||
# útvonalra mutatott, ahová az alkalmazás soha nem írt, így a db.sqlite a
|
||||
# konténer írható rétegében maradt: újratelepítéskor elveszett, a napi mentés
|
||||
# pedig üres könyvtárat tarolt, miközben a healthcheck végig zöld volt.
|
||||
#
|
||||
# Az alkalmazás adatútvonalainak átállítása (/app/data-ra) is járható lett
|
||||
# volna, de az HÁROM környezeti változó karbantartását jelentené: ha a
|
||||
# felsőbb projekt egy negyedik adatútvonalat vezet be, az megint csendben az
|
||||
# írható rétegbe szökne. A gyökér csatolása minden jelenlegi ÉS jövőbeli
|
||||
# adatútvonalat elkap. Ellenőrzi: scripts/check-volume-persistence.py.
|
||||
- papra_data:/app/app-data
|
||||
networks:
|
||||
- traefik-public
|
||||
deploy:
|
||||
|
||||
Reference in New Issue
Block a user