papra: mount the volume where the app actually writes (R-156, last leg)
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:
2026-08-03 11:29:51 +02:00
parent 7cb58ecdf8
commit 122bbeea48
3 changed files with 104 additions and 33 deletions
+30
View File
@@ -1,5 +1,35 @@
# Changelog # 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) ## 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 **No version bump, no build, no deploy** — this adds a workflow file only. Stated explicitly so the
+59 -32
View File
@@ -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`. **Date:** 2026-08-03 · **Repo:** `app-catalog-felhom.eu` · **No version** (catalog templates are unversioned)
**No version bump, no build, no deploy.** This adds `.gitea/workflows/gates.yml` and a CHANGELOG
entry. Nothing compiled changed.
## What changed ## What changed
`.gitea/workflows/gates.yml` — on every push, a Gitea Actions runner obtains this repo at the `templates/papra/docker-compose.yml` — the volume mount moved from `/app/data` to `/app/app-data`.
**exact pushed SHA** (shallow `git fetch`, no `uses:` step anywhere) and runs One line, plus a Hungarian comment recording why, so the next reader does not "fix" it back.
`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.
**It REPORTS, it cannot REFUSE**, and the workflow header says so in the pre-push hook's voice: this ## Why
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**.
**A failed run emails the operator.** Probe P5 measured that Gitea itself sends **nothing** on a papra mounted `papra_data:/app/data` while the application writes to `/app/app-data`. Its database
failed run — no mail, no notification row, no log line — so the workflow sends its own via Resend and therefore lived in the container's writable layer: lost on redeploy, and tarred nightly as an empty
prints the provider's accepted id, making "a message left the machine" an observable. Demonstrated on directory while the healthcheck stayed green. Last of the three apps R-156 convicted.
a real red run in `felhom.eu`: `RESEND-ACCEPTED id=5ff34766-c5f8-4588-8104-08296aeb45ab`.
**`--fast` only, and that is the point.** `check-image-pins.py` runs; `check-image-resolvable.py` ## Precondition — checked, not inherited
(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.
## 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 Deployed nowhere ⇒ the template fix strands no live data.
skip announced (`--fast SKIPPED: image-resolvable, volume-persistence …`), and **zero** lines of
resolvability output, volume-persistence output, canary output or image pulls.
The runner is unprivileged host-mode, shared with the other three repos on a single owner-scoped ## How the fix was chosen
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` From the **image**, not the README:
and `felhom.eu/REPORT.md`.
```
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.
+15 -1
View File
@@ -16,7 +16,21 @@ services:
- APP_BASE_URL=https://${SUBDOMAIN}.${DOMAIN} - APP_BASE_URL=https://${SUBDOMAIN}.${DOMAIN}
- AUTH_SECRET=${AUTH_SECRET} - AUTH_SECRET=${AUTH_SECRET}
volumes: 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: networks:
- traefik-public - traefik-public
deploy: deploy: