Compare commits

...

192 Commits

Author SHA1 Message Date
admin 459766cb16 docs: R-168 is CLOSED — the "CI is still owed" sentence was stale (R-229 part 2)
gates / gates (push) Successful in 0s
Corrected in all four instruction files across all four repos. Found while confirming this
session own push by run ID, which is precisely the check that catches it.

In felhom-agent/CLAUDE.md the sentence contradicted the same file release section, which
already said R-168 mails the failure -- a contradiction inside one instruction file, the exact
class the R-229 work exists to find.

REPORT.md deliberately NOT overwritten in the sibling repos: a one-line docs correction must not
destroy the record of their last real implementation.
2026-08-06 11:03:02 +02:00
admin ee2c810201 pre-push: refuse a push from a clone outside the felhom workspace (R-204 rider)
gates / gates (push) Successful in 1s
The workspace root is already documented (workspace-CLAUDE.md, the workspace-root
CLAUDE.md 'stay inside it') and work drifted into a home directory anyway. A rule
that has failed once as a reminder is not fixed by writing it down again, so it is
now asserted where it can bite.

A push is the right trigger: throwaway clones under /tmp for probes and red-proofs
never push, so nothing legitimate breaks. Symlinks are resolved on both sides; an
absent workspace root SKIPS the check rather than failing it, so this cannot brick
a legitimate clone on another machine. The only bypass is the documented
--no-verify, whose use is already reportable.

Identical in all four repos.
2026-08-05 10:46:40 +02:00
admin 122bbeea48 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.
2026-08-03 11:29:51 +02:00
admin 7cb58ecdf8 docs: CHANGELOG + REPORT for the CI workflow
gates / gates (push) Successful in 1s
2026-08-02 16:35:45 +02:00
admin aa57588f55 ci: run the static catalog gate on every push (R-168)
gates / gates (push) Successful in 1s
--fast only: check-image-pins runs; the network and container-runtime gates do NOT. CI that
pulls 53 images on every push gets disabled, and they remain deliberate periodic runs.

No sibling clone needed here — unlike the controller and the agent, catalog_gates --fast
does not invoke the shared reuse checker. No uses: step, no version bump.
2026-08-02 16:27:28 +02:00
admin f16f29757e REPORT: catalog_gates --fast + pre-push hook 2026-08-02 15:37:11 +02:00
admin 340ff2a2d6 docs: CHANGELOG for catalog_gates --fast + the pre-push hook 2026-08-02 15:28:43 +02:00
admin c3e4bb18c7 gates: catalog_gates --fast + pre-push hook
--fast selects only gates that touch no network and no container runtime: gate 1
(check-image-pins) runs, image-resolvable and volume-persistence do NOT. Default behaviour with
no flag is unchanged. The skip is ANNOUNCED with the reason and with what still owes a periodic
run — a silently narrowed run reads as 'covered everything' when it did not.

Why the runtime gates are never in a hook: a push that pulls images and starts containers gets
bypassed within a week, and the bypass becomes the habit. They stay deliberate periodic runs at
the start of a catalog campaign, before a publish train, and when a template's volumes: block or
image tag changes — on a scratch host, never a customer box.

.githooks/pre-push runs catalog_gates.py --fast and refuses the push. Per-clone and
--no-verify-able, both stated in the hook itself.

test_catalog_gates.py pins --fast's CONTENT, not just its exit code: the runtime gates must not
run, the skip must be announced, and the no-flag path must still select all three. Red-proofed:
an inert run_gate turns it red.
2026-08-02 15:23:08 +02:00
admin fd7747d129 catalog gates: one entry point, mandated in CLAUDE.md (R-161 ruling)
scripts/catalog_gates.py runs all three gates - image-pins, image-resolvable,
volume-persistence - and exits non-zero if any fails. Mandated in CLAUDE.md the way
felhom.eu/scripts/site_gates.py is: run it after any template change, naming the
app(s) you touched.

Operator ruling, recorded because both alternatives were rejected for measured
reasons. Controller-side enforcement at template load was rejected because such a
check can only read the file, and a static audit of all 53 templates reports the
catalog clean INCLUDING papra - it would pass on the exact defect it exists to
catch; the property is decidable only at runtime. CI was rejected for now: neither
repo has any, and there are no users yet. What was chosen copies the shape that
demonstrably works here - of this project's gates, the only ones that ever get run
are the ones with a single entry point named in a CLAUDE.md; site_gates.py is run,
and R-29's three orphans are named nowhere and have stopped nothing.

Behaviour: 0 all clean / 1 convicted / 2 UNDETERMINED, never a pass; a conviction
outranks an undetermined result so the reader knows which they have. Gate output is
streamed, not captured. App names scope the two gates that accept scoping; with no
names the runtime gate deploys every template and belongs on a scratch host.
Adding a fourth gate means one line in GATES.

R-161 stays OPEN at reduced scope: this is convention, run by a person. Real
automatic enforcement is owed when a second person touches templates.

Verified: image-pins passes standalone (53 templates, 0 unpinned), the
unknown-option path exits 2, and the aggregation was unit-checked over five
gate-code combinations. The runtime leg was deliberately NOT executed - it deploys
templates via docker compose and DooPlex is the recovery chain - so the runner's
end-to-end invocation of that third gate is inferred, not measured, and is flagged
in REPORT.md to be closed on a scratch host at the next campaign.

REPORT.md overwritten per convention; the persistence sweep's report is preserved
at audits/persistence-sweep-2026-08-02/ and pointed to from the new one.
2026-08-02 14:03:55 +02:00
admin 6d45b60f94 persistence sweep: renumber proposed findings R-159..R-162 (R-158 collided mid-session)
The register grep that put these at R-158..R-161 was true when run and stale within hours: the
parallel session pushed SPIKE-recovery-unit-space-2026-08-02.md and CAMPAIGN-10-closeout-2026-08-02.md
mid-run, both using R-158 for an unrelated finding, and neither files it — ROADMAP.md and
OPEN-ITEMS.md still stop at R-155.

So two sessions minted the same number for different findings on the same day, which is the exact
failure §8.0 was already documenting about R-154/R-155 and R-156/R-157. Now recorded with itself as
the third instance. R-156, R-157 and R-158 are all live in audit documents and none is filed.
2026-08-02 12:24:48 +02:00
admin acb44672fd persistence sweep: teardown complete (all 3 layers) + papra referral updated
Layer 1: guest 9301 destroyed, vm-9301-disk-0 removed, pct list clean.
Layer 2: local-lvm available returned to 258 702 410 KiB — EXACTLY the pre-run baseline (29.27%).
Layer 3: no hub record was ever created (the scratch guest ran no controller and was never
enrolled, by design); confirmed absent from /configs and /hosts after teardown.

papra referral updated: c10-soak was torn down by the other session mid-run and papra disappeared
from hub telemetry with grafana, homebox and rallly — Campaign 10's four discriminator apps (§A4).
So papra's one deployment was on c10-soak. The fix is STILL not applied: that is absence evidence,
demo-hp is fenced, and applying it wrongly is irreversible while leaving it is a one-line push.
The single command that settles it is recorded.
2026-08-02 12:23:40 +02:00
admin 2b22a23d60 persistence sweep: 53 templates measured; gramps-web + wishlist fixed; runtime gate added
Campaign 10's R-156 found papra writing its database into the container's writable layer while the
volume the template preserves stayed empty — a backup that completes, verifies, and contains
nothing. papra was never the point: nothing anywhere checked that the folder a template preserves
is the folder the app writes to. All 53 templates have now been measured live.

43 CLEAN / 3 BROKEN / 7 UNDETERMINED. UNDETERMINED is counted separately, each with its reason,
and never folded into CLEAN.

FIXED (neither app is deployed anywhere, so nothing was stranded):
- gramps-web mounted /app/data, /app/media, /tmp — and /app/data is a path the application never
  writes. Its accounts database and ITS FAMILY TREE both landed in the writable layer while
  gramps_data was tarred nightly as an empty directory. Now persists the eight paths the image's
  own environment names, matching upstream's reference compose. Proven: users.sqlite and the
  family-tree files survive a redeploy byte-identical, same inode.
- wishlist mounted wishlist_data:/data, another path the app never writes; prod.db landed in the
  ANONYMOUS volume from the image's VOLUME directive — absent from ResolveDockerVolumeNames, so
  never backed up, and orphaned by a redeploy. Now mounts /usr/src/app/data + /usr/src/app/uploads.
  Proven: prod.db byte-identical, same inode, across a redeploy.

Every corrected path confirmed by two independent sources — the shipped image's own
environment/Config.Volumes and upstream's reference compose — never inferred from a directory name.

papra is NOT fixed. It is live on one box, and changing the mount target makes the next compose up
recreate the container and destroy the writable layer its documents live in. The fix is prepared
and proven in the scratch guest (current: db.sqlite differs after a redeploy, so a real account
created via the API is lost; fixed: byte-identical, it survives). Referred to the operator with the
two options; no migration written.

NEW GATE scripts/check-volume-persistence.py — the third catalog gate and the only RUNTIME one.
This class is invisible to static analysis, measured not assumed: a static audit of all 53 composes
reports the catalog clean AND reports papra clean. Exit 0 clean / 1 REFUSED / 2 undecided. It
refuses to report at all unless it has just re-proven itself in both directions against two canary
templates that differ only in which path the volume mounts at, so every run carries a live
demonstration of R-156 and of its fix. No docker exec anywhere (Campaign 7 §1.1). 44 fixture tests
driving check(), the function __main__ calls; every rule red-proofed.

Enforcement is convention, not CI — this repo has no CI. Stated plainly in the report; raising it
is proposed as R-160.

Report, per-app evidence, proofs and proposed register entries (R-158..R-161, NOT filed — felhom.eu
is fenced this session): audits/persistence-sweep-2026-08-02/
2026-08-02 12:21:30 +02:00
admin 4252121519 R-75: ingest binds move to ${IMPORT_PATH}; data_paths annotation
paperless-ngx and calibre-web are the only two catalog apps with a drop-zone,
and each has exactly ONE ingest bind. Both move from ${USERDATA_PATH}/import/<app>
to ${IMPORT_PATH}/<app> — the canonical root on the system drive — so a
multi-drive box has one drop-zone instead of one real folder plus a dead
lookalike on every other drive (and import/* is class: excluded, so files
stranded in a dead one would never be backed up either).

The matching backup: entries move to the new `import:` list IN THE SAME COMMIT.
This is not cosmetic: ValidateBackupSpec rejects an entry matching no compose
bind, and the rejection is WHOLE-BLOCK, so a stale `userdata: import/paperless`
would have discarded paperless's `hdd: appdata/paperless/media class: mandatory`
too and silently degraded the customer's document originals to legacy handling.
Both classes stay `excluded` — the move must not change data handling.

New data_paths: blocks on paperless-ngx, calibre-web and romm — role + Hungarian
label over paths that already exist as compose binds. Covers all three roles and
the multi-entry case. Requires controller v0.172.0 (deployed to both demo boxes
before this push, since ${IMPORT_PATH} is unset on older controllers).

Storage-layout header comments updated in both composes — they are the only
in-repo documentation of the layout.
2026-07-26 08:17:55 +02:00
admin 3067a94652 docs(report): lifecycle replaces retired/, live-proven on 9201 2026-07-21 16:36:41 +02:00
admin a32541684a catalog: lifecycle field replaces the retired/ directory move
Moving a template out of templates/ un-offers it but also makes the
controller's orphan detector see it as GONE for anyone already running the
app - flagging their working install Elavult with a Torles button. Withdrawing
an app must never take a working app away from a customer.

Optional lifecycle: available|hidden|abandoned in .felhom.yml instead.
plant-it returns to templates/ as the first abandoned app; retired/ removed.
Resolvability gate skips (and reports) non-available apps.
2026-07-21 16:19:49 +02:00
admin 857ba53233 gitignore: __pycache__ from the new python gate tests 2026-07-21 15:30:21 +02:00
admin b3eabfd611 catalog: re-pin wanderer to the current upstream shape, retire plant-it, add the resolvability gate
wanderer: ghcr.io/flomp/wanderer:0.16.0 is a ghost - upstream split the app
into web+db images, moved registry and renamed the org. Restructured to
upstream's own v0.20.0 compose (3 services, new /data/plugins volume, second
public hostname for PocketBase, meilisearch pinned DOWN to upstream's v1.36.0
per the R-42 ruling).

plant-it: retired. The repo name was wrong (plant-it-server) but upstream has
DELETED self-hosting; last server image is 2024-12-10 and it needs MySQL+Redis
the template never had. Moved to retired/ rather than deleted - reversible.

R-41 slice 1: check-image-resolvable.py. Encodes two traps - manifest inspect
exits 0 while printing toomanyrequests, and the inverse, where the first sweep
called 24 of 65 pins dead because Hub throttled it. Ambiguity is INCONCLUSIVE,
never an accusation.
2026-07-21 15:30:15 +02:00
admin 34d50a33ac docs: migrate workflow to DooPlex-local execution 2026-07-19 12:28:09 +02:00
admin a14c97db65 CHANGELOG + REPORT: campaign 7 full catalog sweep (53/53, 45 pass, 13 fixes)
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 03:22:56 +02:00
admin fd4426b1b2 wger: probe and route port 8000 (the image exposes 8000, not 80)
With 2.6 pulling and booting, wger still sat unhealthy: the healthcheck got
wget rc=4 (network failure, not an HTTP error) because nothing listens on :80.
wger's own log says it plainly -- 'Using django's development server on port
8000...' -- and the image declares ExposedPorts {"8000/tcp"}.

Corrected both the healthcheck target and the Traefik loadbalancer port, which
was pointing at :80 as well (so routing would have failed even once healthy).

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 03:17:26 +02:00
admin 27fab821b1 tandoor: port 8080 -> 80 and set ALLOWED_HOSTS (2.x moved both)
WIP within campaign 7 -- validated in the next step.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 03:09:22 +02:00
admin 94074840d1 wger: 2.6 with the full DJANGO_DB_* set (2.3 no longer exists upstream)
Correcting my own earlier revert. Reverting to 2.3 was WRONG: wger/server:2.3 is
no longer published on Docker Hub (only 2.4, 2.5, 2.6 resolve), so that pin could
not be pulled at all -- the deploy was accepted and no container was ever created.
An unpullable pin is strictly worse than the problem it was meant to avoid.

Every available version (2.4/2.5/2.6, verified) reads the whole DJANGO_DB_* set
unconditionally, even with the sqlite engine. So supplying it is not a hack
around one version -- it is now the only way to run wger at all. Verified live:
2.6 with the full set boots and applies its migrations ('Applying auth.0001_initial
... OK').

USER/PASSWORD/HOST/PORT are ignored by the sqlite backend but must be present.
DJANGO_DB_DATABASE points into the existing wger_data volume (/home/wger/db),
which is where wger's own default sqlite file lived -- so an existing install is
not pointed at an empty database somewhere else.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 03:04:55 +02:00
admin 2c0788db35 zipline: health path /api/health -> /api/healthcheck (404 in v4)
With the DATABASE_URL fix zipline started cleanly ('server started
hostname=0.0.0.0 port=3000') but stayed unhealthy. Probing the running container
from inside:

  /api/health       -> 404
  /api/healthcheck  -> 200

v4 renamed the endpoint; the template still probed the v3 path.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 02:32:58 +02:00
admin 28764f19c0 tandoor: start_period 30s -> 240s (probes exhausted before gunicorn bound)
After the memory fix tandoor still never went healthy. The probe output was
'wget: can't connect to remote host (127.0.0.1): Connection refused' while the
app log was still printing 'Booting worker' / 'Running django-vite' -- i.e. the
app had simply not finished starting. With start_period 30s and retries 3 the
healthcheck gave up at roughly two minutes.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 02:32:58 +02:00
admin ccbc52787a homebox: healthcheck must GET, not HEAD (--spider hit a 405)
With the image and the pepper fixed, homebox still sat unhealthy. The probe's own
exit code was 8 (server error response) and homebox's log shows exactly why:

  method=HEAD path=/api/v1/status status=405
  method=GET  path=/api/v1/status status=200

'wget --spider' issues a HEAD request; homebox's status endpoint only implements
GET. Switched to 'wget -q -O /dev/null' (a real GET).

NOTE: 24 templates use --spider. The rest validated green, so their endpoints do
answer HEAD -- but this is a latent trap worth a convention note (see the
campaign doc).

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 02:32:58 +02:00
admin 7350cd905a wger: revert 2.6 -> 2.3 (2.6 needs a full DB config the template cannot supply)
wger 2.6 crash-loops on a fresh deploy. Its settings/main.py reads the whole
DJANGO_DB_* set unconditionally -- even when the engine is sqlite:

  ImproperlyConfigured: Set the DJANGO_DB_DATABASE environment variable
  ...then, once that was supplied:
  ImproperlyConfigured: Set the DJANGO_DB_USER environment variable

Satisfying it means either stuffing in dummy USER/PASSWORD/HOST/PORT values that
the sqlite backend ignores, or giving wger a real Postgres sidecar. The first is
a hack, the second is compose restructuring -- both outside this campaign's
allowed-fix set.

Reverted to the previously shipped 2.3. NOTE: the interim DJANGO_DB_DATABASE
line added earlier in this campaign is reverted TOO, deliberately -- on 2.3 the
sqlite path came from wger's own default, and pinning a different explicit path
would have pointed an existing customer's wger at an empty database.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 02:26:02 +02:00
admin acfbd6e527 papra + wishlist: fix lying wget healthchecks (images ship only node)
Both crashes above were masking a second defect: once each app actually stayed
up, it sat permanently unhealthy because its healthcheck called wget, and
neither image has wget or curl -- only node. Traefik does not route to an
unhealthy container, so both would have served 404 to the customer regardless.

Switched both to the Node-exec family (http.get, exit non-zero on 5xx/error).

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 02:23:46 +02:00
admin b9b5b9246d zipline: CORE_DATABASE_URL -> DATABASE_URL (v4 dropped the prefix for the DB var)
zipline crash-looped 15 times on a fresh deploy:

  ERROR config::readDbVars] No database environment variables found
  (DATABASE_URL or all of [DATABASE_USERNAME, DATABASE_PASSWORD,
  DATABASE_HOST, DATABASE_PORT, DATABASE_NAME]), exiting...

In Zipline v4 the database variable is DATABASE_URL with NO prefix, while
CORE_SECRET kept its prefix (confirmed against the upstream config docs) -- so
only the DB variable was renamed here.

The template was already pinned to the v4 line (4.0.0) before this campaign, and
v4 has always required DATABASE_URL, so zipline has been undeployable from the
catalog for the whole v4 series. PRE-EXISTING defect, surfaced by the sweep.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 01:40:30 +02:00
admin bf1e339738 wishlist: follow upstream to ghcr.io and pin v0.66.0 (Hub image is gone)
wishlist deployed but never created a container: the pull fails because
cmintey/wishlist:1.9.0 no longer exists on Docker Hub. Upstream publishes to
ghcr.io/cmintey/wishlist, where the current release is v0.66.0 (2026-07-08,
confirmed against the GitHub releases API).

Note the version string goes '1.9.0' -> 'v0.66.0'. That is NOT a downgrade: the
old Hub pin used a scheme upstream does not publish under, so the catalog was
pinned to an image reference that has no counterpart in the real release stream.
This was a PRE-EXISTING defect -- wishlist was undeployable before this campaign
too (it was not version-bumped by the sweep).

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 01:35:16 +02:00
admin a35d67ba63 wger: set DJANGO_DB_DATABASE (2.6 dropped the sqlite default)
wger 2.6 crash-looped 15 times on a fresh deploy:

  django.core.exceptions.ImproperlyConfigured:
  Set the DJANGO_DB_DATABASE environment variable

The template already selected the sqlite3 engine, but 2.6 no longer supplies a
default database path -- it must be given explicitly even for sqlite. Pointed at
the existing wger_data volume (/home/wger/db) so the database survives redeploys.
Env var proven wrong by the deploy.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 01:27:22 +02:00
admin 48ee8be363 tandoor: raise app memory 512M -> 1024M (OOM proven) + correct mem_limit sum
tandoor 2.6.13 never reached healthy; gunicorn workers were SIGKILLed in a loop:

  [ERROR] Worker (pid:366) was sent SIGKILL! Perhaps out of memory?
  [ERROR] Worker (pid:367) was sent SIGKILL! Perhaps out of memory?

Separately, .felhom.yml mem_limit was already inconsistent with the compose file:
it claimed 512M while the services summed to 512+256 = 768M. Per the REUSE.md
rule mem_limit is the SUM of the compose limits, so it is now 1024+256 = 1280M.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 01:16:21 +02:00
admin d7ffcbe74d rallly: raise app memory 256M -> 768M (OOM-killed at 256M)
rallly 4.11.1 (Next.js 16) booted and was immediately killed by the cgroup OOM
killer, 14 times in 420s:

  ▲ Next.js 16.2.6
  - Local:  http://localhost:3000
  ✓ Ready in 0ms
  Killed

Migrations applied fine; it simply cannot live in 256M any more. App limit
256M -> 768M; sidecar Postgres left at 256M; .felhom.yml mem_limit updated to
the new sum (768+256 = 1024M) per the REUSE.md rule.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 01:05:32 +02:00
admin 63f0ca5622 homebox: add required HBOX_AUTH_API_KEY_PEPPER (0.26 panics without it)
With the tag fixed so the image actually pulls, homebox 0.26.2 then panicked on
every start (16 restarts):

  panic: auth.api_key_pepper must be set to at least 32 bytes; generate with
  `openssl rand -base64 48` and provide via HBOX_AUTH_API_KEY_PEPPER.
  Rotating it invalidates all issued API keys

Added as a generated base64key:48 secret (matching upstream's own suggestion)
and marked data_key: true -- rotating it invalidates every issued API key, so
restore must recover the original rather than mint a new one.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 00:54:20 +02:00
admin 05cb216968 papra: add required AUTH_SECRET (app refuses to boot without it)
papra was NOT version-bumped by this campaign -- it crash-looped at its existing
26.6.1-rootless pin, so this is a PRE-EXISTING catalog defect: papra has never
been deployable from this template.

  Invalid configuration: In production, the auth secret must not be the default
  one. Please set a secure auth secret using the AUTH_SECRET environment
  variable.

Added as a generated hex:32 secret and marked data_key: true -- it signs
sessions, so regenerating it on restore would invalidate every login.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 00:42:29 +02:00
admin 1c06ef00bd n8n: raise memory 512M -> 1536M (V8 heap OOM proven during validation)
n8n 2.31.3 died on a fresh deploy under the 512M limit:

  FATAL ERROR: Ineffective mark-compacts near heap limit
  Allocation failed - JavaScript heap out of memory

Note the container still reported *healthy* while the Node process was crashing,
so docker health alone did not catch this -- the log scan did.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 00:42:29 +02:00
admin 4ce7beea9e outline: set PGSSLMODE=disable (1.x defaults to SSL; the sidecar Postgres has none)
outline 1.9.1 crash-looped (15 restarts in 420s) on a fresh deploy:

  SequelizeConnectionError: The server does not support SSL connections
  Set the PGSSLMODE environment variable to 'disable' or enable SSL on your
  database server.

The stack's own Postgres sidecar is on the app-internal network and does not
serve SSL, so the correct answer is to disable it client-side. Env var proven
wrong by the deploy.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 00:42:28 +02:00
admin 9938c5564e gramps-web: raise memory 384M -> 1024M (OOM proven during validation)
v25.6.0 could not stay up under the 384M limit. Live evidence from the demo box:

  [ERROR] Worker (pid:559) was sent SIGKILL! Perhaps out of memory?
  [ERROR] Worker (pid:556) was sent SIGKILL! Perhaps out of memory?

gunicorn kept booting replacement workers that were killed in turn, so the
container never reached healthy and Traefik returned 404. mem_limit in
.felhom.yml raised to match the compose limit; mem_request 100M -> 384M.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 00:02:25 +02:00
admin 25a2480396 homebox: v0.16.3 -> 0.26.2 and drop the 'v' tag prefix (old pin no longer resolves)
Upstream stopped publishing v-prefixed tags on ghcr.io/sysadminsmedia/homebox.
Neither the new v0.26.2 NOR the previously shipped v0.16.3 resolves any more --
so homebox was already undeployable from the catalog before this campaign; the
deploy is accepted and then no container is ever created because the pull fails.
Pinned to the un-prefixed 0.26.2, which resolves.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 00:02:25 +02:00
admin 604284f91c gokapi: revert v2.2.4 -> v1.9.6 (v2 refuses to start against the seeded config)
Campaign 7 bumped gokapi to v2.2.4. It crash-loops on a FRESH deploy:

  Gokapi v2.2.4 starting
  Warning: Salt for admin password invalid, generating new salt.
  Please update to version 2.0.0 before running this version.

The catalog seeds a v1-era config.json (ConfigVersion 21). v2.2.4 sees that
config version and demands an intermediate v2.0.0 migration pass -- even with an
empty data volume -- so it never comes up.

Making v2 work needs the seeded config regenerated in the v2 format, which is
template/config restructuring and therefore OUTSIDE this campaign's allowed-fix
set (pins, healthchecks, start_period, proven-wrong env, OOM limits). Shipping
the broken pin would break gokapi for every NEW customer deploy, so the pin is
reverted to the last known-good version and the upgrade is recorded as a
finding for a dedicated task.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:32:20 +02:00
admin 8f35697931 uptime-kuma: pin floating ':2' -> 2.4.0
House style is a specific upstream tag. ':2' is a floating major that silently
moves under customers on every pull -- the same class of problem as ':latest',
just narrower. Pinned to the current 2.x release.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:39 +02:00
admin e5657c926b zipline: 4.0.0 -> 4.6.1
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:06 +02:00
admin e43fa89f80 wger: 2.3 -> 2.6
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:06 +02:00
admin 501c02fbfc wanderer: meilisearch v1.12 -> v1.49 (app image left: ghcr.io/flomp/wanderer does not resolve, see campaign doc)
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:06 +02:00
admin 3fa63cddbd vikunja: 0.24.6 -> 2.3.0 [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:06 +02:00
admin 4ff5b8f95b vaultwarden: 1.33.2-alpine -> 1.36.0-alpine
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:05 +02:00
admin 7fc24df469 tandoor: 1.5.26 -> 2.6.13 [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:05 +02:00
admin 712c760fc8 sparkyfitness: v0.17.2 -> v0.17.3 (server + web)
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:05 +02:00
admin 507eb3f6dd sonarr: 4.0.13 -> 4.0.19
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:05 +02:00
admin 780f4f457f seerr: 2.3.0 -> 2.7.3
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:05 +02:00
admin 52e03be3eb romm: 4.5.0 -> 5.0.0 [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:05 +02:00
admin e3f3a815e9 rallly: 3.11.2 -> 4.11.1 [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:05 +02:00
admin e0b6da6376 radarr: 5.17.2 -> 6.3.0 [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:05 +02:00
admin cf8b6456ee privatebin: 1.7.5 -> 2.0.5 [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:04 +02:00
admin f01ed3f963 paperless-ngx: 2.15.3 -> 2.20.15
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:04 +02:00
admin 4d6b55d2cb outline: 0.82.0 -> 1.9.1 [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:04 +02:00
admin 0ee1ce42eb opengist: 1.10 -> 1.13
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:04 +02:00
admin bc2a2dbe08 onlyoffice: 8.3.0 -> 9.4.0 [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:04 +02:00
admin 5e2c1ae31e nextcloud: 31.0.14-apache -> 34.0.1-apache [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:04 +02:00
admin c615bde179 navidrome: 0.54.5 -> 0.63.2
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:04 +02:00
admin ec7f9c41ec n8n: 1.79.3 -> 2.31.3 [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:04 +02:00
admin 9055a6aa0b mealie: v3.10.2 -> v3.20.1
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:03 +02:00
admin aa27d0c054 komga: 1.20.0 -> 1.25.0
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:03 +02:00
admin a3f9b02ebf kimai: apache-2.25.0 -> apache-2.57.0
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:27:03 +02:00
admin 373d6cfb08 emby: fix lying healthcheck (curl absent; image ships only BusyBox)
The healthcheck ran 'curl -f', but the Emby image has neither curl nor a
standalone wget -- only BusyBox. The probe ENOENT'd every run, the container
stayed permanently unhealthy, and Traefik will not route to an unhealthy
container, so the customer got a 404 while Emby itself was serving fine.

Switched to the BusyBox-wget family via the /bin/busybox multi-call binary.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 23:14:02 +02:00
admin 4dd4f47363 jellyfin: 10.11.6 -> 10.11.11
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:13 +02:00
admin cb10409098 homepage: v1.2.0 -> v1.13.2
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:13 +02:00
admin 893e26ac8b homebox: v0.16.3 -> v0.26.2
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:13 +02:00
admin 2f6ef07f50 home-assistant: 2026.2.2 -> 2026.7.2
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:13 +02:00
admin 4ebd11d021 gramps-web: v24.12.1 -> v25.6.0 [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:13 +02:00
admin b789acc427 grafana: 11.5.1 -> 13.1.0 [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:13 +02:00
admin 0873294781 gokapi: v1.9.6 -> v2.2.4 [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:12 +02:00
admin 12e36d3c01 glance: v0.7.4 -> v0.8.5
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:12 +02:00
admin 6742d5faf1 gitea: 1.23.4 -> 1.27.0
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:12 +02:00
admin e881018fd4 ghost: 6.19.2-alpine -> 6.53.0-alpine
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:12 +02:00
admin bcd2c35c7a emby: 4.9.0.42 -> 4.10.0.20
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:12 +02:00
admin a2115b27c7 docmost: 0.25.3 -> 0.95.0
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:12 +02:00
admin 19a6ea421e code-server: 4.96.4 -> 4.129.0
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:12 +02:00
admin da04c3583f claper: 1.8 -> 2.5 [MAJOR]
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:59:11 +02:00
admin c7de1e6364 adventurelog: fix lying frontend healthcheck (wget in a distroless image)
The frontend healthcheck ran 'wget --spider', but the image is distroless: no
shell, no wget, no curl, and node itself is only reachable at an absolute path
(/nodejs/bin/node, off PATH). The probe therefore ENOENT'd on every run, the
container stayed permanently unhealthy, and Traefik -- which will not route to
an unhealthy container -- returned 404 to the customer even though the app was
serving 200 on :3000 the whole time.

Replaced with the Node-exec family using the absolute interpreter path.
Live-verified on the demo box.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:55:40 +02:00
admin 147cee7795 calcom: v4.6.9 -> v6.2.0 [MAJOR]
MAJOR: two major versions (v4 -> v6); breaking notes pending upstream changelog
review, recorded in the campaign doc.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:37:35 +02:00
admin b5abc84d4e audiobookshelf: 2.19.5 -> 2.35.1
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:37:34 +02:00
admin 413832cf55 adventurelog: v0.11.0 -> v0.12.1 (backend + frontend)
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:37:34 +02:00
admin 0e34997594 actualbudget: 26.1.0 -> 26.7.0
Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:37:34 +02:00
admin 75e7cf0c5b immich: v2.5.5 -> v3.0.3 [MAJOR], postgres -> 16-vectorchord0.4.3-pgvectors0.2.0
MAJOR: immich v3.0.0 drops pgvecto.rs support and requires VectorChord. Our pin
was already VectorChord, so fresh deploys are unaffected; an in-place update from
a pgvecto.rs-era install would need the upstream migration first.

Postgres sidecar moved to the vectorchord/pgvectors extension versions immich
v3.0.3 ships in its own compose (0.4.3 / 0.2.0), keeping our PG major (16)
rather than upstream's 14 to avoid a needless major change.

NOTE (recorded, not fixed): upstream v3 migrated redis -> valkey:9. Kept
redis:7-alpine here; the swap is a structural change, not a pin bump.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:32:58 +02:00
admin 0b73e5ecc9 bookstack: 25.02.2 -> 26.05.2 [MAJOR], mariadb 11.6 -> 12.3 [MAJOR]
MAJOR (bookstack): v26.05 needs storage/fonts writable for PDF export; revision
viewing is now a separate permission. Fresh deploys unaffected; flagged for the
future in-place update path.
MAJOR (mariadb): 11.6 -> 12.3.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 22:21:04 +02:00
admin 21e8df1e3a backup classification: backup: blocks for the 13 bind-bearing apps
Adds the referential-coupling backup: block to every app that binds
${HDD_PATH}/${USERDATA_PATH} (immich, paperless-ngx, nextcloud, calibre-web,
audiobookshelf, komga, navidrome, radarr, sonarr, emby, jellyfin, plex, romm).
Each lists its userdata:/hdd: binds with class mandatory|optional|excluded
(operator-ruled + spike SQ2).

Requires controller v0.132.0 (deployed) which parses+validates these blocks.
INERT — no backup tier changes behavior yet (Task 3/4 consume it). All 13
verified against the shipped parser: parse-clean, every bind resolves explicit.
audiobookshelf media/audiobooks = optional (PENDING Viktor veto to excluded).

Ref: felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md
2026-07-14 18:51:36 +02:00
admin 365a017e81 REPORT: git-sync no-op verified on 9201 (pinned compose synced, calibre not restarted) 2026-07-12 14:39:58 +02:00
admin 71828a81cf image pinning: eliminate :latest from all 5 unpinned templates + standing gate
bentopdf :latest -> v2.8.6; calibre-web :latest -> v4.0.6 (== running digest on
demo 9201, c31a738b - pin is a no-op); papra :latest -> 26.6.1-rootless (latest
was the rootless variant); recipe-importer :latest -> v0.9.11 (tag pre-existed,
digest-equal, no retag needed); termix :latest -> 2.5.0.

All five pins digest-identical to what :latest resolved to on 2026-07-12.
New gate scripts/check-image-pins.py (catches floating tags AND untagged refs;
red-proofed both shapes). Standing rule in CLAUDE.md + REUSE.md row.
2026-07-12 14:37:57 +02:00
admin 2ebe082a2a docs: healthcheck sweep REPORT (localhost -> 127.0.0.1, 48 templates, live-validated)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-06 20:43:13 +02:00
admin 8ddd3c9da5 fix(healthcheck): sweep localhost -> 127.0.0.1 across all 48 templates
BusyBox wget (+ node/python/curl one-shots, incl mealie's socket tuple) resolve
localhost -> IPv6 ::1 with no cross-family fallback; an IPv4-only-binding app
reads docker-unhealthy while serving (vaultwarden, re-run 2026-07-06). Escalates
that instance to the class. Scoped strictly to healthcheck test: lines
(diff-reviewed: no env/config/label changed; .felhom.yml already clean). New
REUSE.md convention row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-06 20:25:54 +02:00
admin d86e25662f fix(vaultwarden): _ENABLE_SMTP boot-gate — fresh email-off deploys crash-looped (campaign F1)
Vaultwarden treats defined-but-empty SMTP_HOST/SMTP_FROM as "set"; with upstream
_enable_smtp defaulting true its validation errors out and the container
crash-loops on every fresh deploy with app-email off (the default). Gate the
SMTP group with _ENABLE_SMTP: compose default false (clean boot, mail off),
flipped "true" by the app-email injection via smtp_mapping.extra. Proven on the
pinned 1.33.2-alpine image (P1 repro exit 12 / P2 off boots / P3 on boots).
REUSE.md: strict-image TRAP row. Sweep note: the other 5 smtp-mapped templates
are campaign-boot-proven tolerant; no edits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-06 14:01:11 +02:00
admin 13eedb1a83 docs: CHANGELOG + REPORT — sparkyfitness finalization + validation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-03 19:48:31 +02:00
admin c7490f7a40 sparkyfitness: finalize — image-inspected healthchecks + probe-container naming (REUSE row added)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-03 19:40:09 +02:00
admin 15cdf7b090 docs: CLAUDE.md light expansion (repo purpose, deploy contract, pointers)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-03 11:26:12 +02:00
admin c5a3d1b15b docs: REUSE.md introduced — catalog conventions map (canonical app, healthcheck families, traps) + minimal CLAUDE.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-03 09:39:38 +02:00
admin bb81cd414a app-email: calcom+nextcloud wired & send-tested; calcom healthcheck GET fix + docs
calcom + nextcloud both send live via the :2526 plaintext listener (calcom signup =
Nodemailer; nextcloud password-reset = Symfony Mailer), both From <app>@felhom.eu.
calcom healthcheck: wget GET not --spider (HEAD 405s). CHANGELOG + REPORT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:56:49 +02:00
admin e90dae890e calcom: fix healthcheck path (/api/health 404 in v4.x -> /api/auth/providers)
/api/health doesn't exist in cal.com v4.x (404) -> container stayed unhealthy ->
Traefik wouldn't route it. /api/auth/providers is a stable 200 once serving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:47:18 +02:00
admin 342a389f3d calcom: add DATABASE_DIRECT_URL (required by Prisma; migrations failed without it)
Cal.com's prisma schema declares directUrl=env(DATABASE_DIRECT_URL); without it the
startup 'prisma migrate deploy' fails (P1012) and the schema stays incomplete -> the app
500s. Set to the same direct Postgres URL. Pre-existing, never caught (bad image pin
meant calcom never deployed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:42:15 +02:00
admin 9dd4923053 calcom: fix non-existent image pin v4.8.7 -> v4.6.9 (nearest valid 4.x)
calcom/cal.com:v4.8.7 is a git tag with no published Docker image; the manifest is
unknown. v4.6.9 is the nearest valid 4.x image (same EMAIL_SERVER_* env behavior).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:33:04 +02:00
admin 1b89692a49 app-email: calcom + nextcloud mappings (tls_mode=plaintext, nextcloud split-From)
calcom (Nodemailer) + nextcloud (Symfony) opportunistically STARTTLS with no cert-skip
-> tls_mode=plaintext (:2526, no STARTTLS advertised). nextcloud splits From via
from_domain_var (MAIL_FROM_ADDRESS + MAIL_DOMAIN). Compose files reference injected env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:14:01 +02:00
admin 42bf76f540 docs: REPORT + CHANGELOG — gitea/rallly send-tested live; findings + template fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:06:57 +02:00
admin 32c730f749 rallly: fix healthcheck (image has no wget → node http check)
The wget healthcheck always failed (exit 127, no wget in image) → container
unhealthy → Traefik refused to route it. Pre-existing, never caught because the
3.12.1 image pin didn't exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:54:49 +02:00
admin 47f23bf1e3 rallly: add required SUPPORT_EMAIL + valid NOREPLY_EMAIL default (boots without email)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:39:47 +02:00
admin 3b5d102d21 app-email rollout: gitea + rallly mappings; calcom/nextcloud/immich = findings
gitea (STARTTLS + FORCE_TRUST_SERVER_CERT) and rallly (Nodemailer STARTTLS +
SMTP_REJECT_UNAUTHORIZED=false) wired. Fixed rallly's non-existent 3.12.1 pin → 3.11.2.
calcom/nextcloud/immich don't fit the mechanism (self-signed opportunistic-STARTTLS,
split From, no-SMTP-env) — see FINDING doc in felhom.eu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:32:55 +02:00
admin f0529bc9a0 docs: REPORT — note live relay validation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:58:07 +02:00
admin 4581a92781 app-email: smtp_mapping for vaultwarden + mealie
Vaultwarden via STARTTLS (accepts self-signed shim cert); Mealie via plaintext
(NONE) — no accept-invalid-cert option, spike-validated mode. Compose files
reference injected ${SMTP_*}. README documents the pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:45:23 +02:00
admin 5d42ca18bf docs: SparkyFitness (v0.17.2) — CHANGELOG + README rows + REPORT
Live-validated on guest 9201 / controller v0.87.0: synced, deployed via the
real dashboard flow, all 3 containers healthy, /api/health UP through the public
edge. Records §1.3 image-probe findings and the deferred interactive-login item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:06:50 +02:00
admin 4351d08daf Add SparkyFitness (v0.17.2) — nutrition/workout tracker
frontend (nginx SPA :80, sole Traefik ingress) + server (Node :3010) +
dedicated postgres:15-alpine. Native email auth (no OIDC), subdomain 'sparky'.
Two DB roles (sparky superuser + sparkyapp limited, auto-created first boot),
PGDATA subdir, data_key on API_ENCRYPTION_KEY + BETTER_AUTH_SECRET.
Transcribed from the validated k3s manifest (homelab-manifests/workout-system).

New files:
  templates/sparkyfitness/docker-compose.yml
  templates/sparkyfitness/.felhom.yml

Image probes (build server, v0.17.2): server node-exec /api/health probe (node
present); frontend wget --spider probe (wget+curl both present).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:57:56 +02:00
admin d7a6a17504 docs: correct crafty 4.10.7 report — the 401 was operator-configured MFA, not an upgrade bug (no regression); real MC server on 25565 verified from LAN 2026-06-26 11:57:31 +02:00
admin 0012d66fc3 docs: CHANGELOG + REPORT for crafty 4.10.7 bump, port range, guidance; flag in-place-upgrade TOTP lockout 2026-06-26 11:48:53 +02:00
admin 2d5b835636 crafty-controller: bump image 4.4.8->4.10.7 (security CVEs + Java); publish Java port range 25565-25575; app-page port/connection guidance 2026-06-26 11:37:09 +02:00
admin 886f596393 docs: REPORT for crafty seeded admin password (gokapi pattern); live-verified fresh install + API login 2026-06-26 11:29:04 +02:00
admin 5a403d1003 crafty-controller: seed felhom-generated admin password (gokapi pattern) instead of Crafty's random one; app-page card reads default.json 2026-06-26 11:22:26 +02:00
admin 8fd9c007af crafty-controller: initial_credentials block — surface auto-generated admin password on app page 2026-06-26 11:00:08 +02:00
admin 1216a8a78a crafty-controller: Traefik https backend scheme + scoped skip-verify transport (fixes 502 to TLS backend) 2026-06-26 10:28:59 +02:00
admin 9abd392247 crafty-controller: changelog + report for healthcheck fix (route un-withheld; 502 backend-scheme tracked separately) 2026-06-26 09:56:52 +02:00
admin 68ce009f19 crafty-controller: python3 TLS healthcheck (image has no curl); controller-side probe http->tcp (8443 is TLS) 2026-06-26 09:45:17 +02:00
admin 6a71c5aa0a catalog: add open_path for apps whose UI isn't at / (gokapi=/admin, ghost=/ghost/)
New .felhom.yml field consumed by the controller's Megnyitás link. Audit of all 53 templates: only
gokapi (file-share index redirects away) and ghost (bare / is the public blog; admin at /ghost/) need it;
the rest serve/redirect usefully at root. onlyoffice is API-only (status page at /) — left at root.
2026-06-23 12:00:30 +02:00
admin 6e91a5cbe5 docs: gokapi RedirectUrl + default_creds follow-ups 2026-06-23 11:49:07 +02:00
admin 9db1259750 gokapi: point index RedirectUrl at the app's own /admin (not Gokapi's GitHub)
Gokapi's bare root '/' redirects to RedirectUrl. It was the upstream default (github.com/Forceu/Gokapi),
so the controller's 'Megnyitás' link (always the bare subdomain root) landed on Gokapi's GitHub instead
of the app. Point it at https://${SUBDOMAIN}.${DOMAIN}/admin so the root + the controller link reach
the Gokapi login/panel.
2026-06-23 11:47:14 +02:00
admin ed10e5bb06 gokapi: show admin username (default_creds) + fix first_steps for headless setup
App now auto-configures (no setup wizard), so first_steps described a flow that no longer happens.
Add default_creds 'Felhasználó: admin · jelszó a Beállítások oldalon' so the app-info page shows the
initial admin user like other apps; password stays on the Beállítások page (generated field).
2026-06-23 11:23:41 +02:00
admin 72d8c8b97c docs: gokapi headless-setup fix (CHANGELOG + REPORT) 2026-06-23 11:13:35 +02:00
admin 3ec67ef2f5 gokapi: reproducible headless setup via seed config + --deployment-password
Gokapi v1.9.6 has no env-var setup and otherwise exposes a public /setup (admin-takeover risk).
Entrypoint seeds config.json on first boot (admin, this app's URL, local storage, no-encryption so
it restarts without a prompt) with password/salts cleared, then runs the documented
--deployment-password one-shot to set the felhom-generated admin password before serving — claims
admin at first boot, never exposes /setup. Seed pinned to v1.9.6 (ConfigVersion 21).
2026-06-23 11:07:50 +02:00
admin 690c8dd0b8 Revert gokapi headless-env change: Gokapi (v1.9.6/v2.0.1) has no GOKAPI_USERNAME/PASSWORD env
Verified against Gokapi docs + the v1.9.6 binary: no env-var headless admin setup exists in any
version. Non-interactive setup requires a pre-seeded config.json + --deployment-password. Reverting
the non-functional env so the template isn't misleading; choosing a real fix path with the user.
2026-06-23 08:40:11 +02:00
admin 01936716d3 gokapi: headless first-run setup (GOKAPI_USERNAME=admin + generated GOKAPI_PASSWORD)
Gokapi sat in maintenance mode since deploy because /setup was never completed and the
template provided no headless-setup env. GOKAPI_USERNAME/PASSWORD configure it on first
start; password is felhom-generated (deploy field, shown to customer). Fixes share.* maintenance page.
2026-06-23 08:26:15 +02:00
admin d334dd7007 docs: REPORT covers both catalog HC fixes (komga + gitea)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:14:34 +02:00
admin 1a44c1403f docs: CHANGELOG entry for gitea HC fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:18:10 +02:00
admin 17e00b788e gitea: fix healthcheck — probe /api/healthz (was 404 on /api/v1/version pre-install)
Surfaced during the unattended deploy sweep: a fresh gitea reported unhealthy because
the HC hit /api/v1/version (404 until install-lock); /api/healthz returns 200 while
serving. Fixed both the docker HC and the controller .felhom.yml probe; bumped
start_period to 90s for first-boot DB migration. Same class as the komga fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:17:52 +02:00
admin f51f794cf0 docs: REPORT for komga healthcheck fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:12:19 +02:00
admin 9b066de331 komga: fix controller-side health probe path too (/actuator/health)
The .felhom.yml controller probe also pointed at the auth-gated /api/v1/actuator/health
(401) — that is what drives the dashboard 'Nem egészséges' state. Repoint to the
unauthenticated /actuator/health, matching the docker HC fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:04:52 +02:00
admin 3faa5ae193 komga: fix healthcheck — probe unauthenticated /actuator/health (was 401 on /api/v1/...)
The /api/v1 prefix is auth-gated → old HC got 401 → curl -f failed → container
reported unhealthy while serving fine. Diagnosed live on guest 9201.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:03:37 +02:00
admin 939864f2a6 catalog: repoint HDD_PATH defaults/placeholders to /mnt/felhom-drives/<name>
Intermediary-mount re-architecture: drives are visible in-guest at the stable
/mnt/felhom-drives/<name>. Composes already use ${HDD_PATH}/${USERDATA_PATH}
(injected + repointed by controller v0.67.0); this updates the UI placeholders,
templates.json defaults, and doc/script examples to the new convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:48:52 +02:00
admin f7458487fb docs: REPORT for paperless OCR lang-pack fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:29:03 +02:00
admin 67037cc064 paperless: fix OCR lang-pack install (space-separated PAPERLESS_OCR_LANGUAGES)
The compose fed the +-joined PAPERLESS_OCR_LANGUAGE (e.g. hun+eng) into
PAPERLESS_OCR_LANGUAGES (the install list), so the image tried to apt-install a bogus
'tesseract-ocr-hun+eng' → hun pack missing → Django check crash-loop on the default
hun+eng. PAPERLESS_OCR_LANGUAGES must be space-separated; set a fixed superset
'eng hun deu' (verified each installs). Found live deploying paperless on guest 9201.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:23:21 +02:00
admin 3c13d3e8e9 docs: REPORT for userdata layout repoint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:32:03 +02:00
admin c99070c105 komga + audiobookshelf: add no-new-privileges (root-fallback hardening)
Address security review: reverting to root removed the user:1000 boundary, so add
security_opt no-new-privileges:true to block SUID-based escalation. Full cap_drop
is intentionally NOT applied — the images' root-init needs CHOWN/SETUID/SETGID to
set up /config and /metadata (dropping them reproduces the crash we hit at user:1000).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:17:32 +02:00
admin 0d60a5cb2e komga + audiobookshelf: revert to root (user:1000 fallback)
Live try-then-fallback: pinning user 1000:1000 crash-loops both — their named
config/metadata volumes are Docker-created root-owned and the pinned process can't
write them (komga: SQLite /config open fails; audiobookshelf: EACCES mkdir
/metadata/logs; neither has a PUID-style root-init chown). Reverted to root; they
rely on the setgid 2775 userdata dirs (files land group 1000 → FileBrowser browses/reads).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:13:29 +02:00
admin 69611cec18 userdata layout: repoint customer-content mounts to ${USERDATA_PATH}
Move every shared-media + ingest mount off ${HDD_PATH}/media onto the new
${USERDATA_PATH}/... convention (controller injects USERDATA_PATH = <namespace>/userdata):
- jellyfin/emby/plex: media:/media:ro ; navidrome: media/music:ro
- audiobookshelf (user 1000:1000): media/{audiobooks,podcasts}
- komga (user 1000:1000): media/comics
- calibre-web (UMASK=002): library->media/books, ingest->import/calibre
- radarr/sonarr (UMASK=002): media/{movies,tv} + downloads (sibling, same FS)
- romm: ROM library -> userdata/roms (browsable); resources stay in appdata
- immich: + external library media/photos:/external/photos:ro (registration is a
  post-deploy admin step — flagged in compose + .felhom.yml)
- paperless: consume -> import/paperless (USERMAP 1000)
- nextcloud: unchanged (fully app-managed)
.felhom.yml first_steps updated for the new browsable drop-zones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:46:23 +02:00
admin 5ec2b2cadc F5: fix uptime-kuma healthcheck (v2 uses compiled extra/healthcheck binary)
The override pointed at a v1-era node /app/extra/healthcheck.mjs that does not
exist in louislam/uptime-kuma:2, so the container was permanently unhealthy and
Traefik withheld the route → the app URL 404'd despite the app running. Point at
the correct binary (extra/healthcheck, WORKDIR /app) and use the image's timing
(180s start_period) to avoid transient unhealthy→404 on first boot.
2026-06-14 09:51:40 +02:00
admin 86d473c779 adventurelog: mark SECRET_KEY as data_key (fail-closed restore annotation)
SECRET_KEY ("Titkosítási kulcs") is a data-encrypting key — regenerating it on
restore would render stored data unreadable. data_key:true makes the controller
recover it from the guest's own app.yaml (live/PBS) and fail closed if it can't,
rather than ever regenerating. Pairs with controller v0.53.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 10:20:36 +02:00
admin 7e6223190b fix: drop doubled felhom-data segment from HDD app compose templates
Model A binds the guest mount /mnt/<drive> directly onto the host's
<drive>/felhom-data namespace, so the guest mount already IS felhom-data.
The templates' ${HDD_PATH}/felhom-data/appdata/<app> therefore double-nested
to <drive>/felhom-data/felhom-data/appdata/<app> on disk, diverging from the
provenance-aware backup helpers (NamespaceRoot(drive,true) -> single-nested).

Change all four HDD app templates (romm, nextcloud, immich, paperless-ngx)
to ${HDD_PATH}/appdata/<app>, matching AppDataDir(NamespaceRoot(HDD_PATH,true)).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:23:48 +02:00
admin e1c6e062d9 docs: update repo link for deploy-felhom-compose -> felhom-controller rename
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:39:55 +02:00
admin 004e4308be fix(nextcloud): add internal hostname to trusted_domains
Add 'nextcloud' to NEXTCLOUD_TRUSTED_DOMAINS so internal callbacks
from other containers (e.g. OnlyOffice Document Server) arriving with
Host: nextcloud are not rejected. Only affects initial install — for
existing deployments, the integration handler sets it via occ.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:22:28 +01:00
admin 6bd7ea4d73 Add X-Forwarded-Proto header middleware to OnlyOffice template
Fixes mixed content errors when OO is behind Traefik HTTPS reverse
proxy. Without this, OO Document Server generates http:// URLs for
editor resources, which browsers block on HTTPS pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:04:31 +01:00
admin 8d0b991207 feat: add integrations metadata to onlyoffice template
Declares FileBrowser and Nextcloud as integration targets for the
new app-to-app integration framework in the controller.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:06:22 +01:00
admin 65acc925fc home-assistant: fix initial config - remove !include directives
The !include directives for automations/scripts/scenes reference files
that don't exist on fresh install, causing HA to enter recovery mode
which ignores trusted_proxies. Simplified to minimal config with just
default_config and http block - HA adds the rest during onboarding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:12:44 +01:00
admin ba78eb060f home-assistant: add trusted_proxies entrypoint wrapper
HA rejects proxied requests (400 Bad Request) without trusted_proxies
in configuration.yaml. Uses entrypoint wrapper pattern (like romm) to
ensure the http config block exists before HA starts.

Handles both fresh deploys (creates full configuration.yaml) and
existing installs (appends http block if missing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:10:26 +01:00
admin 56dc87af58 fix(home-assistant): healthcheck uses /manifest.json instead of authenticated /api/
The /api/ endpoint requires authentication, causing healthcheck to always
fail and spam HA logs with "invalid authentication" warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:34:22 +01:00
admin ac074b5176 fix(ghost): fix healthchecks for Ghost 6.x (301 redirects to external URL)
Ghost 6.x redirects all HTTP requests to the configured external URL.
- Docker healthcheck: use node http.get (accepts 301 as healthy)
- Controller probe: switch from api (expect 200) to http (any response)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:26:12 +01:00
admin 750f7b8ec9 fix(ghost): update image from non-existent 5.108.0-alpine to 6.19.2-alpine
Ghost 5.108.0 doesn't exist on Docker Hub. Latest stable is 6.19.2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:21:00 +01:00
admin b111610f32 fix(bentopdf): remove empty volumes block that breaks docker compose validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:13:19 +01:00
admin cb65587c3e updated adventurelog template 2026-02-25 15:01:53 +01:00
admin a04882e296 feat: add controller-side healthcheck configs to all 52 apps
Each app template now declares a healthcheck: section in .felhom.yml
with appropriate probe type (http, api, or tcp) and endpoint based on
the app's known health endpoints. The controller uses these to verify
services are actually responding, not just that containers are running.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:11:27 +01:00
admin 2febc99148 fix(vikunja): remove healthcheck — container has no shell/wget
The vikunja/vikunja:0.24.6 image is a scratch/distroless build with
only the Go binary. wget, curl, sh, and all other utilities are
missing, making in-container healthchecks impossible.

Removing the healthcheck lets the controller detect the container
as "running" directly from Docker state, which is the correct
behavior for shell-less images.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:48:53 +01:00
admin ab3f9e7b35 fix(vikunja): run as root to fix /db volume permissions
Vikunja 0.24.6 runs as uid=1000 but named Docker volumes are
created with root ownership, causing permission denied on /db.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:34:45 +01:00
admin b406bd39f7 updated recipe-importer tag to latest 2026-02-25 08:32:35 +01:00
admin c0e4564295 chore(recipe-importer): bump to v0.8.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:33:15 +01:00
admin 771263c90f updated racipe importer details 2026-02-24 20:09:46 +01:00
admin b3280c976d feat: add recipe-importer app template
Hungarian recipe scraper for Mealie & Tandoor. Supports mindmegette.hu,
streetkitchen.hu, nosalty.hu, sobors.hu, kiskegyed.hu, gastrohobbi.hu
with schema.org fallback for other sites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:49:02 +01:00
admin 153a05e2d8 fix: change admin password fields from secret to password type
All user-facing admin passwords now use type: password instead of
type: secret, giving users a visible editable field with Generate
button (matching Paperless-ngx pattern).

Affected apps: Nextcloud, Grafana, Kimai, Code-server

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 16:27:37 +01:00
admin dbe92bad5c fix: use felhom-data/appdata/ path for HDD volume mounts
Compose templates were mounting app data at ${HDD_PATH}/appdata/ instead
of ${HDD_PATH}/felhom-data/appdata/ as designed in the v0.26.0+ path
structure. Affects: nextcloud, immich, paperless-ngx, romm.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:53:15 +01:00
admin 3a24723c07 fix(adventurelog): healthcheck + SECRET_KEY for v0.11.0
Backend: wget missing from image, use python urllib instead.
Frontend: localhost resolves to IPv6, use 127.0.0.1.
Backend: add SECRET_KEY env var (v0.11.0 reads it directly).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:08:14 +01:00
admin 5b23e89c6a fix(adventurelog): use v0.11.0 image tags (v0.12.0 doesn't exist)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:28:10 +01:00
admin 87d0e5e59d feat: use ${SUBDOMAIN} variable in all templates
All 51 docker-compose.yml: replaced hardcoded subdomain.${DOMAIN}
with ${SUBDOMAIN}.${DOMAIN} in Traefik labels, app env vars, and
comments.

All 51 .felhom.yml: added SUBDOMAIN deploy field (type: subdomain)
with default matching existing subdomain metadata value.

Works with felhom-controller v0.27.0 which validates and stores the
user-chosen subdomain in app.yaml. Existing deployed apps get
SUBDOMAIN auto-injected via InjectMissingFields() on next sync.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 15:06:44 +01:00
admin a969cbd557 updated bookstack template and readme 2026-02-20 11:03:30 +01:00
admin 817e2d6d1a bookstack template updated 2026-02-20 10:06:43 +01:00
admin 1f9448027e Rename storage/ to appdata/ in all compose templates
Part of v0.14.0 storage architecture overhaul — standardize
app data paths under appdata/ instead of storage/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:47:12 +01:00
admin 34a01a7b4e remove filebrowser from catalog — now infra service
FileBrowser is now deployed as infrastructure via docker-setup.sh,
no longer managed through the app catalog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:03:16 +01:00
admin d82e2398c5 filebroswer and calibre updated, stirling-pds removed, readme updated 2026-02-15 09:00:14 +01:00
admin 0bd3f2a0e2 added apps! 2026-02-15 08:47:15 +01:00
admin 95bc16309e romm: fix YAML parse error — escape inner double quotes in help_text
Hungarian quotation marks „..." used ASCII " as closing quote, which
terminated the YAML double-quoted string prematurely. Escaped with \".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 20:49:27 +01:00
admin feb36d708d RoMM: add app_info + optional_config metadata, add missing env vars
- Add app_info section: tagline, use cases, first steps, prerequisites, default creds
- Add optional_config: 6 metadata provider fields (IGDB, SteamGridDB, ScreenScraper, MobyGames)
- Add missing env vars to docker-compose.yml: SCREENSCRAPER_USER, SCREENSCRAPER_PASSWORD, MOBYGAMES_API_KEY
- Fix display_name: "ROMM" → "RomM"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 20:14:08 +01:00
admin 8b752492bf updated mealie healthcheck 2026-02-14 18:57:02 +01:00
admin 063c077c9e updated immich template 2026-02-14 17:33:20 +01:00
admin 6d7e64da93 updated app templates 2026-02-14 13:39:44 +01:00
admin 1deacc08cb updated ram to memory_request 2026-02-14 12:06:59 +01:00
admin dbfcf624c1 Updated paperless template 2026-02-14 11:43:02 +01:00
admin d92dfd9f85 updated paperless template 2026-02-13 21:25:58 +01:00
admin 0c03abf567 added paperless metadata config 2026-02-13 20:15:33 +01:00
kisfenyo 6857548275 made script executable 2026-02-12 18:54:46 +01:00
admin 5995041697 architecture changed to preconfigured customer templates 2026-02-12 18:47:02 +01:00
admin d5624825a9 removed customer folders and script 2026-02-12 18:01:43 +01:00
admin 68100aa37c updated templates for Portainer 2026-02-12 17:44:57 +01:00
admin 5b0c6aa9c0 updated filebrowser volume 2026-02-12 14:58:44 +01:00
admin 1ff89ab9da updated app templates to use letsencrypt 2026-02-12 14:09:54 +01:00
admin cceb0ec798 updated docmost health-check command 2026-02-12 13:21:12 +01:00
kisfenyo 5b07156ee3 script executable 2026-02-12 12:51:31 +01:00
admin 0ea4121701 fixed paste 2026-02-12 12:50:31 +01:00
admin 7760221cba updated actualbudget healthcheck 2026-02-12 12:49:21 +01:00
admin ebf57c0259 updated script 2026-02-12 08:31:50 +01:00
kisfenyo a59fbdff66 made script executable 2026-02-12 08:19:15 +01:00
admin 82b55c73a2 updated to v2.0, monorepo customer output 2026-02-12 08:18:35 +01:00
214 changed files with 40250 additions and 855 deletions
+98
View File
@@ -0,0 +1,98 @@
# gates — re-run this repo's gate entry point on every push, on a machine that does not care who
# pushed or what they typed.
#
# *** THIS REPORTS. IT CANNOT REFUSE. ***
#
# felhom repos push 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`, which is local to a clone and which
# `git push --no-verify` skips; this half is what notices when that happened. Neither half is the
# whole thing, and both are named in felhom.eu documentation/backlog/OPEN-ITEMS.md R-168.
#
# NO `uses:` STEP ANYWHERE, deliberately: JavaScript actions need a node runtime in the runner, and
# the runner is a host-mode container with python3 and git and nothing else (see
# homelab-manifests/gitea-system/act-runner.yaml for why it is not privileged). Probe P3 measured
# that a plain `git fetch` of the pushed SHA from the in-cluster Gitea service is enough.
#
# A failing run must reach a person — a detector nobody hears is the defect R-29 filed, rebuilt one
# layer up. That is the last step, and it runs ONLY on failure.
name: gates
on: [push]
jobs:
gates:
runs-on: felhom-gates
steps:
- name: Fetch the pushed commit
run: |
# Shallow, and pinned to the exact SHA that was pushed — not to the branch tip,
# which can move under us if two pushes race.
mkdir -p ws/app-catalog-felhom.eu
cd ws/app-catalog-felhom.eu
git init -q .
git remote add origin http://gitea.gitea-system.svc.cluster.local:3000/admin/app-catalog-felhom.eu.git
git fetch -q --depth 1 origin "$GITHUB_SHA"
git checkout -q FETCH_HEAD
echo "checked out $(git rev-parse HEAD)"
- name: Run the gate entry point
# The ONLY thing CI runs. No go build, no go test, no linting, no deploy. The
# exit code IS the result: no `|| true`, no pipe that could swallow it.
run: cd ws/app-catalog-felhom.eu && python3 scripts/catalog_gates.py --fast
- name: Alarm on failure
# THE POINT OF THE WHOLE THING. Probe P5 measured that a failed run produces NO mail, NO
# notification row and NO log line from Gitea itself — a red tick in a web UI nobody watches
# is exactly the shape R-29 filed against. So the run sends its own alarm, on the project's
# existing transactional path (Resend, the same one the hub uses), and prints the provider's
# accepted id so "a message left the machine" is an observable, not an assumption.
#
# Pure python3 and urllib, NOT curl: the runner image carries python3 and git and nothing
# else on purpose, and the first version of this step died on `curl: command not found`.
# Reaching for a bigger image to send one HTTP request would have been the wrong trade.
if: failure()
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
python3 - <<'PY'
import json, os, sys, urllib.request, urllib.error
key = os.environ.get("RESEND_API_KEY", "")
if not key:
sys.exit("ALARM FAILED: RESEND_API_KEY is empty — the alarm cannot be sent, and a "
"silent alarm is worse than none. Set the user-level Actions secret.")
repo = os.environ.get("GITHUB_REPOSITORY", "?")
sha = os.environ.get("GITHUB_SHA", "?")
run = os.environ.get("GITHUB_RUN_NUMBER", "?")
srv = os.environ.get("GITHUB_SERVER_URL", "https://gitea.dooplex.hu")
body = json.dumps({
"from": "Felhom CI <monitoring@felhom.eu>",
"to": ["admin@felhom.eu"],
"subject": "[felhom CI] gates FAILED in %s" % repo,
"text": (
"The gate entry point exited non-zero.\n\n"
"Repository : %s\n"
"Commit : %s\n"
"Run : %s/%s/actions/runs/%s\n\n"
"The failing gate names itself in the run log.\n\n"
"If the local pre-push hook was GREEN for this commit, then CI and the hook\n"
"disagree - that is a finding about the gates themselves, not about CI, and it\n"
"outranks whatever the push was for.\n"
) % (repo, sha, srv, repo, run),
}).encode()
req = urllib.request.Request(
"https://api.resend.com/emails", data=body, method="POST",
headers={"Authorization": "Bearer %s" % key,
"Content-Type": "application/json",
# Cloudflare fronts api.resend.com and BLOCKS the default
# "Python-urllib/3.x" agent with its own 403 (error 1010) — which looks
# exactly like an auth failure and is not one. Measured 2026-08-02.
"User-Agent": "felhom-ci/1.0"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
print("RESEND-ACCEPTED id=%s" % json.load(r)["id"])
except urllib.error.HTTPError as e:
sys.exit("ALARM FAILED: Resend returned HTTP %s: %s" % (e.code, e.read().decode()[:300]))
PY
+82
View File
@@ -0,0 +1,82 @@
#!/bin/sh
# pre-push — refuse a push that carries a broken gate. (2026-08-02, R-29 leg (b) first half.)
#
# Runs this repo's ONE gate entry point in --fast mode: only checks that touch no network and no
# container runtime, so a push stays a push and never pulls images or starts containers. The slow
# gates stay deliberate periodic runs; a hook that takes minutes gets bypassed within a week and
# the bypass becomes the habit.
#
# BOTH LINES BELOW ARE DELIBERATE. An absent log line is not evidence a hook ran — a silent pass is
# equally consistent with "gates green" and "hook never fired", so a passing push says so out loud.
#
# HONEST LIMITS, stated so this is not mistaken for enforcement it cannot provide:
# * per-clone — core.hooksPath is local config and a clone does not carry it. Arm a clone once:
# git config core.hooksPath .githooks
# Any manual entry-point run WARNS when the clone is unarmed.
# * skippable — `git push --no-verify` bypasses this entirely. That is on purpose: an escape
# hatch that cannot be reached is one that gets removed the first time it is
# inconvenient. USING IT MUST BE STATED IN THE SESSION REPORT.
# The half that is neither per-clone nor skippable is CI — felhom.eu OPEN-ITEMS.md R-168.
#
# Measured 2026-08-02 (git 2.47.3): a relative core.hooksPath resolves correctly and the hook's cwd
# is the repo root whether `git push` is issued from the root or from any subdirectory. The
# explicit rev-parse below does not depend on that.
set -u
root=$(git rev-parse --show-toplevel 2>/dev/null) || {
echo "pre-push: FAIL - cannot resolve the repo root (git rev-parse --show-toplevel)." >&2
exit 1
}
cd "$root" || exit 1
# ── WORKSPACE-ROOT ASSERTION (2026-08-05, R-204 rider) ───────────────────────────────────────────
# Refuse a push from a clone outside the felhom workspace.
#
# WHY THIS IS A HOOK AND NOT A LINE IN A DOCUMENT: the workspace root is ALREADY written down, in
# documentation/runbooks/workspace-CLAUDE.md and in the workspace-root CLAUDE.md ("stay inside it"),
# and work drifted into a home directory anyway. A rule that has failed once as a reminder is not
# fixed by writing it down again — it has to be asserted where it can bite.
#
# A PUSH IS THE RIGHT TRIGGER, deliberately: throwaway clones under /tmp for probes and red-proofs
# never push, so nothing legitimate breaks. Reads and builds elsewhere stay unaffected.
#
# Symlinks are resolved on BOTH sides before comparison, so a symlinked path neither falsely passes
# nor falsely fails. If the workspace root does not exist on this machine the check is SKIPPED, not
# failed — this hook must not brick a legitimate clone on a different host.
#
# The only bypass is the documented `git push --no-verify`, whose use is already reportable.
FELHOM_WORKSPACE_ROOT=/mnt/5_hdd/felhom.eu
if [ -d "$FELHOM_WORKSPACE_ROOT" ]; then
ws_real=$(cd "$FELHOM_WORKSPACE_ROOT" 2>/dev/null && pwd -P) || ws_real=""
root_real=$(pwd -P) || root_real=""
if [ -n "$ws_real" ] && [ -n "$root_real" ]; then
case "$root_real/" in
"$ws_real"/*) : ;; # inside the workspace — proceed
*)
echo "pre-push: PUSH REFUSED - this clone is OUTSIDE the felhom workspace." >&2
echo " clone: $root_real" >&2
echo " expected: under $ws_real (repos live in $ws_real/git/<repo>)" >&2
echo " Work in the workspace clone, or bypass with 'git push --no-verify'" >&2
echo " and state that you did in the session report." >&2
exit 1
;;
esac
fi
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "pre-push: FAIL - python3 not found, so the gates CANNOT run. This is a failure, never a" >&2
echo " pass by default. Install python3, or push with --no-verify and say so." >&2
exit 1
fi
echo "pre-push [app-catalog-felhom.eu]: running scripts/catalog_gates.py --fast ..."
python3 "scripts/catalog_gates.py" --fast
rc=$?
if [ "$rc" -ne 0 ]; then
echo "pre-push [app-catalog-felhom.eu]: PUSH REFUSED - gates exited $rc. Fix the finding above, or bypass with" >&2
echo " 'git push --no-verify' and state that you did in the session report." >&2
else
echo "pre-push [app-catalog-felhom.eu]: gates OK - push proceeding."
fi
exit $rc
+1
View File
@@ -11,3 +11,4 @@
# OS files
.DS_Store
Thumbs.db
__pycache__/
+604
View File
@@ -0,0 +1,604 @@
# Changelog
## docs — the "CI is still owed" claim was stale; corrected (2026-08-06, R-229 part 2) — no version bump
**One sentence, no code.** This file asserted that continuous integration was still owed
(`felhom.eu` `OPEN-ITEMS.md` R-168). **R-168 was CLOSED on 2026-08-02** — a Gitea Actions runner
re-runs each repo's gate entry point on every push and emails the operator on failure. Found while
confirming this session's own push by run ID, which is the check that caught it.
The same stale sentence was in four instruction files across all four repos and is corrected in all
four. In `felhom-agent/CLAUDE.md` it **contradicted the same file's release section**, which already
said R-168 mails the failure — a contradiction inside one instruction file, which is the exact class
the R-229 work exists to find.
### 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
omission reads as a decision rather than a miss.
**`.gitea/workflows/gates.yml` (new).** Triggers on `push`, `runs-on: felhom-gates`, obtains the
source with a shallow `git fetch` of the **exact pushed SHA** from the in-cluster Gitea Service, and
runs this repo's entry point with `--fast` — nothing else. **No `uses:` step anywhere**: JavaScript
actions need a node runtime the host-mode runner does not have, and probe P3 measured a plain
`git fetch` as sufficient. No `|| true`; the entry point's exit code IS the job's result.
**It REPORTS, it cannot REFUSE**, and the workflow header says so: 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`, which is per-clone and `--no-verify`-able; this half notices when that was
skipped. Making CI blocking needs branch protection plus a PR workflow → felhom.eu `OPEN-ITEMS.md`
R-169, an operator decision.
**A failed run emails the operator** via Resend and prints the provider's accepted id, because probe
P5 measured that Gitea itself sends nothing at all on a failed run. Demonstrated end to end on a real
red run (`RESEND-ACCEPTED id=…`), not assumed. Full detail:
`felhom.eu/documentation/audits/SPIKE-ci-runner-2026-08-02.md`.
**`--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. Measured in the first run:
`image-pin gate OK — 53 templates, 0 unpinned images`, with both runtime gates announced as skipped
and their own output absent from the log. They remain deliberate periodic runs.
No sibling clone is needed here — unlike the controller and the agent, `catalog_gates --fast` does
not invoke the shared reuse checker.
## 2026-08-02 — `--fast` for the pre-push hook (no version: this repo carries none)
**`scripts/catalog_gates.py --fast`** selects only gates that touch no network and no container
runtime. Today that is gate 1, `check-image-pins.py`. `check-image-resolvable.py` (network) and
`check-volume-persistence.py` (Docker, minutes per app) are **not** in it, and the skip is
**announced**, with the reason and with what still owes a periodic run — a silently narrowed run
reads as "covered everything" when it did not. Default behaviour with no flag is unchanged.
**Why the runtime gates are never in a hook.** A push that pulls images and starts containers gets
bypassed within a week, and the bypass becomes the habit. They stay deliberate periodic runs: the
start of a catalog campaign, before a publish train that vouches the catalog, and whenever a
template's `volumes:` block or image tag changes — on a scratch host, never a customer box.
**`.githooks/pre-push` (new)** runs `catalog_gates.py --fast` and refuses the push. It is per-clone
(`git config core.hooksPath .githooks`) and `git push --no-verify` bypasses it on purpose; both
limits are written into the hook. This is R-161's convention half made automatic-ish; the
unbypassable half is CI, now tracked as `felhom.eu` `OPEN-ITEMS.md` **R-168**.
**`scripts/test_catalog_gates.py` (new, 5 tests)** pins `--fast`'s CONTENT, not just its exit code:
the static gate's own stdout must appear (an inert runner prints the summary while calling nothing),
the runtime gates' must not, the skip must be announced, and the no-flag path must still select all
three. Red-proofed with an inert `run_gate`.
## 2026-08-02 — one entry point for the catalog's gates (R-161 ruling)
`scripts/catalog_gates.py` runs all three gates — image-pins, image-resolvable, volume-persistence —
and exits non-zero if any fails. Mandated in `CLAUDE.md` the way `felhom.eu/scripts/site_gates.py` is:
**run it after any template change**, naming the app(s) you touched.
**Operator ruling, recorded because the alternatives were rejected for measured reasons.**
Controller-side enforcement at template load was rejected: such a check can only read the file, and a
static audit of all 53 templates reports the catalog clean **including papra** — it would pass on the
exact defect it exists to catch. CI was rejected for now: neither repo has any, and there are no users
yet. What was chosen copies the shape that demonstrably works here — of this project's gates, the only
ones that ever get run are the ones with a single entry point named in a CLAUDE.md; `site_gates.py` is
run, and R-29's three orphaned gates are named nowhere and have stopped nothing.
Behaviour: `0` all clean · `1` convicted · `2` UNDETERMINED, **never a pass**; a conviction outranks an
undetermined result in the summary so the reader knows which they have. Gate output is streamed, not
captured — a runner that swallows diagnostics makes a conviction unreadable. Scoping passes app names
through to the two gates that accept them; with no names the runtime gate deploys every template and
belongs on a scratch host.
**R-161 stays OPEN at reduced scope:** this is convention, run by a person. Real automatic enforcement
is owed when a second person touches templates.
Verified: `image-pins` passes standalone (53 templates, 0 unpinned); the unknown-option path exits 2;
the aggregation was unit-checked over five gate-code combinations. **The runtime leg was deliberately
NOT executed on DooPlex** — it deploys templates via `docker compose`, and DooPlex is the recovery
chain; it belongs on a scratch host.
## 2026-08-02 — persistence sweep: does every app's data land in a folder the template preserves?
Campaign 10's R-156 found papra writing its database into the container's writable layer while the
volume the template preserves stayed empty — so its backup completed, verified, and contained
nothing. papra was never the point: **nothing anywhere checked that the folder a template preserves
is the folder the app writes to**, across 53 templates. All 53 have now been measured live.
**Result: 43 CLEAN · 3 BROKEN · 7 UNDETERMINED.** Full report and per-app evidence:
`audits/persistence-sweep-2026-08-02/`.
**New gate — `scripts/check-volume-persistence.py`, the third and the only RUNTIME one.**
The two image gates are static, and **this defect class is invisible to static analysis** — measured,
not assumed: a static audit of all 53 composes (every declared volume attached, no anonymous mounts,
no stray host binds) reports the catalog clean *and reports papra clean*. papra's compose is
well-formed; only its behaviour is wrong. So the gate deploys each template, exercises it into
writing data, and compares where the data landed with what is mounted. Exit **0** all clean /
**1 REFUSED** / **2** undecided. `UNDETERMINED` is exit 2 and is never a pass.
It **refuses to report at all** unless it has just re-proven itself in both directions against two
canary templates built from a purpose-made image reproducing papra's ownership shape — the pair
differ only in which path the volume mounts at, so every run carries a live demonstration of R-156
and of its fix. A detector that flags nothing turns an unexamined catalog into a documented-clean one.
41 fixture tests (`scripts/test_check_volume_persistence.py`, no Docker) driving `check()` — the
function `__main__` calls — plus `rollup_diff`/`classify`. Every rule red-proofed.
**Two templates FIXED** (neither deployed anywhere in the fleet, so no data was stranded):
- **`gramps-web`** — mounted `/app/data`, `/app/media`, `/tmp`, and **`/app/data` is a path the
application never writes**. Its accounts database (`GRAMPSWEB_USER_DB_URI``/app/users`) and
**its family tree** (`GRAMPS_DATABASE_PATH``/root/.gramps/grampsdb`) both landed in the
container's writable layer: destroyed by any redeploy, absent from every backup, while
`gramps_data` was tarred nightly as an empty directory. Now persists the eight paths the image's
own environment names, matching upstream's reference compose.
- **`wishlist`** — mounted `wishlist_data:/data`, another path the app never writes. `prod.db` went
into the **anonymous** volume docker creates for the image's `VOLUME /usr/src/app/data` directive.
Anonymous volumes are absent from `ResolveDockerVolumeNames`, so `DumpAppVolumes` never backs them
up, and `compose down` + `up` orphans them — a store that survives a restart, loses on redeploy and
is never in a backup. Now mounts `/usr/src/app/data` and `/usr/src/app/uploads` per upstream.
Every corrected path is confirmed by **two independent sources** — the shipped image's own
environment/`Config.Volumes`, and upstream's reference compose — never inferred from a directory name.
**`papra` is NOT fixed — referred to the operator.** The one-line fix is prepared and proven, but
papra is live on one box, and changing the mount target makes the next `compose up -d` recreate the
container and destroy the writable layer its documents currently live in. That data is already on
borrowed time, but the fix is what *schedules* the loss. See the report §6.1 for which box it is,
how far that was determined, and the two options. No migration was written.
**7 UNDETERMINED, counted separately and never folded into CLEAN**`bentopdf` (stateless by
design), `uptime-kuma` / `privatebin` / `recipe-importer` (write nothing until a user completes
setup), `glance` (crash-loops for want of a seeded config — pre-existing, Campaign 7 §6.2),
`plant-it` (image does not resolve; `lifecycle: abandoned`), `wanderer` (unhealthy).
`CLAUDE.md` and `REUSE.md` updated with the gate and the traps it encodes.
## 2026-07-21 (later) — app lifecycle replaces the `retired/` directory move
**The `retired/` mechanism shipped earlier today was wrong and is withdrawn.** Moving a template out
of `templates/` does un-offer it — but it also makes the controller's orphan detector see the
template as GONE for anyone already running the app, flagging their working install `Elavult` and
offering a Törlés button. Withdrawing an app must never take a working app away from a customer.
Replaced by an optional top-level `lifecycle:` field in `.felhom.yml` (controller v0.158.0):
- `available` — default. Absent or empty means this, so all existing templates are unchanged.
- `hidden` — not offered for new installs; nothing shown to anyone already running it.
- `abandoned` — not offered for new installs, and every box already running it shows a permanent
„Nem karbantartott" badge plus a notice that updates and security fixes will no longer arrive.
Deployed instances keep full function in every state; the controller refuses a deploy of a
non-available template server-side. An unknown value degrades to `available` with one WARN.
- **`plant-it` returns to `templates/`** with `lifecycle: abandoned` — the first user of the
mechanism, and the case that motivated it. Its compose is deliberately unchanged: it pins
`msdeluise/plant-it:0.10.0`, a repository that does not exist (the real one is `-server`), and the
app is not installable, so rewriting it would imply it is. `retired/` is removed.
- **The resolvability gate is now lifecycle-aware.** Non-available apps are skipped by default and
REPORTED, not silently dropped; `--all` includes them. An abandoned app's dead image is the
expected end state, not a finding — counting it would leave the gate permanently red for something
nobody intends to fix, and a gate that is always red is a gate nobody reads. 6 new fixture tests
(19 total), including one asserting an all-skipped run is a pass rather than an error.
Catalog is back to **53 apps** (52 offered + plant-it abandoned).
## 2026-07-21 — catalog honesty: wanderer re-pinned, plant-it retired, and a standing rot gate (R-41 slice 1)
Campaign 7 left two apps sitting behind a working "Telepítés" button with images that did not
resolve at all, recorded as findings rather than fixed. Both are now diagnosed rather than hidden,
and the class of defect gets a gate so it cannot recur silently.
**wanderer — RE-PINNED. The project is alive; the template was pointing at a ghost.**
`ghcr.io/flomp/wanderer:0.16.0` does not resolve because upstream did three things at once: split
the app into two images, moved registry, and renamed the GitHub org (Flomp → open-wanderer). Current
shape, taken from upstream's own compose at tag v0.20.0 (2026-07-07):
- `flomp/wanderer-web:v0.20.0` — the SvelteKit web app, port 3000, `curl` on PATH.
- `flomp/wanderer-db:v0.20.0` — PocketBase, port 8090. Built FROM `scratch`: no shell, no package
manager, a static curl baked in at `/curl` — hence the absolute-path healthcheck.
- `getmeili/meilisearch:v1.36.0` — still a required sidecar; both other services wait on its health.
**Pinned DOWN from the v1.49 Campaign 7 had set**, per the R-42 ruling: a sidecar pin follows the
app template's own proposed pin, never the newest tag independently.
- **New required volume** `/data/plugins` on the db — v0.20.0 moved the Strava/Komoot/Hammerhead
integrations into a WASM plugin sandbox that lives there.
- **New: a second hostname** (`SUBDOMAIN_DB`, default `hike-db`). `PUBLIC_POCKETBASE_URL` is a
browser-side variable — the user's browser talks to PocketBase directly, so it cannot be an
internal address. Upstream's own proxy example uses two hostnames for the same reason.
- New generated secret `POCKETBASE_ENCRYPTION_KEY` (`hex:16` → exactly the 32 characters upstream
requires). `mem_limit` 384M → 1024M, matching the sum of the three services.
**plant-it — RETIRED to `retired/plant-it/` (operator ruling 2026-07-21).** The pin was only
slightly wrong — the repository is `msdeluise/plant-it-server`, and `0.10.0` was the right version —
but correcting the name would have been the wrong fix. Upstream has **discontinued self-hosting**:
`backend/` and `deployment/` are deleted from `main`, the project is now an Android app on
F-Droid/Obtainium, and the last server image was pushed **2024-12-10** (a security-frozen Spring
Boot 3.4.0). It also requires **MySQL 8.0 + Redis**, which the template never had — its header
claimed "Database: None (file-based)", which was never true. Ruling: do not ship unmaintained
software to customers. Retirement is reversible (`git mv retired/plant-it templates/plant-it`);
nothing is deleted. Catalog is now **52 apps**.
**`scripts/check-image-resolvable.py` — R-41 slice 1: the standing rot gate.** `check-image-pins.py`
is syntactic and proves only that a template pins *something* concrete; it cannot see that the thing
is gone. This resolves every unique pin with `docker manifest inspect`, one image at a time, and
exits 0 / 1 (GONE) / 2 (inconclusive). Two traps are encoded in it, both observed live during this
change:
- `docker manifest inspect` prints `toomanyrequests: …` and **still exits 0** — the same
exits-0-on-failure shape as the ISO tooling's `validate-answer`, so stderr is checked even on rc=0.
- The inverse, which the first full sweep actually did: it called **24 of 65 pins dead**, including
`postgres:16-alpine` and `redis:7-alpine`, purely because Docker Hub throttled it partway through.
Ambiguity now resolves to INCONCLUSIVE, never to an accusation — a gate that cries wolf gets
ignored, and then it protects nothing.
14 fixture tests (`scripts/test_check_image_resolvable.py`), no network — the resolver is injected.
## 2026-07-19 — docs: workspace-root pointer follows the CC move to DooPlex
**Docs only, no template change.** Claude Code now runs on DooPlex (192.168.0.180, Debian 13)
instead of the Windows workstation. `CLAUDE.md`'s cross-repo pointer becomes
`/mnt/5_hdd/felhom.eu/git/CLAUDE.md`. This repo carried **no other** environment-specific content —
it was the only one of the four that needed nothing else.
## 2026-07-19 — CAMPAIGN 7: full catalog sweep (53/53 apps deployed + validated on the demo box)
Every app in the catalog was bumped to its newest stable upstream tag where one existed, then
**actually deployed** through the controller's real endpoints on the demo box (controller 0.146.0),
validated (all containers healthy, HTTP through the real Traefik ingress, log scan), and removed
again through the real delete flow. Full evidence + result matrix:
`felhom.eu/documentation/audits/CAMPAIGN-7-catalog-sweep-2026-07-19.md`.
**Result: 45 apps pass end-to-end, 4 do not, 1 is not automatable (plex needs a real PLEX_CLAIM).**
**Version bumps** — ~40 templates moved to current upstream, 15 of them across a major
(bookstack 25.02→26.05, immich v2→v3, calcom v4→v6, nextcloud 31→34, grafana 11→13, n8n 1→2,
outline 0.82→1.9, vikunja 0.24→2.3, tandoor 1→2, romm 4→5, radarr 5→6, privatebin 1→2,
onlyoffice 8→9, claper 1→2, gramps-web v24→v25). `uptime-kuma` moved off the floating `:2` tag
to `2.4.0`. **DB/cache sidecar majors were deliberately NOT bumped** — rationale in the campaign
doc §4 (a DB major is the application's decision, and `postgres:16-alpine` already tracks 16.x).
**13 template fixes, every one live-re-validated:**
- **7 broken healthchecks.** This is not cosmetic: Traefik will not route to an `unhealthy`
container, so a probe that cannot run makes the app return **404 to the customer while it serves
200 on its own port**. adventurelog (wget in a distroless image → Node-exec at an absolute path),
emby (curl absent, BusyBox only), papra + wishlist (node-only images), homebox (`--spider` sends
HEAD, endpoint answers 405 to HEAD / 200 to GET), zipline (v4 renamed `/api/health`
`/api/healthcheck`), tandoor (`start_period` too short for gunicorn).
- **5 apps that had NEVER been deployable** and were fixed: papra (missing required `AUTH_SECRET`,
now a generated `data_key` secret), zipline (v4 `CORE_DATABASE_URL``DATABASE_URL`), wishlist
(dead Docker Hub image → followed upstream to `ghcr.io/cmintey/wishlist:v0.66.0`), homebox
(upstream dropped the `v` tag prefix + new required `HBOX_AUTH_API_KEY_PEPPER`), wger (2.6 needs
the full `DJANGO_DB_*` set and listens on :8000, not :80 — the Traefik port was wrong too).
- **4 memory/OOM corrections proven by a live OOM:** gramps-web 384M→1024M, n8n 512M→1536M
(V8 heap), rallly 256M→768M, tandoor 512M→1024M (+ its `mem_limit` sum was already wrong).
- **gokapi reverted v2.2.4 → v1.9.6**: v2 refuses to run against the seeded ConfigVersion-21
config and demands an intermediate v2.0.0 pass, even on a fresh deploy. Shipping it would have
broken every new gokapi deploy. Needs a dedicated v2 config-migration task.
**Still failing (recorded, not fixed):** `glance` (needs a seeded `glance.yml`; PROVEN pre-existing —
the pre-campaign v0.7.4 pin fails identically), `gokapi` (above), `plant-it` and `wanderer`
(their images do not resolve at all — neither the new tag nor the one the catalog already shipped).
## 2026-07-14 — backup classification `backup:` blocks for the 13 bind-bearing apps (controller v0.132.0)
Adds the referential-coupling `backup:` classification block to every catalog app that binds
`${HDD_PATH}`/`${USERDATA_PATH}` (13 apps: immich, paperless-ngx, nextcloud, calibre-web,
audiobookshelf, komga, navidrome, radarr, sonarr, emby, jellyfin, plex, romm). Each block lists its
`userdata:`/`hdd:` binds with a `class ∈ {mandatory, optional, excluded}` (COUPLED /
DECOUPLED-precious / DECOUPLED-bulk); classes are operator-ruled (Viktor, 2026-07-14) + spike SQ2.
Requires **controller v0.132.0**, which parses + validates these blocks (Task 2 of the
backup-classification-redesign arc, `felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md`).
The classification is **INERT** — no backup tier changes behavior yet; Task 3 (tier policy engine)
and Task 4 (manual `.fab` UI) consume it. All 13 blocks were verified against the shipped controller
parser: parse-clean, every bind resolves `explicit` to its ruled class (zero validation errors).
Notes: audiobookshelf `media/audiobooks` = **optional** (consistency with komga/romm curated media;
the spike proposed excluded — PENDING a Viktor veto). radarr/sonarr `downloads` = excluded (transient
cross-app queue). emby/jellyfin/plex `media` = excluded (`:ro` readers; state in volumes). The 42
volume-only apps get no block (classification moot — state rides in the recovery unit's volume dumps).
## 2026-07-12 — image pinning sweep: `:latest` eliminated from all templates (5 pins) + standing gate
A catalog sweep found 5/53 templates with unpinned images. Beyond version discipline, `:latest`
breaks restore fidelity: the controller's recovery-unit `ImagePins` pins the *tag*, so restoring a
`:latest` app re-pulls whatever `:latest` means at restore time — potentially schema-incompatible
with the data being restored. Rule applied: a deployed app pins to the digest it is RUNNING
(pin ≠ upgrade); undeployed apps pin to the verified upstream stable. All five pins are
digest-identical to what `:latest` resolved to on 2026-07-12 — a pure no-op for running apps.
| App | Old | New | Evidence |
|-----|-----|-----|----------|
| bentopdf | `ghcr.io/alam00000/bentopdf:latest` | `:v2.8.6` | digest == latest (`eaeea1e4…`); undeployed |
| calibre-web | `crocodilestick/calibre-web-automated:latest` | `:v4.0.6` | digest == RUNNING image on demo 9201 (`c31a738b…`) |
| papra | `ghcr.io/papra-hq/papra:latest` | `:26.6.1-rootless` | latest == the -rootless variant (`a7a42e22…`); `-root` differs — variant preserved |
| recipe-importer | `gitea.dooplex.hu/admin/recipe-importer:latest` | `:v0.9.11` | tag pre-existed in registry, digest == latest (`f3cb617c…`) — no retag needed |
| termix | `ghcr.io/lukegus/termix:latest` | `:2.5.0` | digest == latest == release-2.5.0 (`4d337131…`); undeployed |
- New rerunnable gate `scripts/check-image-pins.py`: fails on `:latest`/`dev`/`nightly`/`edge`/
`main`/`master` AND on untagged image refs (implicit :latest); `@sha256:` digests count as pinned.
Red-proofed both shapes (revert→exit 1→restore).
- Standing rule added to `CLAUDE.md` (never :latest / untagged; deployed apps pin to running digest).
- `templates.json` carries no image strings (legacy metadata only) — untouched.
- Fleet caveat: non-deployment of bentopdf/papra/termix verified on demo 9201 only; felhotest
unreachable + Peti's box offline at sweep time (operator approved proceeding — pins are
digest-equal to latest, so worst case equals the status quo).
## 2026-07-06 — healthcheck sweep: `localhost` → `127.0.0.1` across all 48 templates
Escalation of the re-run vaultwarden observation
(`felhom.eu/documentation/audits/RERUN-p1p3-2026-07-06.md`) from an instance to a **class**: 48/53
templates used `localhost` in their docker healthcheck `test:` line. BusyBox `wget` (and the node /
python / curl one-shot forms, incl. mealie's `socket.create_connection`) resolve `localhost`→IPv6
`::1` with no cross-address-family fallback, so an IPv4-only-binding app reads docker-`unhealthy`
while fully serving. Mechanical sweep `localhost``127.0.0.1`, scoped strictly to the healthcheck
`test:` lines (diff-reviewed: no app env/config/label line changed; `.felhom.yml` files were already
clean). Industry practice — never `localhost` in container healthchecks. New REUSE.md convention row.
## 2026-07-06 — vaultwarden F1 fix: _ENABLE_SMTP boot-gate (campaign finding, pilot-blocking)
The no-mercy campaign (felhom.eu `audits/CAMPAIGN-nomercy-2026-07-06.md`, finding F1) proved that a
FRESH vaultwarden deploy with app-email off — the default state — crash-loops: the template always
defines `SMTP_HOST=${SMTP_HOST:-}` / `SMTP_FROM=${SMTP_FROM:-}`, and vaultwarden treats a
defined-but-EMPTY env var as "set", so its config validation (`smtp_host.is_some() ==
smtp_from.is_empty()`) errors out and the process exits. The old comment ("empty SMTP_HOST = mail
stays disabled") was wrong for this image. Empirically proven on the pinned
`vaultwarden/server:1.33.2-alpine` (probe P1: defined-empty pair → exact campaign error, exit 12;
P2: `_ENABLE_SMTP=false` + same empty pair → boots; P3: `_ENABLE_SMTP=true` + host+from → boots).
Fix: gate the whole SMTP group with vaultwarden's own `_ENABLE_SMTP` flag — compose default
`false` (validation skipped, mail off, clean boot), flipped to `"true"` by the app-email injection
via `smtp_mapping.extra` (no controller change needed — `extra` already rides `smtpEnv`). The ON
path is byte-identical to the previously send-tested state plus the flag.
Sweep note (no edits): the other five smtp-mapped templates (calcom, gitea, mealie, nextcloud,
rallly) are boot-proven tolerant of defined-empty mail env — all ran healthy as fresh email-off
deploys during the campaign; gitea's `GITEA__mailer__SMTP_ADDR=${...:-}` pattern likewise.
Vaultwarden was the only strict image. New REUSE.md trap row: strict images need an enable-flag
gated `false` in compose + `"true"` in `smtp_mapping.extra`; boot-prove fresh email-off deploys.
## 2026-07-03 — sparkyfitness FINALIZED + live-validated (both VERIFY markers resolved); REUSE probe-naming row
The first worked example of the new `felhom-app-catalog` skill (felhom.eu). Both
`VERIFY-BEFORE-FINALIZE` healthcheck guesses resolved by inspecting the real images on the demo box:
frontend (Alpine/nginx) HAS BusyBox wget → drafted `wget --spider :80/` probe confirmed + kept;
server HAS node v24.17.0 → node-exec `:3010/api/health` probe confirmed (path proven live:
`{"status":"UP"}`). Frontend `container_name` renamed → `sparkyfitness` (= the stack name): the
controller-side probe dials the exact-name container, fallback is the FIRST prefix match (could be
the DB) — new REUSE.md §2 "Probe-container naming" row records the convention (verified in
felhom-controller healthprobe.go). Mem-sum comment added (512+1024+256 = 1792M, value unchanged).
Live-validated on demo via the real dashboard UI (sync + Frissítés): 3/3 containers healthy,
controller probe `healthy: true` (http :80 → 200), `sparky.demo-felhom.eu` 200 via Traefik;
data_key secrets untouched (server/db containers not recreated). Kept deployed.
## 2026-07-03 — docs: CLAUDE.md light expansion
The minimal REUSE-rollout stub expanded to a proper (still ~30-line) CLAUDE.md: what the repo is
(one dir per app, two template files, Hungarian customer text), the push-to-main = deploy contract
(controller sync ≤15 min / manual trigger), legacy `templates.json` warning, and pointers
(REUSE.md, README format spec, the `felhom-build-deploy` skill). No template changes.
## 2026-07-03 — docs: REUSE.md introduced
Cross-repo reuse-map rollout (docs-only). New `REUSE.md`: catalog conventions verified against all
53 apps — the canonical example app (paperless-ngx), `.felhom.yml` required fields, healthcheck
family per image type (BusyBox wget / curl / Node / Python / DB sidecars), memory-limit convention,
new-app checklist, and traps (gokapi entrypoint hack, legacy templates.json). Known README drift
recorded in §6 (NOT fixed). Also a minimal `CLAUDE.md` carrying the REUSE.md pointer + maintenance
rule (full CLAUDE.md is a separate task).
## 2026-06-29 — App-email: calcom + nextcloud (tls_mode=plaintext :2526 + nextcloud split-From)
- **nextcloud** — `smtp_mapping` with `tls_mode: plaintext` (controller injects port 2526, the plaintext-only
listener) + **split From** (`from_var=MAIL_FROM_ADDRESS` + `from_domain_var=MAIL_DOMAIN` → nextcloud@felhom.eu).
Compose references the injected `${SMTP_*}`/`${MAIL_*}`. Live-confirmed: real password-reset delivered via
plaintext :2526 (Symfony Mailer never attempted STARTTLS).
- **calcom** — `smtp_mapping` with `tls_mode: plaintext` (EMAIL_SERVER_HOST/PORT, EMAIL_FROM=calcom@felhom.eu).
**Plus three pre-existing template fixes** (calcom never deployed before — the image pin was invalid):
(1) image `v4.8.7``v4.6.9` (the pinned tag has no published image); (2) added required `DATABASE_DIRECT_URL`
(Prisma `migrate deploy` fails without it → incomplete schema → 500s); (3) healthcheck `/api/health`
`/api/auth/providers` (the old path 404s in v4.x → container stayed unhealthy → Traefik wouldn't route).
- Both apps point at the controller's `:2526` plaintext-only listener because their SMTP clients
opportunistically STARTTLS-upgrade and can't skip the self-signed cert — the listener simply doesn't offer
STARTTLS, so they stay plaintext (accepted on the single-tenant app bridge).
## 2026-06-29 — App-email rollout: gitea + rallly (calcom/nextcloud/immich = findings)
- **gitea 1.23.4** — added `smtp_mapping` (STARTTLS via `GITEA__mailer__PROTOCOL=smtp+starttls` +
`FORCE_TRUST_SERVER_CERT=true` to trust the shim's self-signed cert; single `GITEA__mailer__FROM`). Compose
references the injected `GITEA__mailer__*` keys; env applied every boot.
- **rallly** — added `smtp_mapping` (Nodemailer STARTTLS, `SMTP_SECURE=false` + `SMTP_REJECT_UNAUTHORIZED=false`
to accept the self-signed cert; single `NOREPLY_EMAIL`). **Also fixed three pre-existing template bugs** that
made rallly undeployable (never caught because the bad pin never ran): (1) image pin `3.12.1` doesn't exist →
`3.11.2`; (2) healthcheck used `wget`, absent from the rallly image (exit 127) → container unhealthy →
**Traefik wouldn't route it** → replaced with a Node http check; (3) added required `SUPPORT_EMAIL` + a valid
`NOREPLY_EMAIL` default (rallly refuses to boot without them).
- **Both gitea and rallly send-tested live** end-to-end (app → shim → hub → Resend): gitea password-reset
(From `gitea@felhom.eu`) and rallly registration code (From `rallly@felhom.eu`) both delivered.
- **NOT wired — reported as findings** (`felhom.eu/documentation/audits/FINDING-app-email-rollout-2026-06-29.md`):
- **cal.com v4.8.7** — hard-codes TLS `rejectUnauthorized:true` with no override; opportunistic STARTTLS
against the self-signed shim fails. Needs a non-STARTTLS-advertising plaintext listener (mechanism change).
- **nextcloud 31** — no cert-skip env (same opportunistic-STARTTLS gap) **and** a split From
(`MAIL_FROM_ADDRESS`+`MAIL_DOMAIN`) the single-`from_var` mapping can't express.
- **immich v2.5.5** — no SMTP env vars at all; config is admin-UI/DB or an `IMMICH_CONFIG_FILE` JSON. Does not
fit env-injection; left for a future config-file-injection mechanism (or manual admin-UI setup).
## 2026-06-29 — App-email: smtp_mapping for Vaultwarden + Mealie
- Added the `smtp_mapping` block to `templates/vaultwarden/.felhom.yml` and `templates/mealie/.felhom.yml`,
enabling managed outbound email (app → in-controller shim → hub → Resend) for the two spike-proven apps
(`SPIKE-smtp-app-relay-2026-06-28`). The controller injects `SMTP_*` at deploy/redeploy when app-email is
on (global + per-app); the From address is `<app>@felhom.eu`. SMTP auth creds are intentionally left unset
(the shim accepts no-auth on the Docker network).
- **Vaultwarden:** STARTTLS (`SMTP_SECURITY=starttls`) + `SMTP_ACCEPT_INVALID_CERTS/HOSTNAMES=true` to
accept the shim's self-signed cert.
- **Mealie:** plaintext (`SMTP_AUTH_STRATEGY=NONE`) on :2525 — Mealie has no accept-invalid-cert option, so
STARTTLS to a self-signed shim would fail; plaintext to the Docker-network-only shim is the spike-validated
mode.
- Both `docker-compose.yml` files now reference the injected `${SMTP_*}` keys (with harmless defaults) so the
values reach the container; empty `SMTP_HOST` keeps mail disabled when the toggle is off.
- Documented the `smtp_mapping` pattern in `README.md` so further apps are easy adds.
## 2026-06-28 — Add SparkyFitness (v0.17.2) — nutrition/workout tracker
- New app `templates/sparkyfitness/{docker-compose.yml,.felhom.yml}`: a self-hosted nutrition/calorie +
workout/weight tracker (alternative to wger). Three containers — nginx **frontend** (SPA :80, the sole
Traefik ingress, proxies `/api`+`/uploads` internally) + Node **server** (:3010) + dedicated
**postgres:15-alpine**. Server + DB stay on the internal network with no Traefik labels.
- **Native email/password auth** (no OIDC/Authentik — that's DooPlex-specific); subdomain `sparky`
(deliberately ≠ wger's `fitness` to avoid a Host() collision). `pi_compatible: false`, `needs_hdd: false`.
- **Two DB roles**: `sparky` (POSTGRES superuser, runs init/migrations) + `sparkyapp` (limited app role the
server auto-creates on first boot) — separate `DB_PASSWORD`/`APP_DB_PASSWORD`. `PGDATA` in a `pgdata`
subdir of the named volume. Four auto-generated, `locked_after_deploy` secrets; `API_ENCRYPTION_KEY` +
`BETTER_AUTH_SECRET` carry `data_key: true` (restore recovers, never regenerates — both are 64-char hex).
- Transcribed from the validated k3s manifest `homelab-manifests/workout-system/sparkyfitness.yaml`
(pinned image tags, two-DB-role model, never-change crypto keys, `/api/health`, pg15 + PGDATA subdir).
- **Image-probe findings (build server, v0.17.2):** server keeps the `node -e` `/api/health` probe (node
present); frontend keeps the `wget --spider` probe (both `wget` and `curl` present). No probe changes needed.
- **Live-validated on guest 9201 (controller v0.87.0):** synced via "Sablonok frissítése"; deployed through
the real dashboard flow (Domain auto, Subdomain `sparky`, 4 secrets auto-gen). All 3 containers healthy;
server log shows clean migrations + `sparkyapp` role created + RLS applied, no crash loop, no uploads
EACCES; `GET /api/health` through the public edge returns `{"status":"UP"}`; login/register page serves
over a valid TLS cert at `https://sparky.demo-felhom.eu`.
## 2026-06-26 — crafty-controller: image bump 4.4.8→4.10.7 + publish Java port range + connection guidance
- **Image bump** `crafty-4:4.4.8``4.10.7` (latest stable; 4.10.8/4.11.0 don't exist in the registry).
6 minor versions of fixes incl. security CVEs. **Java 25 verified present** in 4.10.7
(`/usr/lib/jvm/java-25-openjdk-amd64`, default `java -version` = openjdk 25.0.3; 8/11/17/21 also
available) — so the latest-Minecraft (`26.x`, needs Java 25) blocker is resolved. Healthcheck + Traefik
https-backend labels unchanged (Crafty still serves HTTPS on 8443).
- **Published the Java game-port range** `25565-25575:25565-25575` (TCP, 11 ports = up to 11 Java
servers; first server 25565, rest 2556625575). No `network_mode: host` (would break Traefik routing).
Bedrock UDP 19132 intentionally out of scope.
- **App-page guidance** (`.felhom.yml` first_steps + prerequisites): how to set the server port within
2556525575, how to connect on the LAN (manual IP:port — "scan for LAN" won't auto-list), and that
internet access needs operator port-forwarding. (Static text — can't show the live LAN IP.)
- **Live-verified on guest 9201:** 4.10.7 healthy; public URL 302; the guest's bridged LAN IP
`192.168.0.121` reaches the real Crafty "test" server on `25565` (TCP OPEN + Minecraft SLP handshake
returns JSON status); `:25575` reachable, `:25600` closed (negative control). In-place upgrade preserved
the admin, the operator's configured MFA, and the test server.
- **Correction (earlier draft was wrong):** an earlier note here claimed the upgrade "locked out the
admin (TOTP)." That was a misdiagnosis — the `totp_data` row + recovery codes were **operator-configured
MFA**, so the 401 on a password-only login was correct behaviour, NOT an upgrade bug. There is **no
upgrade regression**; the bump preserves data and MFA correctly.
## 2026-06-26 — crafty-controller: seed a felhom-generated admin password (replaces Crafty's ugly random one)
- **crafty-controller**: instead of reading Crafty's auto-generated (long, symbol-laden) random admin
password, we now **seed** a clean felhom-generated one — same pattern as gokapi, so initial passwords are
consistent across the catalog.
- Crafty's image ships `app/config_original/default.json = {"username":"admin","password":"crafty"}`;
"crafty" is 6 chars < Crafty's 8-char minimum, so Crafty rejected it and generated a random password.
- New `CRAFTY_PASSWORD` deploy field (`type: password`, `generate: password:24`, locked after deploy —
mirrors gokapi's `GOKAPI_PASSWORD`). The compose **entrypoint** overwrites the `default.json` template
with this password before the launcher runs; on fresh install Crafty creates the `admin` user with it.
- `initial_credentials.file` repointed `default-creds.txt``default.json` (same json/username/password
keys), so the controller's app-page "Kezdeti belépési adatok" card shows the **seeded** password — the
customer sees the same value at deploy time and on the app page.
- Catalog-only change (reuses felhom-controller v0.84.0's initial_credentials reader + the gokapi-style
seed). Requires a fresh install to take effect (the seed is only read on first run).
## 2026-06-26 — crafty-controller: surface the auto-generated initial admin password on the app page
- **crafty-controller**: Crafty writes a random admin password to `/crafty/app/config/default-creds.txt`
at first boot (its built-in default is rejected as "too short"). Customers had to read the container
logs to find it. Added an `initial_credentials` block (new general felhom-controller v0.84.0 mechanism):
`file` + `format: json` + `username_key`/`password_key` + a `note`. The controller reads the file live
from the container and shows username + password (masked, reveal/copy) on the app's page under "Kezdeti
belépési adatok". Requires felhom-controller ≥ v0.84.0.
- Updated `first_steps` to point at the app page for the initial login instead of "find it in the logs".
## 2026-06-26 — crafty-controller: Traefik https backend + scoped skip-verify (fixes 502)
- **crafty-controller**: the healthcheck fix un-withheld the Traefik route, exposing a pre-existing
**502** — Traefik proxied `http://…:8443` to Crafty's **HTTPS-only** self-signed backend (Crafty serves
no plain-HTTP panel; `:8000` only redirects). Added two service labels:
- `loadbalancer.server.scheme=https` — Traefik now speaks HTTPS to the backend.
- `loadbalancer.serverstransport=insecure-skip-verify@file` — references the **named**
serversTransport defined in the controller-managed Traefik dynamic config (felhom-controller v0.83.0),
which skips verifying Crafty's per-container self-signed cert. Verification stays ON for every other
backend (scoped Option B; no global `insecureSkipVerify`). The `@file` suffix is the cross-provider
reference from the docker provider to the file-provider transport.
- Requires felhom-controller ≥ v0.83.0 (which renders the `insecure-skip-verify` transport). `port=8443`
and the router/tls labels are unchanged.
## 2026-06-26 — crafty-controller healthcheck fix (curl-absent + http-vs-TLS probe)
- **crafty-controller**: container was permanently `unhealthy` → route withheld (`routeUnpublished`).
Two independent healthcheck root causes, both fixed in one change:
- **Docker healthcheck** ran `curl -fk https://localhost:8443`, but the `crafty-4:4.4.8` image has
**no `curl` and no `wget`** (`exec: "curl": not found`, FailingStreak 150). Replaced with a
dependency-free **python3 TLS-socket** liveness probe (`/usr/bin/python3` is present): completes a
TLS handshake to `127.0.0.1:8443` (unverified context mirrors the old `-k`; Crafty's cert is
self-signed). `start_period` 30s → 60s for cold-boot headroom (cert gen + migrations).
- **Controller-side probe** (`.felhom.yml healthcheck.checks`) was `type: http` against Crafty's
**TLS-only** 8443 → `probeHTTP` sent plaintext HTTP, got a TLS record → `HealthProbe.Healthy=false`,
which `manager.go` re-applies to override Docker's verdict back to `unhealthy`. Changed `http`
`tcp` (`probeTCP` dial succeeds against a TLS listener). Both layers had to change together.
- Live-validated on guest 9201 (`demo-felhom`): synced → recreated via the update path → Docker
`State.Health: healthy` (ExitCode 0), `health_probe.healthy: true` (tcp :8443, 5ms), http-vs-TLS
WARNs stopped, stable green 3+ min, Traefik now **publishes** the route (`crafty-controller@docker`).
- **Known follow-up (separate, out of this fix's scope):** the public URL still returns **502** — a
distinct pre-existing bug the un-withheld route exposed: Traefik proxies `http://…:8443` to Crafty's
HTTPS-only backend. Needs a Traefik HTTPS-backend + self-signed `serversTransport`
(`insecureSkipVerify`) in the controller-generated Traefik config — tracked separately.
## 2026-06-23 — gokapi: index redirect + admin username display
- **gokapi**: seed `RedirectUrl` repointed from Gokapi's GitHub default → `https://${SUBDOMAIN}.${DOMAIN}/admin`.
Gokapi's bare root `/` redirects to `RedirectUrl`; the controller's "Megnyitás" link is always the bare
subdomain root, so it was landing on Gokapi's GitHub instead of the app. Now `/``/admin` → login.
Applied to the live demo (config.json edit + restart) and the seed (future deploys).
- **gokapi**: added `app_info.default_creds` ("Felhasználó: admin · jelszó a Beállítások oldalon") so the
app-info page shows the initial admin user like other apps; fixed `first_steps` (no more setup wizard).
## 2026-06-23 — gokapi reproducible headless setup (fixes public "maintenance mode")
- **gokapi**: was stuck in "maintenance mode" on the public URL since first deploy — Gokapi's one-time
`/setup` wizard was never completed, and (verified against the docs + the v1.9.6 binary) **no Gokapi
version supports env-var headless setup** for admin credentials. Worse, the unconfigured `/setup` was
publicly reachable = an unauthenticated admin-takeover window.
- Fix: the compose `entrypoint` now seeds a `config.json` on first boot (admin user, this app's public
URL `https://${SUBDOMAIN}.${DOMAIN}/`, local storage, Encryption Level 0 so it restarts without a
prompt) with `Password`/`SaltAdmin`/`SaltFiles` cleared, then runs Gokapi's documented
`--deployment-password` one-shot to set the **felhom-generated** admin password **before** the
server starts serving. The admin account is claimed at first boot → `/setup` is never exposed.
- `.felhom.yml`: new `GOKAPI_PASSWORD` deploy field (`type: password`, `generate: password:24`,
shown to the customer, locked after deploy). Admin username is `admin`.
- Seed is pinned to Gokapi **v1.9.6** (`ConfigVersion 21`) — re-capture the seed if the image is bumped.
- Live-validated on guest 9201: fresh remove+redeploy → headless auto-config, public login works, no
maintenance page, admin claimed at first boot (browser-verified login).
## 2026-06-22 — gitea healthcheck fix (unattended test campaign)
- **gitea**: healthcheck probe repointed `/api/v1/version``/api/healthz` (docker HC + controller
`.felhom.yml` probe), `start_period` 30s → 90s.
- Surfaced during the Phase-2 deploy sweep: a fresh gitea reported `unhealthy` because
`/api/v1/version` returns 404 until the install wizard / INSTALL_LOCK completes, while the
container was serving fine on :3000 (`/api/healthz` → 200). Same class as the komga fix.
## 2026-06-22 — komga healthcheck fix (unattended test campaign)
- **komga**: healthcheck probe repointed `/api/v1/actuator/health``/actuator/health`.
- Root cause: komga's Spring Boot actuator endpoint is served unauthenticated at `/actuator/health`
(HTTP 200), while everything under the `/api/v1` prefix is auth-gated — so the old probe got
HTTP 401, `curl -f` exited 22, and the container reported `unhealthy` despite serving normally
on :25600. Diagnosed live on guest 9201 (probe matrix: `/`, `/actuator/health`,
`/api/v1/oauth2/providers`, `/login` all 200; `/api/v1/actuator/health` → 401).
- The `gotson/komga:1.20.0` image ships `curl` (verified), so the probe tool is unchanged.
+80
View File
@@ -0,0 +1,80 @@
# CLAUDE.md — `app-catalog-felhom.eu`
> Loads when Claude Code touches this repo. Current state: `CONTEXT.md` + `CHANGELOG.md` top.
> Cross-repo orientation: workspace-root `/mnt/5_hdd/felhom.eu/git/CLAUDE.md`.
## What this repo is
The Felhom **app catalog**: one directory per app under `templates/<app>/`, each holding exactly
`docker-compose.yml` + `.felhom.yml` (deploy fields, resources, healthcheck probe, app_info — all
customer-facing text in Hungarian). The felhom-controller git-syncs these to every customer box;
`.felhom.yml` drives the deploy wizard. `templates.json` + `scripts/generate-customer.sh` are LEGACY
(Portainer-era) — new apps don't touch them.
## Deploy contract
**Push to `main` = deploy.** The controller's sync picks changes up within 15 minutes (or trigger via
the dashboard "Sablonok frissítése" button / `POST /api/sync`). Only the two template files sync;
deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details: the
`felhom-build-deploy` skill.
## Conventions
- **See `REUSE.md` before adding or editing an app** — canonical example app (paperless-ngx),
required `.felhom.yml` fields, healthcheck family per image type, memory-limit rules, traps.
- Update `REUSE.md` in the same commit that changes a catalog-wide convention.
- `README.md` is the format spec — update its app tables when adding an app.
- Update `CHANGELOG.md` (newest on top) and overwrite `REPORT.md` with every pushed change.
- No secrets in any committed file; secrets are generated at deploy time via `deploy_fields`
`generate:` specs.
- **Run `python3 scripts/catalog_gates.py <app>` after ANY template change** — it is the ONE entry
point and runs all three gates below, exiting non-zero if any fails. Name the app(s) you touched
and it scopes the two gates that accept scoping, which is fast; with no names the runtime gate
deploys **every** template, so that form belongs **on a scratch host, never a customer box**.
Exit: 0 all clean · 1 convicted · 2 UNDETERMINED, which is never a pass.
**Why a runner and not four separate invocations** (operator ruling 2026-08-02, R-161): of this
project's gates, the only ones that ever get run are the ones with a single entry point named in a
CLAUDE.md — `felhom.eu/scripts/site_gates.py` is run, and R-29's three orphans are named nowhere and
have stopped nothing. Controller-side enforcement was rejected because a check at template load can
only read the file, and a static audit of all 53 templates reports the catalog clean **including
papra** — it would pass on the exact defect it exists to catch. CI was rejected for now: neither
repo has any, and there are no users yet. **R-161 stays open at reduced scope** — this is
convention, run by a person; real automatic enforcement is owed when a second person touches
templates. **Update 2026-08-02:** `.githooks/pre-push` now runs `catalog_gates.py --fast` on every
push, which is gate 1 (`check-image-pins.py`) only — the other two need network and a container
runtime and take minutes per app, and a push that pulls images and starts containers gets bypassed
within a week, after which the bypass is the habit. They stay deliberate periodic runs. The hook is
per-clone (`git config core.hooksPath .githooks`) and `git push --no-verify` bypasses it, which is
why gate 1 alone cannot be the whole story — CI re-runs the entry point on every push and **emails
on failure** (`felhom.eu` `OPEN-ITEMS.md` R-168, CLOSED 2026-08-02), which is what notices a bypass.
- **Never `:latest` or untagged images in templates** — pin a concrete version tag; an app deployed
anywhere in the fleet is pinned to the digest it is currently running (a pin must never cause a
version jump). Digest pins (`@sha256:`) also count. Gate: `python scripts/check-image-pins.py`
(run after any compose change; exit 1 on any floating/missing tag).
- **A pinned tag can still rot away upstream** — the pin gate is syntactic and cannot see that.
Second gate: `python3 scripts/check-image-resolvable.py` (exit 0 resolve / 1 GONE / 2 inconclusive),
run at the start of every catalog campaign and before any publish train that vouches the catalog.
Needs network + `docker`; unauthenticated Docker Hub throttles a full sweep, so `docker login`
first or expect exit 2. It reports a throttle as INCONCLUSIVE, never as a dead image.
- **A well-formed template can still preserve the wrong folder** — and no static check can see it.
Third gate, the only RUNTIME one: `python3 scripts/check-volume-persistence.py`
(0 all clean / **1 REFUSED** / 2 undecided). It deploys each template, exercises it into writing
data, and compares where the data landed against what the compose mounts. Needs Docker + network
and minutes per app, so it is periodic like the resolvability gate — run it whenever a template's
`volumes:` block or image tag changes, and at the start of every catalog campaign, **on a scratch
host, never a customer box**. It refuses to report at all unless it has just re-proven itself in
both directions against two canary templates. Fixture tests (no Docker):
`python3 scripts/test_check_volume_persistence.py`.
**UNDETERMINED is exit 2 and is never a pass** — an app that wrote nothing has not been shown to
be correct. Why it exists: papra mounted `papra_data:/app/data` while the app wrote its database
to `/app/app-data/db/`, so its backup completed, verified, and contained an empty directory
(R-156, Campaign 10).
- **Taking an app out of circulation — use `lifecycle:`, never a directory move.** `.felhom.yml`
gains an optional `lifecycle:` field: `available` (default; absent/empty means this), `hidden`
(not offered for new installs, no explanation owed), `abandoned` (upstream stopped developing it —
not offered for new installs, and every box already running it shows a permanent "Nem karbantartott"
notice). **Deployed instances keep working in full either way** — the state affects what is
OFFERED, never what already runs, and the controller REFUSES a deploy of a non-available template
server-side. An unknown value degrades to `available` with one WARN, so a typo can never brick a
template. This supersedes the short-lived `retired/` directory move, which was wrong: removing a
template orphans every customer already running it.
+7
View File
@@ -0,0 +1,7 @@
# CONTEXT — app-catalog-felhom.eu working state
> Created with the REUSE.md rollout (2026-07-03). History: `CHANGELOG.md`; format spec: `README.md`.
- **2026-07-12 — `:latest` banned from catalog — pin rule recorded.** 5 templates pinned (bentopdf v2.8.6, calibre-web v4.0.6 = running digest on 9201, papra 26.6.1-rootless, recipe-importer v0.9.11, termix 2.5.0; all digest-equal to that day's `:latest` — no-op for running apps). Standing rule in CLAUDE.md + gate `scripts/check-image-pins.py` (run after any compose change). Deployed apps pin to their RUNNING digest; pin ≠ upgrade.
- **2026-07-03 — CLAUDE.md expanded** (repo purpose, push-to-main deploy contract, pointers); still intentionally light.
- **2026-07-03 — `REUSE.md` exists at the repo root** (catalog conventions, healthcheck families, canonical example app = paperless-ngx, traps); maintenance rule active: update it in the same commit that changes a catalog-wide convention.
+341 -80
View File
@@ -1,104 +1,365 @@
# Felhom App Catalog
Central repository for all Felhom customer application deployments.
Central repository for Felhom customer application templates.
## Architecture
```
felhom-app-catalog/
├── templates/ # Docker Compose templates ({{DOMAIN}} placeholder)
app-catalog-felhom.eu/ <- This repo (source of truth)
├── templates.json # Portainer App Templates index (LEGACY — Portainer-only setups)
├── templates/ # Docker Compose templates with ${VAR} env var syntax
│ ├── actualbudget/
│ │ ├── docker-compose.yml
│ │ └── .felhom.yml # App metadata for felhom-controller
│ ├── adventurelog/
│ ├── audiobookshelf/
│ ├── bentopdf/
│ ├── bookstack/
│ ├── calcom/
│ ├── calibre-web/
│ ├── claper/
│ ├── code-server/
│ ├── crafty-controller/
│ ├── docmost/
│ ├── emby/
│ ├── filebrowser/
│ ├── ghost/
│ ├── gitea/
│ ├── glance/
│ ├── gokapi/
│ ├── grafana/
│ ├── gramps-web/
│ ├── home-assistant/
│ ├── homebox/
│ ├── homepage/
│ ├── immich/
│ ├── jellyfin/
│ ├── kimai/
│ ├── komga/
│ ├── mealie/
│ ├── n8n/
│ ├── navidrome/
│ ├── nextcloud/
│ ├── onlyoffice/
│ ├── opengist/
│ ├── outline/
│ ├── paperless-ngx/
│ ├── papra/
│ ├── plex/
│ ├── privatebin/
│ ├── radarr/
│ ├── rallly/
│ ├── romm/
│ ├── stirling-pdf/
── vaultwarden/
├── customers/ # Per-customer configuration
│ ├── demo-felhom.yaml
── pi-customer-1.yaml
├── scripts/
── render.sh # Renders customer repos from templates
└── README.md
│ ├── seerr/
── sonarr/
│ ├── sparkyfitness/
│ ├── tandoor/
── termix/
│ ├── uptime-kuma/
── vaultwarden/
│ ├── vikunja/
│ ├── wanderer/
│ ├── wger/
│ ├── wishlist/
│ └── zipline/
└── scripts/
└── generate-customer.sh # LEGACY — generates customer-specific templates for Portainer
```
## How It Works
## How It Works (Controller-based deployments)
1. **Templates** contain Docker Compose files with `{{DOMAIN}}` and `{{HDD_PATH}}` placeholders
2. **Customer configs** define which apps each customer gets, their domain, HDD path, and any version overrides
3. **render.sh** generates per-customer Gitea repos with all placeholders substituted
4. **Portainer GitOps** on each customer node pulls from their repo and deploys
The **felhom-controller** syncs directly from this repo:
### Placeholder Reference
1. Controller periodically pulls this repo (configurable interval, default 15m)
2. Copies `docker-compose.yml` and `.felhom.yml` from `templates/<app>/` to `/opt/docker/stacks/<app>/`
3. **Never overwrites** `app.yaml` (deployed config/secrets) or `.env` files
4. Only copies files if content has actually changed (SHA-256 comparison)
5. After sync, triggers a stack rescan so new/updated apps appear on the dashboard
6. Manual sync available via "Sablonok frissítése" button on the Alkalmazások page
| Placeholder | Source | Example |
|-------------|--------|---------|
| `{{DOMAIN}}` | `domain:` in customer YAML | `demo-felhom.eu` |
| `{{HDD_PATH}}` | `hdd_path:` in customer YAML | `/mnt/hdd_1` |
### Storage Strategy
- **HDD host paths** (`{{HDD_PATH}}/storage/...`): Large user data — photos, documents, ROMs
- **Named Docker volumes** (on NVMe): Databases, app config, caches — need fast I/O
- Templates that don't use `{{HDD_PATH}}` work without it (e.g. ActualBudget, Mealie)
## Workflow
### Adding a new app to the catalog
1. Create `templates/<appname>/docker-compose.yml` using `{{DOMAIN}}` placeholder
2. Add to relevant customer configs in `customers/`
3. Run `./scripts/render.sh` to regenerate customer repos
### Updating an app version
1. Edit the image tag in `templates/<appname>/docker-compose.yml`
2. Run `./scripts/render.sh` — skips customers with version overrides
3. Portainer auto-detects git changes and redeploys (if polling enabled)
### Adding a new customer
1. Create `customers/<customer-id>.yaml`
2. Create the Gitea repo: `customers/<customer-id>-stacks`
3. Run `./scripts/render.sh --customer <customer-id>`
4. Set up Portainer GitOps stacks on the customer node
## Portainer Stack Setup (per app)
On the customer's Portainer:
1. **Stacks → Add Stack → Repository**
2. Repository URL: `https://gitea.felhom.eu/customers/<customer-id>-stacks`
3. Compose path: `<appname>/docker-compose.yml`
4. Add environment variables (secrets — DB passwords, API keys, etc.)
5. Enable **GitOps auto-update** (optional, 5-minute polling)
6. Deploy
## Environment Variables
Secrets are **never stored in Git**. They live in Portainer's stack environment
variables on each customer node. Each template documents required env vars
in comments at the top of the compose file.
## Version Pinning
In a customer YAML, you can pin specific app versions:
### Controller git config (in controller.yaml)
```yaml
overrides:
immich_version: "v2.4.1" # Don't auto-update Immich for this customer
auto_update: false # Skip ALL updates for this customer
git:
repo_url: "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git"
branch: "main"
sync_interval: "15m"
username: "" # Optional, for private repos
token: "" # Optional, for private repos
```
## .felhom.yml Format
Each app template has a `.felhom.yml` metadata file that the controller uses for:
- Display info on the dashboard (name, description, category, subdomain)
- Resource hints (memory request/limit, Pi compatibility, HDD requirement)
- Deploy fields (what the user fills in during first deployment)
### Field types
| Type | Description |
|------|-------------|
| `domain` | Auto-filled from controller config, read-only |
| `secret` | Auto-generated, hidden from user (user sees "Generated ✓") |
| `password` | Auto-generated but shown, user can override |
| `path` | Filesystem path (validated for existence) |
| `text` | Free text input |
| `select` | Dropdown with predefined options |
| `boolean` | Toggle switch |
### Generator types (for secret/password fields)
| Generator | Description |
|-----------|-------------|
| `password:N` | N chars alphanumeric |
| `hex:N` | N bytes hex-encoded |
| `base64key:N` | `base64:` + N random bytes base64-encoded (Laravel APP_KEY format) |
| `static:VAL` | Fixed value |
### Example .felhom.yml
```yaml
display_name: "Paperless-ngx"
description: "Dokumentumok digitalizálása és rendszerezése"
category: "productivity"
subdomain: "paperless"
slug: "paperless-ngx"
resources:
mem_request: "500M"
mem_limit: "1152M"
pi_compatible: true
needs_hdd: true
deploy_fields:
- env_var: DOMAIN
label: "Domain"
type: domain
locked_after_deploy: true
- env_var: DB_PASSWORD
label: "Adatbázis jelszó"
type: secret
generate: "password:24"
locked_after_deploy: true
- env_var: HDD_PATH
label: "Adattárolási útvonal"
type: path
required: true
placeholder: "/mnt/felhom-drives/hdd_1"
locked_after_deploy: true
```
### App-email mapping (`smtp_mapping`)
Apps that can send outbound email (password resets, invites, confirmations) get it through
one managed path: **app → in-controller SMTP shim → hub → Resend**. The Resend key stays
hub-side; nothing app-specific lives on the box. An app opts in by declaring `smtp_mapping`,
which renames the generic relay settings to that app's own env-var names.
When app-email is on (the household's **global** toggle AND the app's **per-app** toggle),
the controller injects at deploy/redeploy: `host` = the on-box shim, `port` = `2525`,
`from` = `<from_local>@felhom.eu`, the `security` value, the optional display name, and any
fixed `extra` vars. The values are derived from settings on every compose — never written to
`app.yaml` — so a toggle change applies on the next redeploy without touching secrets. The
shim accepts **no-auth** on the Docker network, so leave any `SMTP_USERNAME`/`SMTP_PASSWORD`
unset.
**The compose file must reference the mapped `${VAR}` keys** (with a harmless default) so the
injected values reach the container, e.g. `- SMTP_HOST=${SMTP_HOST:-}`. An empty `SMTP_HOST`
keeps the app's mail disabled when the toggle is off.
| Field | Meaning |
|-------|---------|
| `host_var` | env key receiving the shim host (required) |
| `port_var` | env key receiving the port `2525` (required) |
| `security_var` | env key receiving the TLS mode (optional) |
| `security_value` | the app's term for the chosen mode — `starttls`, `TLS`, `NONE`, … |
| `from_var` | env key receiving the From address (required) |
| `from_name_var` | env key receiving the From display name (optional) |
| `from_local` | local-part of the From address (defaults to the app slug) → `<local>@felhom.eu` |
| `extra` | fixed extra env (e.g. accept-invalid-cert flags) |
```yaml
# Vaultwarden — STARTTLS to the shim (it accepts the self-signed cert):
smtp_mapping:
host_var: SMTP_HOST
port_var: SMTP_PORT
security_var: SMTP_SECURITY
security_value: starttls
from_var: SMTP_FROM
from_name_var: SMTP_FROM_NAME
from_local: vaultwarden
extra:
SMTP_ACCEPT_INVALID_CERTS: "true"
SMTP_ACCEPT_INVALID_HOSTNAMES: "true"
```
> **TLS choice per app.** Prefer STARTTLS to the shim for apps that can accept a self-signed
> cert (an "accept invalid certs" option). For apps that **can't** (e.g. Mealie), use
> plaintext (`security_value: "NONE"`) on `2525` — the shim offers it on the Docker network and
> it was spike-validated. Never publish the shim off-box, so plaintext there is safe.
Currently mapped: **Vaultwarden**, **Mealie** (the two apps proven in
`SPIKE-smtp-app-relay-2026-06-28`). Adding email to another app is just its `smtp_mapping`
block + the matching compose `${VAR}` lines.
## App Catalog
| App | DB Type | RAM | Pi | HDD Data | Subdomain |
|-----|---------|-----|-----|----------|-----------|
| ActualBudget | None (file) | ~50MB | ✅ | | budget.* |
| Docmost | PostgreSQL + Redis | ~200MB | ⚠️ | | docs.* |
| FileBrowser | None (file) | ~30MB | ✅ | `{{HDD_PATH}}/` | files.* |
| Homebox | None (SQLite) | ~50MB | ✅ | | inventory.* |
| Immich | PostgreSQL + Redis | ~4GB | | `{{HDD_PATH}}/storage/immich/` | photos.* |
| Mealie | None (SQLite) | ~200MB | | | recipes.* |
| Paperless-ngx | PostgreSQL + Redis | ~500MB | | `{{HDD_PATH}}/storage/paperless/` | paperless.* |
| ROMM | MariaDB + Redis | ~300MB | ⚠️ | `{{HDD_PATH}}/storage/romm/` | arcade.* |
| Stirling-PDF | None | ~200MB | | | pdf.* |
| Vaultwarden | None (SQLite) | ~50MB | | | vault.* |
| App | DB Type | RAM (request / limit) | Pi | HDD Data | Subdomain |
|-----|---------|----------------------|-----|----------|-----------|
| ActualBudget | None (file) | 50M / 256M | yes | -- | budget.* |
| AdventureLog | PostgreSQL | 100M / 384M | yes | -- | travel.* |
| Audiobookshelf | None (file) | 100M / 512M | yes | `${HDD_PATH}/media/audiobooks/` | audiobooks.* |
| BentoPDF | None (file) | 100M / 384M | yes | -- | pdf.* |
| BookStack | MariaDB | 150M / 512M | yes | -- | wiki.* |
| Cal.com | PostgreSQL | 200M / 768M | no | -- | cal.* |
| Calibre-Web Automated | None (file) | 200M / 768M | no | `${HDD_PATH}/media/books/` | books.* |
| Claper | PostgreSQL | 100M / 384M | yes | -- | present.* |
| Code-Server | None (file) | 200M / 1024M | no | -- | code.* |
| Crafty Controller | None (file) | 256M / 2048M | no | -- | minecraft.* |
| Docmost | PostgreSQL + Redis | 200M / 768M | no | -- | docs.* |
| Emby | None (file) | 512M / 2048M | no | `${HDD_PATH}/media/` | emby.* |
| FileBrowser Quantum | None (file) | 50M / 256M | yes | `${HDD_PATH}/storage/filebrowser/` | files.* |
| Ghost | SQLite | 150M / 512M | no | -- | blog.* |
| Gitea | SQLite | 100M / 512M | yes | -- | git.* |
| Glance | None (file) | 20M / 128M | yes | -- | dashboard.* |
| Gokapi | None (file) | 30M / 128M | yes | -- | share.* |
| Grafana | None (file) | 100M / 512M | yes | -- | grafana.* |
| Gramps Web | None (file) | 100M / 384M | yes | -- | family.* |
| Home Assistant | None (file) | 256M / 1024M | yes | -- | ha.* |
| Homebox | None (SQLite) | 50M / 256M | yes | -- | inventory.* |
| Homepage | None (file) | 50M / 256M | yes | -- | home.* |
| Immich | PostgreSQL + Redis | 2048M / 4096M | no | `${HDD_PATH}/storage/immich/` | photos.* |
| Jellyfin | None (file) | 512M / 2048M | no | `${HDD_PATH}/media/` | media.* |
| Kimai | MariaDB | 100M / 384M | yes | -- | time.* |
| Komga | None (file) | 200M / 512M | yes | `${HDD_PATH}/media/comics/` | comics.* |
| Mealie | None (SQLite) | 200M / 1000M | yes | -- | recipes.* |
| n8n | None (file) | 150M / 512M | no | -- | auto.* |
| Navidrome | None (file) | 50M / 256M | yes | `${HDD_PATH}/media/music/` | music.* |
| Nextcloud | MariaDB + Redis | 256M / 1024M | no | `${HDD_PATH}/storage/nextcloud/` | cloud.* |
| OnlyOffice | None (file) | 512M / 2048M | no | -- | office.* |
| OpenGist | None (file) | 30M / 128M | yes | -- | gist.* |
| Outline | PostgreSQL + Redis | 200M / 768M | no | -- | kb.* |
| Paperless-ngx | PostgreSQL + Redis | 500M / 1152M | yes | `${HDD_PATH}/storage/paperless/` | paperless.* |
| Papra | None (file) | 50M / 256M | yes | -- | papra.* |
| Plant-it | None (file) | 50M / 256M | yes | -- | plants.* |
| Plex | None (file) | 512M / 2048M | no | `${HDD_PATH}/media/` | plex.* |
| PrivateBin | None (file) | 30M / 128M | yes | -- | paste.* |
| Radarr | None (file) | 150M / 512M | yes | `${HDD_PATH}/media/` | radarr.* |
| Rallly | PostgreSQL | 50M / 256M | yes | -- | poll.* |
| RomM | MariaDB + Redis | 300M / 1024M | no | `${HDD_PATH}/storage/romm/` | arcade.* |
| Jellyseerr | None (file) | 100M / 384M | yes | -- | requests.* |
| Sonarr | None (file) | 150M / 512M | yes | `${HDD_PATH}/media/` | sonarr.* |
| SparkyFitness | PostgreSQL | 400M / 1792M | no | -- | sparky.* |
| Tandoor Recipes | PostgreSQL | 150M / 512M | yes | -- | recipes.* |
| Termix | None (file) | 30M / 128M | yes | -- | terminal.* |
| Uptime Kuma | None (file) | 50M / 256M | yes | -- | status.* |
| Vaultwarden | None (SQLite) | 50M / 256M | yes | -- | vault.* |
| Vikunja | None (file) | 50M / 256M | yes | -- | tasks.* |
| Wanderer | None (file) | 100M / 384M | yes | -- | hike.* |
| wger | SQLite | 100M / 384M | yes | -- | fitness.* |
| Wishlist | None (file) | 30M / 128M | yes | -- | wishes.* |
| Zipline | PostgreSQL | 100M / 512M | no | -- | img.* |
### Variable types per app
| App | DOMAIN | HDD_PATH | Secrets |
|-----|:------:|:--------:|---------|
| ActualBudget | yes | -- | -- |
| AdventureLog | yes | -- | SECRET_KEY, DB_PASSWORD |
| Audiobookshelf | yes | yes | -- |
| BentoPDF | yes | -- | -- |
| BookStack | yes | -- | APP_KEY, DB_PASSWORD |
| Cal.com | yes | -- | NEXTAUTH_SECRET, CALENDSO_ENCRYPTION_KEY, DB_PASSWORD |
| Calibre-Web Automated | yes | yes | -- |
| Claper | yes | -- | SECRET_KEY_BASE, DB_PASSWORD |
| Code-Server | yes | -- | PASSWORD |
| Crafty Controller | yes | -- | -- |
| Docmost | yes | -- | APP_SECRET, DB_PASSWORD |
| Emby | yes | yes | -- |
| FileBrowser Quantum | yes | yes | -- |
| Ghost | yes | -- | -- |
| Gitea | yes | -- | -- |
| Glance | yes | -- | -- |
| Gokapi | yes | -- | -- |
| Grafana | yes | -- | GF_SECURITY_ADMIN_PASSWORD |
| Gramps Web | yes | -- | GRAMPSWEB_SECRET_KEY |
| Home Assistant | yes | -- | -- |
| Homebox | yes | -- | -- |
| Homepage | yes | -- | -- |
| Immich | yes | yes | DB_PASSWORD |
| Jellyfin | yes | yes | -- |
| Kimai | yes | -- | DB_PASSWORD, ADMIN_EMAIL, ADMIN_PASSWORD |
| Komga | yes | yes | -- |
| Mealie | yes | -- | -- |
| n8n | yes | -- | N8N_ENCRYPTION_KEY |
| Navidrome | yes | yes | -- |
| Nextcloud | yes | yes | DB_PASSWORD, MYSQL_ROOT_PASSWORD, NEXTCLOUD_ADMIN_USER, NEXTCLOUD_ADMIN_PASSWORD |
| OnlyOffice | yes | -- | JWT_SECRET |
| OpenGist | yes | -- | -- |
| Outline | yes | -- | SECRET_KEY, UTILS_SECRET, DB_PASSWORD |
| Paperless-ngx | yes | yes | PAPERLESS_SECRET_KEY, DB_PASSWORD, PAPERLESS_ADMIN_USER, PAPERLESS_ADMIN_PASSWORD |
| Papra | yes | -- | -- |
| Plant-it | yes | -- | JWT_SECRET |
| Plex | yes | yes | PLEX_CLAIM |
| PrivateBin | yes | -- | -- |
| Radarr | yes | yes | -- |
| Rallly | yes | -- | SECRET_PASSWORD, DB_PASSWORD |
| RomM | yes | yes | DB_PASSWORD, MYSQL_ROOT_PASSWORD, ROMM_AUTH_SECRET_KEY |
| Jellyseerr | yes | -- | -- |
| Sonarr | yes | yes | -- |
| SparkyFitness | yes | -- | DB_PASSWORD, APP_DB_PASSWORD, API_ENCRYPTION_KEY, BETTER_AUTH_SECRET |
| Tandoor Recipes | yes | -- | SECRET_KEY, DB_PASSWORD |
| Termix | yes | -- | -- |
| Uptime Kuma | yes | -- | -- |
| Vaultwarden | yes | -- | ADMIN_TOKEN |
| Vikunja | yes | -- | VIKUNJA_SERVICE_JWTSECRET |
| Wanderer | yes | -- | MEILI_MASTER_KEY |
| wger | yes | -- | SECRET_KEY |
| Wishlist | yes | -- | -- |
| Zipline | yes | -- | CORE_SECRET, DB_PASSWORD |
### Storage strategy
- **HDD host paths** (`${HDD_PATH}/storage/...`): Large user data — photos, documents, ROMs
- **Named Docker volumes** (on internal SSD): Databases, app config, caches — need fast I/O
- Templates without `${HDD_PATH}` work without an external HDD (e.g., ActualBudget, Mealie)
### Docker Compose template standards
All templates follow these standards (enforced via audit):
- `${DOMAIN}` variable syntax for all domain references (not hardcoded)
- `deploy.resources.limits.memory` on every service container
- Healthchecks on every service (appropriate for service type)
- `restart: unless-stopped` on every service
- `TZ=Europe/Budapest` on every service
- `traefik-public` external network + internal network for DB services
- Explicit `container_name:` on every service
- Header comment block with: app name, domain, DB type, RAM, Pi compatibility
- `depends_on` with `condition: service_healthy` for DB/Redis dependencies
## Legacy: Portainer-based deployments
The `generate-customer.sh` script and `templates.json` are kept for Portainer-only setups
where the felhom-controller is not used. For controller-based deployments, these are not needed.
### Legacy workflow
```bash
# Generate customer templates (on your workstation)
./scripts/generate-customer.sh --customer demo-felhom \
--domain demo-felhom.eu --hdd-path /mnt/felhom-drives/hdd_1 --push
```
## Adding a New App
1. Create `templates/<appname>/docker-compose.yml` following the template standards above
2. Create `templates/<appname>/.felhom.yml` following the metadata format
3. Commit and push — the controller will pick it up on next sync
4. (Legacy) If also needed for Portainer: add entry to `templates.json` and `generate-customer.sh`
## Related Repositories
| Repository | Purpose |
|------------|---------|
| [app-catalog-felhom.eu](https://gitea.dooplex.hu/admin/app-catalog-felhom.eu) | This repo — templates + metadata |
| [felhom-controller](https://gitea.dooplex.hu/admin/felhom-controller) | felhom-controller + deploy scripts |
| [felhom.eu](https://gitea.dooplex.hu/admin/felhom.eu) | Website + k3s manifests |
+70
View File
@@ -0,0 +1,70 @@
# REPORT — papra volume persistence (R-156, last leg)
**Date:** 2026-08-03 · **Repo:** `app-catalog-felhom.eu` · **No version** (catalog templates are unversioned)
## What changed
`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.
## Why
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.
## Precondition — checked, not inherited
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:
- `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
Deployed nowhere ⇒ the template fix strands no live data.
## 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.
+65
View File
@@ -0,0 +1,65 @@
# REUSE.md — app-catalog-felhom.eu
> Before adding or editing an app, check here. Conventions to copy, traps to avoid.
> Maintenance: update in the SAME commit that changes a catalog-wide convention.
> Entries cite real files. Line numbers are landmarks only — reconfirm before editing.
## 1. Canonical helpers
None — this repo is templates/config, not code. See §2/§5.
## 2. Canonical patterns (copy structure from THE named file)
| Pattern | Canonical file | Key traits |
|---|---|---|
| **The one canonical example app** | `templates/paperless-ngx/` (both files) | Multi-container (app + postgres + redis), HDD + userdata mounts, full deploy_fields spectrum (domain/subdomain/secret/password/text/path/select). Copy this structure for any new app. |
| `.felhom.yml` required fields | `templates/paperless-ngx/.felhom.yml` | All 53 apps: `display_name`, `description` (Hungarian), `category`, `subdomain`, `slug`, `resources{mem_request, mem_limit, pi_compatible, needs_hdd}`, `deploy_fields`, `app_info{tagline, use_cases, first_steps, ...}`, `healthcheck`. Optional: `smtp_mapping` (email-capable apps), `open_path` (non-root landing page, e.g. ghost). |
| deploy_fields conventions | `templates/paperless-ngx/.felhom.yml` (`deploy_fields:` block) | Every app starts with `DOMAIN` (type `domain`) + `SUBDOMAIN` (type `subdomain`, `locked_after_deploy: true`). Secrets: `type: secret` + `generate:` — dominant generators `password:24` (DB passwords) and `hex:32` (app secret keys); `password:16` for shown admin passwords (`type: password`). HDD apps add `HDD_PATH` (`type: path`, placeholder `/mnt/felhom-drives/hdd_1`, locked). Labels/descriptions in Hungarian. |
| Controller-side health probe | `templates/vaultwarden/.felhom.yml` (`healthcheck:` block) | `healthcheck.checks[]` with `type: http` (port only), `type: api` (port + `path` + `expect.status: 200`), or `type: tcp` (port only — mealie, crafty-controller). Prefer `api` with a real health path when the app has one. |
| App lifecycle (`available`/`hidden`/`abandoned`) | `templates/plant-it/.felhom.yml` (`lifecycle:` block) | Optional top-level `lifecycle:` in `.felhom.yml`. Absent/empty ≡ `available`. `hidden` = not offered for new installs; `abandoned` = same, PLUS a permanent "Nem karbantartott" badge + notice on every box already running it. **Deployed instances keep full function in both states** — lifecycle governs what is OFFERED, never what runs; the controller refuses a deploy of a non-available template server-side (fail-closed, so a stale link or direct POST cannot install one). Unknown value → treated as `available` + one WARN, never a broken template. **Do NOT take an app out of circulation by deleting or moving its directory** — that orphans every customer already running it, which is what the 2026-07-21 `retired/` experiment got wrong. The resolvability gate skips non-available apps, so an abandoned app's dead image is not a standing red. |
| **Catalog gates — THE entry point** | `scripts/catalog_gates.py` | **Run `python3 scripts/catalog_gates.py <app>` after ANY template change** (mandated in `CLAUDE.md`). Runs all three gates below in order — image-pins, image-resolvable, volume-persistence — and exits **non-zero if any fails**; **2 (UNDETERMINED) is reported distinctly and is never a pass**, 1 (convicted) outranks 2 in the summary. Naming app(s) scopes the two gates that accept scoping, which is the normal after-a-change run; with no names the RUNTIME gate deploys every template, so that form is **scratch host only**. **Why a runner** (operator ruling 2026-08-02, R-161): the only gates in this project that ever get run are the ones with a single entry point named in a CLAUDE.md — `felhom.eu/scripts/site_gates.py` is run, R-29's three orphans are named nowhere and have stopped nothing. Controller-side enforcement was rejected because a load-time check reads only the file and a static audit reports the catalog clean **including papra** — it would pass on the very defect it exists to catch; CI was rejected for now (neither repo has any, no users yet). Adding a fourth gate here means adding it to `GATES` in this file — nothing else. |
| Image pinning | ALL `templates/*/docker-compose.yml` (`image:` line) | **Never `:latest` or untagged** (recovery-unit `ImagePins` pins the tag — `:latest` breaks restore fidelity). Pin a concrete version tag; an app deployed anywhere in the fleet pins to the digest it is RUNNING (pin ≠ upgrade); `@sha256:` digest pins also count. Gate: `python scripts/check-image-pins.py` after any compose change (swept 2026-07-12: 5 pins). TRAP: ghcr `tags/list` can be stale/partial — verify tag existence via `docker manifest inspect`, never the tag list. |
| Image RESOLVABILITY (does the pin still exist?) | `scripts/check-image-resolvable.py` + `scripts/test_check_image_resolvable.py` | The complement to the pin gate, which is purely syntactic and cannot see rot. Run it at the START of every catalog campaign and before any publish train that vouches the catalog: `python3 scripts/check-image-resolvable.py [app …]`. Exit **0** all resolve, **1** the registry says an image is GONE, **2** INCONCLUSIVE/harness error. **Two traps it encodes, both live-observed:** (a) `docker manifest inspect` prints `toomanyrequests` and **still exits 0** — never trust the exit code alone (same shape as the ISO `validate-answer` trap); (b) the inverse — the first sweep called 24 of 65 pins dead, `postgres:16-alpine` among them, because Docker Hub throttled it partway. Ambiguity therefore resolves to INCONCLUSIVE, never to an accusation; a gate that cries wolf gets ignored. Unauthenticated Hub lookups WILL throttle on a full 65-pin sweep — `docker login` first, or expect exit 2. |
| Volume PERSISTENCE (does the app write where the template preserves?) | `scripts/check-volume-persistence.py` + `scripts/test_check_volume_persistence.py` | The third gate and the only RUNTIME one — **the two image gates are static and this class is invisible to static analysis**, which was measured, not assumed: a static audit of all 53 composes (every declared volume attached, no anonymous mounts, no stray host binds) reports the catalog clean AND reports papra clean. papra's compose is well-formed; it mounts `papra_data:/app/data` while the app writes `/app/app-data/db/db.sqlite` into the container's **writable layer** and cannot write `/app/data` at all — so `DumpAppVolumes` (`felhom-controller internal/backup/backup.go:543`) tars an empty directory and the backup verifies (R-156, Campaign 10). Run: `python3 scripts/check-volume-persistence.py [app …]` **on a scratch host, never a customer box**. Exit **0** all CLEAN, **1** REFUSED, **2** UNDETERMINED/prober untrustworthy. **Traps it encodes:** (a) `A` vs `C` in `docker diff` — a linuxserver.io entrypoint chowning its app tree produced 1305 `C` entries and called calibre-web BROKEN on the first pass, so DATA is decided from `A` only and a `C` on a DB file is adjudicated by comparing bytes against a pristine container of the same image; (b) no `docker exec` anywhere — Campaign 7 §1.1's OCI-error-to-stdout trap, so uid comes from `/proc/<pid>/status` and writability from a host-side `stat`; (c) `base64key` secrets need the controller's `base64:` prefix (`deploy.go:904`) or bookstack serves 500s and the harness looks like an app defect; (d) it self-tests in BOTH directions against two canary templates before reporting anything — a detector that flags nothing turns an unexamined catalog into a documented-clean one. UNDETERMINED is **never** folded into CLEAN. |
| Docker healthcheck host | ALL `templates/*/docker-compose.yml` (`healthcheck.test:`) | **Always `127.0.0.1`, never `localhost`.** BusyBox `wget` (and node/python/curl one-shots) resolve `localhost`→IPv6 `::1` with NO cross-address-family fallback; an app that binds IPv4-only then reads docker-`unhealthy` while fully serving (vaultwarden, re-run 2026-07-06 — swept all 48 templates). |
| Docker healthcheck — BusyBox/wget images | `templates/vaultwarden/docker-compose.yml` (~L49) | `test: ["CMD", "wget", "--spider", "-q", "http://localhost:<port>/<path>"]`. Most common family (~20 apps, e.g. homebox, glance). |
| Docker healthcheck — curl-capable images | `templates/paperless-ngx/docker-compose.yml` (~L76) | `test: ["CMD", "curl", "-f", "http://localhost:<port>/<path>"]` (~18 apps: jellyfin, immich, sonarr…). |
| Docker healthcheck — Node images (no wget/curl) | `templates/rallly/docker-compose.yml` (~L49) | `test: ["CMD", "node", "-e", "require('http').get(...)"]` — used when the image lacks wget (that was rallly's actual bug). |
| Docker healthcheck — Python images | `templates/mealie/docker-compose.yml` (~L47) | `test: ["CMD-SHELL", "python3 -c \"import socket; s=socket.create_connection(('localhost',<port>),2); s.close()\""]` (mealie, crafty-controller). tandoor/wger use `urllib.request` variants for real HTTP checks. |
| Docker healthcheck — DB/Redis sidecars | `templates/paperless-ngx/docker-compose.yml` (~L107, L129) | postgres: `pg_isready -U <user> -d <db>`; mariadb: `healthcheck.sh --connect --innodb_initialized`; redis: `redis-cli ping`. App container gets `depends_on: <db>: condition: service_healthy`. |
| Memory convention | `templates/paperless-ngx/docker-compose.yml` (~L71) + `.felhom.yml resources:` | EVERY service has `deploy.resources.limits.memory` (compose is the enforcement). NO `reservations` anywhere. `.felhom.yml mem_limit` = SUM of all containers' limits (see paperless header comment: 768+256+128=1152M); `mem_request` = expected steady-state usage, display-only. |
| Compose file skeleton | `templates/paperless-ngx/docker-compose.yml` (header) | Header comment (app, domain, DB type, RAM math, Pi), `restart: unless-stopped`, `TZ=Europe/Budapest`, explicit `container_name`, `traefik-public` external network + `<app>-internal` for DBs, Traefik labels with ``Host(`${SUBDOMAIN}.${DOMAIN}`)``, named volumes for DB/config (NVMe), `${HDD_PATH}/appdata/<app>/...` for bulk data, `${USERDATA_PATH}/...` for customer-browsable content. |
| App-email (SMTP shim) opt-in | `templates/vaultwarden/.felhom.yml` (`smtp_mapping:`) + README.md §smtp_mapping | `smtp_mapping` maps shim host/port/security/from to the app's own env names; compose MUST reference the mapped `${VAR:-}` keys with empty defaults. STARTTLS if the app can accept self-signed certs, else `security_value: "NONE"` plaintext (or the :2526 plaintext listener for STARTTLS-insistent clients — see calcom/nextcloud). TRAP: an image that treats defined-but-EMPTY mail vars as "set" (vaultwarden — campaign F1 2026-07-06) needs its own enable-flag gated `false` in compose and flipped `"true"` via `smtp_mapping.extra`; boot-prove a fresh email-off deploy for every new smtp-mapped app. |
| Probe-container naming | `templates/vaultwarden/docker-compose.yml` (`container_name: vaultwarden`) + `templates/sparkyfitness/` | The controller-side `healthcheck.checks[]` probe dials the container whose **name equals the stack (directory) name exactly**; fallback = the FIRST running prefix-match, which in a multi-container stack can be the DB (verified: `felhom-controller/controller/internal/stacks/healthprobe.go` `findProbeContainer`). So the Traefik-exposed service's `container_name` must be exactly the stack name; sidecars `<app>-db`, `<app>-redis`, …. |
## 3. Dangerous lookalikes — do NOT copy
| Trap | Why it bites | Use instead |
|---|---|---|
| `templates/gokapi/docker-compose.yml` custom `entrypoint` seeding `config.json` + `--deployment-password` | One-off hack because Gokapi has no env-var headless setup (pinned to ConfigVersion 21 / v1.9.6). Copying this entrypoint pattern to another app will break on image updates. | Normal env-var config via `deploy_fields`; entrypoint-seeding only as last resort. |
| `templates.json` + `scripts/generate-customer.sh` | LEGACY Portainer-only mechanism (marked so in README.md). New apps do NOT need entries here; the controller syncs `templates/<app>/` directly. | Just `templates/<app>/{docker-compose.yml,.felhom.yml}`. |
| `templates/uptime-kuma/docker-compose.yml` `test: ["CMD", "extra/healthcheck"]` | Image-provided binary, unique to this image — not a portable pattern. | Pick the wget/curl/node/python family matching your image (§2). |
## 4. Seams & interfaces (cross-repo)
- Controller pulls this repo (default 15m) and copies `templates/<app>/docker-compose.yml` + `.felhom.yml` to `/opt/docker/stacks/<app>/`; it NEVER overwrites deployed `app.yaml` / `.env`; SHA-256 change detection. Contract description: `README.md` §"How It Works".
- `.felhom.yml` is the contract surface consumed by felhom-controller (repo `felhom-controller/`): `deploy_fields` drive the deploy wizard, `resources` the deploy screen hints, `healthcheck.checks` the controller-side probe, `smtp_mapping` the app-email injection at deploy/redeploy.
- Assets are NOT in this repo: logo/screenshots resolve from felhom.eu via `slug` (`{assets.base_url}/assets/{slug}-logo.webp` — see comment block in `templates/paperless-ngx/.felhom.yml`).
- Commit+push to main IS the deploy: the controller picks changes up on next sync.
## 5. Extension points (adding a new app)
1. `templates/<app>/docker-compose.yml` — copy `templates/paperless-ngx/docker-compose.yml` skeleton; every service needs `container_name`, `restart: unless-stopped`, `TZ=Europe/Budapest`, `deploy.resources.limits.memory`, a healthcheck (family per §2), Traefik labels on the web service, `traefik-public` external + `<app>-internal` network if it has a DB.
2. `templates/<app>/.felhom.yml` — copy `templates/paperless-ngx/.felhom.yml`; required keys per §2; DOMAIN + SUBDOMAIN fields always; `mem_limit` = sum of compose limits; Hungarian user-facing text; `healthcheck.checks` probe.
3. Update `README.md` App Catalog + Variable-types tables (convention — every existing app is listed).
4. Skip `templates.json` / `generate-customer.sh` (legacy, §3).
5. Email-capable app: add `smtp_mapping` + matching `${VAR:-}` compose lines (§2 last row).
## 6. Known inconsistencies (observed — NOT fixed)
- `README.md` lists `templates/filebrowser/` in the tree, but no such directory exists; conversely `templates/recipe-importer/` exists but is absent from README's tree and both catalog tables.
- `README.md` field-type table (§".felhom.yml Format") omits `subdomain` (used by all 53 apps) and `secret_input` (`templates/romm/.felhom.yml`), and lists `boolean` which no descriptor uses.
- `README.md` says `smtp_mapping` is "Currently mapped: Vaultwarden, Mealie" — six descriptors now carry it (calcom, gitea, mealie, nextcloud, rallly, vaultwarden).
- Healthcheck URL style drifts: `localhost` vs `127.0.0.1`, trailing-slash vs none, `curl -f` vs `curl -sf`, and two distinct Node one-liner styles (compact rallly vs verbose docmost).
- One unquoted generator value (`generate: password:24`) among otherwise-quoted `generate: "..."` values.
- The in-file comment block in `.felhom.yml` headers (e.g. `templates/paperless-ngx/.felhom.yml` "Generator types") omits `base64key:N`, which `README.md` documents and one app uses.
@@ -0,0 +1,793 @@
# Catalog persistence sweep — does every app's data land in a persisted volume?
**Run:** 2026-08-02 · **Repo:** `app-catalog-felhom.eu` (only) · **Class:** implementation —
detection first, fixes second.
Campaign 10 found that papra stores its database somewhere the backup never looks (R-156). papra is
not the interesting part. **Nothing anywhere checked that the folder a template preserves is the
folder the app writes to**, across 53 templates. This is what that check found.
> **Parallel-session fence, observed.** Another session was running the M-band RTO measurement on
> `demo-hp`, writing to `felhom.eu`. This session ran on `demo-felhom` and committed only to
> `app-catalog-felhom.eu`. **No commit to `felhom.eu`, no register edit, nothing on `demo-hp`.**
> All register entries below are proposed text for later filing by one session, in one place.
---
## 1. Venue and baselines
| | |
|---|---|
| Host | **`demo-felhom`** (N100), Tier 0 — `ssh felhom-pve` (Tailscale `100.70.170.35`) |
| Guest | **LXC 9301 `catalog-persist`**, created for this run and destroyed at teardown (§9) |
| Guest spec | Debian 13, 4 cores, 6 GB + 2 GB swap, `local-lvm:120`, `nesting=1,keyctl=1`, DHCP `192.168.0.22` |
| Reach | `ssh felhom-pve "pct exec 9301 -- …"`. **No direct SSH**: the N100 sits on a remote LAN whose `192.168.0.0/24` collides with DooPlex's, so `192.168.0.22` is unroutable from here (`No route to host`) |
| Docker | 29.7.1, compose v5.3.1 |
| Untouched | guest **9201** on `demo-felhom` (read-only `docker ps`/`volume ls` only), `drill-r50`, Peti's cluster, both boxes' backup configuration, **everything on `demo-hp`** |
### Baselines, read fresh
| What | Value | How |
|---|---|---|
| `app-catalog-felhom.eu` HEAD | **`4252121`** = `origin/main`, clean at start | `git rev-parse` |
| Templates | **53** | `ls templates \| wc -l` |
| `felhom.eu` HEAD (read-only, for citation) | **`7ba7c2a`** | `git rev-parse`**not committed to** |
| felhom-controller `main` | **v0.188.0**, HEAD `4115e88` | `CHANGELOG.md` top |
| Hub | 0.86.0 | live `/apps` footer |
### Why the scratch guest runs no controller — stated, because the brief asks for its version
There is **no felhom-controller on 9301, deliberately.** The property under test is a property of
the *template*, and the controller's role in it is exactly one thing: it resolves the `${…}`
variables and runs `docker compose up -d` (`felhom-controller/internal/stacks/deploy.go:374`,
`:551-582`). It does not rewrite the compose file, so a plain Docker host reproducing that env
substitution exercises the identical code path in the app.
Standing a controller up would have meant enrolling a box with the hub — creating hub-side state
this session is told not to create, and which R-131 records four orphaned instances of. The
substitution the controller performs is instead **mirrored exactly**, including the trap that
`generateValue` prefixes `base64key` with `base64:` (`deploy.go:904`); getting that wrong cost one
false verdict before it was caught (§3.4).
---
## 2. Part 0 — had this ground been covered?
**No. Neither Campaign 7 nor any gate, `.felhom.yml` field, or CI check tests this property.**
Each negative below names what was searched.
### 2.1 Campaign 7 tested deployability and health, not persistence
`felhom.eu/documentation/audits/CAMPAIGN-7-catalog-sweep-2026-07-19.md:38-51` states its method:
per-container terminal health verdict, healthcheck-binary audit, HTTP probe through the real
Traefik ingress, login where scriptable, and a log scan. Its result matrix (`:114`) carries the
columns *pin · MAJOR · deploy · health · http · logs · settle*. **There is no persistence column,
and no step in the method reads where data landed.**
The proof that this is a gap rather than a technicality is in that same matrix: **papra passed it**
`deploy ok`, `health **fixed** ×2`, `http 200`, `logs clean` (`:151`) — while its database was
being written to a directory no backup would ever contain. Campaign 7 also demonstrated the
narrower fact that a healthcheck can be a lie (C7-F4, `:210-224`); this sweep demonstrates that a
*truthful* healthcheck is still not evidence about data.
**What changed since:** nothing that would have closed it. Between Campaign 7 and today the catalog
gained the resolvability gate (2026-07-21), `lifecycle:` (2026-07-21) and R-75's `data_paths:`
(2026-07-26) — image currency, circulation status, and folder *labelling* respectively.
### 2.2 No gate asserts a volume ⟷ data-path relationship. Searched:
| Where | What was searched | Result |
|---|---|---|
| `app-catalog-felhom.eu/scripts/` | full listing | exactly two gates, `check-image-pins.py` (syntactic tag check) and `check-image-resolvable.py` (does the tag still exist) — **both about images, neither about volumes** |
| catalog repo CI | `.gitea/workflows`, `.github`, drone/woodpecker/ci config; every non-template YAML | **none exists.** Catalog gates are convention-run, invoked by whoever edits the catalog — this matters for §7 |
| all four repos | `grep -rniE "volume.*(persist\|data.?path\|writable.layer)\|writable.layer\|docker diff\|persistence.(gate\|check)" --include=*.py --include=*.go --include=*.sh` | no check of this kind; the only hits are comments and a test fixture name |
| all gate scripts, four repos | `ls felhom-controller/scripts/*.py felhom.eu/scripts/*.py app-catalog-felhom.eu/scripts/*.py felhom-agent/scripts/*` | 11 gates. The closest by name, `felhom-controller/scripts/docker_run_volume_path_gate.py`, is unrelated: it allowlists `docker … -v` arguments in **controller Go code** so a controller-local host path cannot be mounted from inside a container (v0.125.0 scenario D) |
| `.felhom.yml` schema | `backup:`, `data_paths:`, `lifecycle:`, `deploy_fields:` | none expresses "the app writes here" |
### 2.3 The two validators that look closest cannot see this class
`ValidateBackupSpec` (`felhom-controller/internal/appbackup/classify.go`) and `ValidateDataPaths`
(`internal/stacks/datapaths.go:58`) both reject an entry matching no compose bind — real checks, but
of **declaration consistency**, not of runtime behaviour. And both operate on
`ParseComposeClassifiableBinds` (`internal/stacks/classify_binds.go`), which keeps only mounts whose
host side passes `classifyRoot` — i.e. paths under `${USERDATA_PATH}` / `${HDD_PATH}` /
`${IMPORT_PATH}`. **Named volumes are skipped entirely.**
That is decisive for papra, which has **zero binds**: every one of these validators is vacuously
satisfied by it. It is also decisive for scale — **39 of 53 templates declare no felhom-root bind at
all**, so for three-quarters of the catalog the entire data-protection story is `DumpAppVolumes`
(`internal/backup/backup.go:543`) tarring a named volume, and nothing checks that the volume has
anything in it.
### 2.4 R-156's evidence, read before building
`felhom.eu/documentation/tests/campaign10-evidence-2026-07-31/r156-papra-volume.txt` — five numbered
observations: the volume mounted at `/app/data`; `uid=999(nonroot)` with `/app/data` `root root`;
`touch: cannot touch '/app/data/.w': Permission denied`; the volume empty but for a canary the
campaign wrote **as root**; and `db.sqlite` 475136 B in the container's writable layer at
`/app/app-data/`. The detector was built against those observations, and it reproduces all of them.
---
## 3. The detector
`scripts/check-volume-persistence.py`. **The detector and the gate are the same program** — the
sweep below was produced by running the committed gate, so the durable artifact is the thing that
generated the evidence rather than a paper copy of it.
Per app: deploy from the template → exercise it into writing data → compare where the data landed
against what the compose mounts.
### 3.1 What is observed, and why nothing uses `docker exec`
| | signal | how |
|---|---|---|
| M | mount inventory, by persistence class | `docker inspect .Mounts` |
| D | the writable layer | `docker diff` — Docker **excludes every mounted path** from it, so an entry here is definitionally in no volume and no bind |
| U | the app's real uid/gid | `/proc/<State.Pid>/status`, **not** `Config.User` — which is empty for papra even though it drops to 999 |
| W | writability of each mount by that uid | host-side `stat` of the mount source |
| E | mount occupancy | host-side walk of the mount source |
**No `docker exec` anywhere.** Campaign 7 §1.1 recorded that `docker exec` writes its OCI error to
STDOUT, so a *missing* binary printed `executable file not found` and read as **present** — which
made that campaign's entire healthcheck audit report every app honest. Everything here is read from
`docker inspect`, `docker diff`, `/proc` and the host filesystem, so a distroless or shell-less
image is observed exactly like any other.
### 3.2 Mount classes — the second defect class this exposes
| class | persists a redeploy? | backed up? |
|---|---|---|
| `named-declared` — in the compose `volumes:` block | yes | **yes** |
| `anonymous` — an image `VOLUME` or unnamed mount (64-hex docker name) | survives a *restart* | **NO** |
| `bind` | yes | per the backup spec |
| `tmpfs` | no | no |
The anonymous case is deceptive precisely because it survives a restart. `ResolveDockerVolumeNames`
(`felhom-controller/internal/appbackup/appdata.go`) returns `<project>_<name>` only for volumes
**declared in the compose file**, so an anonymous volume is invisible to `DumpAppVolumes` — and a
`compose down` + `up` orphans it and creates a fresh one. Data there is unbacked-up *and* lost on
redeploy, with no symptom until someone looks.
### 3.3 What counts as "exercised", and what counts as data
**Exercised.** Every container reaches `running`, and `healthy` where a healthcheck exists; then an
HTTP request the app's *own router* answers. Any status code — including 3xx/4xx/5xx — proves the
request reached application code; a connection refusal (`000`) does not. If that produced no writes
anywhere, a **second-chance pass** walks a wider path list following redirects, so a first-run
wizard is actually reached, and waits again. **An app that still writes nothing is UNDETERMINED,
never CLEAN** — health-check-passing is not data-writing, and conflating the two is what let papra
look fine.
**Data versus noise.** Three rules, deliberately explicit rather than buried in a heuristic:
1. **Noise**`/tmp`, `/run`, `/var/log`, `/var/cache`, package-manager state, `__pycache__`,
`node_modules`, `.cache`, `*.pid`, `*.sock`, `*.log`, `*.pyc`, and the per-container files
Docker itself writes (`/etc/hosts`, `/etc/resolv.conf`, …).
2. **Database signature** — a filename that *proves* a database is in that directory
(`*.sqlite*`, `PG_VERSION`, `ib_logfile*`, `data.mdb`, `dump.rdb`, `MANIFEST-*`, `data.ms`, …).
The strongest signal, and it needs no path heuristics.
3. **Data token** — a path saying "app state" (`data`, `storage`, `uploads`, `library`, `documents`,
`var/lib/postgresql`, …), used only where rule 2 is silent.
**`A` versus `C` is the whole difficulty**, and rule 3 is where judgement actually lives:
- `A` (added) — the app **created** this file; it exists only in the writable layer. papra's
`db.sqlite` is an `A`. **DATA is decided from `A` entries only.**
- `C` (changed) — a file that **ships in the image** was touched. Usually a chown sweep and
entirely benign. A `C` on a *database-signature* file is genuinely ambiguous, because an app
writing into a DB shipped in its image looks identical. Those are held as **SUSPECT** and settled
**by bytes**: the file is copied out of the running container and out of a pristine container
created from the same image, and compared. Identical → a chown, recorded and dismissed.
Different → a real write into an image-layer file, which is papra's defect with a different verb.
Unreadable → **UNDETERMINED**, never folded into CLEAN.
### 3.4 Red-proofs — both directions, before the sweep, and again on every run
| | claim | result |
|---|---|---|
| **positive** | it flags **papra** | **BROKEN**, reproducing R-156's evidence line for line: `/app/data` EMPTY and `NOT writable by the app's own uid=999 gid=999`, `db.sqlite` in the writable layer at `/app/app-data/db` |
| **negative** | it stays quiet on a known-good app | **vaultwarden CLEAN** — and *demonstrably* good, not merely unflagged (below) |
The negative is a **proven** negative. `state/survive2.py` deployed vaultwarden, let it write, ran a
real `docker compose down` + `up -d` (no `-v` — that is a redeploy; `down --volumes` is the
uninstall path, Campaign 7 §2), and re-fingerprinted every file by sha256 **and inode**:
```
vaultwarden SURVIVES before=4 after=4 survived=2 changed=2 lost=0
survived: /data::db.sqlite3 <- the database itself, byte-identical, SAME INODE
/data::rsa_key.pem
changed : /data::db.sqlite3-shm , /data::db.sqlite3-wal <- SQLite sidecars, expected on restart
```
**The self-test runs on every invocation, in both directions.** Before the gate reports anything it
puts two canary templates through the same prober and refuses to issue a verdict unless it calls
one BROKEN and the other CLEAN. Both are built from a purpose-made image reproducing papra's exact
ownership shape — `/app/data` root-owned, `/app/app-data` owned by the app's uid — and **differ only
in which path the volume is mounted at**, so the canary pair is a live demonstration of R-156 *and*
of its fix, every run. A detector that flags nothing is worse than none: it converts an unexamined
catalog into a documented-clean one.
### 3.5 Seven defects in the detector, every one found by adjudicating a flag rather than trusting it
Recorded because each would have put a wrong number in the matrix, and **six of the seven made a
correct app look broken** — the failure mode that gets a gate switched off and then protects nothing.
| # | defect | what it did |
|---|---|---|
| 1 | **`base64key` lost the controller's `base64:` prefix** (`deploy.go:904`) | bookstack's Laravel `APP_KEY` was invalid, it served 500s, read UNDETERMINED. With the prefix: CLEAN. A harness bug wearing an app defect's clothes — the class Campaign 7 §1.1 and Campaign 10 §4d both record |
| 2 | **`C` entries scored as data** | a linuxserver.io entrypoint re-owning its application tree produced **1305 `C` entries** for calibre-web, 92 of them PNGs under `cps/static/css/images/**`. Called BROKEN. Led to the `A`/`C` split and the byte-level adjudicator (§3.3) |
| 3 | **a cache *directory* escaped the noise filter** | `docker diff` lists directories, so the bytecode cache appeared as a bare `…/crafty/config/__pycache__` entry while its `.pyc` children were filtered by suffix — the directory then being the only surviving entry under a path containing `config`. crafty-controller called BROKEN four times over with its data correctly placed. Fixed by normalising with a trailing slash, covering `node_modules`, `.cache`, `.git` at once |
| 4 | **`postgresql.conf` listed as a database signature** | it is a *config* file the postgres entrypoint writes at init. immich called BROKEN with its PGDATA correctly in its volume, **1831 files** |
| 5 | **path tokens convicted on their own** | onlyoffice unpacks its OWN static assets into the writable layer at first boot — plugin icons, slide-theme `media/`, `web-apps/apps/api/documents/api.js`, **2560 added entries** — while its real data mount received data normally. Rule 3 was **demoted to report-only** |
| 6 | **the structural check asked per container, not per app** | fired on **docmost, immich and claper** — three correct apps, identical shape: the app container's only volume is for user uploads and legitimately empty on a fresh install, while all real state sits in the sibling database container's volume (1540 / 1833 / 1470 files) |
| 7 | **mount rules never consulted the noise list** | the prefixes are written `/run/`, so a bare mount target `/run` matched none of them. privatebin's image declares `VOLUME /run`; the anonymous volume docker made there holds `nginx.pid`, `php-fpm.sock` and s6 fifos, and read as 14 files of unbacked-up customer data |
Plus one that was not a false verdict but a **reporting** defect, and arguably the worst of the set:
8. **A finding was being swallowed by a louder one.** gramps-web's structural finding — its *family
tree* landing outside every mount, the entire point of the app — went into the undetermined
bucket, and that bucket is discarded whenever a BROKEN leg exists. Its lesser defect (the
accounts database) convicted first and hid it. A finding that disappears because a different
finding won belongs to the same family as an absent log line read as health, and the fix is that
the structural finding is now reported whatever the verdict.
**The pattern, which is the real lesson.** Every false positive came from a rule that identified data
by its **name** rather than by evidence of what it is: `C` assumed to be a write, `postgresql.conf`
assumed to be a database, `media/` and `config/` assumed to be customer content, `/run` assumed to be
storage because it was a volume. The rules that never needed correcting are the ones grounded in
something checkable — a database file the app **created**, a mount its own uid **cannot write**,
bytes that **differ** from the image's copy, and *did anything land in a preserved folder at all*.
**Restarts, stated plainly.** The sweep was restarted from the first app **twice** rather than merge
a matrix built by more than one version of the rules — the call Campaign 10 made restarting run 2b
from cycle 1. Defects 6, 7 and 8 were found after the final pass had begun; all three are in
`classify()`, which reads only what the probe already captured, so those verdicts were **re-derived**
rather than re-measured (`state/reclassify.py`), and then **every non-CLEAN and every flipped app was
re-run live end-to-end** through the final gate so no reported verdict rests on post-processing
(§5.3). Six verdicts changed on re-derivation: claper, docmost, immich, sparkyfitness and zipline
UNDETERMINED→CLEAN, privatebin BROKEN→UNDETERMINED.
**And one rule was written, red-proofed, and deleted.** A second "are all this directory's children
noise?" filter looked like a sensible belt to defect 3's brace. Its red-proof came back **green**,
which is the tell: `e["added"]` can only ever contain entries that already passed `is_noise`, so the
check is always False — dead code wearing the costume of a safeguard, exactly the trap `CLAUDE.md`
catalogues. It is gone, the code says why it is not there, and
`test_cache_directories_are_filtered_at_ENTRY_level` pins the mechanism that actually does the work.
## 4. The sweep — all 53 templates
**43 CLEAN · 3 BROKEN · 7 UNDETERMINED.** UNDETERMINED is counted separately and is never folded
into CLEAN; each one's reason is in §4.2.
Evidence per app: `evidence/<app>/probe.json` — the full probe (mount inventory with occupancy and
writability, the app's real uid, the writable-layer rollup, the adjudicated suspects) plus the
verdict and its reasons. Runner log: `state/gate.log`.
**How to read the mount column:** `[vol]` a named volume declared in the compose (persisted AND
backed up) · `[ANON]` an anonymous volume from the image's own `VOLUME` directive (persisted across
a restart, **never backed up**, orphaned by a redeploy) · `[bind]` a host bind · `,EMPTY` the mount
received nothing.
### 4.1 The matrix
| app | verdict | what the template mounts | where the app actually wrote | mount not writable by app uid |
|---|---|---|---|---|
| `gramps-web` | **BROKEN** | /app/data[vol,EMPTY], /app/media[vol,EMPTY], /tmp[vol,EMPTY] | **/app/users (writable layer)** | — |
| `papra` | **BROKEN** | /app/data[vol,EMPTY] | **/app/app-data/db (writable layer)** | /app/data |
| `wishlist` | **BROKEN** | /data[vol,EMPTY], /usr/src/app/uploads[ANON,EMPTY], /usr/src/app/data[ANON] | /usr/src/app/data(1) | — |
| `bentopdf` | **UNDETERMINED** | — | nothing | — |
| `glance` | **UNDETERMINED** | /app/config[vol,EMPTY] | nothing | — |
| `plant-it` | **UNDETERMINED** | — | nothing | — |
| `privatebin` | **UNDETERMINED** | /tmp[ANON,EMPTY], /var/lib/nginx/tmp[ANON,EMPTY], /srv/data[vol,EMPTY], /run[ANON] | /run(14) | — |
| `recipe-importer` | **UNDETERMINED** | /data[vol,EMPTY] | nothing | — |
| `uptime-kuma` | **UNDETERMINED** | /app/data[vol,EMPTY] | nothing | — |
| `wanderer` | **UNDETERMINED** | /app/uploads[vol,EMPTY], /pb_data[vol], /data/plugins[vol,EMPTY], /meili_data[vol] | /pb_data(6), /meili_data(11) | — |
| `actualbudget` | **CLEAN** | /data[vol] | /data(2) | — |
| `adventurelog` | **CLEAN** | /code/media[vol], /var/lib/postgresql/data[vol] | /code/media(251), /var/lib/postgresql/data(2287) | — |
| `audiobookshelf` | **CLEAN** | /config[vol], /metadata[vol], /audiobooks[bind,EMPTY], /podcasts[bind,EMPTY] | /config(16), /metadata(1) | — |
| `bookstack` | **CLEAN** | /config[vol], /var/lib/mysql[vol] | /config(20), /var/lib/mysql(291) | — |
| `calcom` | **CLEAN** | /var/lib/postgresql/data[vol] | /var/lib/postgresql/data(2261) | — |
| `calibre-web` | **CLEAN** | /config[vol], /cwa-book-ingest[bind,EMPTY], /calibre-library[bind] | /config(11), /calibre-library(3) | — |
| `claper` | **CLEAN** | /app/priv/static/uploads[vol,EMPTY], /var/lib/postgresql/data[vol] | /var/lib/postgresql/data(1470) | — |
| `code-server` | **CLEAN** | /config[vol] | /config(6) | — |
| `crafty-controller` | **CLEAN** | /crafty/servers[vol,EMPTY], /crafty/app/config[vol], /crafty/import[vol,EMPTY], /crafty/backups[vol,EMPTY], /crafty/logs[vol] | /crafty/app/config(16), /crafty/logs(8) | — |
| `docmost` | **CLEAN** | /app/data/storage[vol,EMPTY], /var/lib/postgresql/data[vol], /data[vol] | /var/lib/postgresql/data(1540), /data(3) | — |
| `emby` | **CLEAN** | /config[vol], /media[bind] | /config(46), /media(3) | — |
| `ghost` | **CLEAN** | /var/lib/ghost/content[vol] | /var/lib/ghost/content(7) | — |
| `gitea` | **CLEAN** | /data[vol] | /data(8) | — |
| `gokapi` | **CLEAN** | /app/data[vol], /app/config[vol] | /app/data(4), /app/config(1) | — |
| `grafana` | **CLEAN** | /var/lib/grafana[vol] | /var/lib/grafana(543) | — |
| `home-assistant` | **CLEAN** | /config[vol] | /config(21) | — |
| `homebox` | **CLEAN** | /data[vol] | /data(3) | — |
| `homepage` | **CLEAN** | /app/config[vol], /var/run/docker.sock[bind,EMPTY] | /app/config(3) | — |
| `immich` | **CLEAN** | /cache[vol,EMPTY], /var/lib/postgresql/data[vol], /data[vol], /usr/src/app/upload[bind], /external/photos[bind,EMPTY], /data[ANON,EMPTY] | /var/lib/postgresql/data(1833), /data(3), /usr/src/app/upload(6) | — |
| `jellyfin` | **CLEAN** | /config[vol], /cache[vol], /media[bind] | /config(20), /cache(2), /media(3) | — |
| `kimai` | **CLEAN** | /opt/kimai/var[vol], /var/lib/mysql[vol] | /opt/kimai/var(2547), /var/lib/mysql(279) | — |
| `komga` | **CLEAN** | /config[vol], /data[bind,EMPTY] | /config(11) | — |
| `mealie` | **CLEAN** | /app/data[vol] | /app/data(5) | — |
| `n8n` | **CLEAN** | /home/node/.n8n[vol] | /home/node/.n8n(7) | — |
| `navidrome` | **CLEAN** | /data[vol], /music[bind,EMPTY] | /data(6) | — |
| `nextcloud` | **CLEAN** | /var/www/html/data[bind], /var/www/html[vol], /var/lib/mysql[vol], /data[vol] | /var/www/html/data(69), /var/www/html(4001), /var/lib/mysql(311), /data(3) | — |
| `onlyoffice` | **CLEAN** | /var/www/onlyoffice/Data[vol], /var/log/onlyoffice[vol], /usr/share/fonts/truetype/custom[ANON,EMPTY], /var/lib/onlyoffice[ANON,EMPTY] | /var/www/onlyoffice/Data(3), /var/log/onlyoffice(11) | — |
| `opengist` | **CLEAN** | /opengist[vol] | /opengist(9) | — |
| `outline` | **CLEAN** | /var/lib/outline/data[vol,EMPTY], /var/lib/postgresql/data[vol], /data[vol,EMPTY] | /var/lib/postgresql/data(1655) | — |
| `paperless-ngx` | **CLEAN** | /var/lib/postgresql/data[vol], /data[vol], /usr/src/paperless/export[bind,EMPTY], /usr/src/paperless/data[vol], /usr/src/paperless/media[bind], /usr/src/paperless/consume[bind,EMPTY] | /var/lib/postgresql/data(1927), /data(3), /usr/src/paperless/data(9), /usr/src/paperless/media(1) | — |
| `plex` | **CLEAN** | /media[bind], /config[vol], /transcode[vol,EMPTY] | /media(3), /config(82) | — |
| `radarr` | **CLEAN** | /config[vol], /media/movies[bind,EMPTY], /downloads[bind,EMPTY] | /config(11) | — |
| `rallly` | **CLEAN** | /var/lib/postgresql/data[vol] | /var/lib/postgresql/data(1580) | — |
| `romm` | **CLEAN** | /redis-data[ANON,EMPTY], /romm[ANON,EMPTY], /romm/library[bind,EMPTY], /romm/resources[bind,EMPTY], /romm/config[vol], /var/lib/mysql[vol], /data[vol] | /romm/config(1), /var/lib/mysql(271), /data(4) | — |
| `seerr` | **CLEAN** | /app/config[vol] | /app/config(11) | — |
| `sonarr` | **CLEAN** | /media/tv[bind,EMPTY], /downloads[bind,EMPTY], /config[vol] | /config(11) | — |
| `sparkyfitness` | **CLEAN** | /var/lib/postgresql/data[vol], /app/SparkyFitnessServer/uploads[vol,EMPTY], /app/SparkyFitnessServer/backup[vol,EMPTY] | /var/lib/postgresql/data(1815) | — |
| `tandoor` | **CLEAN** | /opt/recipes/staticfiles[vol], /opt/recipes/mediafiles[vol,EMPTY], /var/lib/postgresql/data[vol] | /opt/recipes/staticfiles(3047), /var/lib/postgresql/data(1984) | — |
| `termix` | **CLEAN** | /app/data[vol] | /app/data(4) | — |
| `vaultwarden` | **CLEAN** | /data[vol] | /data(4) | — |
| `vikunja` | **CLEAN** | /app/vikunja/files[vol,EMPTY], /db[vol] | /db(3) | — |
| `wger` | **CLEAN** | /home/wger/db[vol], /home/wger/media[vol,EMPTY] | /home/wger/db(1) | — |
| `zipline` | **CLEAN** | /zipline/uploads[vol,EMPTY], /zipline/public[vol,EMPTY], /var/lib/postgresql/data[vol] | /var/lib/postgresql/data(1357) | — |
### 4.2 UNDETERMINED — seven, each with its reason
**None of these is a pass.** Four are apps that genuinely wrote nothing, which is a limit of the
exercise rather than a property of the template; three could not be started at all.
| app | why it is undetermined | is this a template defect? |
|---|---|---|
| `bentopdf` | Declares **zero volumes** and wrote nothing. Stateless by design — a browser-side PDF tool. | No — but the gate cannot distinguish "stateless" from "not exercised hard enough", and saying CLEAN would claim a check that was never made. |
| `uptime-kuma` | Healthy, 302 to `/setup`, **0 entries in `docker diff`**, volume empty. v2 creates its database only when the setup wizard is submitted. | Unknown. Needs a completed wizard to decide. |
| `privatebin` | Wrote nothing to `/srv/data`; no paste was ever posted. (Its `/run`, `/tmp`, `/var/lib/nginx/tmp` anonymous volumes are runtime state — see §3.5.6.) | Unknown. Needs a posted paste to decide. |
| `recipe-importer` | Its one mount `/data` stayed empty and the only writes were at `/`. | Unknown. Needs a real import to decide. |
| `glance` | **Crash-loop** (`restarting`, exit 1). It needs `/app/config/glance.yml` and the template mounts an empty config volume without seeding one. | Pre-existing and already recorded — Campaign 7 §6.2 proved the same failure on the pre-campaign pin. Not a persistence finding; the app never ran. |
| `plant-it` | `pull access denied for msdeluise/plant-it, repository does not exist` — no container was created. | No. The app is `lifecycle: abandoned` for exactly this reason (CHANGELOG 2026-07-21); its dead image is the expected end state. |
| `wanderer` | Container `unhealthy`. Its `/pb_data` (6) and `/meili_data` (11) did receive data, so the app is not obviously broken — but a verdict on an unhealthy app would be a verdict on a half-started one. | Unknown. Needs a healthy deploy to decide. |
### 4.3 What the CLEAN column actually establishes
For the 43 CLEAN apps the claim is bounded, and worth stating precisely: **the data each app wrote
during a first-boot-plus-one-HTTP-request exercise landed inside a mount the template preserves,
none of it landed in the writable layer, and every mount was writable by the app's own uid.** It is
not a claim that every future write will land there — an app that stores uploads correctly but
writes, say, a search index elsewhere on first use would still read CLEAN here. That is the
residual gap, and it is what the periodic re-run in §7.1 exists for.
## 5. Fixes — two made, each with a survives-a-redeploy proof
Every corrected path is confirmed by **two independent sources**, never inferred from a directory
name: the shipped image's own environment/`Config.Volumes`, and upstream's reference compose.
Guessing at a data path is how this defect class was created.
Neither app is deployed anywhere in the fleet (hub `/apps` lists 10 deployed apps; neither is among
them), so neither fix strands anything. **papra is deployed and is therefore NOT fixed** — §6.1.
### 5.1 `gramps-web`
Mounted `/app/data`, `/app/media`, `/tmp`. **`/app/data` appears nowhere in the image's environment**
— the application never writes it. Now mounts the eight paths the image itself names, matching
upstream's reference compose:
| volume | path | what lives there |
|---|---|---|
| `gramps_users` | `/app/users` | `GRAMPSWEB_USER_DB_URI` — the accounts database |
| `gramps_db` | `/root/.gramps/grampsdb` | `GRAMPS_DATABASE_PATH`**the family tree** |
| `gramps_index` | `/app/indexdir` | `GRAMPSWEB_SEARCH_INDEX_DB_URI` |
| `gramps_media` | `/app/media` | `GRAMPSWEB_MEDIA_BASE_DIR` (was already correct) |
| `gramps_thumb_cache` · `gramps_cache` · `gramps_secret` · `gramps_tmp` | | caches, exports, secret |
**Proof — `proofs/gramps-web/survive-gramps-web.json`.** Deploy → exercise → `docker compose down`
(no `-v`; that is a redeploy, `down --volumes` is the uninstall path) → `up -d` → re-fingerprint by
sha256 **and inode**:
```
gramps-web SURVIVES before=5 after=5 survived=4 changed=1 lost=0
survived (byte-identical, SAME INODE):
/app/users::users.sqlite <- the accounts database
/root/.gramps/grampsdb::<uuid>/database.txt <- THE FAMILY TREE
/root/.gramps/grampsdb::<uuid>/name.txt
/app/thumbnail_cache::<hash>
changed in place: /app/cache::request_cache/<hash> <- a request cache, expected
```
### 5.2 `wishlist`
Mounted `wishlist_data:/data` — again a path the application never writes. The image declares
`VOLUME /usr/src/app/data` and `VOLUME /usr/src/app/uploads` (`Config.Volumes`), and upstream's own
compose mounts exactly those two, so `prod.db` was landing in the **anonymous** volume docker
creates for an image `VOLUME` directive: invisible to `ResolveDockerVolumeNames`, therefore never
backed up, and orphaned by `down` + `up`. Now mounts both real paths.
**Proof — `proofs/wishlist/survive-wishlist-content.json`.**
```
before redeploy: prod.db inode=47151 size=307200 sha256=f35df23b…
after redeploy: prod.db inode=47151 size=307200 sha256=f35df23b…
same_inode=true verdict: SURVIVES
```
Same file object, byte-identical content.
> **A correction, and the limit of this proof.** The generic prover (`state/survive2.py`) first
> reported `DATA-LOST` for wishlist. That was the prover, not the app: its rule counts any change
> in bytes as a loss, and cannot separate "the app modified its own database on boot" (fine —
> vaultwarden's SQLite WAL does exactly that) from "the app recreated it" (data gone). The focused
> check settles it on inode. **And the limit:** the seeded content step 404'd — the signup endpoint
> was a guess and wrong — so this is a **file-level** proof, not a user-row-level one. Byte-identical
> content means nothing in the database was lost, but no row was independently confirmed present
> beforehand.
### 5.3 Both fixes re-checked with the gate that convicted them
The strongest available confirmation is the same instrument reaching the opposite verdict, with its
own two-way self-test green on both runs. `proofs/fixed-check/`:
```
self-testing the prober (both directions)…
prober flags the R-156 signature and clears a correct template — trustworthy
gramps-web CLEAN
wishlist CLEAN
2 clean · 0 broken · 0 undetermined (of 2 in scope)
volume-persistence gate OK rc=0
```
Before the fixes the same gate reported both BROKEN (`state/verify.log`).
### 5.4 papra — the fix is proven, and deliberately not applied
Run in the scratch guest only, on a temporary copy of the template
(`proofs/_papra-fix-proof/papra-fix-proof.json`). **A real account was created through papra's own
API in both legs** (`sign-up -> HTTP/1.1 200 OK`), so real user data was written to the database
before each redeploy:
| leg | `db.sqlite` before | after | |
|---|---|---|---|
| **current** `papra_data:/app/data` | `1e9b695a…` | `5d179bf1…` | **different — the account is gone** |
| **fixed** `papra_data:/app/app-data` | `9dd5cf37…` | `9dd5cf37…` | **byte-identical — it survives** |
An incidental confirmation from the same run: under the fixed template `/app/data` reports
`present: false`. **The directory does not exist in the papra image at all** — it existed only
because the template mounted a volume there. So the volume was not merely unwritable; it was
mounted at a path the application has no concept of.
**Limit of this proof:** the document-upload step did not complete (the follow-up
`/api/organizations` call returned `auth.unauthorized` — a session-cookie handling problem in the
harness, not in papra), so the user data verified is the account, not an uploaded document.
**This fix is not committed.** §6.1.
---
## 6. Referred to the operator — not decided here
### 6.1 papra is deployed. Its fix would strand the data it is meant to protect.
**The fix is prepared and deliberately NOT committed.**
```diff
volumes:
- - papra_data:/app/data
+ - papra_data:/app/app-data
```
**Why it is a strand risk.** The volume *name* is unchanged, so no volume is orphaned — but the
mount target changes, which makes `docker compose up -d` **recreate the container**, and the
container's writable layer is where papra's documents and database currently live. Applying this to
a running papra destroys exactly the data the change exists to protect.
**The honest framing, both halves.** That data is already living on borrowed time: an image bump, a
`RedeployFromEnv`, a restore, or any other container recreation destroys it just the same, and no
backup has ever contained it. But the fix is what *schedules* the loss, on a date the customer does
not choose. Scaling that decision down is not this session's call.
Note that pushing the template alone destroys nothing: the controller's git-sync updates the two
template files and does **not** restart the stack (`CLAUDE.md`, deploy contract). The change would
take effect at the next deploy. It is still an armed change, which is why it is held.
**Which box — determined as far as the fences allow, and no further.**
| checked | result |
|---|---|
| hub `/apps` (read-only) | **Papra: 1 deployment**, 218 MB avg / 250 MB P95 — it is live somewhere |
| `demo-felhom` guest 9201 | **not there**`docker ps -a` shows no papra container, `docker volume ls` no papra volume |
| hub `/configs` | boxes currently reporting: `c10-soak`, `demo-felhom`, `demo-hp` (`david` —, `drill-r50` BLOCKED, `peti-felhom` DOWN, `sess-f` DOWN) |
| hub `/hosts/<id>` and `/apps/papra` | neither page attributes a deployment to a host |
So it is on **`c10-soak`** or **`demo-hp`**, both of which live on demo-hp hardware that this
session is fenced from. `c10-soak` is the likelier of the two and would make this moot: it is
Campaign 10's own rig, its hub customer record reads *"Campaign 10 two-storage soak (SCRATCH -
delete at teardown)"*, and papra was deployed on it **to find R-156 in the first place** — so the
"customer data" would be the campaign's own canary. **That is a guess, and it is labelled as one.**
One `docker ps` on either box settles it, and the other session owns both.
**What is asked:** confirm which box, and then either (a) it is `c10-soak` → push the fix, nothing
of value is lost, or (b) it is a demo box someone cares about → copy `/app/app-data` out of the
running container first (`docker cp papra:/app/app-data <dest>`), then push, then copy back into
the new volume. **No migration is shipped here** — §5 of the brief, and inventing one is how this
class of defect gets worse.
> ### 6.1a — UPDATE at teardown: the deployment disappeared mid-session
>
> Between the start of this run and its teardown, the hub's `/configs` and `/hosts` both **stopped
> listing `c10-soak`** — the other session tore Campaign 10's rig down while this work was in
> flight (its teardown was owed). In the same interval the hub's `/apps` went from 10 deployed apps
> to 6, and the four that disappeared are **papra, grafana, homebox and rallly** — precisely the
> four apps Campaign 10's audit §A4 names as its discriminator set. papra's single deployment was
> therefore on `c10-soak`, as §6.1 suspected, and it is gone.
>
> **The fix is still not applied, deliberately.** The evidence is strong but it is *absence*
> evidence — papra no longer appearing in aggregated telemetry is not the same as a positive
> observation that no box runs it, and this project's standing rule 3 exists for exactly that
> distinction. The one box I cannot check is `demo-hp`, which is fenced this session; the hub says
> it is reporting normally and shows no papra, but that is the same absence argument one level down.
>
> The asymmetry decides it: applying the fix while a papra instance does exist destroys its
> documents at the next redeploy and is irreversible; leaving it is a one-line change the operator
> can push in seconds. **So it is left.**
>
> **What settles it:** `ssh demo-hp "pct exec 9201 -- docker ps -a --filter name=papra"` — one
> command, on the box this session may not touch. If it returns nothing, apply the diff in §6.1;
> there is then no strand risk and papra joins gramps-web and wishlist as an ordinary fix.
### 6.2 Nothing else needed a ruling
Both other BROKEN apps have an unambiguous correct path — each confirmed by **two independent
sources**, never inferred — and neither is deployed anywhere in the fleet, so both were fixed
outright (§5). No app was left BROKEN for want of a decision, and no UNDETERMINED app was touched.
---
## 7. Part 4 — the gate
### 7.1 Where catalog gates live, and what runs them
They live in `app-catalog-felhom.eu/scripts/` as dependency-free Python that exits non-zero on
violation, and **nothing automated runs them.** There is no CI in this repo — no `.gitea/workflows`,
no `.github`, no drone/woodpecker config; the only YAML outside `templates/` is generated customer
output. Each gate is invoked by whoever edits the catalog, and `CLAUDE.md` says when.
That is the honest description, and it is stated rather than glossed because this project's record
on the point is unambiguous: R-29 has three orphaned gates and one enforced one, and the enforced
one is the only one that has ever stopped anything. **This gate is at the same enforcement level as
its two siblings — convention, not CI.** What would raise it is named in §8 as a proposed register
entry; building it would mean touching the controller's sync path, which is out of scope here
(§5 fences) and would need a decision about failing a customer's template sync.
The gate could be built here, so it was. It did not have to wait for `felhom.eu`.
### 7.2 Why the gate has to be a runtime check — measured, not assumed
A static audit of all 53 composes — every declared top-level volume attached to a service, no
anonymous mounts, no host binds outside the felhom roots — reports the catalog **completely clean**.
The single hit is `homepage`'s deliberate read-only `/var/run/docker.sock`.
**It reports papra clean too.** papra's compose is well-formed: it declares `papra_data` and mounts
it at `/app/data`. Nothing in the file is wrong. What is wrong is only observable at runtime.
So a static gate would have been a gate that could never have caught the defect it was written for —
coverage that isn't. `scripts/check-volume-persistence.py` deploys, exercises, and measures.
### 7.3 What it refuses
**Exit 1 — REFUSED, not a warning:**
- DATA in a container's writable layer (never persisted, never backed up);
- an app-data mount the app's own uid cannot write (R-156's second leg);
- data in an **anonymous** volume (never backed up, orphaned by a redeploy);
- a database-signature file in the writable layer whose bytes **differ** from the image's copy.
**Exit 2 — UNDETERMINED, and explicitly *not* a clean bill of health:** a container that never
reached running/healthy; nothing written anywhere; a suspect that could not be adjudicated; or —
load-bearing — **the prober failing its own two-way canary**, in which case the gate refuses to
report any verdict at all rather than issue a false all-clear.
**Exit 0** only when every app in scope is CLEAN.
### 7.4 The test through the path that actually invokes it
`scripts/test_check_volume_persistence.py`**31 tests, no Docker** (the prober is injected).
They drive **`check()`, the function `__main__` calls**, not just the verdict logic: a gate whose
decision logic is tested but whose entry point is not has shipped inert in this project before
(the seam-wiring rule, four recorded instances).
Covered at the entry point: one broken app ⇒ **rc 1** and the app is named; all clean ⇒ rc 0;
undetermined ⇒ **rc 2, never rc 0**; broken outranks undetermined; out-of-circulation apps skipped
and reported; **a prober that flags nothing ⇒ rc 2**; **a prober that flags everything ⇒ rc 2**.
Every rule was red-proofed by breaking it and confirming the suite goes red:
| break | suite |
|---|---|
| score `C` entries as data again (the calibre-web false positive) | **RED** |
| revert the trailing-slash noise normalisation (the crafty false positive) | **RED** |
| fold "wrote nothing" into CLEAN | **RED** |
| warn instead of refuse (rc 0 on broken) | **RED** |
| drop the writable-layer leg (the papra signature) | **RED** |
| let an unresolved suspect fall through to CLEAN | **RED** |
| drop the `base64:` prefix | **RED** |
| *(the deleted all-children-noise rule)* | **GREEN — which is why it was deleted** |
Registered in `CLAUDE.md` (conventions) and `REUSE.md` (the map), in the same commit.
---
## 8. Findings for later filing — PROPOSED TEXT, NOT FILED
The register is `felhom.eu/documentation/backlog/{ROADMAP,OPEN-ITEMS}.md`, and `felhom.eu` is
fenced this session. These are drafts for one session to file, in one place.
**Register grep before proposing** (read-only, `ROADMAP.md` + `OPEN-ITEMS.md`):
`wishlist` -> 1 hit, R-41's deployability gate, unrelated · `gramps` -> 0 · `anonymous` -> 2 hits,
both about the golden bake, unrelated · `ResolveDockerVolumeNames` -> 0 · `DumpAppVolumes` -> 0 ·
`writable layer` -> 0 · `check-volume-persistence` -> 0. **Highest filed: R-155.** So these start
at **R-158**.
### 8.0 First, a process finding — R-156 and R-157 are themselves unfiled
`grep -c "R-156\|R-157"` over both register files returns **0 / 0**. They exist only inside
`audits/CAMPAIGN-10-two-storage-soak-2026-07-31.md`. This is the identical failure the brief cites
for R-154/R-155 — *"minted in a spike doc and never carried across"* — recurring immediately, on
the very finding this session was commissioned to act on. **R-156 is the reason this work happened
and it is not in the register.** Whoever files the entries below should carry R-156 and R-157
across at the same time.
**And it happened again, to this document, while it was being written.** The first draft of this
section numbered its proposals R-158…R-161 on the strength of a register grep showing R-155 as the
highest filed. Mid-session the parallel session pushed
`audits/SPIKE-recovery-unit-space-2026-08-02.md` and `audits/CAMPAIGN-10-closeout-2026-08-02.md`,
both of which **use R-158** for an unrelated finding (recovery-unit space ceiling on mp1) — and
neither adds it to `ROADMAP.md` or `OPEN-ITEMS.md`, which still stop at R-155. So a grep of the
register was true when it was run and stale within hours, and two sessions minted the same number
for different findings on the same day.
These proposals are therefore renumbered **R-159…R-162**, and the number to claim should be taken
at filing time, not at drafting time. The register being the only source of truth is precisely what
is not happening: **R-156, R-157 and R-158 are all live in audit documents and none of them is
filed.**
### R-159 — wishlist's data lands in an ANONYMOUS volume: never backed up, orphaned by a redeploy
**FIXED HERE** (`templates/wishlist/docker-compose.yml`); filed to record the class.
The image declares `VOLUME /usr/src/app/data` and `VOLUME /usr/src/app/uploads`
(`docker image inspect ghcr.io/cmintey/wishlist:v0.66.0` -> `Config.Volumes`). The template mounted
`wishlist_data:/data`**a path the application never writes**. So `prod.db` went into the
anonymous volume docker creates for an image `VOLUME` directive.
**Why this is worse than an ordinary miss: it survives a restart.** `ResolveDockerVolumeNames`
(`felhom-controller/internal/appbackup/appdata.go`) returns `<project>_<name>` only for volumes
**declared in the compose file**, so `DumpAppVolumes` never sees an anonymous one — and
`compose down` + `up` orphans it and mounts a fresh empty one. Meanwhile `wishlist_data` was tarred
nightly as an empty directory. A store that survives a restart, loses on redeploy, and is never in
a backup is harder to notice than papra's, because casual testing finds nothing wrong.
**The class, not just the app:** any image `VOLUME` directive at a path the template does not
explicitly mount creates unbacked-up storage silently. **`immich-server` has one today** at `/data`
(`Config.Volumes = {"/data":{}}`) — empty when measured, so nothing is at risk now, but anything
immich ever writes there is invisible to backup. Suggested standing rule for `REUSE.md`: *a template
must mount every path in its image's `Config.Volumes`, or state why not.* Checkable statically only
if the image is pulled, which is why it lives in the runtime gate.
### R-160 — gramps-web persisted three paths and wrote to none of them
**FIXED HERE** (`templates/gramps-web/docker-compose.yml`).
The template mounted `/app/data`, `/app/media` and `/tmp`; **`/app/data` appears nowhere in the
image's environment** — the application never writes it. Its accounts database
(`GRAMPSWEB_USER_DB_URI=sqlite:////app/users/users.sqlite`) and **its family tree**
(`GRAMPS_DATABASE_PATH=/root/.gramps/grampsdb`) both landed in the container's writable layer.
Upstream's reference compose persists **eight** paths; this template persisted three, one a phantom.
Severity above papra's: papra loses documents a customer uploaded, which they may still hold
elsewhere. gramps-web loses the family tree — the artefact the customer *built inside the app*, of
which no other copy exists by construction.
### R-161 — the gate exists, and nothing automated runs it
The check R-156 asks for now exists (`scripts/check-volume-persistence.py`, §7). Its enforcement
level is **convention**: the catalog repo has no CI of any kind (`.gitea/workflows`, `.github`,
drone/woodpecker — none), so like its two sibling gates it runs when a person runs it.
This project's own record says that is not enough: R-29 has three orphaned gates and one enforced
one, and the enforced one is the only one that has ever stopped anything. Two directions, neither
buildable from this repo:
1. **CI in the catalog repo** — a Gitea Actions runner. Nothing exists to build on today.
2. **Controller-side at sync** — the controller already validates `.felhom.yml` at template load
(`internal/stacks/metadata.go:336-358`) and a persistence assertion could ride the same choke
point. But that needs a runtime probe on a customer box, and a decision about what a failing
sync should do to a customer's working app. **Cross-repo, and a design question, not a task.**
Interim position, recorded in `CLAUDE.md`: run it when a template's `volumes:` block or image tag
changes, and at the start of every catalog campaign.
### R-162 — `docker diff` is the only witness, and its failure mode is quiet
A limitation, not a defect. The gate's power comes from `docker diff` excluding mounted paths, which
makes "in the writable layer" mechanically decidable — an implementation detail of the overlay
storage driver. On a driver where `docker diff` is unsupported or lies, the gate degrades to the
mount-occupancy and writability legs and would not say so. The canary self-test *would* catch it
(the broken canary would stop reporting BROKEN, and the gate would then refuse to report at all),
so it fails closed — but the message it prints would blame the prober rather than the driver.
---
## 9. Teardown — all three layers
Per `felhom.eu/documentation/PROMPT-TEMPLATE.md` §13. Layer 3 is the one that has been missed every
time (`drill-r50`, `sess-c`, `sess-d`), so it is stated explicitly rather than implied.
### Layer 1 — the machine
**DONE.** `pct stop 9301 && pct destroy 9301 --purge``Logical volume "vm-9301-disk-0"
successfully removed` / `purging CT 9301 from related configurations` / rc=0. Verified after:
`pct list` shows only 9201, and `lvs | grep 9301` returns nothing. Evidence: `state/teardown.txt`.
### Layer 2 — the host
`pvesm status` before and after, and the space actually returned.
| | `local-lvm` available (KiB) | used |
|---|---|---|
| before the run began | 258 702 410 | 29.27 % |
| end of run, before teardown | 169 968 909 | 53.53 % |
| **after teardown** | **258 702 410** | **29.27 %** |
**The space returned exactly to the pre-run baseline** — 258 702 410 KiB, identical to the figure
recorded before the guest was created. `local` is unchanged at 26.00 %. Full before/after:
`state/teardown.txt`.
The guest's 120 G volume was thin-provisioned on an **over-subscribed** pool (`pct create` warned:
sum of thin volume sizes 402 G against a 348 G pool), which is why a disk watchdog ran throughout
(`state/diskwatch.log`) and why the space actually returning is measured rather than assumed.
### Layer 3 — the hub
**Nothing to remove: this run created no hub record of any kind, by design.**
That is an absence of *creation*, not an absence of checking. The scratch guest ran **no
felhom-controller** and was never enrolled (§1), precisely so no customer or appliance record could
exist to orphan. The hub was touched **read-only** throughout — `/apps`, `/apps/papra`, `/configs`,
`/hosts`, `/hosts/<id>`, over the ClusterIP with Basic auth — to answer "is papra deployed
anywhere?" (§6.1).
Confirmed after teardown against the live hub: `/configs` contains no entry for this run
(`catalog-persist` / `9301`: absent), and `/hosts` lists only `demo-felhom-8363b5`,
`demo-hp-bb76ea`, `drill-r50-0a4f9a`, `sess-f-2670b5`. The list is **two shorter** than at the
start of the run — `c10-soak` was removed by the other session mid-run, not by this one (§6.1a).
The scratch host paths the gate creates for `${HDD_PATH}` / `${IMPORT_PATH}` (`/srv/felhom-gate/…`)
and the `felhom-volgate-canary:1` image existed only inside the guest, so both go with layer 1.
---
## 10. What did not happen, and why
- **papra was not fixed.** It is deployed, and the fix would destroy the data it protects (§6.1).
Prepared, proven, held. The brief says stop rather than ship a migration; no migration was written.
- **No data migration of any kind was shipped**, for papra or anything else.
- **No UNDETERMINED app was fixed.** Seven remain undecided (§4.2) and none was nudged toward CLEAN
to tidy the table. Four need a deeper exercise than a first-boot HTTP request (a completed setup
wizard, a posted paste, a real import); three could not be started at all.
- **`glance` was not fixed** even though its defect is understood (it crash-loops for want of a
seeded `/app/config/glance.yml`). It is a deployability defect, not a persistence one, already
recorded as pre-existing in Campaign 7 §6.2 — outside this brief's scope.
- **`plant-it` was not touched.** Its image does not resolve; it is `lifecycle: abandoned` for
exactly that reason.
- **Nothing outside the catalog templates was changed.** The controller-side observations
(`ResolveDockerVolumeNames` cannot see anonymous volumes; `ParseComposeClassifiableBinds` cannot
see named volumes at all) are findings for §8, not edits.
- **No commit to `felhom.eu`, no register edit, nothing on `demo-hp`.** The other session owns all
three. Every R-number in §8 is proposed text.
- **No hub-side state was created.** The hub was read only — `/apps`, `/configs`, `/hosts`,
`/apps/papra` — via the ClusterIP with Basic auth. Nothing to clean up, so nothing recorded as a
command (R-131's four orphans).
- **Guest 9201 on `demo-felhom` was read, not touched** — `docker ps -a` and `docker volume ls`,
to establish that papra is not deployed there.
- **`claude-in-chrome` was not used** — unavailable on DooPlex. Every observation is endpoint-level
or filesystem-level, and each is named where it is used.
- **No secret value was written anywhere.** The probe records env var KEYS only; generated
credentials are minted per-run and discarded with the container.
---
@@ -0,0 +1,54 @@
{
"probe": {
"app": "actualbudget",
"containers": [
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "actualbudget/actual-server:26.7.0",
"mounts": [
{
"class": "named-declared",
"files": 2,
"name": "volgate-actualbudget_actualbudget_data",
"sample": [
".migrate",
"server-files/account.sqlite"
],
"source": "/var/lib/docker/volumes/volgate-actualbudget_actualbudget_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "actualbudget",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"actualbudget_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,222 @@
{
"probe": {
"app": "adventurelog",
"containers": [
{
"diff_added": 8,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/",
"files": [
"run",
"var",
"code"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/var",
"files": [
"lib"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/var/lib",
"files": [
"nginx"
]
}
],
"diff_token_dirs": [],
"diff_total": 14,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/seanmorley15/adventurelog-backend:v0.12.1",
"mounts": [
{
"class": "named-declared",
"files": 251,
"name": "volgate-adventurelog_adventurelog_media",
"sample": [
"countries+regions+states-v3.1.json",
"flags/ca.png",
"flags/gg.png",
"flags/at.png",
"flags/ck.png",
"flags/eh.png",
"flags/qa.png",
"flags/lc.png",
"flags/io.png",
"flags/es.png",
"flags/gf.png",
"flags/tz.png",
"flags/mx.png",
"flags/vi.png",
"flags/tw.png",
"flags/il.png",
"flags/mk.png",
"flags/bo.png",
"flags/sa.png",
"flags/ly.png",
"flags/bb.png",
"flags/kg.png",
"flags/hr.png",
"flags/tn.png",
"flags/bj.png",
"flags/sh.png",
"flags/eg.png",
"flags/kw.png",
"flags/gn.png",
"flags/vg.png",
"flags/af.png",
"flags/jm.png",
"flags/ug.png",
"flags/je.png",
"flags/ms.png",
"flags/nl.png",
"flags/lv.png",
"flags/pg.png",
"flags/lr.png",
"flags/bl.png"
],
"source": "/var/lib/docker/volumes/volgate-adventurelog_adventurelog_media/_data",
"target": "/code/media",
"writable_by_app": "yes"
}
],
"name": "adventurelog",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 65532,
"health": "healthy",
"image": "ghcr.io/seanmorley15/adventurelog-frontend:v0.12.1",
"mounts": [],
"name": "adventurelog-frontend",
"restarts": null,
"status": "running",
"uid": 65532
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 70,
"health": "healthy",
"image": "postgis/postgis:16-3.5-alpine",
"mounts": [
{
"class": "named-declared",
"files": 2287,
"name": "volgate-adventurelog_adventurelog_postgres_data",
"sample": [
"postmaster.opts",
"postgresql.conf",
"postgresql.auto.conf",
"pg_ident.conf",
"pg_hba.conf",
"postmaster.pid",
"PG_VERSION",
"base/1/826",
"base/1/2996",
"base/1/3764",
"base/1/2610_fsm",
"base/1/3603_fsm",
"base/1/1249_vm",
"base/1/3600_fsm",
"base/1/13512",
"base/1/3602",
"base/1/6228",
"base/1/4174",
"base/1/4157",
"base/1/4146",
"base/1/2611",
"base/1/2657",
"base/1/3079_vm",
"base/1/1247_vm",
"base/1/1259",
"base/1/3601_fsm",
"base/1/1417",
"base/1/4155",
"base/1/2668",
"base/1/2831",
"base/1/2841",
"base/1/3607",
"base/1/2836",
"base/1/1255_fsm",
"base/1/2615_vm",
"base/1/3575",
"base/1/2605_vm",
"base/1/2670",
"base/1/6106",
"base/1/4163"
],
"source": "/var/lib/docker/volumes/volgate-adventurelog_adventurelog_postgres_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "adventurelog-postgres",
"restarts": null,
"status": "running",
"uid": 70
}
],
"declared_volumes": [
"adventurelog_media",
"adventurelog_postgres_data"
],
"env_keys": [
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SECRET_KEY",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,116 @@
{
"probe": {
"app": "audiobookshelf",
"containers": [
{
"diff_added": 4,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"metadata",
"audiobooks",
"podcasts",
"config"
],
"changed_count": 0,
"db_signature": false,
"dir": "/",
"files": [
"metadata",
"audiobooks",
"podcasts",
"config"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/advplyr/audiobookshelf:2.35.1",
"mounts": [
{
"class": "named-declared",
"files": 16,
"name": "volgate-audiobookshelf_audiobookshelf_config",
"sample": [
"absdatabase.sqlite",
"migrations/v2.17.5-remove-host-from-feed-urls.js",
"migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js",
"migrations/v2.17.0-uuid-replacement.js",
"migrations/v2.19.1-copy-title-to-library-items.js",
"migrations/v2.15.0-series-column-unique.js",
"migrations/v2.19.4-improve-podcast-queries.js",
"migrations/v2.26.0-create-auth-tables.js",
"migrations/v2.33.0-add-discover-query-indexes.js",
"migrations/v2.17.7-add-indices.js",
"migrations/v2.35.0-add-last-refresh-token.js",
"migrations/v2.20.0-improve-author-sort-queries.js",
"migrations/v2.17.6-share-add-isdownloadable.js",
"migrations/v2.15.1-reindex-nocase.js",
"migrations/v2.17.3-fk-constraints.js",
"migrations/v2.15.2-index-creation.js"
],
"source": "/var/lib/docker/volumes/volgate-audiobookshelf_audiobookshelf_config/_data",
"target": "/config",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 1,
"name": "volgate-audiobookshelf_audiobookshelf_metadata",
"sample": [
"logs/daily/2026-08-02.txt"
],
"source": "/var/lib/docker/volumes/volgate-audiobookshelf_audiobookshelf_metadata/_data",
"target": "/metadata",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/hdd/userdata/media/audiobooks",
"target": "/audiobooks",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/hdd/userdata/media/podcasts",
"target": "/podcasts",
"writable_by_app": "yes"
}
],
"name": "audiobookshelf",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"audiobookshelf_config",
"audiobookshelf_metadata"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,70 @@
{
"probe": {
"app": "bentopdf",
"containers": [
{
"diff_added": 6,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"var",
"etc"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc",
"files": [
"nginx"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/var",
"files": [
"cache"
]
}
],
"diff_token_dirs": [],
"diff_total": 14,
"diff_unresolved": [],
"exit": 0,
"gid": 101,
"health": "healthy",
"image": "ghcr.io/alam00000/bentopdf:v2.8.6",
"mounts": [],
"name": "bentopdf",
"restarts": null,
"status": "running",
"uid": 101
}
],
"declared_volumes": [],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"nothing was written to any mount and nothing data-classified in any writable layer \u2014 the app produced no data to locate. Health is not data: this is UNDETERMINED, not CLEAN"
],
"verdict": "UNDETERMINED",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,246 @@
{
"probe": {
"app": "bookstack",
"containers": [
{
"diff_added": 247,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 6,
"db_signature": false,
"dir": "/",
"files": [
"app",
"etc",
"usr",
"defaults",
"run",
"var"
]
},
{
"added": [],
"changed_count": 5,
"db_signature": false,
"dir": "/etc",
"files": [
"passwd-",
"group-",
"crontabs",
"logrotate.d",
"s6-overlay"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/crontabs",
"files": [
"root"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/s6-overlay",
"files": [
"s6-rc.d"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/s6-overlay/s6-rc.d",
"files": [
"init-adduser"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/s6-overlay/s6-rc.d/init-adduser",
"files": [
"branding"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"lib"
]
},
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/var",
"files": [
"lib",
"log"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/var/lib",
"files": [
"nginx"
]
}
],
"diff_token_dirs": [],
"diff_total": 277,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "lscr.io/linuxserver/bookstack:26.05.2",
"mounts": [
{
"class": "named-declared",
"files": 20,
"name": "volgate-bookstack_bookstack_config",
"sample": [
".migrations",
"log/php/error.log",
"log/nginx/access.log",
"log/nginx/error.log",
"keys/cert.key",
"keys/cert.crt",
"php/www2.conf",
"php/php-local.ini",
"www/.env",
"www/index.html",
"www/framework/sessions/t9vuOg4XNVgyKgaD1xXXg5Bums6HCmTSiBGnhjPu",
"www/framework/sessions/GTO4GD6UJW2fK5ABAye7PVZKui376YILkTqU0I0e",
"nginx/resolver.conf",
"nginx/worker_processes.conf",
"nginx/nginx.conf.sample",
"nginx/nginx.conf",
"nginx/ssl.conf.sample",
"nginx/ssl.conf",
"nginx/site-confs/default.conf",
"nginx/site-confs/default.conf.sample"
],
"source": "/var/lib/docker/volumes/volgate-bookstack_bookstack_config/_data",
"target": "/config",
"writable_by_app": "yes"
}
],
"name": "bookstack",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 999,
"health": "healthy",
"image": "mariadb:12.3",
"mounts": [
{
"class": "named-declared",
"files": 291,
"name": "volgate-bookstack_bookstack_db_data",
"sample": [
"undo002",
"tc.log",
"mariadb_upgrade_info",
"ibtmp1",
"aria_log.00000001",
"multi-master.info",
"ddl_recovery.log",
"ib_logfile0",
"undo003",
"ibdata1",
".my-healthcheck.cnf",
"aria_log_control",
"undo001",
"ib_buffer_pool",
"bookstack/api_tokens.ibd",
"bookstack/password_resets.ibd",
"bookstack/entity_page_data.frm",
"bookstack/settings.ibd",
"bookstack/email_confirmations.ibd",
"bookstack/views.ibd",
"bookstack/references.ibd",
"bookstack/attachments.frm",
"bookstack/entity_container_data.frm",
"bookstack/deletions.ibd",
"bookstack/permission_role.frm",
"bookstack/favourites.ibd",
"bookstack/deletions.frm",
"bookstack/page_revisions.ibd",
"bookstack/user_invites.frm",
"bookstack/joint_permissions.ibd",
"bookstack/bookshelves_books.frm",
"bookstack/activities.frm",
"bookstack/entity_container_data.ibd",
"bookstack/comments.ibd",
"bookstack/entity_permissions.frm",
"bookstack/email_confirmations.frm",
"bookstack/password_resets.frm",
"bookstack/settings.frm",
"bookstack/cache.ibd",
"bookstack/attachments.ibd"
],
"source": "/var/lib/docker/volumes/volgate-bookstack_bookstack_db_data/_data",
"target": "/var/lib/mysql",
"writable_by_app": "yes"
}
],
"name": "bookstack-db",
"restarts": null,
"status": "running",
"uid": 999
}
],
"declared_volumes": [
"bookstack_config",
"bookstack_db_data"
],
"env_keys": [
"APP_KEY",
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,177 @@
{
"probe": {
"app": "claper",
"containers": [
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"app"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"priv"
]
},
{
"added": [
"static"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app/priv",
"files": [
"static"
]
},
{
"added": [
"uploads"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app/priv/static",
"files": [
"uploads"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/claperco/claper:2.5",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-claper_claper_uploads",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-claper_claper_uploads/_data",
"target": "/app/priv/static/uploads",
"writable_by_app": "yes"
}
],
"name": "claper",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 70,
"health": "healthy",
"image": "postgres:16-alpine",
"mounts": [
{
"class": "named-declared",
"files": 1470,
"name": "volgate-claper_claper_postgres_data",
"sample": [
"postmaster.opts",
"postgresql.conf",
"postgresql.auto.conf",
"pg_ident.conf",
"pg_hba.conf",
"postmaster.pid",
"PG_VERSION",
"base/1/826",
"base/1/2996",
"base/1/3764",
"base/1/2610_fsm",
"base/1/3603_fsm",
"base/1/1249_vm",
"base/1/3600_fsm",
"base/1/13512",
"base/1/3602",
"base/1/6228",
"base/1/4174",
"base/1/4157",
"base/1/4146",
"base/1/2611",
"base/1/2657",
"base/1/3079_vm",
"base/1/1247_vm",
"base/1/1259",
"base/1/3601_fsm",
"base/1/1417",
"base/1/4155",
"base/1/2668",
"base/1/2831",
"base/1/2841",
"base/1/3607",
"base/1/2836",
"base/1/1255_fsm",
"base/1/2615_vm",
"base/1/3575",
"base/1/2605_vm",
"base/1/2670",
"base/1/6106",
"base/1/4163"
],
"source": "/var/lib/docker/volumes/volgate-claper_claper_postgres_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "claper-postgres",
"restarts": null,
"status": "running",
"uid": 70
}
],
"declared_volumes": [
"claper_postgres_data",
"claper_uploads"
],
"env_keys": [
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SECRET_KEY_BASE",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"claper: this container's mounts are all empty while it created entries in ['/app/priv', '/app/priv/static'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"claper: declared volume /app/priv/static/uploads is EMPTY"
],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,110 @@
{
"probe": {
"app": "code-server",
"containers": [
{
"diff_added": 196,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 4,
"db_signature": false,
"dir": "/",
"files": [
"run",
"app",
"etc",
"defaults"
]
},
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/etc",
"files": [
"passwd-",
"group-",
"s6-overlay"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/s6-overlay",
"files": [
"s6-rc.d"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/s6-overlay/s6-rc.d",
"files": [
"init-adduser"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/s6-overlay/s6-rc.d/init-adduser",
"files": [
"branding"
]
}
],
"diff_token_dirs": [],
"diff_total": 208,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "lscr.io/linuxserver/code-server:4.129.0",
"mounts": [
{
"class": "named-declared",
"files": 6,
"name": "volgate-codeserver_codeserver_config",
"sample": [
".profile",
".bashrc",
".config/code-server/config.yaml",
"data/code-server-ipc.sock",
".local/share/code-server/coder-logs/code-server-stderr.log",
".local/share/code-server/coder-logs/code-server-stdout.log"
],
"source": "/var/lib/docker/volumes/volgate-codeserver_codeserver_config/_data",
"target": "/config",
"writable_by_app": "yes"
}
],
"name": "code-server",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"codeserver_config"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"PASSWORD",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,217 @@
{
"probe": {
"app": "docmost",
"containers": [
{
"diff_added": 1,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"app"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"apps"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/apps",
"files": [
"client"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/apps/client",
"files": [
"dist"
]
},
{
"added": [
"index-template.html"
],
"changed_count": 1,
"db_signature": false,
"dir": "/app/apps/client/dist",
"files": [
"index-template.html"
]
}
],
"diff_token_dirs": [],
"diff_total": 6,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "docmost/docmost:0.95.0",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-docmost_docmost_storage",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-docmost_docmost_storage/_data",
"target": "/app/data/storage",
"writable_by_app": "yes"
}
],
"name": "docmost",
"restarts": null,
"status": "running",
"uid": 1000
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 70,
"health": "healthy",
"image": "postgres:16-alpine",
"mounts": [
{
"class": "named-declared",
"files": 1540,
"name": "volgate-docmost_docmost_postgres_data",
"sample": [
"postmaster.opts",
"postgresql.conf",
"postgresql.auto.conf",
"pg_ident.conf",
"pg_hba.conf",
"postmaster.pid",
"PG_VERSION",
"base/1/826",
"base/1/2996",
"base/1/3764",
"base/1/2610_fsm",
"base/1/3603_fsm",
"base/1/1249_vm",
"base/1/3600_fsm",
"base/1/13512",
"base/1/3602",
"base/1/6228",
"base/1/4174",
"base/1/4157",
"base/1/4146",
"base/1/2611",
"base/1/2657",
"base/1/3079_vm",
"base/1/1247_vm",
"base/1/1259",
"base/1/3601_fsm",
"base/1/1417",
"base/1/4155",
"base/1/2668",
"base/1/2831",
"base/1/2841",
"base/1/3607",
"base/1/2836",
"base/1/1255_fsm",
"base/1/2615_vm",
"base/1/3575",
"base/1/2605_vm",
"base/1/2670",
"base/1/6106",
"base/1/4163"
],
"source": "/var/lib/docker/volumes/volgate-docmost_docmost_postgres_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "docmost-postgres",
"restarts": null,
"status": "running",
"uid": 70
},
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "redis:7-alpine",
"mounts": [
{
"class": "named-declared",
"files": 3,
"name": "volgate-docmost_docmost_redis_data",
"sample": [
"appendonlydir/appendonly.aof.1.base.rdb",
"appendonlydir/appendonly.aof.1.incr.aof",
"appendonlydir/appendonly.aof.manifest"
],
"source": "/var/lib/docker/volumes/volgate-docmost_docmost_redis_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "docmost-redis",
"restarts": null,
"status": "running",
"uid": 999
}
],
"declared_volumes": [
"docmost_postgres_data",
"docmost_redis_data",
"docmost_storage"
],
"env_keys": [
"APP_SECRET",
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"docmost: this container's mounts are all empty while it created entries in ['/app/apps/client/dist'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"docmost: declared volume /app/data/storage is EMPTY"
],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,119 @@
{
"probe": {
"app": "emby",
"containers": [
{
"diff_added": 205,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"config",
"media"
],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"config",
"media"
]
}
],
"diff_token_dirs": [],
"diff_total": 207,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "emby/embyserver:4.10.0.20",
"mounts": [
{
"class": "named-declared",
"files": 46,
"name": "volgate-emby_emby_config",
"sample": [
"cache/ffmpeg/filters_4.10.0.20.txt",
"cache/ffmpeg/version_4.10.0.20.txt",
"cache/ffmpeg/decoders_4.10.0.20.txt",
"cache/ffmpeg/encoders_4.10.0.20.txt",
"cache/ffmpeg/protocols_4.10.0.20.txt",
"cache/ffmpeg/hwaccels_4.10.0.20.txt",
"cache/httpclient/0fb243ded9649ae6d18059c674a4ae00_1",
"config/system.xml",
"config/users/a7acc67887ba440f9db1312c97475098/policy.xml",
"plugins/DvdMounter.dll",
"plugins/OMDb.dll",
"plugins/Emby.PortMapper.dll",
"plugins/EmbyGuideData.dll",
"plugins/MusicBrainz.dll",
"plugins/AudioDb.dll",
"plugins/MovieDb.dll",
"plugins/Emby.Server.CinemaMode.dll",
"plugins/Emby.M3UTuner.dll",
"plugins/Emby.Dlna.dll",
"plugins/Fanart.dll",
"plugins/Tvdb.dll",
"plugins/NfoMetadata.dll",
"plugins/StudioImages.dll",
"plugins/Emby.Webhooks.dll",
"plugins/MBBackup.dll",
"plugins/OpenSubtitles.dll",
"plugins/Emby.XmlTV.dll",
"plugins/BlurayMounter.dll",
"data/lastversion.txt",
"data/wan.dat",
"data/library.db",
"data/authentication.db-wal",
"data/device.txt",
"data/activitylog.db-shm",
"data/authentication.db",
"data/users.db-shm",
"data/library.db-shm",
"data/users.db-wal",
"data/authentication.db-shm",
"data/activitylog.db"
],
"source": "/var/lib/docker/volumes/volgate-emby_emby_config/_data",
"target": "/config",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 3,
"name": null,
"sample": [
"books/metadata.db",
"books/metadata.db-shm",
"books/metadata.db-wal"
],
"source": "/srv/felhom-gate/hdd/userdata/media",
"target": "/media",
"writable_by_app": "yes"
}
],
"name": "emby",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"emby_config"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,59 @@
{
"probe": {
"app": "ghost",
"containers": [
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "ghost:6.53.0-alpine",
"mounts": [
{
"class": "named-declared",
"files": 7,
"name": "volgate-ghost_ghost_content",
"sample": [
"themes/casper",
"themes/source",
"data/ghost.db",
"logs/https___blog_gate_invalid_production.error.log",
"logs/https___blog_gate_invalid_production.log",
"public/admin-auth/index.html",
"public/.well-known/recommendations.json"
],
"source": "/var/lib/docker/volumes/volgate-ghost_ghost_content/_data",
"target": "/var/lib/ghost/content",
"writable_by_app": "yes"
}
],
"name": "ghost",
"restarts": null,
"status": "running",
"uid": 1000
}
],
"declared_volumes": [
"ghost_content"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,193 @@
{
"probe": {
"app": "gitea",
"containers": [
{
"diff_added": 16,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"data"
],
"changed_count": 3,
"db_signature": false,
"dir": "/",
"files": [
"data"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"gitea"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/gitea",
"files": [
"gitea"
]
},
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/etc",
"files": [
"ssh",
"s6"
]
},
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/etc/s6",
"files": [
"openssh",
".s6-svscan",
"gitea"
]
},
{
"added": [
"control",
"lock"
],
"changed_count": 0,
"db_signature": false,
"dir": "/etc/s6/.s6-svscan",
"files": [
"control",
"lock"
]
},
{
"added": [
"supervise",
"event"
],
"changed_count": 0,
"db_signature": false,
"dir": "/etc/s6/gitea",
"files": [
"supervise",
"event"
]
},
{
"added": [
"lock",
"status",
"control",
"death_tally"
],
"changed_count": 0,
"db_signature": false,
"dir": "/etc/s6/gitea/supervise",
"files": [
"lock",
"status",
"control",
"death_tally"
]
},
{
"added": [
"event",
"supervise"
],
"changed_count": 0,
"db_signature": false,
"dir": "/etc/s6/openssh",
"files": [
"event",
"supervise"
]
},
{
"added": [
"death_tally",
"lock",
"status",
"control"
],
"changed_count": 0,
"db_signature": false,
"dir": "/etc/s6/openssh/supervise",
"files": [
"death_tally",
"lock",
"status",
"control"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/ssh",
"files": [
"sshd_config"
]
}
],
"diff_token_dirs": [],
"diff_total": 27,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "gitea/gitea:1.27.0",
"mounts": [
{
"class": "named-declared",
"files": 8,
"name": "volgate-gitea_gitea_data",
"sample": [
"git/.ssh/environment",
"gitea/conf/app.ini",
"ssh/ssh_host_ecdsa_key.pub",
"ssh/ssh_host_ed25519_key",
"ssh/ssh_host_ed25519_key.pub",
"ssh/ssh_host_ecdsa_key",
"ssh/ssh_host_rsa_key",
"ssh/ssh_host_rsa_key.pub"
],
"source": "/var/lib/docker/volumes/volgate-gitea_gitea_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "gitea",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"gitea_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,74 @@
{
"probe": {
"app": "glance",
"containers": [
{
"diff_added": 1,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"app"
]
},
{
"added": [
"config"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app",
"files": [
"config"
]
}
],
"diff_token_dirs": [],
"diff_total": 2,
"diff_unresolved": [],
"exit": 1,
"gid": null,
"health": "unhealthy",
"image": "glanceapp/glance:v0.8.5",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-glance_glance_config",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-glance_glance_config/_data",
"target": "/app/config",
"writable_by_app": "unknown-uid"
}
],
"name": "glance",
"restarts": null,
"status": "restarting",
"uid": null
}
],
"declared_volumes": [
"glance_config"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"glance: not running (status=restarting exit=1 restarts=None)"
],
"verdict": "UNDETERMINED",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,92 @@
{
"probe": {
"app": "gokapi",
"containers": [
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"app"
]
},
{
"added": [
"config",
"data"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app",
"files": [
"config",
"data"
]
}
],
"diff_token_dirs": [],
"diff_total": 3,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "f0rc3/gokapi:v1.9.6",
"mounts": [
{
"class": "named-declared",
"files": 4,
"name": "volgate-gokapi_gokapi_data",
"sample": [
"log.txt",
"gokapi.sqlite-shm",
"gokapi.sqlite",
"gokapi.sqlite-wal"
],
"source": "/var/lib/docker/volumes/volgate-gokapi_gokapi_data/_data",
"target": "/app/data",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 1,
"name": "volgate-gokapi_gokapi_config",
"sample": [
"config.json"
],
"source": "/var/lib/docker/volumes/volgate-gokapi_gokapi_config/_data",
"target": "/app/config",
"writable_by_app": "yes"
}
],
"name": "gokapi",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"gokapi_config",
"gokapi_data"
],
"env_keys": [
"DOMAIN",
"GOKAPI_PASSWORD",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,93 @@
{
"probe": {
"app": "grafana",
"containers": [
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "grafana/grafana:13.1.0",
"mounts": [
{
"class": "named-declared",
"files": 543,
"name": "volgate-grafana_grafana_data",
"sample": [
"grafana.db",
"plugins/grafana-exploretraces-app/775.js.map",
"plugins/grafana-exploretraces-app/892.js",
"plugins/grafana-exploretraces-app/102.js",
"plugins/grafana-exploretraces-app/892.js.map",
"plugins/grafana-exploretraces-app/926.js",
"plugins/grafana-exploretraces-app/14.js",
"plugins/grafana-exploretraces-app/766.js.map",
"plugins/grafana-exploretraces-app/941.js",
"plugins/grafana-exploretraces-app/4.js",
"plugins/grafana-exploretraces-app/153.js",
"plugins/grafana-exploretraces-app/145.js",
"plugins/grafana-exploretraces-app/304.js",
"plugins/grafana-exploretraces-app/477.js.map",
"plugins/grafana-exploretraces-app/54.js",
"plugins/grafana-exploretraces-app/914.js.map",
"plugins/grafana-exploretraces-app/903.js",
"plugins/grafana-exploretraces-app/24.js",
"plugins/grafana-exploretraces-app/638.js",
"plugins/grafana-exploretraces-app/24.js.map",
"plugins/grafana-exploretraces-app/24.js.LICENSE.txt",
"plugins/grafana-exploretraces-app/257.js",
"plugins/grafana-exploretraces-app/315.js",
"plugins/grafana-exploretraces-app/397.js",
"plugins/grafana-exploretraces-app/453.js",
"plugins/grafana-exploretraces-app/980.js",
"plugins/grafana-exploretraces-app/944c737f589d02ecf603.svg",
"plugins/grafana-exploretraces-app/485.js",
"plugins/grafana-exploretraces-app/plugin.json",
"plugins/grafana-exploretraces-app/477.js",
"plugins/grafana-exploretraces-app/317.js",
"plugins/grafana-exploretraces-app/276.js",
"plugins/grafana-exploretraces-app/196.js",
"plugins/grafana-exploretraces-app/193.js",
"plugins/grafana-exploretraces-app/397.js.map",
"plugins/grafana-exploretraces-app/684.js",
"plugins/grafana-exploretraces-app/531.js",
"plugins/grafana-exploretraces-app/499.js",
"plugins/grafana-exploretraces-app/276.js.map",
"plugins/grafana-exploretraces-app/LICENSE"
],
"source": "/var/lib/docker/volumes/volgate-grafana_grafana_data/_data",
"target": "/var/lib/grafana",
"writable_by_app": "yes"
}
],
"name": "grafana",
"restarts": null,
"status": "running",
"uid": 472
}
],
"declared_volumes": [
"grafana_data"
],
"env_keys": [
"DOMAIN",
"GF_SECURITY_ADMIN_PASSWORD",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,266 @@
{
"probe": {
"app": "gramps-web",
"containers": [
{
"diff_added": 127,
"diff_benign_db_touches": [],
"diff_data_dirs": [
{
"added": [
"users.sqlite"
],
"changed_count": 0,
"db_signature": true,
"dir": "/app/users",
"files": [
"users.sqlite"
]
}
],
"diff_other_dirs": [
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/",
"files": [
"usr",
"app",
"root"
]
},
{
"added": [
"data"
],
"changed_count": 4,
"db_signature": false,
"dir": "/app",
"files": [
"data"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/cache",
"files": [
"request_cache"
]
},
{
"added": [
"2029240f6d1128be89ddc32729463129"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app/cache/request_cache",
"files": [
"2029240f6d1128be89ddc32729463129"
]
},
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/app/src",
"files": [
"alembic_users",
"gramps_webapi"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/src/alembic_users",
"files": [
"versions"
]
},
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/app/src/gramps_webapi",
"files": [
"api",
"auth",
"util"
]
},
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/app/src/gramps_webapi/api",
"files": [
"resources",
"search"
]
},
{
"added": [
"2029240f6d1128be89ddc32729463129"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app/thumbnail_cache",
"files": [
"2029240f6d1128be89ddc32729463129"
]
},
{
"added": [
".gramps"
],
"changed_count": 1,
"db_signature": false,
"dir": "/root",
"files": [
".gramps"
]
},
{
"added": [
"grampsdb"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.gramps",
"files": [
"grampsdb"
]
},
{
"added": [
"61411b9e-fed7-485a-ab76-6e8ea7326c3b"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.gramps/grampsdb",
"files": [
"61411b9e-fed7-485a-ab76-6e8ea7326c3b"
]
},
{
"added": [
"name.txt",
"database.txt"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.gramps/grampsdb/61411b9e-fed7-485a-ab76-6e8ea7326c3b",
"files": [
"name.txt",
"database.txt"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/root/gramps",
"files": [
"gramps60"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/root/gramps/gramps60",
"files": [
"plugins"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/root/gramps/gramps60/plugins",
"files": [
"FilterRules"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"lib"
]
}
],
"diff_token_dirs": [],
"diff_total": 153,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/gramps-project/grampsweb:v25.6.0",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-grampsweb_gramps_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_data/_data",
"target": "/app/data",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-grampsweb_gramps_media",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_media/_data",
"target": "/app/media",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-grampsweb_gramps_tmp",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_tmp/_data",
"target": "/tmp",
"writable_by_app": "yes"
}
],
"name": "gramps-web",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"gramps_data",
"gramps_media",
"gramps_tmp"
],
"env_keys": [
"DOMAIN",
"GRAMPSWEB_SECRET_KEY",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"gramps-web: DATA in the writable layer at /app/users (db_signature=True, e.g. ['users.sqlite'])",
"NOTHING this app wrote landed in ANY folder the template preserves: all 2 mount(s) across 1 container(s) are empty, yet entries were created in ['/app', '/app/cache/request_cache', '/app/thumbnail_cache', '/root']. Needs adjudication.",
"gramps-web: this container's mounts are all empty while it created entries in ['/app', '/app/cache/request_cache', '/app/thumbnail_cache'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"gramps-web: declared volume /app/data is EMPTY",
"gramps-web: declared volume /app/media is EMPTY"
],
"verdict": "BROKEN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,114 @@
{
"probe": {
"app": "home-assistant",
"containers": [
{
"diff_added": 2267,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/",
"files": [
"run",
"root",
"usr"
]
},
{
"added": [
".config"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root",
"files": [
".config"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"local"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/local",
"files": [
"lib"
]
}
],
"diff_token_dirs": [],
"diff_total": 2561,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/home-assistant/home-assistant:2026.7.2",
"mounts": [
{
"class": "named-declared",
"files": 21,
"name": "volgate-homeassistant_homeassistant_config",
"sample": [
"home-assistant.log",
"home-assistant_v2.db-wal",
"configuration.yaml",
"home-assistant.log.fault",
".HA_VERSION",
"home-assistant.log.1",
"home-assistant_v2.db",
"home-assistant_v2.db-shm",
".ha_run.lock",
".storage/core.config_entries",
".storage/core.restore_state",
".storage/auth",
".storage/http.auth",
".storage/assist_pipeline.pipelines",
".storage/core.device_registry",
".storage/homeassistant.exposed_entities",
".storage/core.uuid",
".storage/core.entity_registry",
"blueprints/script/homeassistant/confirmable_notification.yaml",
"blueprints/automation/homeassistant/notify_leaving_zone.yaml",
"blueprints/automation/homeassistant/motion_light.yaml"
],
"source": "/var/lib/docker/volumes/volgate-homeassistant_homeassistant_config/_data",
"target": "/config",
"writable_by_app": "yes"
}
],
"name": "home-assistant",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"homeassistant_config"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,68 @@
{
"probe": {
"app": "homebox",
"containers": [
{
"diff_added": 1,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"data"
],
"changed_count": 0,
"db_signature": false,
"dir": "/",
"files": [
"data"
]
}
],
"diff_token_dirs": [],
"diff_total": 1,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/sysadminsmedia/homebox:0.26.2",
"mounts": [
{
"class": "named-declared",
"files": 3,
"name": "volgate-homebox_homebox_data",
"sample": [
"homebox.db-shm",
"homebox.db",
"homebox.db-wal"
],
"source": "/var/lib/docker/volumes/volgate-homebox_homebox_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "homebox",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"homebox_data"
],
"env_keys": [
"DOMAIN",
"HBOX_AUTH_API_KEY_PEPPER",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,86 @@
{
"probe": {
"app": "homepage",
"containers": [
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"run",
"app"
]
},
{
"added": [
"config"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app",
"files": [
"config"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/gethomepage/homepage:v1.13.2",
"mounts": [
{
"class": "named-declared",
"files": 3,
"name": "volgate-homepage_homepage_config",
"sample": [
"kubernetes.yaml",
"settings.yaml",
"logs/homepage.log"
],
"source": "/var/lib/docker/volumes/volgate-homepage_homepage_config/_data",
"target": "/app/config",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": -1,
"name": null,
"sample": [],
"source": "/var/run/docker.sock",
"target": "/var/run/docker.sock",
"writable_by_app": "no-source"
}
],
"name": "homepage",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"homepage_config"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,330 @@
{
"probe": {
"app": "immich",
"containers": [
{
"diff_added": 6,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"cache"
],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"cache"
]
},
{
"added": [
".config"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root",
"files": [
".config"
]
},
{
"added": [
"matplotlib"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.config",
"files": [
"matplotlib"
]
}
],
"diff_token_dirs": [],
"diff_total": 7,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/immich-app/immich-machine-learning:v3.0.3",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-immich_immich_ml_cache",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-immich_immich_ml_cache/_data",
"target": "/cache",
"writable_by_app": "yes"
}
],
"name": "immich-machine-learning",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 3,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"etc",
"run"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc",
"files": [
"postgresql"
]
},
{
"added": [
"postgresql.conf"
],
"changed_count": 0,
"db_signature": false,
"dir": "/etc/postgresql",
"files": [
"postgresql.conf"
]
}
],
"diff_token_dirs": [],
"diff_total": 7,
"diff_unresolved": [],
"exit": 0,
"gid": 999,
"health": "healthy",
"image": "ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0",
"mounts": [
{
"class": "named-declared",
"files": 1833,
"name": "volgate-immich_immich_postgres_data",
"sample": [
"postmaster.opts",
"postgresql.conf",
"postgresql.auto.conf",
"pg_ident.conf",
"pg_hba.conf",
"postmaster.pid",
"PG_VERSION",
"base/1/826",
"base/1/2996",
"base/1/3764",
"base/1/2610_fsm",
"base/1/3603_fsm",
"base/1/1249_vm",
"base/1/3600_fsm",
"base/1/3602",
"base/1/6228",
"base/1/4174",
"base/1/4157",
"base/1/4146",
"base/1/2611",
"base/1/2657",
"base/1/3079_vm",
"base/1/1247_vm",
"base/1/13406",
"base/1/1259",
"base/1/3601_fsm",
"base/1/1417",
"base/1/4155",
"base/1/2668",
"base/1/2831",
"base/1/2841",
"base/1/13414",
"base/1/3607",
"base/1/2836",
"base/1/1255_fsm",
"base/1/2615_vm",
"base/1/3575",
"base/1/2605_vm",
"base/1/2670",
"base/1/6106"
],
"source": "/var/lib/docker/volumes/volgate-immich_immich_postgres_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "immich-postgres",
"restarts": null,
"status": "running",
"uid": 999
},
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "redis:7-alpine",
"mounts": [
{
"class": "named-declared",
"files": 3,
"name": "volgate-immich_immich_redis_data",
"sample": [
"appendonlydir/appendonly.aof.1.base.rdb",
"appendonlydir/appendonly.aof.1.incr.aof",
"appendonlydir/appendonly.aof.manifest"
],
"source": "/var/lib/docker/volumes/volgate-immich_immich_redis_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "immich-redis",
"restarts": null,
"status": "running",
"uid": 999
},
{
"diff_added": 4,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"data",
"external"
],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"data",
"external"
]
},
{
"added": [
"photos"
],
"changed_count": 0,
"db_signature": false,
"dir": "/external",
"files": [
"photos"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"src"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/src",
"files": [
"app"
]
},
{
"added": [
"upload"
],
"changed_count": 0,
"db_signature": false,
"dir": "/usr/src/app",
"files": [
"upload"
]
}
],
"diff_token_dirs": [],
"diff_total": 7,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/immich-app/immich-server:v3.0.3",
"mounts": [
{
"class": "bind",
"files": 6,
"name": null,
"sample": [
"library/.immich",
"upload/.immich",
"profile/.immich",
"encoded-video/.immich",
"thumbs/.immich",
"backups/.immich"
],
"source": "/srv/felhom-gate/hdd/appdata/immich",
"target": "/usr/src/app/upload",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/hdd/userdata/media/photos",
"target": "/external/photos",
"writable_by_app": "yes"
},
{
"class": "anonymous",
"files": 0,
"name": "8a33371900fd8f325c75733c4486ad225939fc62a80ae67ee333a598a34b665f",
"sample": [],
"source": "/var/lib/docker/volumes/8a33371900fd8f325c75733c4486ad225939fc62a80ae67ee333a598a34b665f/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "immich-server",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"immich_ml_cache",
"immich_postgres_data",
"immich_redis_data"
],
"env_keys": [
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"immich-machine-learning: this container's mounts are all empty while it created entries in ['/', '/root', '/root/.config'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"immich-machine-learning: declared volume /cache is EMPTY"
],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,153 @@
{
"probe": {
"app": "jellyfin",
"containers": [
{
"diff_added": 7,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"jellyfin",
"root"
]
},
{
"added": [
"Dictionaries"
],
"changed_count": 0,
"db_signature": false,
"dir": "/jellyfin",
"files": [
"Dictionaries"
]
},
{
"added": [
".aspnet"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root",
"files": [
".aspnet"
]
},
{
"added": [
"DataProtection-Keys"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.aspnet",
"files": [
"DataProtection-Keys"
]
},
{
"added": [
"key-b1c34edb-e295-4345-a61a-db5f11d4fb0f.xml"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.aspnet/DataProtection-Keys",
"files": [
"key-b1c34edb-e295-4345-a61a-db5f11d4fb0f.xml"
]
}
],
"diff_token_dirs": [],
"diff_total": 10,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "jellyfin/jellyfin:10.11.11",
"mounts": [
{
"class": "named-declared",
"files": 20,
"name": "volgate-jellyfin_jellyfin_config",
"sample": [
".jellyfin-data",
"root/.jellyfin-root",
"log/.jellyfin-log",
"log/log_20260802.log",
"config/logging.default.json",
"config/system.xml",
"config/encoding.xml",
"config/.jellyfin-config",
"config/database.xml",
"plugins/.jellyfin-plugin",
"plugins/configurations/Jellyfin.Plugin.Tmdb.xml",
"plugins/configurations/Jellyfin.Plugin.MusicBrainz.xml",
"data/device.txt",
"data/jellyfin.db-wal",
"data/.jellyfin-data",
"data/jellyfin.db",
"data/jellyfin.db-shm",
"data/ScheduledTasks/7d8088c1-0902-f1bf-4072-ded42437bcfb.js",
"data/ScheduledTasks/3a025083-141d-3c17-dd96-d5f9b951287b.js",
"data/ScheduledTasks/f9b057c0-54e9-e6da-ee4a-88ffd146a403.js"
],
"source": "/var/lib/docker/volumes/volgate-jellyfin_jellyfin_config/_data",
"target": "/config",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 2,
"name": "volgate-jellyfin_jellyfin_cache",
"sample": [
".jellyfin-cache",
"transcodes/.jellyfin-transcode"
],
"source": "/var/lib/docker/volumes/volgate-jellyfin_jellyfin_cache/_data",
"target": "/cache",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 3,
"name": null,
"sample": [
"books/metadata.db",
"books/metadata.db-shm",
"books/metadata.db-wal"
],
"source": "/srv/felhom-gate/hdd/userdata/media",
"target": "/media",
"writable_by_app": "yes"
}
],
"name": "jellyfin",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"jellyfin_cache",
"jellyfin_config"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,259 @@
{
"probe": {
"app": "kimai",
"containers": [
{
"diff_added": 1,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/",
"files": [
"usr",
"opt",
"run"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/opt",
"files": [
"kimai"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/opt/kimai",
"files": [
"config"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/opt/kimai/config",
"files": [
"packages"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/opt/kimai/config/packages",
"files": [
"monolog.yaml"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"local"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/local",
"files": [
"etc"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/local/etc",
"files": [
"php"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/local/etc/php",
"files": [
"php.ini"
]
}
],
"diff_token_dirs": [],
"diff_total": 13,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "kimai/kimai2:apache-2.57.0",
"mounts": [
{
"class": "named-declared",
"files": 2547,
"name": "volgate-kimai_kimai_var",
"sample": [
"installed",
"cache/.gitkeep",
"cache/pro_/url_generating_routes.php",
"cache/pro_/url_matching_routes.php",
"cache/pro_/url_matching_routes.php.meta",
"cache/pro_/url_generating_routes.php.meta",
"cache/pro_/Symfony/Config/TwigExtraConfig.php",
"cache/pro_/Symfony/Config/DoctrineMigrationsConfig.php",
"cache/pro_/Symfony/Config/MonologConfig.php",
"cache/pro_/Symfony/Config/NelmioCorsConfig.php",
"cache/pro_/Symfony/Config/WebpackEncoreConfig.php",
"cache/pro_/Symfony/Config/NelmioApiDocConfig.php",
"cache/pro_/Symfony/Config/TablerConfig.php",
"cache/pro_/Symfony/Config/FrameworkConfig.php",
"cache/pro_/Symfony/Config/SchebTwoFactorConfig.php",
"cache/pro_/Symfony/Config/TwigConfig.php",
"cache/pro_/Symfony/Config/SecurityConfig.php",
"cache/pro_/Symfony/Config/DoctrineConfig.php",
"cache/pro_/Symfony/Config/FosRestConfig.php",
"cache/pro_/Symfony/Config/JmsSerializerConfig.php",
"cache/pro_/Symfony/Config/Tabler/RoutesConfig.php",
"cache/pro_/Symfony/Config/Tabler/KnpMenuConfig.php",
"cache/pro_/Symfony/Config/Tabler/OptionsConfig.php",
"cache/pro_/Symfony/Config/SchebTwoFactor/TotpConfig.php",
"cache/pro_/Symfony/Config/SchebTwoFactor/BackupCodesConfig.php",
"cache/pro_/Symfony/Config/DoctrineMigrations/StorageConfig.php",
"cache/pro_/Symfony/Config/DoctrineMigrations/Storage/TableStorageConfig.php",
"cache/pro_/Symfony/Config/JmsSerializer/VisitorsConfig.php",
"cache/pro_/Symfony/Config/JmsSerializer/InstancesConfig.php",
"cache/pro_/Symfony/Config/JmsSerializer/HandlersConfig.php",
"cache/pro_/Symfony/Config/JmsSerializer/PropertyNamingConfig.php",
"cache/pro_/Symfony/Config/JmsSerializer/MetadataConfig.php",
"cache/pro_/Symfony/Config/JmsSerializer/DefaultContextConfig.php",
"cache/pro_/Symfony/Config/JmsSerializer/ObjectConstructorsConfig.php",
"cache/pro_/Symfony/Config/JmsSerializer/SubscribersConfig.php",
"cache/pro_/Symfony/Config/JmsSerializer/ExpressionEvaluatorConfig.php",
"cache/pro_/Symfony/Config/JmsSerializer/ObjectConstructors/DoctrineConfig.php",
"cache/pro_/Symfony/Config/JmsSerializer/Subscribers/DoctrineProxyConfig.php",
"cache/pro_/Symfony/Config/JmsSerializer/InstancesConfig/VisitorsConfig.php",
"cache/pro_/Symfony/Config/JmsSerializer/InstancesConfig/HandlersConfig.php"
],
"source": "/var/lib/docker/volumes/volgate-kimai_kimai_var/_data",
"target": "/opt/kimai/var",
"writable_by_app": "yes"
}
],
"name": "kimai",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 999,
"health": "healthy",
"image": "mariadb:11.6",
"mounts": [
{
"class": "named-declared",
"files": 279,
"name": "volgate-kimai_kimai_db_data",
"sample": [
"undo002",
"tc.log",
"mariadb_upgrade_info",
"ibtmp1",
"aria_log.00000001",
"multi-master.info",
"ddl_recovery.log",
"ib_logfile0",
"undo003",
"ibdata1",
".my-healthcheck.cnf",
"aria_log_control",
"undo001",
"ib_buffer_pool",
"sys/x@0024wait_classes_global_by_latency.frm",
"sys/processlist.frm",
"sys/session.frm",
"sys/memory_by_host_by_current_bytes.frm",
"sys/user_summary.frm",
"sys/waits_global_by_latency.frm",
"sys/x@0024statement_analysis.frm",
"sys/x@0024host_summary.frm",
"sys/schema_unused_indexes.frm",
"sys/user_summary_by_file_io_type.frm",
"sys/memory_by_thread_by_current_bytes.frm",
"sys/x@0024statements_with_full_table_scans.frm",
"sys/wait_classes_global_by_avg_latency.frm",
"sys/x@0024schema_table_statistics.frm",
"sys/x@0024statements_with_temp_tables.frm",
"sys/schema_auto_increment_columns.frm",
"sys/x@0024ps_schema_table_statistics_io.frm",
"sys/x@0024memory_by_host_by_current_bytes.frm",
"sys/statements_with_runtimes_in_95th_percentile.frm",
"sys/x@0024statements_with_errors_or_warnings.frm",
"sys/waits_by_user_by_latency.frm",
"sys/x@0024io_global_by_file_by_bytes.frm",
"sys/innodb_buffer_stats_by_table.frm",
"sys/user_summary_by_statement_type.frm",
"sys/statements_with_errors_or_warnings.frm",
"sys/sys_config.MAD"
],
"source": "/var/lib/docker/volumes/volgate-kimai_kimai_db_data/_data",
"target": "/var/lib/mysql",
"writable_by_app": "yes"
}
],
"name": "kimai-db",
"restarts": null,
"status": "running",
"uid": 999
}
],
"declared_volumes": [
"kimai_db_data",
"kimai_var"
],
"env_keys": [
"ADMIN_EMAIL",
"ADMIN_PASSWORD",
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,86 @@
{
"probe": {
"app": "komga",
"containers": [
{
"diff_added": 12,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"data",
"config"
],
"changed_count": 0,
"db_signature": false,
"dir": "/",
"files": [
"data",
"config"
]
}
],
"diff_token_dirs": [],
"diff_total": 13,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "gotson/komga:1.25.0",
"mounts": [
{
"class": "named-declared",
"files": 11,
"name": "volgate-komga_komga_config",
"sample": [
"database.sqlite-shm",
"database.sqlite-wal",
"tasks.sqlite-shm",
"database.sqlite",
"tasks.sqlite-wal",
"tasks.sqlite",
"lucene/_0.cfe",
"lucene/_0.si",
"lucene/segments_1",
"lucene/_0.cfs",
"logs/komga.log"
],
"source": "/var/lib/docker/volumes/volgate-komga_komga_config/_data",
"target": "/config",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/hdd/userdata/media/comics",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "komga",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"komga_config"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,89 @@
{
"probe": {
"app": "mealie",
"containers": [
{
"diff_added": 1,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"etc",
"app"
]
},
{
"added": [
"data"
],
"changed_count": 2,
"db_signature": false,
"dir": "/app",
"files": [
"data"
]
},
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/etc",
"files": [
"passwd-",
"group-"
]
}
],
"diff_token_dirs": [],
"diff_total": 9,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "ghcr.io/mealie-recipes/mealie:v3.20.1",
"mounts": [
{
"class": "named-declared",
"files": 5,
"name": "volgate-mealie_mealie_data",
"sample": [
".session_secret",
"mealie.db",
"mealie.log",
".secret",
"users/6b1f2b18-68e0-468f-9db1-0431546240bd/profile.webp"
],
"source": "/var/lib/docker/volumes/volgate-mealie_mealie_data/_data",
"target": "/app/data",
"writable_by_app": "yes"
}
],
"name": "mealie",
"restarts": null,
"status": "running",
"uid": 1000
}
],
"declared_volumes": [
"mealie_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,79 @@
{
"probe": {
"app": "n8n",
"containers": [
{
"diff_added": 1094,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"home"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/home",
"files": [
"node"
]
}
],
"diff_token_dirs": [],
"diff_total": 1097,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "n8nio/n8n:2.31.3",
"mounts": [
{
"class": "named-declared",
"files": 7,
"name": "volgate-n8n_n8n_data",
"sample": [
"database.sqlite-shm",
"database.sqlite-wal",
"config",
"database.sqlite",
"n8nEventLog.log",
"crash.journal",
"nodes/package.json"
],
"source": "/var/lib/docker/volumes/volgate-n8n_n8n_data/_data",
"target": "/home/node/.n8n",
"writable_by_app": "yes"
}
],
"name": "n8n",
"restarts": null,
"status": "running",
"uid": 1000
}
],
"declared_volumes": [
"n8n_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"N8N_ENCRYPTION_KEY",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,81 @@
{
"probe": {
"app": "navidrome",
"containers": [
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"data",
"music"
],
"changed_count": 0,
"db_signature": false,
"dir": "/",
"files": [
"data",
"music"
]
}
],
"diff_token_dirs": [],
"diff_total": 2,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "deluan/navidrome:0.63.2",
"mounts": [
{
"class": "named-declared",
"files": 6,
"name": "volgate-navidrome_navidrome_data",
"sample": [
"navidrome.db-wal",
"navidrome.db",
"navidrome.db-shm",
"cache/transcoding/.nd-migrated",
"cache/images/.nd-migrated",
"cache/backgrounds/.nd-migrated"
],
"source": "/var/lib/docker/volumes/volgate-navidrome_navidrome_data/_data",
"target": "/data",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/hdd/userdata/media/music",
"target": "/music",
"writable_by_app": "yes"
}
],
"name": "navidrome",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"navidrome_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,317 @@
{
"probe": {
"app": "nextcloud",
"containers": [
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"run",
"usr"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"local"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/local",
"files": [
"etc"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/local/etc",
"files": [
"php"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/local/etc/php",
"files": [
"conf.d"
]
},
{
"added": [
"redis-session.ini"
],
"changed_count": 0,
"db_signature": false,
"dir": "/usr/local/etc/php/conf.d",
"files": [
"redis-session.ini"
]
}
],
"diff_token_dirs": [],
"diff_total": 9,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "nextcloud:34.0.1-apache",
"mounts": [
{
"class": "bind",
"files": 69,
"name": null,
"sample": [
".htaccess",
".ncdata",
"nextcloud.log",
"index.html",
"admin/files/Nextcloud intro.mp4",
"admin/files/Templates credits.md",
"admin/files/Nextcloud Manual.pdf",
"admin/files/Nextcloud.png",
"admin/files/Reasons to use Nextcloud.pdf",
"admin/files/Readme.md",
"admin/files/Photos/Frog.jpg",
"admin/files/Photos/Vineyard.jpg",
"admin/files/Photos/Library.jpg",
"admin/files/Photos/Nextcloud community.jpg",
"admin/files/Photos/Birdie.jpg",
"admin/files/Photos/Toucan.jpg",
"admin/files/Photos/Steps.jpg",
"admin/files/Photos/Readme.md",
"admin/files/Photos/Gorilla.jpg",
"admin/files/Documents/Welcome to Nextcloud Hub.docx",
"admin/files/Documents/Nextcloud flyer.pdf",
"admin/files/Documents/Example.md",
"admin/files/Documents/Readme.md",
"admin/files/Templates/Kanban board.whiteboard",
"admin/files/Templates/Meeting agenda.whiteboard",
"admin/files/Templates/Org chart.odg",
"admin/files/Templates/Invoice.ods",
"admin/files/Templates/Resume.odt",
"admin/files/Templates/Certificate.odt",
"admin/files/Templates/Brainstorming.whiteboard",
"admin/files/Templates/Letter.odt",
"admin/files/Templates/Gotong royong.odp",
"admin/files/Templates/Business model canvas.odg",
"admin/files/Templates/Sticky notes.whiteboard",
"admin/files/Templates/Security.odp",
"admin/files/Templates/Onboarding.odp",
"admin/files/Templates/Mind map.whiteboard",
"admin/files/Templates/Elegant.odp",
"admin/files/Templates/Expense report.ods",
"admin/files/Templates/Simple.odp"
],
"source": "/srv/felhom-gate/hdd/appdata/nextcloud",
"target": "/var/www/html/data",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 4001,
"name": "volgate-nextcloud_nextcloud_html",
"sample": [
"status.php",
".htaccess",
"AUTHORS",
"public.php",
"occ",
"index.php",
"composer.json",
"version.php",
"package-lock.json",
"nextcloud-init-sync.lock",
".user.ini",
"remote.php",
"cron.php",
"console.php",
"COPYING",
"REUSE.toml",
"index.html",
"package.json",
"robots.txt",
"composer.lock",
"lib/versioncheck.php",
"lib/base.php",
"lib/l10n/he.json",
"lib/l10n/bs.js",
"lib/l10n/es_PA.js",
"lib/l10n/hsb.json",
"lib/l10n/fo.json",
"lib/l10n/mk.json",
"lib/l10n/ru.js",
"lib/l10n/th.json",
"lib/l10n/es_GT.json",
"lib/l10n/eo.js",
"lib/l10n/pt_PT.json",
"lib/l10n/bn_BD.js",
"lib/l10n/ast.json",
"lib/l10n/kab.json",
"lib/l10n/fo.js",
"lib/l10n/km.js",
"lib/l10n/mn.js",
"lib/l10n/ig.json"
],
"source": "/var/lib/docker/volumes/volgate-nextcloud_nextcloud_html/_data",
"target": "/var/www/html",
"writable_by_app": "yes"
}
],
"name": "nextcloud",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 999,
"health": "healthy",
"image": "mariadb:11.6",
"mounts": [
{
"class": "named-declared",
"files": 311,
"name": "volgate-nextcloud_nextcloud_db_data",
"sample": [
"undo002",
"tc.log",
"mariadb_upgrade_info",
"ibtmp1",
"aria_log.00000001",
"multi-master.info",
"ddl_recovery.log",
"ib_logfile0",
"undo003",
"ibdata1",
".my-healthcheck.cnf",
"aria_log_control",
"undo001",
"ib_buffer_pool",
"sys/x@0024wait_classes_global_by_latency.frm",
"sys/processlist.frm",
"sys/session.frm",
"sys/memory_by_host_by_current_bytes.frm",
"sys/user_summary.frm",
"sys/waits_global_by_latency.frm",
"sys/x@0024statement_analysis.frm",
"sys/x@0024host_summary.frm",
"sys/schema_unused_indexes.frm",
"sys/user_summary_by_file_io_type.frm",
"sys/memory_by_thread_by_current_bytes.frm",
"sys/x@0024statements_with_full_table_scans.frm",
"sys/wait_classes_global_by_avg_latency.frm",
"sys/x@0024schema_table_statistics.frm",
"sys/x@0024statements_with_temp_tables.frm",
"sys/schema_auto_increment_columns.frm",
"sys/x@0024ps_schema_table_statistics_io.frm",
"sys/x@0024memory_by_host_by_current_bytes.frm",
"sys/statements_with_runtimes_in_95th_percentile.frm",
"sys/x@0024statements_with_errors_or_warnings.frm",
"sys/waits_by_user_by_latency.frm",
"sys/x@0024io_global_by_file_by_bytes.frm",
"sys/innodb_buffer_stats_by_table.frm",
"sys/user_summary_by_statement_type.frm",
"sys/statements_with_errors_or_warnings.frm",
"sys/sys_config.MAD"
],
"source": "/var/lib/docker/volumes/volgate-nextcloud_nextcloud_db_data/_data",
"target": "/var/lib/mysql",
"writable_by_app": "yes"
}
],
"name": "nextcloud-db",
"restarts": null,
"status": "running",
"uid": 999
},
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "redis:7-alpine",
"mounts": [
{
"class": "named-declared",
"files": 3,
"name": "volgate-nextcloud_nextcloud_redis_data",
"sample": [
"appendonlydir/appendonly.aof.1.base.rdb",
"appendonlydir/appendonly.aof.1.incr.aof",
"appendonlydir/appendonly.aof.manifest"
],
"source": "/var/lib/docker/volumes/volgate-nextcloud_nextcloud_redis_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "nextcloud-redis",
"restarts": null,
"status": "running",
"uid": 999
}
],
"declared_volumes": [
"nextcloud_db_data",
"nextcloud_html",
"nextcloud_redis_data"
],
"env_keys": [
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"MYSQL_ROOT_PASSWORD",
"NEXTCLOUD_ADMIN_PASSWORD",
"NEXTCLOUD_ADMIN_USER",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
{
"probe": {
"app": "opengist",
"containers": [
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"opengist"
],
"changed_count": 3,
"db_signature": false,
"dir": "/",
"files": [
"opengist"
]
},
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/etc",
"files": [
"group-",
"passwd-"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/home",
"files": [
"opengist"
]
},
{
"added": [
".gitconfig"
],
"changed_count": 0,
"db_signature": false,
"dir": "/home/opengist",
"files": [
".gitconfig"
]
}
],
"diff_token_dirs": [],
"diff_total": 10,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "ghcr.io/thomiceli/opengist:1.13",
"mounts": [
{
"class": "named-declared",
"files": 9,
"name": "volgate-opengist_opengist_data",
"sample": [
"opengist-secret.key",
"opengist.db",
"sessions/session-encrypt.key",
"log/opengist.log",
"symlinks/opengist",
"symlinks/config.yml",
"opengist.index/index_meta.json",
"opengist.index/store/root.bolt",
"ssh/opengist-ed25519"
],
"source": "/var/lib/docker/volumes/volgate-opengist_opengist_data/_data",
"target": "/opengist",
"writable_by_app": "yes"
}
],
"name": "opengist",
"restarts": null,
"status": "running",
"uid": 1000
}
],
"declared_volumes": [
"opengist_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,166 @@
{
"probe": {
"app": "outline",
"containers": [
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 1001,
"health": "healthy",
"image": "outlinewiki/outline:1.9.1",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-outline_outline_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-outline_outline_data/_data",
"target": "/var/lib/outline/data",
"writable_by_app": "yes"
}
],
"name": "outline",
"restarts": null,
"status": "running",
"uid": 1001
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 70,
"health": "healthy",
"image": "postgres:16-alpine",
"mounts": [
{
"class": "named-declared",
"files": 1655,
"name": "volgate-outline_outline_postgres_data",
"sample": [
"postmaster.opts",
"postgresql.conf",
"postgresql.auto.conf",
"pg_ident.conf",
"pg_hba.conf",
"postmaster.pid",
"PG_VERSION",
"base/1/826",
"base/1/2996",
"base/1/3764",
"base/1/2610_fsm",
"base/1/3603_fsm",
"base/1/1249_vm",
"base/1/3600_fsm",
"base/1/13512",
"base/1/3602",
"base/1/6228",
"base/1/4174",
"base/1/4157",
"base/1/4146",
"base/1/2611",
"base/1/2657",
"base/1/3079_vm",
"base/1/1247_vm",
"base/1/1259",
"base/1/3601_fsm",
"base/1/1417",
"base/1/4155",
"base/1/2668",
"base/1/2831",
"base/1/2841",
"base/1/3607",
"base/1/2836",
"base/1/1255_fsm",
"base/1/2615_vm",
"base/1/3575",
"base/1/2605_vm",
"base/1/2670",
"base/1/6106",
"base/1/4163"
],
"source": "/var/lib/docker/volumes/volgate-outline_outline_postgres_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "outline-postgres",
"restarts": null,
"status": "running",
"uid": 70
},
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "redis:7-alpine",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-outline_outline_redis_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-outline_outline_redis_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "outline-redis",
"restarts": null,
"status": "running",
"uid": 999
}
],
"declared_volumes": [
"outline_data",
"outline_postgres_data",
"outline_redis_data"
],
"env_keys": [
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SECRET_KEY",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH",
"UTILS_SECRET"
],
"exercise": [],
"ports": []
},
"reasons": [
"outline: declared volume /var/lib/outline/data is EMPTY",
"outline-redis: declared volume /data is EMPTY"
],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,250 @@
{
"probe": {
"app": "paperless-ngx",
"containers": [
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 70,
"health": "healthy",
"image": "postgres:16-alpine",
"mounts": [
{
"class": "named-declared",
"files": 1927,
"name": "volgate-paperlessngx_paperless_postgres_data",
"sample": [
"postmaster.opts",
"postgresql.conf",
"postgresql.auto.conf",
"pg_ident.conf",
"pg_hba.conf",
"postmaster.pid",
"PG_VERSION",
"base/1/826",
"base/1/2996",
"base/1/3764",
"base/1/2610_fsm",
"base/1/3603_fsm",
"base/1/1249_vm",
"base/1/3600_fsm",
"base/1/13512",
"base/1/3602",
"base/1/6228",
"base/1/4174",
"base/1/4157",
"base/1/4146",
"base/1/2611",
"base/1/2657",
"base/1/3079_vm",
"base/1/1247_vm",
"base/1/1259",
"base/1/3601_fsm",
"base/1/1417",
"base/1/4155",
"base/1/2668",
"base/1/2831",
"base/1/2841",
"base/1/3607",
"base/1/2836",
"base/1/1255_fsm",
"base/1/2615_vm",
"base/1/3575",
"base/1/2605_vm",
"base/1/2670",
"base/1/6106",
"base/1/4163"
],
"source": "/var/lib/docker/volumes/volgate-paperlessngx_paperless_postgres_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "paperless-postgres",
"restarts": null,
"status": "running",
"uid": 70
},
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "redis:7-alpine",
"mounts": [
{
"class": "named-declared",
"files": 3,
"name": "volgate-paperlessngx_paperless_redis_data",
"sample": [
"appendonlydir/appendonly.aof.1.base.rdb",
"appendonlydir/appendonly.aof.1.incr.aof",
"appendonlydir/appendonly.aof.manifest"
],
"source": "/var/lib/docker/volumes/volgate-paperlessngx_paperless_redis_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "paperless-redis",
"restarts": null,
"status": "running",
"uid": 999
},
{
"diff_added": 265,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/",
"files": [
"usr",
"var",
"run"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"share"
]
},
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/var",
"files": [
"log",
"cache",
"lib"
]
},
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/var/lib",
"files": [
"apt",
"dpkg"
]
}
],
"diff_token_dirs": [],
"diff_total": 294,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/paperless-ngx/paperless-ngx:2.20.15",
"mounts": [
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/hdd/appdata/paperless/export",
"target": "/usr/src/paperless/export",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 9,
"name": "volgate-paperlessngx_paperless_data",
"sample": [
"migration_lock",
".index_version",
"celerybeat-schedule.db",
"index/_MAIN_1.toc",
"index/MAIN_WRITELOCK",
"log/paperless.log",
"log/celery.log",
"log/.__paperless.lock",
"log/.__celery.lock"
],
"source": "/var/lib/docker/volumes/volgate-paperlessngx_paperless_data/_data",
"target": "/usr/src/paperless/data",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 1,
"name": null,
"sample": [
"media.lock"
],
"source": "/srv/felhom-gate/hdd/appdata/paperless/media",
"target": "/usr/src/paperless/media",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/import/paperless",
"target": "/usr/src/paperless/consume",
"writable_by_app": "yes"
}
],
"name": "paperless-webserver",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"paperless_data",
"paperless_postgres_data",
"paperless_redis_data"
],
"env_keys": [
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"PAPERLESS_ADMIN_PASSWORD",
"PAPERLESS_ADMIN_USER",
"PAPERLESS_OCR_LANGUAGE",
"PAPERLESS_SECRET_KEY",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,100 @@
{
"probe": {
"app": "papra",
"containers": [
{
"diff_added": 173,
"diff_benign_db_touches": [],
"diff_data_dirs": [
{
"added": [
"db.sqlite"
],
"changed_count": 0,
"db_signature": true,
"dir": "/app/app-data/db",
"files": [
"db.sqlite"
]
}
],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"app"
]
},
{
"added": [
"data"
],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"data"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/app-data",
"files": [
"db"
]
}
],
"diff_token_dirs": [],
"diff_total": 177,
"diff_unresolved": [],
"exit": 0,
"gid": 999,
"health": "healthy",
"image": "ghcr.io/papra-hq/papra:26.6.1-rootless",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-papra_papra_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-papra_papra_data/_data",
"target": "/app/data",
"writable_by_app": "NO"
}
],
"name": "papra",
"restarts": null,
"status": "running",
"uid": 999
}
],
"declared_volumes": [
"papra_data"
],
"env_keys": [
"AUTH_SECRET",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"papra: mount /app/data is NOT writable by the app's own uid=999 gid=999",
"papra: DATA in the writable layer at /app/app-data/db (db_signature=True, e.g. ['db.sqlite'])",
"NOTHING this app wrote landed in ANY folder the template preserves: all 1 mount(s) across 1 container(s) are empty, yet entries were created in ['/app']. Needs adjudication.",
"papra: this container's mounts are all empty while it created entries in ['/app'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"papra: declared volume /app/data is EMPTY"
],
"verdict": "BROKEN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,11 @@
{
"probe": {
"app": "plant-it",
"error": "no containers created (compose up rc=1: msdeluise/plant-it:0.10.0 Pulling \n Image msdeluise/plant-it:0.10.0 Error pull access denied for msdeluise/plant-it, repository does not exist or may require 'docker login'\nError response from daemon: pull access denied for msdeluise/plant-it, repository does not exist or may require 'docker login'\n)"
},
"reasons": [
"no containers created (compose up rc=1: msdeluise/plant-it:0.10.0 Pulling \n Image msdeluise/plant-it:0.10.0 Error pull access denied for msdeluise/plant-it, repository does not exist or may require 'docker login'\nError response from daemon: pull access denied for msdeluise/plant-it, repository does not exist or may require 'docker login'\n)"
],
"verdict": "UNDETERMINED",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,170 @@
{
"probe": {
"app": "plex",
"containers": [
{
"diff_added": 63,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
".firstRunComplete"
],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
".firstRunComplete"
]
},
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/etc",
"files": [
"cont-init.d",
"services.d"
]
},
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/etc/cont-init.d",
"files": [
"45-plex-hw-transcode-and-connected-tuner",
"50-plex-update",
"40-plex-first-run"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/services.d",
"files": [
"plex"
]
},
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/etc/services.d/plex",
"files": [
"finish",
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 74,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "plexinc/pms-docker:1.41.4.9463-630c9f557",
"mounts": [
{
"class": "bind",
"files": 3,
"name": null,
"sample": [
"books/metadata.db",
"books/metadata.db-shm",
"books/metadata.db-wal"
],
"source": "/srv/felhom-gate/hdd/userdata/media",
"target": "/media",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 82,
"name": "volgate-plex_plex_config",
"sample": [
"Library/Application Support/Plex Media Server/.LocalAdminToken",
"Library/Application Support/Plex Media Server/plexmediaserver.pid",
"Library/Application Support/Plex Media Server/Preferences.xml",
"Library/Application Support/Plex Media Server/Setup Plex.html",
"Library/Application Support/Plex Media Server/Cache/Flags.dat",
"Library/Application Support/Plex Media Server/Cache/UpdateChannels.dat",
"Library/Application Support/Plex Media Server/Cache/Privacy.dat",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.themoviedb/HTTPCookies",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.themoviedb/StoredValues",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.plexthememusic/HTTPCookies",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.plexthememusic/StoredValues",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.lastfm/HTTPCookies",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.lastfm/StoredValues",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.imdb/HTTPCookies",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.imdb/StoredValues",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.none/HTTPCookies",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.none/StoredValues",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.thetvdb/HTTPCookies",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.thetvdb/StoredValues",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.movieposterdb/HTTPCookies",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.movieposterdb/StoredValues",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/org.musicbrainz.agents.music/HTTPCookies",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/org.musicbrainz.agents.music/StoredValues",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.fanarttv/HTTPCookies",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.fanarttv/StoredValues",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.htbackdrops/HTTPCookies",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.htbackdrops/StoredValues",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.localmedia/HTTPCookies",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.localmedia/StoredValues",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.system/HTTPCookies",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.system/StoredValues",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.system/Dict",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.lyricfind/HTTPCookies",
"Library/Application Support/Plex Media Server/Plug-in Support/Data/com.plexapp.agents.lyricfind/StoredValues",
"Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db-shm",
"Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.blobs.db",
"Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db-wal",
"Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.blobs.db-shm",
"Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.blobs.db-wal",
"Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db"
],
"source": "/var/lib/docker/volumes/volgate-plex_plex_config/_data",
"target": "/config",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-plex_plex_transcode",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-plex_plex_transcode/_data",
"target": "/transcode",
"writable_by_app": "yes"
}
],
"name": "plex",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"plex_config",
"plex_transcode"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"PLEX_CLAIM",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"plex: declared volume /transcode is EMPTY"
],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,96 @@
{
"probe": {
"app": "privatebin",
"containers": [
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 82,
"health": "healthy",
"image": "privatebin/pdo:2.0.5",
"mounts": [
{
"class": "anonymous",
"files": 0,
"name": "1588c34a1374a5d4ae265cb892ff0059e87ae07e11a14ac1e17879c54b7b1ce2",
"sample": [],
"source": "/var/lib/docker/volumes/1588c34a1374a5d4ae265cb892ff0059e87ae07e11a14ac1e17879c54b7b1ce2/_data",
"target": "/tmp",
"writable_by_app": "yes"
},
{
"class": "anonymous",
"files": 0,
"name": "08447e377c7efaba21791de7813d6799c785ee1a7e72216608c72e01a5a40d69",
"sample": [],
"source": "/var/lib/docker/volumes/08447e377c7efaba21791de7813d6799c785ee1a7e72216608c72e01a5a40d69/_data",
"target": "/var/lib/nginx/tmp",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-privatebin_privatebin_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-privatebin_privatebin_data/_data",
"target": "/srv/data",
"writable_by_app": "yes"
},
{
"class": "anonymous",
"files": 14,
"name": "ebd9667e81ace0d084e3f07b25360ca806df2a98294ec192d6afd674b70a790b",
"sample": [
"nginx.pid",
"php-fpm.sock",
"services/.s6-svscan/control",
"services/.s6-svscan/lock",
"services/php-fpm85/run",
"services/php-fpm85/supervise/control",
"services/php-fpm85/supervise/death_tally",
"services/php-fpm85/supervise/status",
"services/php-fpm85/supervise/lock",
"services/nginx/run",
"services/nginx/supervise/control",
"services/nginx/supervise/death_tally",
"services/nginx/supervise/status",
"services/nginx/supervise/lock"
],
"source": "/var/lib/docker/volumes/ebd9667e81ace0d084e3f07b25360ca806df2a98294ec192d6afd674b70a790b/_data",
"target": "/run",
"writable_by_app": "yes"
}
],
"name": "privatebin",
"restarts": null,
"status": "running",
"uid": 65534
}
],
"declared_volumes": [
"privatebin_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"nothing was written to any mount and nothing data-classified in any writable layer \u2014 the app produced no data to locate. Health is not data: this is UNDETERMINED, not CLEAN",
"privatebin: declared volume /srv/data is EMPTY"
],
"verdict": "UNDETERMINED",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,155 @@
{
"probe": {
"app": "radarr",
"containers": [
{
"diff_added": 207,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"downloads"
],
"changed_count": 5,
"db_signature": false,
"dir": "/",
"files": [
"downloads"
]
},
{
"added": [],
"changed_count": 4,
"db_signature": false,
"dir": "/etc",
"files": [
"group-",
"passwd-",
"s6-overlay",
"crontabs"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/crontabs",
"files": [
"root"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/s6-overlay",
"files": [
"s6-rc.d"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/s6-overlay/s6-rc.d",
"files": [
"init-adduser"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/s6-overlay/s6-rc.d/init-adduser",
"files": [
"branding"
]
}
],
"diff_token_dirs": [
{
"added": [
"movies"
],
"changed_count": 0,
"db_signature": false,
"dir": "/media",
"files": [
"movies"
]
}
],
"diff_total": 222,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "lscr.io/linuxserver/radarr:6.3.0",
"mounts": [
{
"class": "named-declared",
"files": 11,
"name": "volgate-radarr_radarr_config",
"sample": [
"radarr.pid",
"logs.db-shm",
"logs.db",
"radarr.db",
"radarr.db-wal",
"radarr.db-shm",
"logs.db-wal",
"config.xml",
"logs/radarr.txt",
"logs/radarr.debug.txt",
"asp/key-08e6d4a9-fe58-4059-ae41-87f0c8d018e5.xml"
],
"source": "/var/lib/docker/volumes/volgate-radarr_radarr_config/_data",
"target": "/config",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/hdd/userdata/media/movies",
"target": "/media/movies",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/hdd/userdata/downloads",
"target": "/downloads",
"writable_by_app": "yes"
}
],
"name": "radarr",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"radarr_config"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"radarr: writable-layer writes at /media (path suggests state, no database signature \u2014 judgement needed): ['movies']"
],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,159 @@
{
"probe": {
"app": "rallly",
"containers": [
{
"diff_added": 6,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"app"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"apps"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/apps",
"files": [
"web"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/apps/web",
"files": [
".next"
]
}
],
"diff_token_dirs": [],
"diff_total": 11,
"diff_unresolved": [],
"exit": 0,
"gid": 101,
"health": "healthy",
"image": "lukevella/rallly:4.11.1",
"mounts": [],
"name": "rallly",
"restarts": null,
"status": "running",
"uid": 100
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 70,
"health": "healthy",
"image": "postgres:16-alpine",
"mounts": [
{
"class": "named-declared",
"files": 1580,
"name": "volgate-rallly_rallly_postgres_data",
"sample": [
"postmaster.opts",
"postgresql.conf",
"postgresql.auto.conf",
"pg_ident.conf",
"pg_hba.conf",
"postmaster.pid",
"PG_VERSION",
"base/1/826",
"base/1/2996",
"base/1/3764",
"base/1/2610_fsm",
"base/1/3603_fsm",
"base/1/1249_vm",
"base/1/3600_fsm",
"base/1/13512",
"base/1/3602",
"base/1/6228",
"base/1/4174",
"base/1/4157",
"base/1/4146",
"base/1/2611",
"base/1/2657",
"base/1/3079_vm",
"base/1/1247_vm",
"base/1/1259",
"base/1/3601_fsm",
"base/1/1417",
"base/1/4155",
"base/1/2668",
"base/1/2831",
"base/1/2841",
"base/1/3607",
"base/1/2836",
"base/1/1255_fsm",
"base/1/2615_vm",
"base/1/3575",
"base/1/2605_vm",
"base/1/2670",
"base/1/6106",
"base/1/4163"
],
"source": "/var/lib/docker/volumes/volgate-rallly_rallly_postgres_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "rallly-postgres",
"restarts": null,
"status": "running",
"uid": 70
}
],
"declared_volumes": [
"rallly_postgres_data"
],
"env_keys": [
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SECRET_PASSWORD",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,95 @@
{
"probe": {
"app": "recipe-importer",
"containers": [
{
"diff_added": 13,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"data"
],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"data"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"app"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"local"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/local",
"files": [
"lib"
]
}
],
"diff_token_dirs": [],
"diff_total": 22,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "gitea.dooplex.hu/admin/recipe-importer:v0.9.11",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-recipeimporter_recipe_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-recipeimporter_recipe_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "recipe-importer",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"recipe_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SECRET_KEY",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"NOTHING this app wrote landed in ANY folder the template preserves: all 1 mount(s) across 1 container(s) are empty, yet entries were created in ['/']. Needs adjudication.",
"recipe-importer: this container's mounts are all empty while it created entries in ['/'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"recipe-importer: declared volume /data is EMPTY"
],
"verdict": "UNDETERMINED",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,248 @@
{
"probe": {
"app": "romm",
"containers": [
{
"diff_added": 13,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"root",
"etc"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc",
"files": [
"nginx"
]
},
{
"added": [
".gunicorn"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root",
"files": [
".gunicorn"
]
},
{
"added": [
"gunicorn.ctl"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.gunicorn",
"files": [
"gunicorn.ctl"
]
}
],
"diff_token_dirs": [],
"diff_total": 19,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "rommapp/romm:5.0.0",
"mounts": [
{
"class": "anonymous",
"files": 0,
"name": "1ef425b6ade46b1849fdb2b3f632ed8640a9cb2f684686c1a175028a50ee706a",
"sample": [],
"source": "/var/lib/docker/volumes/1ef425b6ade46b1849fdb2b3f632ed8640a9cb2f684686c1a175028a50ee706a/_data",
"target": "/redis-data",
"writable_by_app": "yes"
},
{
"class": "anonymous",
"files": 0,
"name": "59a9958f0796e5ad06453b0ccf4b66018aa1897c68e3b646f3f469f221060752",
"sample": [],
"source": "/var/lib/docker/volumes/59a9958f0796e5ad06453b0ccf4b66018aa1897c68e3b646f3f469f221060752/_data",
"target": "/romm",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/hdd/userdata/roms",
"target": "/romm/library",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/hdd/appdata/romm/resources",
"target": "/romm/resources",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 1,
"name": "volgate-romm_romm_config",
"sample": [
"config.yml"
],
"source": "/var/lib/docker/volumes/volgate-romm_romm_config/_data",
"target": "/romm/config",
"writable_by_app": "yes"
}
],
"name": "romm",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 999,
"health": "healthy",
"image": "mariadb:11.4",
"mounts": [
{
"class": "named-declared",
"files": 271,
"name": "volgate-romm_romm_db_data",
"sample": [
"undo002",
"tc.log",
"mariadb_upgrade_info",
"ibtmp1",
"aria_log.00000001",
"multi-master.info",
"ddl_recovery.log",
"ib_logfile0",
"undo003",
"ibdata1",
".my-healthcheck.cnf",
"aria_log_control",
"undo001",
"ib_buffer_pool",
"sys/x@0024wait_classes_global_by_latency.frm",
"sys/processlist.frm",
"sys/session.frm",
"sys/memory_by_host_by_current_bytes.frm",
"sys/user_summary.frm",
"sys/waits_global_by_latency.frm",
"sys/x@0024statement_analysis.frm",
"sys/x@0024host_summary.frm",
"sys/schema_unused_indexes.frm",
"sys/user_summary_by_file_io_type.frm",
"sys/memory_by_thread_by_current_bytes.frm",
"sys/x@0024statements_with_full_table_scans.frm",
"sys/wait_classes_global_by_avg_latency.frm",
"sys/x@0024schema_table_statistics.frm",
"sys/x@0024statements_with_temp_tables.frm",
"sys/schema_auto_increment_columns.frm",
"sys/x@0024ps_schema_table_statistics_io.frm",
"sys/x@0024memory_by_host_by_current_bytes.frm",
"sys/statements_with_runtimes_in_95th_percentile.frm",
"sys/x@0024statements_with_errors_or_warnings.frm",
"sys/waits_by_user_by_latency.frm",
"sys/x@0024io_global_by_file_by_bytes.frm",
"sys/innodb_buffer_stats_by_table.frm",
"sys/user_summary_by_statement_type.frm",
"sys/statements_with_errors_or_warnings.frm",
"sys/sys_config.MAD"
],
"source": "/var/lib/docker/volumes/volgate-romm_romm_db_data/_data",
"target": "/var/lib/mysql",
"writable_by_app": "yes"
}
],
"name": "romm-db",
"restarts": null,
"status": "running",
"uid": 999
},
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "redis:7-alpine",
"mounts": [
{
"class": "named-declared",
"files": 4,
"name": "volgate-romm_romm_redis_data",
"sample": [
"dump.rdb",
"appendonlydir/appendonly.aof.1.base.rdb",
"appendonlydir/appendonly.aof.1.incr.aof",
"appendonlydir/appendonly.aof.manifest"
],
"source": "/var/lib/docker/volumes/volgate-romm_romm_redis_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "romm-redis",
"restarts": null,
"status": "running",
"uid": 999
}
],
"declared_volumes": [
"romm_config",
"romm_db_data",
"romm_redis_data"
],
"env_keys": [
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"MYSQL_ROOT_PASSWORD",
"ROMM_AUTH_SECRET_KEY",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,63 @@
{
"probe": {
"app": "seerr",
"containers": [
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "fallenbagel/jellyseerr:2.7.3",
"mounts": [
{
"class": "named-declared",
"files": 11,
"name": "volgate-seerr_seerr_config",
"sample": [
"settings.json",
"DOCKER",
"db/db.sqlite3-wal",
"db/db.sqlite3",
"db/db.sqlite3-shm",
"logs/.machinelogs.json",
"logs/jellyseerr.log",
"logs/.20136e5b8544ec13f7fc29ce3d35150d597108bb-audit.json",
"logs/jellyseerr-2026-08-02.log",
"logs/.machinelogs-2026-08-02.json",
"logs/.4295fcfc416647ffc3751a1d488b45a096cf2d4f-audit.json"
],
"source": "/var/lib/docker/volumes/volgate-seerr_seerr_config/_data",
"target": "/app/config",
"writable_by_app": "yes"
}
],
"name": "seerr",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"seerr_config"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,155 @@
{
"probe": {
"app": "sonarr",
"containers": [
{
"diff_added": 209,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"downloads"
],
"changed_count": 5,
"db_signature": false,
"dir": "/",
"files": [
"downloads"
]
},
{
"added": [],
"changed_count": 4,
"db_signature": false,
"dir": "/etc",
"files": [
"group-",
"s6-overlay",
"passwd-",
"crontabs"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/crontabs",
"files": [
"root"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/s6-overlay",
"files": [
"s6-rc.d"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/s6-overlay/s6-rc.d",
"files": [
"init-adduser"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc/s6-overlay/s6-rc.d/init-adduser",
"files": [
"branding"
]
}
],
"diff_token_dirs": [
{
"added": [
"tv"
],
"changed_count": 0,
"db_signature": false,
"dir": "/media",
"files": [
"tv"
]
}
],
"diff_total": 224,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "lscr.io/linuxserver/sonarr:4.0.19",
"mounts": [
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/hdd/userdata/media/tv",
"target": "/media/tv",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/hdd/userdata/downloads",
"target": "/downloads",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 11,
"name": "volgate-sonarr_sonarr_config",
"sample": [
"logs.db-shm",
"sonarr.db",
"logs.db",
"sonarr.pid",
"logs.db-wal",
"sonarr.db-wal",
"sonarr.db-shm",
"config.xml",
"logs/sonarr.txt",
"logs/sonarr.debug.txt",
"asp/key-4d541e64-de4d-421d-a8f7-8c05ed629e0c.xml"
],
"source": "/var/lib/docker/volumes/volgate-sonarr_sonarr_config/_data",
"target": "/config",
"writable_by_app": "yes"
}
],
"name": "sonarr",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"sonarr_config"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"sonarr: writable-layer writes at /media (path suggests state, no database signature \u2014 judgement needed): ['tv']"
],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,231 @@
{
"probe": {
"app": "sparkyfitness",
"containers": [
{
"diff_added": 11,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/",
"files": [
"etc",
"run",
"var"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc",
"files": [
"nginx"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/var",
"files": [
"cache"
]
}
],
"diff_token_dirs": [],
"diff_total": 19,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "codewithcj/sparkyfitness:v0.17.3",
"mounts": [],
"name": "sparkyfitness",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 70,
"health": "healthy",
"image": "postgres:15-alpine",
"mounts": [
{
"class": "named-declared",
"files": 1815,
"name": "volgate-sparkyfitness_sparkyfitness_db_data",
"sample": [
"pgdata/postmaster.opts",
"pgdata/postgresql.conf",
"pgdata/postgresql.auto.conf",
"pgdata/pg_ident.conf",
"pgdata/pg_hba.conf",
"pgdata/postmaster.pid",
"pgdata/PG_VERSION",
"pgdata/base/1/826",
"pgdata/base/1/2996",
"pgdata/base/1/3764",
"pgdata/base/1/2610_fsm",
"pgdata/base/1/3603_fsm",
"pgdata/base/1/1249_vm",
"pgdata/base/1/3600_fsm",
"pgdata/base/1/13506_vm",
"pgdata/base/1/3602",
"pgdata/base/1/6228",
"pgdata/base/1/4174",
"pgdata/base/1/4157",
"pgdata/base/1/4146",
"pgdata/base/1/2611",
"pgdata/base/1/2657",
"pgdata/base/1/3079_vm",
"pgdata/base/1/1247_vm",
"pgdata/base/1/13501_fsm",
"pgdata/base/1/1259",
"pgdata/base/1/3601_fsm",
"pgdata/base/1/1417",
"pgdata/base/1/4155",
"pgdata/base/1/2668",
"pgdata/base/1/13491_fsm",
"pgdata/base/1/2831",
"pgdata/base/1/2841",
"pgdata/base/1/3607",
"pgdata/base/1/2836",
"pgdata/base/1/1255_fsm",
"pgdata/base/1/2615_vm",
"pgdata/base/1/3575",
"pgdata/base/1/2605_vm",
"pgdata/base/1/2670"
],
"source": "/var/lib/docker/volumes/volgate-sparkyfitness_sparkyfitness_db_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "sparkyfitness-db",
"restarts": null,
"status": "running",
"uid": 70
},
{
"diff_added": 392,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"app"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"SparkyFitnessServer"
]
},
{
"added": [
"backup",
"temp_uploads",
"uploads"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app/SparkyFitnessServer",
"files": [
"backup",
"temp_uploads",
"uploads"
]
}
],
"diff_token_dirs": [],
"diff_total": 395,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "codewithcj/sparkyfitness_server:v0.17.3",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-sparkyfitness_sparkyfitness_uploads",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-sparkyfitness_sparkyfitness_uploads/_data",
"target": "/app/SparkyFitnessServer/uploads",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-sparkyfitness_sparkyfitness_backup",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-sparkyfitness_sparkyfitness_backup/_data",
"target": "/app/SparkyFitnessServer/backup",
"writable_by_app": "yes"
}
],
"name": "sparkyfitness-server",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"sparkyfitness_backup",
"sparkyfitness_db_data",
"sparkyfitness_uploads"
],
"env_keys": [
"API_ENCRYPTION_KEY",
"APP_DB_PASSWORD",
"BETTER_AUTH_SECRET",
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"sparkyfitness-server: this container's mounts are all empty while it created entries in ['/app/SparkyFitnessServer'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"sparkyfitness-server: declared volume /app/SparkyFitnessServer/uploads is EMPTY",
"sparkyfitness-server: declared volume /app/SparkyFitnessServer/backup is EMPTY"
],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,263 @@
{
"probe": {
"app": "tandoor",
"containers": [
{
"diff_added": 527,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"opt",
"usr"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/opt",
"files": [
"recipes"
]
},
{
"added": [
"mediafiles",
"staticfiles"
],
"changed_count": 3,
"db_signature": false,
"dir": "/opt/recipes",
"files": [
"mediafiles",
"staticfiles"
]
},
{
"added": [],
"changed_count": 7,
"db_signature": false,
"dir": "/opt/recipes/cookbook",
"files": [
"views",
"migrations",
"templatetags",
"integration",
"provider",
"connectors",
"helper"
]
},
{
"added": [
"Recipes.conf"
],
"changed_count": 0,
"db_signature": false,
"dir": "/opt/recipes/http.d",
"files": [
"Recipes.conf"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"local"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/local",
"files": [
"lib"
]
}
],
"diff_token_dirs": [],
"diff_total": 576,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/tandoorrecipes/recipes:2.6.13",
"mounts": [
{
"class": "named-declared",
"files": 3047,
"name": "volgate-tandoor_tandoor_static",
"sample": [
"staticfiles.json",
"vue3/service-worker.js.gz",
"vue3/registerSW.js.gz",
"vue3/service-worker.js",
"vue3/manifest.484f96e63b3b.webmanifest",
"vue3/manifest.484f96e63b3b.webmanifest.gz",
"vue3/manifest.77a5899585d8.json.gz",
"vue3/service-worker.e1a4252df0ae.js.gz",
"vue3/registerSW.be6aad568a9d.js",
"vue3/registerSW.js",
"vue3/manifest.webmanifest",
"vue3/manifest.webmanifest.gz",
"vue3/service-worker.e1a4252df0ae.js",
"vue3/registerSW.be6aad568a9d.js.gz",
"vue3/manifest.json.gz",
"vue3/manifest.77a5899585d8.json",
"vue3/manifest.json",
"vue3/assets/PantryPage-ClGNwpHL.80923c1ff539.js.gz",
"vue3/assets/DatabaseLinkCol-d8pnKjbf.js.gz",
"vue3/assets/fontello-BxS_W-ym.86b6414dad9e.svg.gz",
"vue3/assets/VFileUpload-7CWUohzn.d182bb8e4f03.css",
"vue3/assets/fa-regular-400-DZaxPHgR.ttf.gz",
"vue3/assets/number_utils-DFmVVcK0.js.gz",
"vue3/assets/ja-Cqm3qbrP.d617fa524f97.js",
"vue3/assets/ModelListPage-xoBv_A6i.4681a991ae27.js",
"vue3/assets/et-zY9CrDMt.1761c6eb5f48.js",
"vue3/assets/fa-regular-400-BjRzuEpd.89672701a587.woff2",
"vue3/assets/ShoppingListView-C0_akhap.js",
"vue3/assets/VOverlay-CKBxglwy.b98729efffd0.css.gz",
"vue3/assets/ClosableHelpAlert-BoN8dDrm.js.gz",
"vue3/assets/VColorPicker-DoVATq5z.css.gz",
"vue3/assets/ja-Cqm3qbrP.js",
"vue3/assets/position-D7bmUCsu.f212789ebec5.css",
"vue3/assets/number_utils-Brwft-8t.css",
"vue3/assets/SpaceSettings-BPOt7vl3.ab2d8e61f937.js.gz",
"vue3/assets/InviteLinkEditor-BZ7NMcrL.19f59c5d2292.js.gz",
"vue3/assets/hu-DlZ4REsp.c1bc941c31dc.js.gz",
"vue3/assets/fileFilter-JaX38xV6.js.gz",
"vue3/assets/VBtn-rFOjfrXe.df1b89399b11.css",
"vue3/assets/VDataTableServer-BYvhcs5d.js.gz"
],
"source": "/var/lib/docker/volumes/volgate-tandoor_tandoor_static/_data",
"target": "/opt/recipes/staticfiles",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-tandoor_tandoor_media",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-tandoor_tandoor_media/_data",
"target": "/opt/recipes/mediafiles",
"writable_by_app": "yes"
}
],
"name": "tandoor",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 70,
"health": "healthy",
"image": "postgres:16-alpine",
"mounts": [
{
"class": "named-declared",
"files": 1984,
"name": "volgate-tandoor_tandoor_postgres_data",
"sample": [
"postmaster.opts",
"postgresql.conf",
"postgresql.auto.conf",
"pg_ident.conf",
"pg_hba.conf",
"postmaster.pid",
"PG_VERSION",
"base/1/826",
"base/1/2996",
"base/1/3764",
"base/1/2610_fsm",
"base/1/3603_fsm",
"base/1/1249_vm",
"base/1/3600_fsm",
"base/1/13512",
"base/1/3602",
"base/1/6228",
"base/1/4174",
"base/1/4157",
"base/1/4146",
"base/1/2611",
"base/1/2657",
"base/1/3079_vm",
"base/1/1247_vm",
"base/1/1259",
"base/1/3601_fsm",
"base/1/1417",
"base/1/4155",
"base/1/2668",
"base/1/2831",
"base/1/2841",
"base/1/3607",
"base/1/2836",
"base/1/1255_fsm",
"base/1/2615_vm",
"base/1/3575",
"base/1/2605_vm",
"base/1/2670",
"base/1/6106",
"base/1/4163"
],
"source": "/var/lib/docker/volumes/volgate-tandoor_tandoor_postgres_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "tandoor-postgres",
"restarts": null,
"status": "running",
"uid": 70
}
],
"declared_volumes": [
"tandoor_media",
"tandoor_postgres_data",
"tandoor_static"
],
"env_keys": [
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SECRET_KEY",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"tandoor: declared volume /opt/recipes/mediafiles is EMPTY"
],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,94 @@
{
"probe": {
"app": "termix",
"containers": [
{
"diff_added": 9,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"etc",
"app"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"html"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/html",
"files": [
"sw.js"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc",
"files": [
"group-"
]
}
],
"diff_token_dirs": [],
"diff_total": 17,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "ghcr.io/lukegus/termix:2.5.0",
"mounts": [
{
"class": "named-declared",
"files": 4,
"name": "volgate-termix_termix_data",
"sample": [
"db.sqlite.encrypted",
".env",
"opkssh/version.txt",
"opkssh/opkssh-linux-amd64"
],
"source": "/var/lib/docker/volumes/volgate-termix_termix_data/_data",
"target": "/app/data",
"writable_by_app": "yes"
}
],
"name": "termix",
"restarts": null,
"status": "running",
"uid": 1000
}
],
"declared_volumes": [
"termix_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,54 @@
{
"probe": {
"app": "uptime-kuma",
"containers": [
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "louislam/uptime-kuma:2.4.0",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-uptimekuma_uptime_kuma_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-uptimekuma_uptime_kuma_data/_data",
"target": "/app/data",
"writable_by_app": "yes"
}
],
"name": "uptime-kuma",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"uptime_kuma_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"nothing was written to any mount and nothing data-classified in any writable layer \u2014 the app produced no data to locate. Health is not data: this is UNDETERMINED, not CLEAN",
"uptime-kuma: declared volume /app/data is EMPTY"
],
"verdict": "UNDETERMINED",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,58 @@
{
"probe": {
"app": "vaultwarden",
"containers": [
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "vaultwarden/server:1.36.0-alpine",
"mounts": [
{
"class": "named-declared",
"files": 4,
"name": "volgate-vaultwarden_vaultwarden_data",
"sample": [
"rsa_key.pem",
"db.sqlite3-wal",
"db.sqlite3",
"db.sqlite3-shm"
],
"source": "/var/lib/docker/volumes/volgate-vaultwarden_vaultwarden_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "vaultwarden",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"vaultwarden_data"
],
"env_keys": [
"ADMIN_TOKEN",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SIGNUPS_ALLOWED",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,100 @@
{
"probe": {
"app": "vikunja",
"containers": [
{
"diff_added": 4,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"db"
],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"db"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"vikunja"
]
},
{
"added": [
"files"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app/vikunja",
"files": [
"files"
]
}
],
"diff_token_dirs": [],
"diff_total": 6,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": null,
"image": "vikunja/vikunja:2.3.0",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-vikunja_vikunja_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-vikunja_vikunja_data/_data",
"target": "/app/vikunja/files",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 3,
"name": "volgate-vikunja_vikunja_db",
"sample": [
"vikunja.db-shm",
"vikunja.db-wal",
"vikunja.db"
],
"source": "/var/lib/docker/volumes/volgate-vikunja_vikunja_db/_data",
"target": "/db",
"writable_by_app": "yes"
}
],
"name": "vikunja",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"vikunja_data",
"vikunja_db"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH",
"VIKUNJA_SERVICE_JWTSECRET"
],
"exercise": [],
"ports": []
},
"reasons": [
"vikunja: declared volume /app/vikunja/files is EMPTY"
],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,194 @@
{
"probe": {
"app": "wanderer",
"containers": [
{
"diff_added": 6,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"root"
]
},
{
"added": [
".npm"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root",
"files": [
".npm"
]
}
],
"diff_token_dirs": [],
"diff_total": 8,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "unhealthy",
"image": "flomp/wanderer-web:v0.20.0",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-wanderer_wanderer_uploads",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-wanderer_wanderer_uploads/_data",
"target": "/app/uploads",
"writable_by_app": "yes"
}
],
"name": "wanderer",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 3,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"pb_data",
"data"
],
"changed_count": 0,
"db_signature": false,
"dir": "/",
"files": [
"pb_data",
"data"
]
}
],
"diff_token_dirs": [
{
"added": [
"plugins"
],
"changed_count": 0,
"db_signature": false,
"dir": "/data",
"files": [
"plugins"
]
}
],
"diff_total": 3,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "flomp/wanderer-db:v0.20.0",
"mounts": [
{
"class": "named-declared",
"files": 6,
"name": "volgate-wanderer_wanderer_data",
"sample": [
"auxiliary.db-wal",
"data.db",
"data.db-wal",
"auxiliary.db",
"auxiliary.db-shm",
"data.db-shm"
],
"source": "/var/lib/docker/volumes/volgate-wanderer_wanderer_data/_data",
"target": "/pb_data",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-wanderer_wanderer_plugins",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-wanderer_wanderer_plugins/_data",
"target": "/data/plugins",
"writable_by_app": "yes"
}
],
"name": "wanderer-db",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "getmeili/meilisearch:v1.36.0",
"mounts": [
{
"class": "named-declared",
"files": 11,
"name": "volgate-wanderer_wanderer_meili_data",
"sample": [
"data.ms/VERSION",
"data.ms/indexes/a0fe8809-d547-4e9f-9b13-f5962d76ee3f/data.mdb",
"data.ms/indexes/a0fe8809-d547-4e9f-9b13-f5962d76ee3f/lock.mdb",
"data.ms/indexes/45aacc63-1820-432b-9eb5-027bb1f94f5f/data.mdb",
"data.ms/indexes/45aacc63-1820-432b-9eb5-027bb1f94f5f/lock.mdb",
"data.ms/indexes/a982b080-60f2-4811-a993-0e235ea2a5b9/data.mdb",
"data.ms/indexes/a982b080-60f2-4811-a993-0e235ea2a5b9/lock.mdb",
"data.ms/auth/data.mdb",
"data.ms/auth/lock.mdb",
"data.ms/tasks/data.mdb",
"data.ms/tasks/lock.mdb"
],
"source": "/var/lib/docker/volumes/volgate-wanderer_wanderer_meili_data/_data",
"target": "/meili_data",
"writable_by_app": "yes"
}
],
"name": "wanderer-search",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"wanderer_data",
"wanderer_meili_data",
"wanderer_plugins",
"wanderer_uploads"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"MEILI_MASTER_KEY",
"POCKETBASE_ENCRYPTION_KEY",
"SUBDOMAIN",
"SUBDOMAIN_DB",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"wanderer: unhealthy",
"wanderer: this container's mounts are all empty while it created entries in ['/root'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"wanderer: declared volume /app/uploads is EMPTY",
"wanderer-db: declared volume /data/plugins is EMPTY",
"wanderer-db: writable-layer writes at /data (path suggests state, no database signature \u2014 judgement needed): ['plugins']"
],
"verdict": "UNDETERMINED",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,66 @@
{
"probe": {
"app": "wger",
"containers": [
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "wger/server:2.6",
"mounts": [
{
"class": "named-declared",
"files": 1,
"name": "volgate-wger_wger_data",
"sample": [
"database.sqlite"
],
"source": "/var/lib/docker/volumes/volgate-wger_wger_data/_data",
"target": "/home/wger/db",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-wger_wger_media",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-wger_wger_media/_data",
"target": "/home/wger/media",
"writable_by_app": "yes"
}
],
"name": "wger",
"restarts": null,
"status": "running",
"uid": 1000
}
],
"declared_volumes": [
"wger_data",
"wger_media"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SECRET_KEY",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"wger: declared volume /home/wger/media is EMPTY"
],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,163 @@
{
"probe": {
"app": "wishlist",
"containers": [
{
"diff_added": 35,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"data"
],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"data"
]
},
{
"added": [
".config",
".local"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root",
"files": [
".config",
".local"
]
},
{
"added": [
"caddy"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.config",
"files": [
"caddy"
]
},
{
"added": [
"autosave.json"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.config/caddy",
"files": [
"autosave.json"
]
},
{
"added": [
"share"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.local",
"files": [
"share"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"src"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/src",
"files": [
"app"
]
},
{
"added": [
"data",
"uploads"
],
"changed_count": 0,
"db_signature": false,
"dir": "/usr/src/app",
"files": [
"data",
"uploads"
]
}
],
"diff_token_dirs": [],
"diff_total": 45,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/cmintey/wishlist:v0.66.0",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-wishlist_wishlist_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-wishlist_wishlist_data/_data",
"target": "/data",
"writable_by_app": "yes"
},
{
"class": "anonymous",
"files": 0,
"name": "bef31c91bf46c66ba902cc07e89fbd3032b67fd68264bd04afcb1f3ca7cce0c9",
"sample": [],
"source": "/var/lib/docker/volumes/bef31c91bf46c66ba902cc07e89fbd3032b67fd68264bd04afcb1f3ca7cce0c9/_data",
"target": "/usr/src/app/uploads",
"writable_by_app": "yes"
},
{
"class": "anonymous",
"files": 1,
"name": "782ab526684eaca336b6bfc3226ada078e567bce907a5e17b0782b35e38f016e",
"sample": [
"prod.db"
],
"source": "/var/lib/docker/volumes/782ab526684eaca336b6bfc3226ada078e567bce907a5e17b0782b35e38f016e/_data",
"target": "/usr/src/app/data",
"writable_by_app": "yes"
}
],
"name": "wishlist",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"wishlist_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"wishlist: 1 file(s) in an ANONYMOUS volume at /usr/src/app/data \u2014 not in the compose `volumes:` block, so it is never backed up and a redeploy orphans it",
"wishlist: declared volume /data is EMPTY"
],
"verdict": "BROKEN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,170 @@
{
"probe": {
"app": "zipline",
"containers": [
{
"diff_added": 3,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"zipline"
]
},
{
"added": [
"uploads",
"public"
],
"changed_count": 0,
"db_signature": false,
"dir": "/zipline",
"files": [
"uploads",
"public"
]
}
],
"diff_token_dirs": [],
"diff_total": 5,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/diced/zipline:4.6.1",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-zipline_zipline_uploads",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-zipline_zipline_uploads/_data",
"target": "/zipline/uploads",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-zipline_zipline_public",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-zipline_zipline_public/_data",
"target": "/zipline/public",
"writable_by_app": "yes"
}
],
"name": "zipline",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 70,
"health": "healthy",
"image": "postgres:16-alpine",
"mounts": [
{
"class": "named-declared",
"files": 1357,
"name": "volgate-zipline_zipline_postgres_data",
"sample": [
"postmaster.opts",
"postgresql.conf",
"postgresql.auto.conf",
"pg_ident.conf",
"pg_hba.conf",
"postmaster.pid",
"PG_VERSION",
"base/1/826",
"base/1/2996",
"base/1/3764",
"base/1/2610_fsm",
"base/1/3603_fsm",
"base/1/1249_vm",
"base/1/3600_fsm",
"base/1/13512",
"base/1/3602",
"base/1/6228",
"base/1/4174",
"base/1/4157",
"base/1/4146",
"base/1/2611",
"base/1/2657",
"base/1/3079_vm",
"base/1/1247_vm",
"base/1/1259",
"base/1/3601_fsm",
"base/1/1417",
"base/1/4155",
"base/1/2668",
"base/1/pg_internal.init",
"base/1/2831",
"base/1/2841",
"base/1/3607",
"base/1/2836",
"base/1/1255_fsm",
"base/1/2615_vm",
"base/1/3575",
"base/1/2605_vm",
"base/1/2670",
"base/1/6106"
],
"source": "/var/lib/docker/volumes/volgate-zipline_zipline_postgres_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "zipline-postgres",
"restarts": null,
"status": "running",
"uid": 70
}
],
"declared_volumes": [
"zipline_postgres_data",
"zipline_public",
"zipline_uploads"
],
"env_keys": [
"CORE_SECRET",
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"zipline: this container's mounts are all empty while it created entries in ['/zipline'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"zipline: declared volume /zipline/uploads is EMPTY",
"zipline: declared volume /zipline/public is EMPTY"
],
"verdict": "CLEAN",
"verdict_recomputed_by": "check-volume-persistence.py classify() at final rules"
}
@@ -0,0 +1,84 @@
{
"current": {
"after": {
"/app/app-data/db/db.sqlite": {
"bytes": 475136,
"present": true,
"sha256": "5d179bf1c624330a826164486a13dc2930222aac8b2ed729250f7ae2a51a058b"
},
"/app/data": {
"dir": true,
"files": [],
"present": true
},
"documents": []
},
"before": {
"/app/app-data/db/db.sqlite": {
"bytes": 475136,
"present": true,
"sha256": "1e9b695a7aef6d7602b300f7a02bc2074bc2dd9d04bec4b0c561cc2e3a5ff260"
},
"/app/data": {
"dir": true,
"files": [],
"present": true
},
"documents": []
},
"db_identical_after_redeploy": false,
"leg": "current",
"log": [
"sign-up -> HTTP/1.1 200 OK",
"organizations -> no id in {\"error\":{\"message\":\"Unauthorized\",\"code\":\"auth.unauthorized\"}}",
"[before] {\"/app/app-data/db/db.sqlite\": {\"present\": true, \"bytes\": 475136, \"sha256\": \"1e9b695a7aef6d7602b300f7a02bc2074bc2dd9d04bec4b0c561cc2e3a5ff260\"}, \"/app/data\": {\"present\": true, \"dir\": true, \"files\": []}, \"documents\": []}",
"[after] {\"/app/app-data/db/db.sqlite\": {\"present\": true, \"bytes\": 475136, \"sha256\": \"5d179bf1c624330a826164486a13dc2930222aac8b2ed729250f7ae2a51a058b\"}, \"/app/data\": {\"present\": true, \"dir\": true, \"files\": []}, \"documents\": []}"
],
"seeded": {
"account": "HTTP/1.1 200 OK",
"document_uploaded": false
},
"uploaded_document_present_after": false,
"uploaded_document_present_before": false,
"verdict": "INCONCLUSIVE - the document was never uploaded"
},
"fixed": {
"after": {
"/app/app-data/db/db.sqlite": {
"bytes": 475136,
"present": true,
"sha256": "9dd5cf37d7b9a5dd65653eabfb281f6804b2a05a28ef3040fa7db1e8d874da23"
},
"/app/data": {
"present": false
},
"documents": []
},
"before": {
"/app/app-data/db/db.sqlite": {
"bytes": 475136,
"present": true,
"sha256": "9dd5cf37d7b9a5dd65653eabfb281f6804b2a05a28ef3040fa7db1e8d874da23"
},
"/app/data": {
"present": false
},
"documents": []
},
"db_identical_after_redeploy": true,
"leg": "fixed",
"log": [
"sign-up -> HTTP/1.1 200 OK",
"organizations -> no id in {\"error\":{\"message\":\"Unauthorized\",\"code\":\"auth.unauthorized\"}}",
"[before] {\"/app/app-data/db/db.sqlite\": {\"present\": true, \"bytes\": 475136, \"sha256\": \"9dd5cf37d7b9a5dd65653eabfb281f6804b2a05a28ef3040fa7db1e8d874da23\"}, \"/app/data\": {\"present\": false}, \"documents\": []}",
"[after] {\"/app/app-data/db/db.sqlite\": {\"present\": true, \"bytes\": 475136, \"sha256\": \"9dd5cf37d7b9a5dd65653eabfb281f6804b2a05a28ef3040fa7db1e8d874da23\"}, \"/app/data\": {\"present\": false}, \"documents\": []}"
],
"seeded": {
"account": "HTTP/1.1 200 OK",
"document_uploaded": false
},
"uploaded_document_present_after": false,
"uploaded_document_present_before": false,
"verdict": "INCONCLUSIVE - the document was never uploaded"
}
}
@@ -0,0 +1,4 @@
--- current
+++ fixed
- - papra_data:/app/data
+ - papra_data:/app/app-data
@@ -0,0 +1,255 @@
{
"probe": {
"app": "gramps-web",
"containers": [
{
"diff_added": 121,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/",
"files": [
"usr",
"app",
"root"
]
},
{
"added": [
"secret"
],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"secret"
]
},
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/app/src",
"files": [
"gramps_webapi",
"alembic_users"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/src/alembic_users",
"files": [
"versions"
]
},
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/app/src/gramps_webapi",
"files": [
"api",
"auth",
"util"
]
},
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/app/src/gramps_webapi/api",
"files": [
"search",
"resources"
]
},
{
"added": [
".gramps"
],
"changed_count": 1,
"db_signature": false,
"dir": "/root",
"files": [
".gramps"
]
},
{
"added": [
"grampsdb"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.gramps",
"files": [
"grampsdb"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/root/gramps",
"files": [
"gramps60"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/root/gramps/gramps60",
"files": [
"plugins"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/root/gramps/gramps60/plugins",
"files": [
"FilterRules"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"lib"
]
}
],
"diff_token_dirs": [],
"diff_total": 143,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/gramps-project/grampsweb:v25.6.0",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-grampsweb_gramps_media",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_media/_data",
"target": "/app/media",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 1,
"name": "volgate-grampsweb_gramps_thumb_cache",
"sample": [
"2029240f6d1128be89ddc32729463129"
],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_thumb_cache/_data",
"target": "/app/thumbnail_cache",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 1,
"name": "volgate-grampsweb_gramps_cache",
"sample": [
"request_cache/2029240f6d1128be89ddc32729463129"
],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_cache/_data",
"target": "/app/cache",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-grampsweb_gramps_secret",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_secret/_data",
"target": "/app/secret",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-grampsweb_gramps_tmp",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_tmp/_data",
"target": "/tmp",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 1,
"name": "volgate-grampsweb_gramps_users",
"sample": [
"users.sqlite"
],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_users/_data",
"target": "/app/users",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 2,
"name": "volgate-grampsweb_gramps_db",
"sample": [
"10a64f2c-8485-4514-a23b-81c02a1fbd81/name.txt",
"10a64f2c-8485-4514-a23b-81c02a1fbd81/database.txt"
],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_db/_data",
"target": "/root/.gramps/grampsdb",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-grampsweb_gramps_index",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_index/_data",
"target": "/app/indexdir",
"writable_by_app": "yes"
}
],
"name": "gramps-web",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"gramps_cache",
"gramps_db",
"gramps_index",
"gramps_media",
"gramps_secret",
"gramps_thumb_cache",
"gramps_tmp",
"gramps_users"
],
"env_keys": [
"DOMAIN",
"GRAMPSWEB_SECRET_KEY",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"gramps-web: declared volume /app/media is EMPTY",
"gramps-web: declared volume /app/secret is EMPTY",
"gramps-web: declared volume /app/indexdir is EMPTY"
],
"verdict": "CLEAN"
}
@@ -0,0 +1,152 @@
{
"probe": {
"app": "wishlist",
"containers": [
{
"diff_added": 34,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"root",
"usr"
]
},
{
"added": [
".config",
".local"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root",
"files": [
".config",
".local"
]
},
{
"added": [
"caddy"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.config",
"files": [
"caddy"
]
},
{
"added": [
"autosave.json"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.config/caddy",
"files": [
"autosave.json"
]
},
{
"added": [
"share"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.local",
"files": [
"share"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"src"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/src",
"files": [
"app"
]
},
{
"added": [
"uploads",
"data"
],
"changed_count": 0,
"db_signature": false,
"dir": "/usr/src/app",
"files": [
"uploads",
"data"
]
}
],
"diff_token_dirs": [],
"diff_total": 44,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/cmintey/wishlist:v0.66.0",
"mounts": [
{
"class": "named-declared",
"files": 1,
"name": "volgate-wishlist_wishlist_data",
"sample": [
"prod.db"
],
"source": "/var/lib/docker/volumes/volgate-wishlist_wishlist_data/_data",
"target": "/usr/src/app/data",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-wishlist_wishlist_uploads",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-wishlist_wishlist_uploads/_data",
"target": "/usr/src/app/uploads",
"writable_by_app": "yes"
}
],
"name": "wishlist",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"wishlist_data",
"wishlist_uploads"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"wishlist: declared volume /usr/src/app/uploads is EMPTY"
],
"verdict": "CLEAN"
}
@@ -0,0 +1,77 @@
{
"app": "gramps-web",
"changed_in_place": [
"/app/cache::request_cache/2029240f6d1128be89ddc32729463129"
],
"files_after": 5,
"files_before": 5,
"label": "gramps-web",
"lost": [],
"mounts": [
{
"class": "named-declared",
"ctr": "gramps-web",
"name": "svgrampsweb_gramps_secret",
"source": "/var/lib/docker/volumes/svgrampsweb_gramps_secret/_data",
"target": "/app/secret"
},
{
"class": "named-declared",
"ctr": "gramps-web",
"name": "svgrampsweb_gramps_tmp",
"source": "/var/lib/docker/volumes/svgrampsweb_gramps_tmp/_data",
"target": "/tmp"
},
{
"class": "named-declared",
"ctr": "gramps-web",
"name": "svgrampsweb_gramps_users",
"source": "/var/lib/docker/volumes/svgrampsweb_gramps_users/_data",
"target": "/app/users"
},
{
"class": "named-declared",
"ctr": "gramps-web",
"name": "svgrampsweb_gramps_db",
"source": "/var/lib/docker/volumes/svgrampsweb_gramps_db/_data",
"target": "/root/.gramps/grampsdb"
},
{
"class": "named-declared",
"ctr": "gramps-web",
"name": "svgrampsweb_gramps_index",
"source": "/var/lib/docker/volumes/svgrampsweb_gramps_index/_data",
"target": "/app/indexdir"
},
{
"class": "named-declared",
"ctr": "gramps-web",
"name": "svgrampsweb_gramps_media",
"source": "/var/lib/docker/volumes/svgrampsweb_gramps_media/_data",
"target": "/app/media"
},
{
"class": "named-declared",
"ctr": "gramps-web",
"name": "svgrampsweb_gramps_thumb_cache",
"source": "/var/lib/docker/volumes/svgrampsweb_gramps_thumb_cache/_data",
"target": "/app/thumbnail_cache"
},
{
"class": "named-declared",
"ctr": "gramps-web",
"name": "svgrampsweb_gramps_cache",
"source": "/var/lib/docker/volumes/svgrampsweb_gramps_cache/_data",
"target": "/app/cache"
}
],
"survived": [
"/app/thumbnail_cache::2029240f6d1128be89ddc32729463129",
"/app/users::users.sqlite",
"/root/.gramps/grampsdb::034a955e-563e-4cbc-a5bd-6f1bf63e1c5f/database.txt",
"/root/.gramps/grampsdb::034a955e-563e-4cbc-a5bd-6f1bf63e1c5f/name.txt"
],
"up1_rc": 0,
"up2_rc": 0,
"verdict": "SURVIVES"
}
@@ -0,0 +1,2 @@
gramps-web SURVIVES before=5 after=5 survived=4 changed=1 lost=0 []
wishlist DATA-LOST before=1 after=1 survived=0 changed=1 lost=0 []
@@ -0,0 +1,23 @@
{
"after": {
"inode": 47151,
"sha256": "f35df23bcef4fa1e6c5f312dbc0d63db7fb85032f85abd7e3f28935fe553ab76",
"size": 307200
},
"app": "wishlist",
"before": {
"inode": 47151,
"sha256": "f35df23bcef4fa1e6c5f312dbc0d63db7fb85032f85abd7e3f28935fe553ab76",
"size": 307200
},
"log": [
"signup -> HTTP/1.1 404 Not Found",
"before: db={'inode': 47151, 'size': 307200, 'sha256': 'f35df23bcef4fa1e6c5f312dbc0d63db7fb85032f85abd7e3f28935fe553ab76'} users=Unable to find image 'keinos/sqlite3:latest' locally\nlatest: Pulling from keinos/sqlite3\n4f4fb700ef54: Pulling fs layer\n724d4e3100b9: Pulling fs layer\ne8817903f204: Pulling fs layer\n5439b162c22e: Pulling fs layer\n8e2cd5637e13: Pulling fs layer\n4f4fb700ef54: Already exists\n4f4fb700ef54: Pull complete\n8e2cd5637e13: Download complete\n724d4e3100b9: Download complete\n5439b162c22e: Download complete\ne8817903f204: Download complete\ne8817903f204: Pull complete\n289b2de968e1: Download complete\n7f32741739d1: Download complete\na34b169f6c05: Download complete\n724d4e3100b9: Pull complete\n5439b162c22e: Pull complete\n8e2cd5637e13: Pull complete\nDigest: sha256:a5610a155a8c9007f2050120406a0abcffab246570d6ac1ffe370f5f23e14dc1\nStatus: Downloaded newer image for keinos/sqlite3:latest\nParse error in 2nd command line argument: unable to open database file (14)",
"after : db={'inode': 47151, 'size': 307200, 'sha256': 'f35df23bcef4fa1e6c5f312dbc0d63db7fb85032f85abd7e3f28935fe553ab76'} users=Parse error in 2nd command line argument: unable to open database file (14)"
],
"same_inode": true,
"user_rows_after": "Parse error in 2nd command line argument: unable to open database file (14)",
"user_rows_before": "Unable to find image 'keinos/sqlite3:latest' locally\nlatest: Pulling from keinos/sqlite3\n4f4fb700ef54: Pulling fs layer\n724d4e3100b9: Pulling fs layer\ne8817903f204: Pulling fs layer\n5439b162c22e: Pulling fs layer\n8e2cd5637e13: Pulling fs layer\n4f4fb700ef54: Already exists\n4f4fb700ef54: Pull complete\n8e2cd5637e13: Download complete\n724d4e3100b9: Download complete\n5439b162c22e: Download complete\ne8817903f204: Download complete\ne8817903f204: Pull complete\n289b2de968e1: Download complete\n7f32741739d1: Download complete\na34b169f6c05: Download complete\n724d4e3100b9: Pull complete\n5439b162c22e: Pull complete\n8e2cd5637e13: Pull complete\nDigest: sha256:a5610a155a8c9007f2050120406a0abcffab246570d6ac1ffe370f5f23e14dc1\nStatus: Downloaded newer image for keinos/sqlite3:latest\nParse error in 2nd command line argument: unable to open database file (14)",
"user_rows_preserved": false,
"verdict": "SURVIVES (inode only, no seeded rows)"
}
@@ -0,0 +1,30 @@
{
"app": "wishlist",
"changed_in_place": [
"/usr/src/app/data::prod.db"
],
"files_after": 1,
"files_before": 1,
"label": "wishlist",
"lost": [],
"mounts": [
{
"class": "named-declared",
"ctr": "wishlist",
"name": "svwishlist_wishlist_data",
"source": "/var/lib/docker/volumes/svwishlist_wishlist_data/_data",
"target": "/usr/src/app/data"
},
{
"class": "named-declared",
"ctr": "wishlist",
"name": "svwishlist_wishlist_uploads",
"source": "/var/lib/docker/volumes/svwishlist_wishlist_uploads/_data",
"target": "/usr/src/app/uploads"
}
],
"survived": [],
"up1_rc": 0,
"up2_rc": 0,
"verdict": "DATA-LOST"
}
@@ -0,0 +1,90 @@
self-testing the prober (both directions)…
prober flags the R-156 signature and clears a correct template — trustworthy
actualbudget CLEAN
adventurelog CLEAN
audiobookshelf CLEAN
bentopdf UNDETERMINED nothing was written to any mount and nothing data-classified in any writable layer — the app produced no data to locate. Health is not data: this is U
bookstack CLEAN
calcom CLEAN
calibre-web CLEAN calibre-web: /app/calibre-web-automated/empty_library — database file(s) touched but byte-identical to the image; a chown sweep, not a write; calibre-
claper UNDETERMINED claper: EVERY declared mount is empty, yet the container created entries in 2 director(y/ies) outside all of them (e.g. ['/app/priv', '/app/priv/stati
code-server CLEAN
crafty-controller CLEAN crafty-controller: declared volume /crafty/servers is EMPTY; crafty-controller: declared volume /crafty/import is EMPTY; crafty-controller: declared v
docmost UNDETERMINED docmost: EVERY declared mount is empty, yet the container created entries in 1 director(y/ies) outside all of them (e.g. ['/app/apps/client/dist']) —
emby CLEAN
ghost CLEAN
gitea CLEAN
glance UNDETERMINED glance: not running (status=restarting exit=1 restarts=None)
gokapi CLEAN
grafana CLEAN
gramps-web BROKEN gramps-web: DATA in the writable layer at /app/users (db_signature=True, e.g. ['users.sqlite']); gramps-web: declared volume /app/data is EMPTY; gramp
home-assistant CLEAN
homebox CLEAN
homepage CLEAN
immich UNDETERMINED immich-machine-learning: EVERY declared mount is empty, yet the container created entries in 3 director(y/ies) outside all of them (e.g. ['/', '/root'
jellyfin CLEAN
kimai CLEAN
komga CLEAN
mealie CLEAN
n8n CLEAN
navidrome CLEAN
nextcloud CLEAN
onlyoffice CLEAN onlyoffice: writable-layer writes at /var/www/onlyoffice/documentserver/sdkjs-plugins/{07FD8DFA-DFE0-4089-AL24-0730933CC80A}/resources/store (path sug
opengist CLEAN
outline CLEAN outline: declared volume /var/lib/outline/data is EMPTY; outline-redis: declared volume /data is EMPTY
paperless-ngx CLEAN
papra BROKEN papra: mount /app/data is NOT writable by the app's own uid=999 gid=999; papra: DATA in the writable layer at /app/app-data/db (db_signature=True, e.g
plant-it UNDETERMINED no containers created (compose up rc=1: msdeluise/plant-it:0.10.0 Pulling
Image msdeluise/plant-it:0.10.0 Error pull access denied for msdeluise/pla
plex CLEAN plex: declared volume /transcode is EMPTY
privatebin BROKEN privatebin: 14 file(s) in an ANONYMOUS volume at /run — not in the compose `volumes:` block, so it is never backed up and a redeploy orphans it; priva
radarr CLEAN radarr: writable-layer writes at /media (path suggests state, no database signature — judgement needed): ['movies']
rallly CLEAN
recipe-importer UNDETERMINED recipe-importer: EVERY declared mount is empty, yet the container created entries in 1 director(y/ies) outside all of them (e.g. ['/']) — nothing this
romm CLEAN
seerr CLEAN
sonarr CLEAN sonarr: writable-layer writes at /media (path suggests state, no database signature — judgement needed): ['tv']
sparkyfitness UNDETERMINED sparkyfitness-server: EVERY declared mount is empty, yet the container created entries in 1 director(y/ies) outside all of them (e.g. ['/app/SparkyFit
tandoor CLEAN tandoor: declared volume /opt/recipes/mediafiles is EMPTY
termix CLEAN
uptime-kuma UNDETERMINED nothing was written to any mount and nothing data-classified in any writable layer — the app produced no data to locate. Health is not data: this is U
vaultwarden CLEAN
vikunja CLEAN vikunja: declared volume /app/vikunja/files is EMPTY
wanderer UNDETERMINED wanderer: EVERY declared mount is empty, yet the container created entries in 1 director(y/ies) outside all of them (e.g. ['/root']) — nothing this ap
wger CLEAN wger: declared volume /home/wger/media is EMPTY
wishlist BROKEN wishlist: 1 file(s) in an ANONYMOUS volume at /usr/src/app/data — not in the compose `volumes:` block, so it is never backed up and a redeploy orphans
zipline UNDETERMINED zipline: EVERY declared mount is empty, yet the container created entries in 1 director(y/ies) outside all of them (e.g. ['/zipline']) — nothing this
BROKEN — the app's data does NOT land where the template preserves it:
gramps-web
gramps-web: DATA in the writable layer at /app/users (db_signature=True, e.g. ['users.sqlite'])
gramps-web: declared volume /app/data is EMPTY
gramps-web: declared volume /app/media is EMPTY
gramps-web: declared volume /tmp is EMPTY
papra
papra: mount /app/data is NOT writable by the app's own uid=999 gid=999
papra: DATA in the writable layer at /app/app-data/db (db_signature=True, e.g. ['db.sqlite'])
papra: declared volume /app/data is EMPTY
privatebin
privatebin: 14 file(s) in an ANONYMOUS volume at /run — not in the compose `volumes:` block, so it is never backed up and a redeploy orphans it
privatebin: declared volume /srv/data is EMPTY
wishlist
wishlist: 1 file(s) in an ANONYMOUS volume at /usr/src/app/data — not in the compose `volumes:` block, so it is never backed up and a redeploy orphans it
wishlist: declared volume /data is EMPTY
UNDETERMINED — NOT a pass. The question was not answered for these:
bentopdf: nothing was written to any mount and nothing data-classified in any writable layer — the app produced no data to locate. Health is not data: this is UNDETERMINED, not CLEAN
claper: claper: EVERY declared mount is empty, yet the container created entries in 2 director(y/ies) outside all of them (e.g. ['/app/priv', '/app/priv/static']) — nothing this app wrote landed anywhere the
docmost: docmost: EVERY declared mount is empty, yet the container created entries in 1 director(y/ies) outside all of them (e.g. ['/app/apps/client/dist']) — nothing this app wrote landed anywhere the templat
glance: glance: not running (status=restarting exit=1 restarts=None)
immich: immich-machine-learning: EVERY declared mount is empty, yet the container created entries in 3 director(y/ies) outside all of them (e.g. ['/', '/root', '/root/.config']) — nothing this app wrote lande
plant-it: no containers created (compose up rc=1: msdeluise/plant-it:0.10.0 Pulling
Image msdeluise/plant-it:0.10.0 Error pull access denied for msdeluise/plant-it, repository does not exist or may require 'd
recipe-importer: recipe-importer: EVERY declared mount is empty, yet the container created entries in 1 director(y/ies) outside all of them (e.g. ['/']) — nothing this app wrote landed anywhere the template preserves.
sparkyfitness: sparkyfitness-server: EVERY declared mount is empty, yet the container created entries in 1 director(y/ies) outside all of them (e.g. ['/app/SparkyFitnessServer']) — nothing this app wrote landed anyw
uptime-kuma: nothing was written to any mount and nothing data-classified in any writable layer — the app produced no data to locate. Health is not data: this is UNDETERMINED, not CLEAN; uptime-kuma: declared volu
wanderer: wanderer: EVERY declared mount is empty, yet the container created entries in 1 director(y/ies) outside all of them (e.g. ['/root']) — nothing this app wrote landed anywhere the template preserves. Ne
zipline: zipline: EVERY declared mount is empty, yet the container created entries in 1 director(y/ies) outside all of them (e.g. ['/zipline']) — nothing this app wrote landed anywhere the template preserves.
38 clean · 4 broken · 11 undetermined (of 53 in scope)
volume-persistence gate REFUSED
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Render the sweep matrix from the per-app probe.json evidence. Run on DooPlex after pulling
the evidence back from the scratch guest."""
import json, sys
from pathlib import Path
EV = Path(sys.argv[1] if len(sys.argv) > 1 else "audits/persistence-sweep-2026-08-02/evidence")
MOUNT_SHORT = {"named-declared": "vol", "named-external": "vol-ext", "anonymous": "ANON",
"bind": "bind", "tmpfs": "tmpfs"}
def summarise(p):
v = p["verdict"]
probe = p.get("probe") or {}
ctrs = probe.get("containers") or []
mounts, wrote, unwritable = [], [], []
for c in ctrs:
for m in c.get("mounts") or []:
if m["class"] == "tmpfs":
continue
mounts.append(f"{m['target']}[{MOUNT_SHORT.get(m['class'], m['class'])}"
f"{'' if m['files'] > 0 else ',EMPTY'}]")
if m["files"] > 0:
wrote.append(f"{m['target']}({m['files']})")
if m.get("writable_by_app") == "NO":
unwritable.append(m["target"])
for d in c.get("diff_data_dirs") or []:
wrote.append(f"**{d['dir']} (writable layer)**")
return {
"verdict": v,
"mounted": ", ".join(mounts) or "",
"wrote": ", ".join(wrote) or "nothing",
"unwritable": ", ".join(unwritable) or "",
"reason": "; ".join(p.get("reasons") or []),
"containers": len(ctrs),
}
rows = {}
for d in sorted(EV.iterdir()):
f = d / "probe.json"
if f.is_file():
rows[d.name] = summarise(json.loads(f.read_text()))
order = {"BROKEN": 0, "UNDETERMINED": 1, "CLEAN": 2}
print("| app | verdict | what the template mounts | where the app actually wrote | mount not writable by app uid |")
print("|---|---|---|---|---|")
for app in sorted(rows, key=lambda a: (order.get(rows[a]["verdict"], 3), a)):
r = rows[app]
print(f"| `{app}` | **{r['verdict']}** | {r['mounted']} | {r['wrote']} | {r['unwritable']} |")
print()
for v in ("BROKEN", "UNDETERMINED", "CLEAN"):
apps = [a for a in sorted(rows) if rows[a]["verdict"] == v]
print(f"{v}: {len(apps)}{', '.join(apps) if apps else '(none)'}")
print(f"TOTAL: {len(rows)}")
print("\n--- UNDETERMINED reasons ---")
for a in sorted(rows):
if rows[a]["verdict"] == "UNDETERMINED":
print(f"| `{a}` | {rows[a]['reason'][:400]} |")
print("\n--- BROKEN reasons ---")
for a in sorted(rows):
if rows[a]["verdict"] == "BROKEN":
print(f"| `{a}` | {rows[a]['reason'][:600]} |")
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""R-156 fix proof for papra — the BEFORE/AFTER pair, on real user data.
Not "the volume exists afterwards" that is exactly the confusion R-156 is made of. The claim
under test is: a document a real user uploaded is still there after `docker compose down` + `up -d`.
Leg A CURRENT template (`papra_data:/app/data`) expected: the data is DESTROYED
Leg B FIXED template (`papra_data:/app/app-data`) expected: the data SURVIVES, byte-identical
The account and the document are created through papra's own HTTP API, never by writing into a
volume by hand: R-156's own evidence shows a root-written canary making an empty volume read as
populated. Data is read back with `docker cp`, which works identically whether it sits in a volume
or in the writable layer, so both legs are measured the same way.
No password or session token is ever printed or written to the evidence file.
"""
import hashlib, importlib.util, json, os, re, secrets, shutil, sys, tempfile, time
from pathlib import Path
_spec = importlib.util.spec_from_file_location("cvp", "/opt/sweep/scripts/check-volume-persistence.py")
cvp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(cvp)
TPL = Path("/opt/sweep/templates/papra")
EV = Path("/opt/sweep/evidence/_papra-fix-proof")
DOC = b"Felhom R-156 fix proof - ennek a dokumentumnak tul kell elnie az ujratelepitest.\n"
DOC_SHA = hashlib.sha256(DOC).hexdigest()
def curl(url, *args):
return cvp._sh(["curl", "-sS", "--max-time", "25", *args, url], timeout=45)
def wait_healthy(base, tries=60):
for _ in range(tries):
cid = (cvp._sh(base + ["ps", "-q"], timeout=60).stdout or "").split()
if cid:
st = (cvp._inspect(cid[0]) or {}).get("State") or {}
if (st.get("Health") or {}).get("Status") == "healthy":
return cid[0]
time.sleep(5)
return (cvp._sh(base + ["ps", "-q"], timeout=60).stdout or "").split()[0]
def seed(ip, jar, log):
"""Create an account and upload a document through papra's own API."""
email = f"proof-{secrets.token_hex(4)}@felhom.invalid"
pw = "Proof-" + secrets.token_hex(10) # never recorded
r = curl(f"http://{ip}:1221/api/auth/sign-up/email", "-X", "POST",
"-H", "Content-Type: application/json", "-c", jar, "-i",
"-d", json.dumps({"email": email, "password": pw, "name": "Proof"}))
head = (r.stdout or "").splitlines()[0].strip() if r.stdout else "?"
log.append(f"sign-up -> {head}")
orgs = curl(f"http://{ip}:1221/api/organizations", "-b", jar).stdout or ""
m = re.search(r'"id"\s*:\s*"([^"]+)"', orgs)
if not m:
log.append(f"organizations -> no id in {orgs[:160]}")
return {"account": head, "document_uploaded": False}
p = f"/tmp/proofdoc-{secrets.token_hex(3)}.txt"
open(p, "wb").write(DOC)
u = curl(f"http://{ip}:1221/api/organizations/{m.group(1)}/documents", "-X", "POST",
"-b", jar, "-F", f"file=@{p}", "-i")
os.remove(p)
ok = bool(re.search(r"HTTP/1\.[01] 2\d\d", u.stdout or ""))
log.append(f"upload -> {(u.stdout or '?').splitlines()[0].strip()}")
return {"account": head, "document_uploaded": ok}
def snapshot(cid, log, tag):
"""Read the app's data OUT of the container, wherever it lives."""
d = tempfile.mkdtemp(prefix="snap-")
out = {}
try:
for path in ("/app/app-data/db/db.sqlite", "/app/data"):
dst = os.path.join(d, path.strip("/").replace("/", "_"))
if cvp._sh(["docker", "cp", f"{cid}:{path}", dst], timeout=300).returncode != 0:
out[path] = {"present": False}
continue
if os.path.isfile(dst):
b = open(dst, "rb").read()
out[path] = {"present": True, "bytes": len(b),
"sha256": hashlib.sha256(b).hexdigest()}
else:
out[path] = {"present": True, "dir": True,
"files": sorted(os.path.relpath(os.path.join(r, f), dst)
for r, _, fs in os.walk(dst) for f in fs)}
docs = []
if cvp._sh(["docker", "cp", f"{cid}:/app/app-data/documents",
os.path.join(d, "documents")], timeout=300).returncode == 0:
for r, _, fs in os.walk(os.path.join(d, "documents")):
for f in fs:
b = open(os.path.join(r, f), "rb").read()
docs.append({"size": len(b), "sha256": hashlib.sha256(b).hexdigest(),
"is_the_proof_document": hashlib.sha256(b).hexdigest() == DOC_SHA})
out["documents"] = docs
finally:
shutil.rmtree(d, ignore_errors=True)
log.append(f"[{tag}] " + json.dumps(out)[:260])
return out
def leg(tag, compose_body):
log = []
work = Path(tempfile.mkdtemp(prefix=f"papra-{tag}-"))
(work / "docker-compose.yml").write_text(compose_body)
env = cvp.build_env("papra", (TPL / ".felhom.yml").read_text(), compose_body)
(work / ".env").write_text("".join(f"{k}={v}\n" for k, v in sorted(env.items())))
project = "pp" + tag
base = ["docker", "compose", "-p", project, "--project-directory", str(work),
"-f", str(work / "docker-compose.yml")]
cvp._sh(["docker", "network", "create", "traefik-public"], timeout=60)
jar = f"/tmp/pj-{tag}"
try:
cvp._sh(base + ["up", "-d"], timeout=1200)
cid = wait_healthy(base)
ip = [n["IPAddress"] for n in
((cvp._inspect(cid) or {}).get("NetworkSettings") or {}).get("Networks", {}).values()
if n.get("IPAddress")][0]
time.sleep(15)
seeded = seed(ip, jar, log)
time.sleep(20)
before = snapshot(cid, log, "before")
cvp._sh(base + ["down"], timeout=900) # a REDEPLOY: no -v
cvp._sh(base + ["up", "-d"], timeout=1200)
cid2 = wait_healthy(base)
time.sleep(25)
after = snapshot(cid2, log, "after")
db = "/app/app-data/db/db.sqlite"
db_same = bool(before[db].get("sha256")
and before[db].get("sha256") == after[db].get("sha256"))
doc_b = any(x["is_the_proof_document"] for x in before["documents"])
doc_a = any(x["is_the_proof_document"] for x in after["documents"])
return {"leg": tag, "seeded": seeded, "before": before, "after": after,
"db_identical_after_redeploy": db_same,
"uploaded_document_present_before": doc_b,
"uploaded_document_present_after": doc_a,
"verdict": ("USER DATA SURVIVES" if (db_same and doc_b and doc_a)
else "USER DATA LOST" if doc_b and not doc_a
else "INCONCLUSIVE - the document was never uploaded"
if not doc_b else "DATA LOST"),
"log": log}
finally:
cvp._sh(base + ["down", "-v", "--remove-orphans"], timeout=900)
shutil.rmtree(work, ignore_errors=True)
if os.path.exists(jar):
os.remove(jar)
if __name__ == "__main__":
EV.mkdir(parents=True, exist_ok=True)
cur = (TPL / "docker-compose.yml").read_text()
fixed = cur.replace("- papra_data:/app/data", "- papra_data:/app/app-data")
assert fixed != cur, "the mount line did not match — refusing to 'prove' an unchanged template"
(EV / "template.diff").write_text(
"--- current\n+++ fixed\n- - papra_data:/app/data\n+ - papra_data:/app/app-data\n")
out = {}
for tag, body in (("current", cur), ("fixed", fixed)):
out[tag] = leg(tag, body)
r = out[tag]
print(f"{tag:<8} {r['verdict']:<34} db_identical={r['db_identical_after_redeploy']} "
f"doc_before={r['uploaded_document_present_before']} "
f"doc_after={r['uploaded_document_present_after']}", flush=True)
(EV / "papra-fix-proof.json").write_text(json.dumps(out, indent=2, sort_keys=True))
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Re-derive every verdict from the captured probes using the committed `classify()`.
The structural "did anything land in a mount at all?" rule was added to `classify()` after the
sweep had started. It reads only fields the probe already captures (mount occupancy and the
directories created outside every mount), and the Docker-touching half of the gate the part that
produces `probe` was not changed by it. So the matrix is recomputed rather than re-measured, and
the verdict every app carries is the one the COMMITTED gate produces for its captured evidence.
Anything the rule could not have seen would require a fresh capture; nothing here does.
Usage: reclassify.py <evidence-dir>
"""
import importlib.util, json, sys
from pathlib import Path
_spec = importlib.util.spec_from_file_location(
"cvp", Path(__file__).resolve().parent / "check-volume-persistence.py")
cvp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(cvp)
EV = Path(sys.argv[1])
changed = []
for d in sorted(EV.iterdir()):
f = d / "probe.json"
if not f.is_file():
continue
rec = json.loads(f.read_text())
old = rec.get("verdict")
status, why = cvp.classify(rec.get("probe") or {})
if status != old:
changed.append((d.name, old, status))
rec["verdict"] = status
rec["reasons"] = why
rec["verdict_recomputed_by"] = "check-volume-persistence.py classify() (see reclassify.py)"
f.write_text(json.dumps(rec, indent=2, sort_keys=True))
print(f"re-classified {sum(1 for d in EV.iterdir() if (d / 'probe.json').is_file())} apps")
for a, o, n in changed:
print(f" CHANGED {a}: {o} -> {n}")
if not changed:
print(" no verdict changed")
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Survives-a-redeploy prover — the second half of the proof the gate cannot give.
`check-volume-persistence.py` answers *where did the data land*. This answers *is it still there
after a redeploy*, which is the claim the customer actually cares about. Presence is not success:
that a volume exists afterwards says nothing about whether the data is in it.
Method, per app:
1. deploy from the template, exercise, settle
2. FINGERPRINT every file the app created inside every declared mount (relpath, size, sha256,
inode), read host-side from the volume's own directory
3. `docker compose down` NO `-v`. That is a redeploy; `down --volumes` is the UNINSTALL path
and destroys volumes deliberately (Campaign 7 §2)
4. `docker compose up -d`, wait, settle
5. RE-FINGERPRINT. A file SURVIVED only if sha256 AND inode both match the same bytes in the
same file object, not a fresh file the app recreated under the same name.
Nothing is ever seeded into a volume by hand. R-156's own evidence shows a root-written canary
making an empty volume read as populated, which is the confusion this work exists to remove.
Usage: python3 survive2.py <app> [<app> ] (templates under /opt/sweep/templates)
"""
import hashlib, importlib.util, json, os, shutil, sys, tempfile, time
from pathlib import Path
_spec = importlib.util.spec_from_file_location("cvp", "/opt/sweep/check-volume-persistence.py")
cvp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(cvp)
TEMPLATES = Path("/opt/sweep/templates")
EVIDENCE = Path("/opt/sweep/evidence")
def fingerprint(mounts):
fp = {}
for m in mounts:
src = m["source"]
if not src or not os.path.isdir(src):
continue
for dp, _, fns in os.walk(src, onerror=lambda e: None):
for fn in fns:
p = os.path.join(dp, fn)
if not os.path.isfile(p):
continue
try:
st = os.lstat(p)
h = hashlib.sha256(open(p, "rb").read()).hexdigest()
except OSError:
continue
fp[f"{m['target']}::{os.path.relpath(p, src)}"] = {
"size": st.st_size, "sha256": h, "inode": st.st_ino}
return fp
def bring_up(base, declared, project, ports, settle=45, wait=300):
rc = cvp._sh(base + ["up", "-d"], timeout=1800).returncode
cids = [c for c in cvp._sh(base + ["ps", "-aq"], timeout=120).stdout.split() if c]
deadline = time.time() + wait
while time.time() < deadline:
pend = False
for cid in cids:
st = (cvp._inspect(cid) or {}).get("State") or {}
if st.get("Status") in ("created", "restarting") or \
(st.get("Health") or {}).get("Status") == "starting":
pend = True
if not pend:
break
time.sleep(10)
running = [c for c in cids
if ((cvp._inspect(c) or {}).get("State") or {}).get("Status") == "running"]
if running and ports:
cvp._exercise(running, ports)
time.sleep(settle)
mounts = []
for cid in cids:
info = cvp._inspect(cid)
if not info:
continue
nm = (info.get("Name") or cid).lstrip("/")
for m in (info.get("Mounts") or []):
cls = cvp._classify_mount(m, project, declared)
if cls == "tmpfs":
continue
mounts.append({"ctr": nm, "target": m.get("Destination"), "class": cls,
"source": m.get("Source"), "name": m.get("Name")})
return rc, cids, mounts
def prove(app, tdir=None, label=None):
tdir = Path(tdir) if tdir else TEMPLATES / app
label = label or app
edir = EVIDENCE / app
edir.mkdir(parents=True, exist_ok=True)
work = Path(tempfile.mkdtemp(prefix=f"surv-{label}-"))
compose_text = (tdir / "docker-compose.yml").read_text()
felhom_text = (tdir / ".felhom.yml").read_text() if (tdir / ".felhom.yml").is_file() else ""
shutil.copy(tdir / "docker-compose.yml", work / "docker-compose.yml")
env = cvp.build_env(app, felhom_text, compose_text)
(work / ".env").write_text("".join(f"{k}={v}\n" for k, v in sorted(env.items())))
project = "sv" + "".join(ch for ch in label.lower() if ch.isalnum())
base = ["docker", "compose", "-p", project, "--project-directory", str(work),
"-f", str(work / "docker-compose.yml")]
os.makedirs(cvp.SCRATCH_HDD + "/userdata", exist_ok=True)
os.makedirs(cvp.SCRATCH_IMPORT, exist_ok=True)
cvp._sh(["docker", "network", "create", "traefik-public"], timeout=60)
try:
cfg = json.loads(cvp._sh(base + ["config", "--format", "json"], timeout=180).stdout)
declared = set((cfg.get("volumes") or {}).keys())
ports = sorted({int(m.group(1))
for svc in (cfg.get("services") or {}).values()
for lbl in ((svc.get("labels") or {}).values()
if isinstance(svc.get("labels"), dict)
else (svc.get("labels") or []))
for m in [cvp.PORT_RE.search(str(lbl))] if m})
rc1, _, mounts1 = bring_up(base, declared, project, ports)
before = fingerprint(mounts1)
cvp._sh(base + ["down"], timeout=900) # a REDEPLOY — no -v
rc2, _, mounts2 = bring_up(base, declared, project, ports)
after = fingerprint(mounts2)
survived = sorted(k for k in before if k in after
and after[k]["sha256"] == before[k]["sha256"]
and after[k]["inode"] == before[k]["inode"])
changed = sorted(k for k in before if k in after and k not in survived)
lost = sorted(k for k in before if k not in after)
res = {"app": app, "label": label, "up1_rc": rc1, "up2_rc": rc2,
"files_before": len(before), "files_after": len(after),
"survived": survived, "changed_in_place": changed, "lost": lost,
"mounts": mounts1,
"verdict": ("SURVIVES" if (before and not lost and survived)
else "NO-DATA-TO-LOSE" if not before else "DATA-LOST")}
(edir / f"survive-{label}.json").write_text(json.dumps(res, indent=2, sort_keys=True))
return res
finally:
cvp._sh(base + ["down", "-v", "--remove-orphans"], timeout=900)
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
for a in sys.argv[1:]:
r = prove(a)
print("%-16s %-16s before=%d after=%d survived=%d changed=%d lost=%d %s"
% (a, r["verdict"], r["files_before"], r["files_after"], len(r["survived"]),
len(r["changed_in_place"]), len(r["lost"]), r["lost"][:3]), flush=True)
@@ -0,0 +1,27 @@
=== BEFORE ===
Name Type Status Total (KiB) Used (KiB) Available (KiB) %
felhom-backup dir active 960303848 21660676 889788660 2.26%
felhom-pbs pbs active 0 0 0 0.00%
local dir active 98497780 25608552 67839680 26.00%
local-lvm lvmthin active 365760512 196047634 169712877 53.60%
VMID Status Lock Name
9201 running demo-felhom
9301 running catalog-persist
LV VG Attr LSize Pool Origin Data% Meta% Move Log Cpy%Sync Convert
vm-9301-disk-0 pve Vwi-aotz-- 120.00g data 70.73
=== DESTROY 9301 ===
Logical volume "vm-9301-disk-0" successfully removed.
purging CT 9301 from related configurations..
rc=0
=== AFTER ===
Name Type Status Total (KiB) Used (KiB) Available (KiB) %
felhom-backup dir active 960303848 21660676 889788660 2.26%
felhom-pbs pbs active 0 0 0 0.00%
local dir active 98497780 25608496 67839736 26.00%
local-lvm lvmthin active 365760512 107058101 258702410 29.27%
VMID Status Lock Name
9201 running demo-felhom
--- lvs for 9301 (expect none) ---
no 9301 logical volume
@@ -0,0 +1,69 @@
{
"probe": {
"app": "bentopdf",
"containers": [
{
"diff_added": 6,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"etc",
"var"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc",
"files": [
"nginx"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/var",
"files": [
"cache"
]
}
],
"diff_token_dirs": [],
"diff_total": 14,
"diff_unresolved": [],
"exit": 0,
"gid": 101,
"health": "healthy",
"image": "ghcr.io/alam00000/bentopdf:v2.8.6",
"mounts": [],
"name": "bentopdf",
"restarts": null,
"status": "running",
"uid": 101
}
],
"declared_volumes": [],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"nothing was written to any mount and nothing data-classified in any writable layer \u2014 the app produced no data to locate. Health is not data: this is UNDETERMINED, not CLEAN"
],
"verdict": "UNDETERMINED"
}
@@ -0,0 +1,176 @@
{
"probe": {
"app": "claper",
"containers": [
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"app"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"priv"
]
},
{
"added": [
"static"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app/priv",
"files": [
"static"
]
},
{
"added": [
"uploads"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app/priv/static",
"files": [
"uploads"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/claperco/claper:2.5",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-claper_claper_uploads",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-claper_claper_uploads/_data",
"target": "/app/priv/static/uploads",
"writable_by_app": "yes"
}
],
"name": "claper",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 70,
"health": "healthy",
"image": "postgres:16-alpine",
"mounts": [
{
"class": "named-declared",
"files": 1469,
"name": "volgate-claper_claper_postgres_data",
"sample": [
"postmaster.opts",
"postgresql.conf",
"postgresql.auto.conf",
"pg_ident.conf",
"pg_hba.conf",
"postmaster.pid",
"PG_VERSION",
"base/1/826",
"base/1/2996",
"base/1/3764",
"base/1/2610_fsm",
"base/1/3603_fsm",
"base/1/1249_vm",
"base/1/3600_fsm",
"base/1/13512",
"base/1/3602",
"base/1/6228",
"base/1/4174",
"base/1/4157",
"base/1/4146",
"base/1/2611",
"base/1/2657",
"base/1/3079_vm",
"base/1/1247_vm",
"base/1/1259",
"base/1/3601_fsm",
"base/1/1417",
"base/1/4155",
"base/1/2668",
"base/1/2831",
"base/1/2841",
"base/1/3607",
"base/1/2836",
"base/1/1255_fsm",
"base/1/2615_vm",
"base/1/3575",
"base/1/2605_vm",
"base/1/2670",
"base/1/6106",
"base/1/4163"
],
"source": "/var/lib/docker/volumes/volgate-claper_claper_postgres_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "claper-postgres",
"restarts": null,
"status": "running",
"uid": 70
}
],
"declared_volumes": [
"claper_postgres_data",
"claper_uploads"
],
"env_keys": [
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SECRET_KEY_BASE",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"claper: this container's mounts are all empty while it created entries in ['/app/priv', '/app/priv/static'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"claper: declared volume /app/priv/static/uploads is EMPTY"
],
"verdict": "CLEAN"
}
@@ -0,0 +1,216 @@
{
"probe": {
"app": "docmost",
"containers": [
{
"diff_added": 1,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"app"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"apps"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/apps",
"files": [
"client"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/apps/client",
"files": [
"dist"
]
},
{
"added": [
"index-template.html"
],
"changed_count": 1,
"db_signature": false,
"dir": "/app/apps/client/dist",
"files": [
"index-template.html"
]
}
],
"diff_token_dirs": [],
"diff_total": 6,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "docmost/docmost:0.95.0",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-docmost_docmost_storage",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-docmost_docmost_storage/_data",
"target": "/app/data/storage",
"writable_by_app": "yes"
}
],
"name": "docmost",
"restarts": null,
"status": "running",
"uid": 1000
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 70,
"health": "healthy",
"image": "postgres:16-alpine",
"mounts": [
{
"class": "named-declared",
"files": 1540,
"name": "volgate-docmost_docmost_postgres_data",
"sample": [
"postmaster.opts",
"postgresql.conf",
"postgresql.auto.conf",
"pg_ident.conf",
"pg_hba.conf",
"postmaster.pid",
"PG_VERSION",
"base/1/826",
"base/1/2996",
"base/1/3764",
"base/1/2610_fsm",
"base/1/3603_fsm",
"base/1/1249_vm",
"base/1/3600_fsm",
"base/1/13512",
"base/1/3602",
"base/1/6228",
"base/1/4174",
"base/1/4157",
"base/1/4146",
"base/1/2611",
"base/1/2657",
"base/1/3079_vm",
"base/1/1247_vm",
"base/1/1259",
"base/1/3601_fsm",
"base/1/1417",
"base/1/4155",
"base/1/2668",
"base/1/2831",
"base/1/2841",
"base/1/3607",
"base/1/2836",
"base/1/1255_fsm",
"base/1/2615_vm",
"base/1/3575",
"base/1/2605_vm",
"base/1/2670",
"base/1/6106",
"base/1/4163"
],
"source": "/var/lib/docker/volumes/volgate-docmost_docmost_postgres_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "docmost-postgres",
"restarts": null,
"status": "running",
"uid": 70
},
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "redis:7-alpine",
"mounts": [
{
"class": "named-declared",
"files": 3,
"name": "volgate-docmost_docmost_redis_data",
"sample": [
"appendonlydir/appendonly.aof.1.base.rdb",
"appendonlydir/appendonly.aof.1.incr.aof",
"appendonlydir/appendonly.aof.manifest"
],
"source": "/var/lib/docker/volumes/volgate-docmost_docmost_redis_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "docmost-redis",
"restarts": null,
"status": "running",
"uid": 999
}
],
"declared_volumes": [
"docmost_postgres_data",
"docmost_redis_data",
"docmost_storage"
],
"env_keys": [
"APP_SECRET",
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"docmost: this container's mounts are all empty while it created entries in ['/app/apps/client/dist'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"docmost: declared volume /app/data/storage is EMPTY"
],
"verdict": "CLEAN"
}
@@ -0,0 +1,73 @@
{
"probe": {
"app": "glance",
"containers": [
{
"diff_added": 1,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"app"
]
},
{
"added": [
"config"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app",
"files": [
"config"
]
}
],
"diff_token_dirs": [],
"diff_total": 2,
"diff_unresolved": [],
"exit": 1,
"gid": null,
"health": "unhealthy",
"image": "glanceapp/glance:v0.8.5",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-glance_glance_config",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-glance_glance_config/_data",
"target": "/app/config",
"writable_by_app": "unknown-uid"
}
],
"name": "glance",
"restarts": null,
"status": "restarting",
"uid": null
}
],
"declared_volumes": [
"glance_config"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"glance: not running (status=restarting exit=1 restarts=None)"
],
"verdict": "UNDETERMINED"
}
@@ -0,0 +1,265 @@
{
"probe": {
"app": "gramps-web",
"containers": [
{
"diff_added": 127,
"diff_benign_db_touches": [],
"diff_data_dirs": [
{
"added": [
"users.sqlite"
],
"changed_count": 0,
"db_signature": true,
"dir": "/app/users",
"files": [
"users.sqlite"
]
}
],
"diff_other_dirs": [
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/",
"files": [
"app",
"root",
"usr"
]
},
{
"added": [
"data"
],
"changed_count": 4,
"db_signature": false,
"dir": "/app",
"files": [
"data"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/cache",
"files": [
"request_cache"
]
},
{
"added": [
"2029240f6d1128be89ddc32729463129"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app/cache/request_cache",
"files": [
"2029240f6d1128be89ddc32729463129"
]
},
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/app/src",
"files": [
"alembic_users",
"gramps_webapi"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/src/alembic_users",
"files": [
"versions"
]
},
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/app/src/gramps_webapi",
"files": [
"api",
"auth",
"util"
]
},
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/app/src/gramps_webapi/api",
"files": [
"resources",
"search"
]
},
{
"added": [
"2029240f6d1128be89ddc32729463129"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app/thumbnail_cache",
"files": [
"2029240f6d1128be89ddc32729463129"
]
},
{
"added": [
".gramps"
],
"changed_count": 1,
"db_signature": false,
"dir": "/root",
"files": [
".gramps"
]
},
{
"added": [
"grampsdb"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.gramps",
"files": [
"grampsdb"
]
},
{
"added": [
"edaa44da-5ceb-4855-966e-8462f6b067f7"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.gramps/grampsdb",
"files": [
"edaa44da-5ceb-4855-966e-8462f6b067f7"
]
},
{
"added": [
"name.txt",
"database.txt"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.gramps/grampsdb/edaa44da-5ceb-4855-966e-8462f6b067f7",
"files": [
"name.txt",
"database.txt"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/root/gramps",
"files": [
"gramps60"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/root/gramps/gramps60",
"files": [
"plugins"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/root/gramps/gramps60/plugins",
"files": [
"FilterRules"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"lib"
]
}
],
"diff_token_dirs": [],
"diff_total": 153,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/gramps-project/grampsweb:v25.6.0",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-grampsweb_gramps_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_data/_data",
"target": "/app/data",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-grampsweb_gramps_media",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_media/_data",
"target": "/app/media",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-grampsweb_gramps_tmp",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-grampsweb_gramps_tmp/_data",
"target": "/tmp",
"writable_by_app": "yes"
}
],
"name": "gramps-web",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"gramps_data",
"gramps_media",
"gramps_tmp"
],
"env_keys": [
"DOMAIN",
"GRAMPSWEB_SECRET_KEY",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"gramps-web: DATA in the writable layer at /app/users (db_signature=True, e.g. ['users.sqlite'])",
"NOTHING this app wrote landed in ANY folder the template preserves: all 2 mount(s) across 1 container(s) are empty, yet entries were created in ['/app', '/app/cache/request_cache', '/app/thumbnail_cache', '/root']. Needs adjudication.",
"gramps-web: this container's mounts are all empty while it created entries in ['/app', '/app/cache/request_cache', '/app/thumbnail_cache'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"gramps-web: declared volume /app/data is EMPTY",
"gramps-web: declared volume /app/media is EMPTY"
],
"verdict": "BROKEN"
}
@@ -0,0 +1,329 @@
{
"probe": {
"app": "immich",
"containers": [
{
"diff_added": 6,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"cache"
],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"cache"
]
},
{
"added": [
".config"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root",
"files": [
".config"
]
},
{
"added": [
"matplotlib"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.config",
"files": [
"matplotlib"
]
}
],
"diff_token_dirs": [],
"diff_total": 7,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/immich-app/immich-machine-learning:v3.0.3",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-immich_immich_ml_cache",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-immich_immich_ml_cache/_data",
"target": "/cache",
"writable_by_app": "yes"
}
],
"name": "immich-machine-learning",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 3,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"etc",
"run"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc",
"files": [
"postgresql"
]
},
{
"added": [
"postgresql.conf"
],
"changed_count": 0,
"db_signature": false,
"dir": "/etc/postgresql",
"files": [
"postgresql.conf"
]
}
],
"diff_token_dirs": [],
"diff_total": 7,
"diff_unresolved": [],
"exit": 0,
"gid": 999,
"health": "healthy",
"image": "ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0",
"mounts": [
{
"class": "named-declared",
"files": 1832,
"name": "volgate-immich_immich_postgres_data",
"sample": [
"postmaster.opts",
"postgresql.conf",
"postgresql.auto.conf",
"pg_ident.conf",
"pg_hba.conf",
"postmaster.pid",
"PG_VERSION",
"base/1/826",
"base/1/2996",
"base/1/3764",
"base/1/2610_fsm",
"base/1/3603_fsm",
"base/1/1249_vm",
"base/1/3600_fsm",
"base/1/3602",
"base/1/6228",
"base/1/4174",
"base/1/4157",
"base/1/4146",
"base/1/2611",
"base/1/2657",
"base/1/3079_vm",
"base/1/1247_vm",
"base/1/13406",
"base/1/1259",
"base/1/3601_fsm",
"base/1/1417",
"base/1/4155",
"base/1/2668",
"base/1/2831",
"base/1/2841",
"base/1/13414",
"base/1/3607",
"base/1/2836",
"base/1/1255_fsm",
"base/1/2615_vm",
"base/1/3575",
"base/1/2605_vm",
"base/1/2670",
"base/1/6106"
],
"source": "/var/lib/docker/volumes/volgate-immich_immich_postgres_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "immich-postgres",
"restarts": null,
"status": "running",
"uid": 999
},
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 1000,
"health": "healthy",
"image": "redis:7-alpine",
"mounts": [
{
"class": "named-declared",
"files": 3,
"name": "volgate-immich_immich_redis_data",
"sample": [
"appendonlydir/appendonly.aof.1.base.rdb",
"appendonlydir/appendonly.aof.1.incr.aof",
"appendonlydir/appendonly.aof.manifest"
],
"source": "/var/lib/docker/volumes/volgate-immich_immich_redis_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "immich-redis",
"restarts": null,
"status": "running",
"uid": 999
},
{
"diff_added": 4,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"external",
"data"
],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"external",
"data"
]
},
{
"added": [
"photos"
],
"changed_count": 0,
"db_signature": false,
"dir": "/external",
"files": [
"photos"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"src"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/src",
"files": [
"app"
]
},
{
"added": [
"upload"
],
"changed_count": 0,
"db_signature": false,
"dir": "/usr/src/app",
"files": [
"upload"
]
}
],
"diff_token_dirs": [],
"diff_total": 7,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/immich-app/immich-server:v3.0.3",
"mounts": [
{
"class": "bind",
"files": 6,
"name": null,
"sample": [
"library/.immich",
"upload/.immich",
"profile/.immich",
"encoded-video/.immich",
"thumbs/.immich",
"backups/.immich"
],
"source": "/srv/felhom-gate/hdd/appdata/immich",
"target": "/usr/src/app/upload",
"writable_by_app": "yes"
},
{
"class": "bind",
"files": 0,
"name": null,
"sample": [],
"source": "/srv/felhom-gate/hdd/userdata/media/photos",
"target": "/external/photos",
"writable_by_app": "yes"
},
{
"class": "anonymous",
"files": 0,
"name": "51538436a985b60deb1473d5837733528b3440627b2f7761b235f8bd92010975",
"sample": [],
"source": "/var/lib/docker/volumes/51538436a985b60deb1473d5837733528b3440627b2f7761b235f8bd92010975/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "immich-server",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"immich_ml_cache",
"immich_postgres_data",
"immich_redis_data"
],
"env_keys": [
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"immich-machine-learning: this container's mounts are all empty while it created entries in ['/', '/root', '/root/.config'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"immich-machine-learning: declared volume /cache is EMPTY"
],
"verdict": "CLEAN"
}
@@ -0,0 +1,99 @@
{
"probe": {
"app": "papra",
"containers": [
{
"diff_added": 173,
"diff_benign_db_touches": [],
"diff_data_dirs": [
{
"added": [
"db.sqlite"
],
"changed_count": 0,
"db_signature": true,
"dir": "/app/app-data/db",
"files": [
"db.sqlite"
]
}
],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"app"
]
},
{
"added": [
"data"
],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"data"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app/app-data",
"files": [
"db"
]
}
],
"diff_token_dirs": [],
"diff_total": 177,
"diff_unresolved": [],
"exit": 0,
"gid": 999,
"health": "healthy",
"image": "ghcr.io/papra-hq/papra:26.6.1-rootless",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-papra_papra_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-papra_papra_data/_data",
"target": "/app/data",
"writable_by_app": "NO"
}
],
"name": "papra",
"restarts": null,
"status": "running",
"uid": 999
}
],
"declared_volumes": [
"papra_data"
],
"env_keys": [
"AUTH_SECRET",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"papra: mount /app/data is NOT writable by the app's own uid=999 gid=999",
"papra: DATA in the writable layer at /app/app-data/db (db_signature=True, e.g. ['db.sqlite'])",
"NOTHING this app wrote landed in ANY folder the template preserves: all 1 mount(s) across 1 container(s) are empty, yet entries were created in ['/app']. Needs adjudication.",
"papra: this container's mounts are all empty while it created entries in ['/app'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"papra: declared volume /app/data is EMPTY"
],
"verdict": "BROKEN"
}
@@ -0,0 +1,10 @@
{
"probe": {
"app": "plant-it",
"error": "no containers created (compose up rc=1: msdeluise/plant-it:0.10.0 Pulling \n Image msdeluise/plant-it:0.10.0 Error pull access denied for msdeluise/plant-it, repository does not exist or may require 'docker login'\nError response from daemon: pull access denied for msdeluise/plant-it, repository does not exist or may require 'docker login'\n)"
},
"reasons": [
"no containers created (compose up rc=1: msdeluise/plant-it:0.10.0 Pulling \n Image msdeluise/plant-it:0.10.0 Error pull access denied for msdeluise/plant-it, repository does not exist or may require 'docker login'\nError response from daemon: pull access denied for msdeluise/plant-it, repository does not exist or may require 'docker login'\n)"
],
"verdict": "UNDETERMINED"
}
@@ -0,0 +1,95 @@
{
"probe": {
"app": "privatebin",
"containers": [
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 82,
"health": "healthy",
"image": "privatebin/pdo:2.0.5",
"mounts": [
{
"class": "anonymous",
"files": 0,
"name": "81f6e29600a6e6cbaa58dda4e144909abf098adc3c6116ca85166068c6a02e34",
"sample": [],
"source": "/var/lib/docker/volumes/81f6e29600a6e6cbaa58dda4e144909abf098adc3c6116ca85166068c6a02e34/_data",
"target": "/var/lib/nginx/tmp",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-privatebin_privatebin_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-privatebin_privatebin_data/_data",
"target": "/srv/data",
"writable_by_app": "yes"
},
{
"class": "anonymous",
"files": 14,
"name": "6c40818b0f3ef0f6687a2394c61219df6a1d806829e2c6d45c137c9e62427a14",
"sample": [
"nginx.pid",
"php-fpm.sock",
"services/.s6-svscan/control",
"services/.s6-svscan/lock",
"services/php-fpm85/run",
"services/php-fpm85/supervise/control",
"services/php-fpm85/supervise/death_tally",
"services/php-fpm85/supervise/status",
"services/php-fpm85/supervise/lock",
"services/nginx/run",
"services/nginx/supervise/control",
"services/nginx/supervise/death_tally",
"services/nginx/supervise/status",
"services/nginx/supervise/lock"
],
"source": "/var/lib/docker/volumes/6c40818b0f3ef0f6687a2394c61219df6a1d806829e2c6d45c137c9e62427a14/_data",
"target": "/run",
"writable_by_app": "yes"
},
{
"class": "anonymous",
"files": 0,
"name": "660faab3050406b4dac39dedfc189331d712fb02b3990aed2e81033a08917cf3",
"sample": [],
"source": "/var/lib/docker/volumes/660faab3050406b4dac39dedfc189331d712fb02b3990aed2e81033a08917cf3/_data",
"target": "/tmp",
"writable_by_app": "yes"
}
],
"name": "privatebin",
"restarts": null,
"status": "running",
"uid": 65534
}
],
"declared_volumes": [
"privatebin_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"nothing was written to any mount and nothing data-classified in any writable layer \u2014 the app produced no data to locate. Health is not data: this is UNDETERMINED, not CLEAN",
"privatebin: declared volume /srv/data is EMPTY"
],
"verdict": "UNDETERMINED"
}
@@ -0,0 +1,94 @@
{
"probe": {
"app": "recipe-importer",
"containers": [
{
"diff_added": 13,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"data"
],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"data"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"app"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"local"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/local",
"files": [
"lib"
]
}
],
"diff_token_dirs": [],
"diff_total": 22,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "gitea.dooplex.hu/admin/recipe-importer:v0.9.11",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-recipeimporter_recipe_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-recipeimporter_recipe_data/_data",
"target": "/data",
"writable_by_app": "yes"
}
],
"name": "recipe-importer",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"recipe_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SECRET_KEY",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"NOTHING this app wrote landed in ANY folder the template preserves: all 1 mount(s) across 1 container(s) are empty, yet entries were created in ['/']. Needs adjudication.",
"recipe-importer: this container's mounts are all empty while it created entries in ['/'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"recipe-importer: declared volume /data is EMPTY"
],
"verdict": "UNDETERMINED"
}
@@ -0,0 +1,230 @@
{
"probe": {
"app": "sparkyfitness",
"containers": [
{
"diff_added": 11,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 3,
"db_signature": false,
"dir": "/",
"files": [
"etc",
"var",
"run"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/etc",
"files": [
"nginx"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/var",
"files": [
"cache"
]
}
],
"diff_token_dirs": [],
"diff_total": 19,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "codewithcj/sparkyfitness:v0.17.3",
"mounts": [],
"name": "sparkyfitness",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 70,
"health": "healthy",
"image": "postgres:15-alpine",
"mounts": [
{
"class": "named-declared",
"files": 1814,
"name": "volgate-sparkyfitness_sparkyfitness_db_data",
"sample": [
"pgdata/postmaster.opts",
"pgdata/postgresql.conf",
"pgdata/postgresql.auto.conf",
"pgdata/pg_ident.conf",
"pgdata/pg_hba.conf",
"pgdata/postmaster.pid",
"pgdata/PG_VERSION",
"pgdata/base/1/826",
"pgdata/base/1/2996",
"pgdata/base/1/3764",
"pgdata/base/1/2610_fsm",
"pgdata/base/1/3603_fsm",
"pgdata/base/1/1249_vm",
"pgdata/base/1/3600_fsm",
"pgdata/base/1/13506_vm",
"pgdata/base/1/3602",
"pgdata/base/1/6228",
"pgdata/base/1/4174",
"pgdata/base/1/4157",
"pgdata/base/1/4146",
"pgdata/base/1/2611",
"pgdata/base/1/2657",
"pgdata/base/1/3079_vm",
"pgdata/base/1/1247_vm",
"pgdata/base/1/13501_fsm",
"pgdata/base/1/1259",
"pgdata/base/1/3601_fsm",
"pgdata/base/1/1417",
"pgdata/base/1/4155",
"pgdata/base/1/2668",
"pgdata/base/1/13491_fsm",
"pgdata/base/1/2831",
"pgdata/base/1/2841",
"pgdata/base/1/3607",
"pgdata/base/1/2836",
"pgdata/base/1/1255_fsm",
"pgdata/base/1/2615_vm",
"pgdata/base/1/3575",
"pgdata/base/1/2605_vm",
"pgdata/base/1/2670"
],
"source": "/var/lib/docker/volumes/volgate-sparkyfitness_sparkyfitness_db_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "sparkyfitness-db",
"restarts": null,
"status": "running",
"uid": 70
},
{
"diff_added": 392,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"app"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/app",
"files": [
"SparkyFitnessServer"
]
},
{
"added": [
"uploads",
"backup",
"temp_uploads"
],
"changed_count": 0,
"db_signature": false,
"dir": "/app/SparkyFitnessServer",
"files": [
"uploads",
"backup",
"temp_uploads"
]
}
],
"diff_token_dirs": [],
"diff_total": 395,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "codewithcj/sparkyfitness_server:v0.17.3",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-sparkyfitness_sparkyfitness_uploads",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-sparkyfitness_sparkyfitness_uploads/_data",
"target": "/app/SparkyFitnessServer/uploads",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-sparkyfitness_sparkyfitness_backup",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-sparkyfitness_sparkyfitness_backup/_data",
"target": "/app/SparkyFitnessServer/backup",
"writable_by_app": "yes"
}
],
"name": "sparkyfitness-server",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"sparkyfitness_backup",
"sparkyfitness_db_data",
"sparkyfitness_uploads"
],
"env_keys": [
"API_ENCRYPTION_KEY",
"APP_DB_PASSWORD",
"BETTER_AUTH_SECRET",
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"sparkyfitness-server: this container's mounts are all empty while it created entries in ['/app/SparkyFitnessServer'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"sparkyfitness-server: declared volume /app/SparkyFitnessServer/uploads is EMPTY",
"sparkyfitness-server: declared volume /app/SparkyFitnessServer/backup is EMPTY"
],
"verdict": "CLEAN"
}
@@ -0,0 +1,53 @@
{
"probe": {
"app": "uptime-kuma",
"containers": [
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "louislam/uptime-kuma:2.4.0",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-uptimekuma_uptime_kuma_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-uptimekuma_uptime_kuma_data/_data",
"target": "/app/data",
"writable_by_app": "yes"
}
],
"name": "uptime-kuma",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"uptime_kuma_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"nothing was written to any mount and nothing data-classified in any writable layer \u2014 the app produced no data to locate. Health is not data: this is UNDETERMINED, not CLEAN",
"uptime-kuma: declared volume /app/data is EMPTY"
],
"verdict": "UNDETERMINED"
}
@@ -0,0 +1,193 @@
{
"probe": {
"app": "wanderer",
"containers": [
{
"diff_added": 6,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"root"
]
},
{
"added": [
".npm"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root",
"files": [
".npm"
]
}
],
"diff_token_dirs": [],
"diff_total": 8,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "unhealthy",
"image": "flomp/wanderer-web:v0.20.0",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-wanderer_wanderer_uploads",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-wanderer_wanderer_uploads/_data",
"target": "/app/uploads",
"writable_by_app": "yes"
}
],
"name": "wanderer",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 3,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"data",
"pb_data"
],
"changed_count": 0,
"db_signature": false,
"dir": "/",
"files": [
"data",
"pb_data"
]
}
],
"diff_token_dirs": [
{
"added": [
"plugins"
],
"changed_count": 0,
"db_signature": false,
"dir": "/data",
"files": [
"plugins"
]
}
],
"diff_total": 3,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "flomp/wanderer-db:v0.20.0",
"mounts": [
{
"class": "named-declared",
"files": 6,
"name": "volgate-wanderer_wanderer_data",
"sample": [
"auxiliary.db-wal",
"data.db",
"data.db-wal",
"auxiliary.db",
"auxiliary.db-shm",
"data.db-shm"
],
"source": "/var/lib/docker/volumes/volgate-wanderer_wanderer_data/_data",
"target": "/pb_data",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-wanderer_wanderer_plugins",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-wanderer_wanderer_plugins/_data",
"target": "/data/plugins",
"writable_by_app": "yes"
}
],
"name": "wanderer-db",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 0,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [],
"diff_token_dirs": [],
"diff_total": 0,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "getmeili/meilisearch:v1.36.0",
"mounts": [
{
"class": "named-declared",
"files": 11,
"name": "volgate-wanderer_wanderer_meili_data",
"sample": [
"data.ms/VERSION",
"data.ms/indexes/7ee7d19b-c036-412d-be25-65ae8f9e4801/data.mdb",
"data.ms/indexes/7ee7d19b-c036-412d-be25-65ae8f9e4801/lock.mdb",
"data.ms/indexes/82fdecc6-7425-4e45-989b-bdb644ecd606/data.mdb",
"data.ms/indexes/82fdecc6-7425-4e45-989b-bdb644ecd606/lock.mdb",
"data.ms/indexes/c48904e5-2b2a-41c0-a34c-c9575e31be45/data.mdb",
"data.ms/indexes/c48904e5-2b2a-41c0-a34c-c9575e31be45/lock.mdb",
"data.ms/auth/data.mdb",
"data.ms/auth/lock.mdb",
"data.ms/tasks/data.mdb",
"data.ms/tasks/lock.mdb"
],
"source": "/var/lib/docker/volumes/volgate-wanderer_wanderer_meili_data/_data",
"target": "/meili_data",
"writable_by_app": "yes"
}
],
"name": "wanderer-search",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"wanderer_data",
"wanderer_meili_data",
"wanderer_plugins",
"wanderer_uploads"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"MEILI_MASTER_KEY",
"POCKETBASE_ENCRYPTION_KEY",
"SUBDOMAIN",
"SUBDOMAIN_DB",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"wanderer: unhealthy",
"wanderer: this container's mounts are all empty while it created entries in ['/root'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"wanderer: declared volume /app/uploads is EMPTY",
"wanderer-db: declared volume /data/plugins is EMPTY",
"wanderer-db: writable-layer writes at /data (path suggests state, no database signature \u2014 judgement needed): ['plugins']"
],
"verdict": "UNDETERMINED"
}
@@ -0,0 +1,162 @@
{
"probe": {
"app": "wishlist",
"containers": [
{
"diff_added": 35,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [
"data"
],
"changed_count": 2,
"db_signature": false,
"dir": "/",
"files": [
"data"
]
},
{
"added": [
".config",
".local"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root",
"files": [
".config",
".local"
]
},
{
"added": [
"caddy"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.config",
"files": [
"caddy"
]
},
{
"added": [
"autosave.json"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.config/caddy",
"files": [
"autosave.json"
]
},
{
"added": [
"share"
],
"changed_count": 0,
"db_signature": false,
"dir": "/root/.local",
"files": [
"share"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr",
"files": [
"src"
]
},
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/usr/src",
"files": [
"app"
]
},
{
"added": [
"data",
"uploads"
],
"changed_count": 0,
"db_signature": false,
"dir": "/usr/src/app",
"files": [
"data",
"uploads"
]
}
],
"diff_token_dirs": [],
"diff_total": 45,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/cmintey/wishlist:v0.66.0",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-wishlist_wishlist_data",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-wishlist_wishlist_data/_data",
"target": "/data",
"writable_by_app": "yes"
},
{
"class": "anonymous",
"files": 1,
"name": "c5b4a3bf4591884b54c90465299d2c299b60a9692efc85699222663c7cc8efcb",
"sample": [
"prod.db"
],
"source": "/var/lib/docker/volumes/c5b4a3bf4591884b54c90465299d2c299b60a9692efc85699222663c7cc8efcb/_data",
"target": "/usr/src/app/data",
"writable_by_app": "yes"
},
{
"class": "anonymous",
"files": 0,
"name": "0a719c708e8a1351e6e7661358e5f7376e1f81ad0bc6d883660aa79d29999fd4",
"sample": [],
"source": "/var/lib/docker/volumes/0a719c708e8a1351e6e7661358e5f7376e1f81ad0bc6d883660aa79d29999fd4/_data",
"target": "/usr/src/app/uploads",
"writable_by_app": "yes"
}
],
"name": "wishlist",
"restarts": null,
"status": "running",
"uid": 0
}
],
"declared_volumes": [
"wishlist_data"
],
"env_keys": [
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"wishlist: 1 file(s) in an ANONYMOUS volume at /usr/src/app/data \u2014 not in the compose `volumes:` block, so it is never backed up and a redeploy orphans it",
"wishlist: declared volume /data is EMPTY"
],
"verdict": "BROKEN"
}
@@ -0,0 +1,169 @@
{
"probe": {
"app": "zipline",
"containers": [
{
"diff_added": 3,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"zipline"
]
},
{
"added": [
"uploads",
"public"
],
"changed_count": 0,
"db_signature": false,
"dir": "/zipline",
"files": [
"uploads",
"public"
]
}
],
"diff_token_dirs": [],
"diff_total": 5,
"diff_unresolved": [],
"exit": 0,
"gid": 0,
"health": "healthy",
"image": "ghcr.io/diced/zipline:4.6.1",
"mounts": [
{
"class": "named-declared",
"files": 0,
"name": "volgate-zipline_zipline_public",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-zipline_zipline_public/_data",
"target": "/zipline/public",
"writable_by_app": "yes"
},
{
"class": "named-declared",
"files": 0,
"name": "volgate-zipline_zipline_uploads",
"sample": [],
"source": "/var/lib/docker/volumes/volgate-zipline_zipline_uploads/_data",
"target": "/zipline/uploads",
"writable_by_app": "yes"
}
],
"name": "zipline",
"restarts": null,
"status": "running",
"uid": 0
},
{
"diff_added": 2,
"diff_benign_db_touches": [],
"diff_data_dirs": [],
"diff_other_dirs": [
{
"added": [],
"changed_count": 1,
"db_signature": false,
"dir": "/",
"files": [
"run"
]
}
],
"diff_token_dirs": [],
"diff_total": 4,
"diff_unresolved": [],
"exit": 0,
"gid": 70,
"health": "healthy",
"image": "postgres:16-alpine",
"mounts": [
{
"class": "named-declared",
"files": 1356,
"name": "volgate-zipline_zipline_postgres_data",
"sample": [
"postmaster.opts",
"postgresql.conf",
"postgresql.auto.conf",
"pg_ident.conf",
"pg_hba.conf",
"postmaster.pid",
"PG_VERSION",
"base/1/826",
"base/1/2996",
"base/1/3764",
"base/1/2610_fsm",
"base/1/3603_fsm",
"base/1/1249_vm",
"base/1/3600_fsm",
"base/1/13512",
"base/1/3602",
"base/1/6228",
"base/1/4174",
"base/1/4157",
"base/1/4146",
"base/1/2611",
"base/1/2657",
"base/1/3079_vm",
"base/1/1247_vm",
"base/1/1259",
"base/1/3601_fsm",
"base/1/1417",
"base/1/4155",
"base/1/2668",
"base/1/2831",
"base/1/2841",
"base/1/3607",
"base/1/2836",
"base/1/1255_fsm",
"base/1/2615_vm",
"base/1/3575",
"base/1/2605_vm",
"base/1/2670",
"base/1/6106",
"base/1/4163"
],
"source": "/var/lib/docker/volumes/volgate-zipline_zipline_postgres_data/_data",
"target": "/var/lib/postgresql/data",
"writable_by_app": "yes"
}
],
"name": "zipline-postgres",
"restarts": null,
"status": "running",
"uid": 70
}
],
"declared_volumes": [
"zipline_postgres_data",
"zipline_public",
"zipline_uploads"
],
"env_keys": [
"CORE_SECRET",
"DB_PASSWORD",
"DOMAIN",
"HDD_PATH",
"IMPORT_PATH",
"SUBDOMAIN",
"TZ",
"USERDATA_PATH"
],
"exercise": [],
"ports": []
},
"reasons": [
"zipline: this container's mounts are all empty while it created entries in ['/zipline'] \u2014 benign when a sibling container holds the state, worth a look when none does",
"zipline: declared volume /zipline/public is EMPTY",
"zipline: declared volume /zipline/uploads is EMPTY"
],
"verdict": "CLEAN"
}
@@ -0,0 +1,48 @@
self-testing the prober (both directions)…
prober flags the R-156 signature and clears a correct template — trustworthy
bentopdf UNDETERMINED nothing was written to any mount and nothing data-classified in any writable layer — the app produced no data to locate. Health is not data: this is U
claper CLEAN claper: this container's mounts are all empty while it created entries in ['/app/priv', '/app/priv/static'] — benign when a sibling container holds th
docmost CLEAN docmost: this container's mounts are all empty while it created entries in ['/app/apps/client/dist'] — benign when a sibling container holds the state
glance UNDETERMINED glance: not running (status=restarting exit=1 restarts=None)
gramps-web BROKEN gramps-web: DATA in the writable layer at /app/users (db_signature=True, e.g. ['users.sqlite']); NOTHING this app wrote landed in ANY folder the templ
immich CLEAN immich-machine-learning: this container's mounts are all empty while it created entries in ['/', '/root', '/root/.config'] — benign when a sibling con
papra BROKEN papra: mount /app/data is NOT writable by the app's own uid=999 gid=999; papra: DATA in the writable layer at /app/app-data/db (db_signature=True, e.g
plant-it UNDETERMINED no containers created (compose up rc=1: msdeluise/plant-it:0.10.0 Pulling
Image msdeluise/plant-it:0.10.0 Error pull access denied for msdeluise/pla
privatebin UNDETERMINED nothing was written to any mount and nothing data-classified in any writable layer — the app produced no data to locate. Health is not data: this is U
recipe-importer UNDETERMINED NOTHING this app wrote landed in ANY folder the template preserves: all 1 mount(s) across 1 container(s) are empty, yet entries were created in ['/'].
sparkyfitness CLEAN sparkyfitness-server: this container's mounts are all empty while it created entries in ['/app/SparkyFitnessServer'] — benign when a sibling container
uptime-kuma UNDETERMINED nothing was written to any mount and nothing data-classified in any writable layer — the app produced no data to locate. Health is not data: this is U
wanderer UNDETERMINED wanderer: unhealthy; wanderer: this container's mounts are all empty while it created entries in ['/root'] — benign when a sibling container holds the
wishlist BROKEN wishlist: 1 file(s) in an ANONYMOUS volume at /usr/src/app/data — not in the compose `volumes:` block, so it is never backed up and a redeploy orphans
zipline CLEAN zipline: this container's mounts are all empty while it created entries in ['/zipline'] — benign when a sibling container holds the state, worth a loo
BROKEN — the app's data does NOT land where the template preserves it:
gramps-web
gramps-web: DATA in the writable layer at /app/users (db_signature=True, e.g. ['users.sqlite'])
NOTHING this app wrote landed in ANY folder the template preserves: all 2 mount(s) across 1 container(s) are empty, yet entries were created in ['/app', '/app/cache/request_cache', '/app/thumbnail_cache', '/root']. Needs adjudication.
gramps-web: this container's mounts are all empty while it created entries in ['/app', '/app/cache/request_cache', '/app/thumbnail_cache'] — benign when a sibling container holds the state, worth a look when none does
gramps-web: declared volume /app/data is EMPTY
gramps-web: declared volume /app/media is EMPTY
papra
papra: mount /app/data is NOT writable by the app's own uid=999 gid=999
papra: DATA in the writable layer at /app/app-data/db (db_signature=True, e.g. ['db.sqlite'])
NOTHING this app wrote landed in ANY folder the template preserves: all 1 mount(s) across 1 container(s) are empty, yet entries were created in ['/app']. Needs adjudication.
papra: this container's mounts are all empty while it created entries in ['/app'] — benign when a sibling container holds the state, worth a look when none does
papra: declared volume /app/data is EMPTY
wishlist
wishlist: 1 file(s) in an ANONYMOUS volume at /usr/src/app/data — not in the compose `volumes:` block, so it is never backed up and a redeploy orphans it
wishlist: declared volume /data is EMPTY
UNDETERMINED — NOT a pass. The question was not answered for these:
bentopdf: nothing was written to any mount and nothing data-classified in any writable layer — the app produced no data to locate. Health is not data: this is UNDETERMINED, not CLEAN
glance: glance: not running (status=restarting exit=1 restarts=None)
plant-it: no containers created (compose up rc=1: msdeluise/plant-it:0.10.0 Pulling
Image msdeluise/plant-it:0.10.0 Error pull access denied for msdeluise/plant-it, repository does not exist or may require 'd
privatebin: nothing was written to any mount and nothing data-classified in any writable layer — the app produced no data to locate. Health is not data: this is UNDETERMINED, not CLEAN; privatebin: declared volum
recipe-importer: NOTHING this app wrote landed in ANY folder the template preserves: all 1 mount(s) across 1 container(s) are empty, yet entries were created in ['/']. Needs adjudication.; recipe-importer: this contai
uptime-kuma: nothing was written to any mount and nothing data-classified in any writable layer — the app produced no data to locate. Health is not data: this is UNDETERMINED, not CLEAN; uptime-kuma: declared volu
wanderer: wanderer: unhealthy; wanderer: this container's mounts are all empty while it created entries in ['/root'] — benign when a sibling container holds the state, worth a look when none does; wanderer: dec
5 clean · 3 broken · 7 undetermined (of 15 in scope)
volume-persistence gate REFUSED
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""wishlist survives-a-redeploy proof, on REAL user data.
`survive2.py` reported DATA-LOST because `prod.db`'s bytes changed across the redeploy — but that
rule cannot tell "the app modified its own database on boot" (fine; vaultwarden's WAL does the
same) from "the app deleted and recreated it" (data gone). Two things settle it:
1. the file's INODE — same object, or a new one?
2. a row the USER created still queryable after the redeploy, or not?
(2) is the claim that actually matters, so it is the verdict. It is created through wishlist's own
HTTP API, never by writing into the volume by hand.
"""
import hashlib, importlib.util, json, os, re, secrets, shutil, tempfile, time
from pathlib import Path
_spec = importlib.util.spec_from_file_location(
"cvp", "/opt/sweep/scripts/check-volume-persistence.py")
cvp = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(cvp)
TPL = Path("/opt/sweep/templates/wishlist")
EV = Path("/opt/sweep/evidence/wishlist")
def curl(url, *a):
return cvp._sh(["curl", "-sS", "--max-time", "25", *a, url], timeout=45)
def stat_db(mounts):
"""(inode, sha256, size) of prod.db, read host-side from the volume itself."""
for m in mounts:
if m["target"] == "/usr/src/app/data" and m["source"]:
f = os.path.join(m["source"], "prod.db")
if os.path.isfile(f):
st = os.lstat(f)
return {"inode": st.st_ino, "size": st.st_size,
"sha256": hashlib.sha256(open(f, "rb").read()).hexdigest()}
return None
def sqlite_rows(mounts, table):
"""Count rows in `table` by reading the volume's DB with a throwaway sqlite container —
a READ of what the app wrote, never a write into the volume."""
for m in mounts:
if m["target"] == "/usr/src/app/data" and m["source"]:
r = cvp._sh(["docker", "run", "--rm", "-v", f"{m['name']}:/v:ro",
"keinos/sqlite3:latest", "sqlite3", "/v/prod.db",
f"SELECT COUNT(*) FROM {table};"], timeout=180)
return (r.stdout or r.stderr or "").strip()
return "no-db"
def up(base, wait=300, settle=40):
cvp._sh(base + ["up", "-d"], timeout=1200)
cids = [c for c in cvp._sh(base + ["ps", "-aq"], timeout=120).stdout.split() if c]
dl = time.time() + wait
while time.time() < dl:
if not any(((cvp._inspect(c) or {}).get("State") or {}).get("Status") in ("created", "restarting")
or ((((cvp._inspect(c) or {}).get("State") or {}).get("Health") or {}).get("Status")
== "starting") for c in cids):
break
time.sleep(10)
ip = None
for c in cids:
for n in ((cvp._inspect(c) or {}).get("NetworkSettings") or {}).get("Networks", {}).values():
if n.get("IPAddress"):
ip = n["IPAddress"]
mounts = []
for c in cids:
info = cvp._inspect(c) or {}
for m in (info.get("Mounts") or []):
mounts.append({"target": m.get("Destination"), "source": m.get("Source"),
"name": m.get("Name")})
time.sleep(settle)
return cids, ip, mounts
if __name__ == "__main__":
EV.mkdir(parents=True, exist_ok=True)
work = Path(tempfile.mkdtemp(prefix="wl-"))
shutil.copy(TPL / "docker-compose.yml", work / "docker-compose.yml")
env = cvp.build_env("wishlist", (TPL / ".felhom.yml").read_text(),
(TPL / "docker-compose.yml").read_text())
(work / ".env").write_text("".join(f"{k}={v}\n" for k, v in sorted(env.items())))
base = ["docker", "compose", "-p", "wlproof", "--project-directory", str(work),
"-f", str(work / "docker-compose.yml")]
cvp._sh(["docker", "network", "create", "traefik-public"], timeout=60)
log = []
try:
cids, ip, mounts = up(base)
# --- seed REAL user data through wishlist's own API
email = f"proof-{secrets.token_hex(4)}@felhom.invalid"
pw = "Proof-" + secrets.token_hex(10) # never recorded
r = curl(f"http://{ip}:3000/api/auth/signup", "-X", "POST", "-i",
"-H", "Content-Type: application/json",
"-d", json.dumps({"email": email, "password": pw, "name": "Proof",
"username": "proofuser"}))
log.append("signup -> " + ((r.stdout or "?").splitlines() or ["?"])[0].strip())
time.sleep(20)
before_db = stat_db(mounts)
before_users = sqlite_rows(mounts, "user")
log.append(f"before: db={before_db} users={before_users}")
cvp._sh(base + ["down"], timeout=900) # a REDEPLOY — no -v
cids, ip, mounts = up(base)
after_db = stat_db(mounts)
after_users = sqlite_rows(mounts, "user")
log.append(f"after : db={after_db} users={after_users}")
same_inode = bool(before_db and after_db and before_db["inode"] == after_db["inode"])
rows_kept = (before_users.isdigit() and after_users.isdigit()
and int(after_users) >= int(before_users) and int(before_users) > 0)
res = {"app": "wishlist", "before": before_db, "after": after_db,
"user_rows_before": before_users, "user_rows_after": after_users,
"same_inode": same_inode, "user_rows_preserved": rows_kept,
"verdict": ("SURVIVES" if same_inode and rows_kept
else "SURVIVES (inode only, no seeded rows)" if same_inode
else "DATA LOST"),
"log": log}
(EV / "survive-wishlist-content.json").write_text(json.dumps(res, indent=2, sort_keys=True))
print(json.dumps({k: v for k, v in res.items() if k != "log"}, indent=2))
finally:
cvp._sh(base + ["down", "-v", "--remove-orphans"], timeout=900)
shutil.rmtree(work, ignore_errors=True)
-84
View File
@@ -1,84 +0,0 @@
# Customer: Demo / Test Server (N100 Mini PC)
# Hardware: Intel N100, 16GB RAM, 512GB NVMe + 1TB HDD
# Network: Local + Cloudflare Tunnel for demo access
customer_id: demo-felhom
domain: demo-felhom.eu
hdd_path: /mnt/hdd_1
gitea_repo: customers/demo-felhom-stacks
hardware: n100
notes: "Internal demo/test server for validating deployments"
# Apps to deploy on this node
apps:
- actualbudget
- docmost
- filebrowser
- homebox
- immich
- mealie
- paperless-ngx
- romm
- stirling-pdf
- vaultwarden
# Per-customer overrides (optional)
# Uncomment to pin versions or disable auto-updates
overrides: {}
# immich_version: "v2.5.5" # Pin Immich to specific version
# auto_update: false # Skip ALL version updates from catalog
# Portainer env vars to set (reference only — actual secrets go in Portainer!)
# These are documented here so you remember what each stack needs.
env_vars_reference:
docmost:
APP_SECRET: "generate with: openssl rand -hex 32"
DB_PASSWORD: "generate secure password"
immich:
DB_PASSWORD: "generate secure password"
paperless-ngx:
PAPERLESS_SECRET_KEY: "generate with: openssl rand -hex 32"
DB_PASSWORD: "generate secure password"
PAPERLESS_ADMIN_USER: "admin"
PAPERLESS_ADMIN_PASSWORD: "set initial password"
romm:
DB_PASSWORD: "generate secure password"
MYSQL_ROOT_PASSWORD: "generate secure password"
ROMM_AUTH_SECRET_KEY: "generate with: openssl rand -hex 32"
vaultwarden:
ADMIN_TOKEN: "generate with: openssl rand -hex 32"
SIGNUPS_ALLOWED: "true (set to false after account creation)"
# Storage layout reference
# This shows where user data lives after render (HDD host paths):
#
# /mnt/hdd_1/ ← HDD root (filebrowser serves this)
# /mnt/hdd_1/storage/immich/ ← photos & videos
# /mnt/hdd_1/storage/paperless/consume/ ← drop documents here for OCR
# /mnt/hdd_1/storage/paperless/media/ ← processed documents
# /mnt/hdd_1/storage/paperless/export/ ← document exports / backup
# /mnt/hdd_1/storage/romm/library/ ← ROM files
# /mnt/hdd_1/storage/romm/resources/ ← cover art, metadata
#
# Named volumes (on NVMe, managed by Docker):
# actualbudget_data, docmost_*, homebox_data, mealie_data,
# immich_postgres_data, paperless_data, vaultwarden_data, etc.
# Backup considerations
backup_notes:
databases:
- "docmost: PostgreSQL (docmost-postgres)"
- "immich: PostgreSQL (immich-postgres)"
- "paperless-ngx: PostgreSQL (paperless-postgres)"
- "romm: MariaDB (romm-db)"
hdd_paths:
- "/mnt/hdd_1/storage/immich (photos — large, Backrest read-only mount)"
- "/mnt/hdd_1/storage/paperless/media (documents — Backrest read-only mount)"
- "/mnt/hdd_1/storage/romm/library (ROMs — Backrest read-only mount)"
named_volumes:
- "actualbudget_data"
- "docmost_storage"
- "homebox_data"
- "mealie_data"
- "stirling_data"
- "vaultwarden_data"
-44
View File
@@ -1,44 +0,0 @@
# Customer: Pi Test Customer #1 (Raspberry Pi)
# Hardware: Raspberry Pi 4/5, 4-8GB RAM, SD/USB + External HDD
# Network: Local only (.local domain with self-signed cert)
customer_id: pi-customer-1
domain: pi-customer-1.local
hdd_path: /mnt/hdd_1
gitea_repo: customers/pi-customer-1-stacks
hardware: rpi
notes: "Test customer on Raspberry Pi — lightweight apps only"
# Apps to deploy on this node (Pi-compatible only)
apps:
- actualbudget
- filebrowser
- mealie
- stirling-pdf
- vaultwarden
# Per-customer overrides
overrides: {}
# Portainer env vars to set (reference only)
env_vars_reference:
vaultwarden:
ADMIN_TOKEN: "generate with: openssl rand -hex 32"
SIGNUPS_ALLOWED: "true (set to false after account creation)"
# Storage layout reference:
# /mnt/hdd_1/ ← HDD root (filebrowser serves this)
#
# Named volumes (on SD/USB boot, managed by Docker):
# actualbudget_data, mealie_data, stirling_data, vaultwarden_data
# Backup considerations
backup_notes:
databases: [] # No database containers — all apps use SQLite/file storage
hdd_paths:
- "/mnt/hdd_1 (filebrowser root — user files)"
named_volumes:
- "actualbudget_data"
- "mealie_data"
- "stirling_data"
- "vaultwarden_data"
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""catalog_gates.py — THE entry point for this repo's gates. Run from the repo root:
python3 scripts/catalog_gates.py # every AVAILABLE app, all three gates
python3 scripts/catalog_gates.py papra wishlist # only these app dirs (the normal case)
python3 scripts/catalog_gates.py --all # include hidden/abandoned apps too
python3 scripts/catalog_gates.py --fast # gate 1 only — no network, no containers;
# this is what .githooks/pre-push runs
Gates, in order (all must pass; **non-zero exit on any failure**):
1. image-pins static, instant, whole repo no :latest / untagged / floating alias
2. image-resolvable network every pinned tag still EXISTS upstream
3. volume-persistence RUNTIME the folder a template preserves is the folder the app writes to
WHY THIS FILE EXISTS (operator ruling, 2026-08-02 R-161).
The volume-persistence gate was built because papra's backup completed, verified, and contained an
empty directory. The obvious enforcement points were both rejected, each for a measured reason:
- **Controller-side, at template load: rejected because it would PASS on the defect it exists to
catch.** A check at load time can only read the file, and papra's compose is well-formed — a
static audit of all 53 templates reports the catalog clean, papra included. The property is only
decidable at runtime (see `check-volume-persistence.py`'s header).
- **CI: rejected for now** neither repo has any CI to build on, and there are no users yet.
What was chosen instead is the shape that demonstrably works in this project. Of every gate written
here, **the only ones that ever get run are the ones with a single entry point named in a CLAUDE.md**:
`felhom.eu/scripts/site_gates.py` is run; R-29's three orphaned gates are named nowhere and have
stopped nothing. So this copies that shape rather than adding a fourth gate nobody invokes. It is
mandated in `CLAUDE.md` the way `site_gates.py` is.
**R-161 stays OPEN at reduced scope:** this is convention, run by a person. Real automatic
enforcement is owed when a second person touches templates.
EXIT CODES. Each gate returns 0 clean / 1 convicted / 2 inconclusive. This runner exits **non-zero if
any gate is non-zero**, and reports 2 distinctly as INCONCLUSIVE an undetermined result is never a
pass (an app that wrote nothing has not been shown correct; a throttled registry has not shown an
image alive), but it is also not a conviction, and the operator reading the summary needs to know
which they have.
SCOPE. With app names, every gate that accepts scoping is scoped to them that is the normal
after-a-template-change run and it is fast. With no names the runtime gate deploys **every** template,
which takes minutes per app and **belongs on a scratch host, never a customer box** (see CLAUDE.md).
"""
import os
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPTS = os.path.join(ROOT, "scripts")
# (label, filename, accepts_app_scope, fast)
#
# `fast` = touches NO network and NO container runtime, so it is safe to run on every push.
# image-resolvable talks to registries and volume-persistence deploys containers for minutes per
# app — neither belongs in a hook. A push that pulls images and starts containers gets bypassed
# within a week, and the bypass becomes the habit; both stay deliberate periodic runs (start of a
# catalog campaign, before a publish train that vouches the catalog, whenever a template's
# volumes: block or image tag changes) — on a scratch host, never a customer box.
GATES = [
("image-pins", "check-image-pins.py", False, True),
("image-resolvable", "check-image-resolvable.py", True, False),
("volume-persistence", "check-volume-persistence.py", True, False),
]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
def run_gate(label, script, args):
path = os.path.join(SCRIPTS, script)
if not os.path.exists(path):
print("FAIL: %s%s is missing from scripts/" % (label, script))
return 1
print("\n" + "=" * 78)
print("== gate: %s (%s%s)" % (label, script, (" " + " ".join(args)) if args else ""))
print("=" * 78, flush=True)
# stream the gate's own output rather than capturing it — its diagnostics are the point,
# and a runner that swallows them makes a conviction unreadable.
return subprocess.call([sys.executable, path] + args, cwd=ROOT)
def main(argv):
include_hidden = "--all" in argv
fast = "--fast" in argv
apps = [a for a in argv if not a.startswith("-")]
unknown = [a for a in argv if a.startswith("-") and a not in ("--all", "--fast")]
if unknown:
print("unknown option(s): %s" % " ".join(unknown))
print(__doc__.strip().splitlines()[0])
return 2
scope_note = ("apps: " + ", ".join(apps)) if apps else (
"static gate only" if fast else
"ALL apps (runtime gate deploys every template — scratch host only)")
print("catalog_gates — %s%s%s" % (scope_note, " [--fast]" if fast else "",
" [--all: incl. hidden/abandoned]" if include_hidden else ""))
selected = [g for g in GATES if g[3] or not fast]
skipped = [g[0] for g in GATES if not (g[3] or not fast)]
if skipped:
print(" --fast SKIPPED: %s — they need network and a container runtime and take minutes\n"
" per app, so they are NEVER in a hook. They remain deliberate periodic runs: start\n"
" of a catalog campaign, before a publish train, or when a template's volumes:/image\n"
" changes. Run them with no --fast, on a scratch host." % ", ".join(skipped))
results = []
for label, script, scoped, _f in selected:
args = []
if include_hidden:
args.append("--all")
if scoped and apps:
args += apps
results.append((label, run_gate(label, script, args)))
print("\n" + "=" * 78)
print("== summary")
print("=" * 78)
worst = 0
for label, rc in results:
print(" %-20s %-13s (exit %d)" % (label, VERDICT.get(rc, "ERROR"), rc))
# 1 (a conviction) outranks 2 (undetermined) in what it tells the operator to do
if rc != 0:
worst = 1 if rc == 1 or worst == 1 else 2
if worst == 0:
print("\nall catalog gates OK")
return 0
convicted = [l for l, rc in results if rc == 1]
undecided = [l for l, rc in results if rc not in (0, 1)]
if convicted:
print("\nCONVICTED: %s" % ", ".join(convicted))
if undecided:
print("UNDETERMINED (never a pass): %s" % ", ".join(undecided))
return worst
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""check-image-pins.py — catalog gate: no :latest / untagged images in templates.
Scans every templates/*/docker-compose.yml `image:` line and fails (exit 1) on:
- an explicit `:latest` tag (including `:latest@sha256:...` the tag is a lie there,
but the digest pins it, so that shape is allowed and only the bare tag is banned),
- a floating alias tag (`dev`, `nightly`, `edge`, `main`, `master`),
- a missing tag entirely (`image: nginx` implicit :latest).
A digest reference (`repo@sha256:...`) counts as pinned. Registry ports
(`host:5000/img:1.2`) are handled: the tag is what follows the LAST colon of the
LAST path segment.
Standing rule (CLAUDE.md): never :latest or untagged images in templates pin a
concrete version tag; deployed apps pin to their running digest.
"""
import re
import sys
from pathlib import Path
BANNED_TAGS = {"latest", "dev", "nightly", "edge", "main", "master"}
IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\s\"'#]+)")
def check(root: Path) -> int:
failures = []
files = sorted(root.glob("templates/*/docker-compose.yml"))
if not files:
print(f"ERROR: no templates found under {root}/templates/", file=sys.stderr)
return 2
for f in files:
for lineno, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
m = IMAGE_RE.match(line)
if not m:
continue
ref = m.group(1)
if "@sha256:" in ref:
continue # digest-pinned — strongest pin there is
last_seg = ref.rsplit("/", 1)[-1]
if ":" not in last_seg:
failures.append((f, lineno, ref, "NO TAG (implicit :latest)"))
continue
tag = last_seg.rsplit(":", 1)[-1]
if tag.lower() in BANNED_TAGS:
failures.append((f, lineno, ref, f"floating tag :{tag}"))
if failures:
print("UNPINNED IMAGES FOUND:")
for f, lineno, ref, why in failures:
print(f" {f.as_posix()}:{lineno}: {ref} [{why}]")
return 1
print(f"image-pin gate OK — {len(files)} templates, 0 unpinned images")
return 0
if __name__ == "__main__":
sys.exit(check(Path(__file__).resolve().parent.parent))
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""check-image-resolvable.py — catalog gate: every pinned image must still EXIST upstream.
The companion to `check-image-pins.py`, which is purely syntactic: it proves a template pins a
concrete tag, never that the tag is still there. That gap is how `plant-it` and `wanderer` sat behind
a working "Telepítés" button for months with images that did not resolve at all the templates were
perfectly well-formed and pointed at nothing (Campaign 7, §6.2). **Silent rot is the real risk**
(ROADMAP R-41): an upstream rename, a repo split, or a pruned tag breaks a template without touching
this repo, so nothing in a change-triggered gate would ever notice.
This resolves each unique `image:` pin against its registry with
`docker manifest inspect <ref>` and exits non-zero listing everything that did not resolve.
python3 scripts/check-image-resolvable.py # every AVAILABLE app
python3 scripts/check-image-resolvable.py --all # include hidden/abandoned apps too
python3 scripts/check-image-resolvable.py wanderer # only these app dirs
THE TRAP THIS SCRIPT IS BUILT AROUND: gate on EACH `docker manifest inspect`'s OWN exit code, one
image at a time. Never pipe the run through anything that summarises (`| grep`, `| tee`, `&&` chains,
a wrapping shell) and then read the exit code of THAT you get the pipeline's status, which is the
last element's, and unresolvable images sail straight through reporting success. This is the same
class of defect as the `validate-answer` trap in the ISO tooling (exits 0 on failure, so the build
gates on its OUTPUT text instead).
Requires network + a working `docker`. It is therefore a PERIODIC/manual gate, not a pre-commit one
run it at the start of every catalog campaign, and before any publish train that vouches the
catalog. Unit tests inject `resolver` and never touch the network.
"""
import re
import subprocess
import sys
from pathlib import Path
IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\s\"'#]+)") # same shape as check-image-pins.py
# A ref that must never resolve, for self-testing the resolver end of the gate. `.invalid` is
# reserved by RFC 2606 and can never be a real registry.
CANARY_REF = "felhom-nonexistent.invalid/no/such:image"
# `lifecycle:` at the top level of .felhom.yml. Matched with a line regex rather than a YAML parse so
# this gate keeps working with no dependencies (the repo ships no requirements file).
LIFECYCLE_RE = re.compile(r"""^lifecycle:\s*["']?([a-z]+)""", re.MULTILINE)
def app_lifecycle(app_dir: Path) -> str:
"""available / hidden / abandoned. Absent, empty or unknown ≡ available (see CLAUDE.md)."""
f = app_dir / ".felhom.yml"
if not f.is_file():
return "available"
m = LIFECYCLE_RE.search(f.read_text(encoding="utf-8"))
if not m:
return "available"
v = m.group(1)
return v if v in ("available", "hidden", "abandoned") else "available"
def collect_images(root: Path, only: list[str] | None = None,
include_unavailable: bool = False) -> tuple[dict[str, list[str]], list[str]]:
"""Map each unique image ref -> the ['app:line'] sites that pin it. Pure; no network.
Returns (sites, skipped_apps). Apps whose `lifecycle:` is not `available` are SKIPPED by default:
they are not offered for new installs, so a dead upstream image is the expected end state, not a
finding. Including them would leave the gate permanently red for a reason nobody intends to fix
and a gate that is always red is a gate nobody reads. They are reported, never silently dropped.
"""
sites: dict[str, list[str]] = {}
skipped: list[str] = []
for f in sorted(root.glob("templates/*/docker-compose.yml")):
app = f.parent.name
if only and app not in only:
continue
lc = app_lifecycle(f.parent)
if lc != "available" and not include_unavailable:
skipped.append(f"{app} ({lc})")
continue
for lineno, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
m = IMAGE_RE.match(line)
if m:
sites.setdefault(m.group(1), []).append(f"{app}:{lineno}")
return sites, skipped
OK, ABSENT, INCONCLUSIVE = "ok", "absent", "inconclusive"
# Substrings that mean the registry positively answered "that image is not here". ONLY these
# justify failing the gate.
ABSENT_MARKERS = (
"manifest unknown", "not found", "no such manifest", "does not exist",
"repository name not known", "unknown: unknown", "manifest_unknown",
"name unknown", "no such host", "unsupported protocol scheme",
)
# Substrings that mean "we could not find out" — a throttle, an auth wall, a network fault. These
# must NEVER be reported as a dead image.
INCONCLUSIVE_MARKERS = (
"toomanyrequests", "rate limit", "too many requests",
"unauthorized", "authentication required", "denied",
"timeout", "timed out", "temporary failure", "connection refused",
"i/o timeout", "tls handshake", "service unavailable", "500 internal",
)
def classify(returncode: int, err: str) -> str:
"""Turn one `docker manifest inspect` result into ok / absent / inconclusive.
TWO TRAPS, BOTH LIVE-OBSERVED, BOTH LOAD-BEARING:
1. `docker manifest inspect` prints `toomanyrequests: You have reached your unauthenticated
pull rate limit` and **still exits 0**. Same shape as the ISO tooling's `validate-answer`.
So a non-empty stderr is checked even on rc=0, or a throttled run reports a clean bill of
health for images it never actually resolved.
2. The inverse, which is what a naive gate does: treating that throttle as a failure. On
2026-07-21 the first full sweep called 24 of 65 pins dead including `postgres:16-alpine`
and `redis:7-alpine` purely because Docker Hub started throttling partway through. A gate
that cries wolf gets ignored, and then it protects nothing.
Ambiguity therefore resolves to INCONCLUSIVE, never to a failure: this gate may only accuse an
image when the registry positively said it is gone.
"""
low = err.lower()
if any(m in low for m in INCONCLUSIVE_MARKERS):
return INCONCLUSIVE
if returncode == 0:
# rc=0 WITH error text is trap 1 — do not trust it as success.
return OK if not low.strip() else INCONCLUSIVE
if any(m in low for m in ABSENT_MARKERS):
return ABSENT
return INCONCLUSIVE # an unrecognised failure is not evidence of absence
def docker_resolver(ref: str) -> tuple[str, str]:
"""(status, detail) from `docker manifest inspect ref`.
LOAD-BEARING: the decision comes from THIS call, for THIS one image never from a piped or
aggregated summary, whose exit status is the last element's.
"""
try:
r = subprocess.run(
["docker", "manifest", "inspect", ref],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=120, text=True,
)
except subprocess.TimeoutExpired:
return INCONCLUSIVE, "timed out after 120s"
except OSError as e:
return INCONCLUSIVE, f"could not run docker: {e}"
err = (r.stderr or "").strip()
return classify(r.returncode, err), err.splitlines()[0] if err else ""
def check_images(sites: dict[str, list[str]], resolver) -> tuple[list[str], list[tuple[str, str]]]:
"""Resolve every ref once. Returns (absent_refs, [(inconclusive_ref, why)]). No I/O of its own."""
absent, inconclusive = [], []
for ref in sorted(sites):
status, detail = resolver(ref)
if status == ABSENT:
absent.append(ref)
elif status != OK:
inconclusive.append((ref, detail))
return absent, inconclusive
def check(root: Path, only: list[str] | None = None, resolver=docker_resolver,
include_unavailable: bool = False) -> int:
sites, skipped = collect_images(root, only, include_unavailable)
if skipped:
print(f"skipping {len(skipped)} app(s) not offered for new installs: {', '.join(skipped)}")
print(" (their images are not expected to resolve; re-run with --all to check them anyway)")
if not sites:
if skipped:
# Everything in scope was deliberately skipped. That is a clean result, not a broken
# catalog — saying "ERROR: no images found" here would be a false alarm of its own.
print("nothing to check — every app in scope is out of circulation")
return 0
print(f"ERROR: no images found under {root}/templates/", file=sys.stderr)
return 2
# Self-test the resolver before trusting a green result: if it says a ref that CANNOT exist
# resolves, it is broken (or something is intercepting the registry) and a clean run would be a
# false all-clear — the exact failure this gate exists to prevent.
if resolver(CANARY_REF)[0] == OK:
print(f"ERROR: resolver returned success for {CANARY_REF} — it is not trustworthy; "
"refusing to report a result", file=sys.stderr)
return 2
print(f"resolving {len(sites)} unique image pin(s)…")
absent, inconclusive = check_images(sites, resolver)
if absent:
print("\nUNRESOLVABLE IMAGES (the registry says these are GONE):")
for ref in absent:
print(f" {ref}")
for site in sites[ref]:
print(f" pinned at templates/{site}")
if inconclusive:
print("\nINCONCLUSIVE (could NOT be checked — this is not an accusation):")
for ref, why in inconclusive:
print(f" {ref} [{why}]")
print("\n Docker Hub throttles unauthenticated manifest lookups, and a large sweep will hit")
print(" the ceiling partway through. Re-run after `docker login`, or wait out the window —")
print(" the result above is NOT evidence that these images are missing.")
if absent:
print(f"\n{len(absent)} of {len(sites)} image pin(s) are GONE"
f"{f'; {len(inconclusive)} could not be checked' if inconclusive else ''}.")
return 1
if inconclusive:
print(f"\nINCOMPLETE: {len(sites) - len(inconclusive)} of {len(sites)} pins verified, "
f"{len(inconclusive)} unchecked. No dead images among those checked.")
return 2
print(f"image-resolvability gate OK — {len(sites)} unique pins, all resolve")
return 0
if __name__ == "__main__":
argv = sys.argv[1:]
all_apps = "--all" in argv
argv = [a for a in argv if a != "--all"]
sys.exit(check(Path(__file__).resolve().parent.parent, only=argv or None,
include_unavailable=all_apps))
+877
View File
@@ -0,0 +1,877 @@
#!/usr/bin/env python3
"""check-volume-persistence.py — catalog gate: the folder a template preserves must be the
folder the app actually writes to.
The third catalog gate, and the only RUNTIME one. Its two siblings are static:
`check-image-pins.py` proves a template names a concrete tag, `check-image-resolvable.py` proves
that tag still exists. Neither can see where an app puts its data, and **neither can any static
check** that was measured, not assumed: over all 53 templates a static audit of the compose
files (declared volumes attached, no anonymous mounts, no stray host binds) reports the catalog
completely clean, and it reports **papra** clean too. papra's compose is well-formed. It mounts
`papra_data` at `/app/data`. The application writes its database to `/app/app-data/db/db.sqlite`,
in the container's writable layer, and cannot write to `/app/data` at all.
The consequence (R-156, Campaign 10): the app runs, the healthcheck is green, the tier-1/tier-2
backup completes and verifies and it contains an empty directory. `DumpAppVolumes`
(felhom-controller `internal/backup/backup.go:543`) tars the volume, and the volume holds nothing.
A backup that fails loudly gets fixed; a backup that succeeds while holding nothing is discovered
when someone needs it.
python3 scripts/check-volume-persistence.py # every AVAILABLE app
python3 scripts/check-volume-persistence.py --all # include hidden/abandoned apps
python3 scripts/check-volume-persistence.py papra # only these app dirs
Exit codes: 0 every app in scope CLEAN · 1 at least one BROKEN (the gate REFUSES) ·
2 nothing could be decided / the prober failed its own self-test.
Requires Docker, network, and several minutes per app, so it is a PERIODIC gate like
`check-image-resolvable.py` run it when a template's `volumes:` block or image tag changes, and
at the start of every catalog campaign. `classify()` is pure and unit-tested with no Docker
(`scripts/test_check_volume_persistence.py`).
WHY THE PROBER SELF-TESTS ON EVERY RUN. A detector that flags nothing is worse than no detector:
it converts an unexamined catalog into a documented-clean one. So before this gate is allowed to
report anything, it runs two synthetic canary templates through the *same* prober one built to
the exact R-156 signature, one built to write correctly into its volume and refuses to issue a
verdict unless it calls the first BROKEN and the second CLEAN. A green run therefore always
carries a live proof that the instrument discriminates.
NO `docker exec` ANYWHERE. Campaign 7 §1.1 recorded that `docker exec` writes its OCI error to
STDOUT, so a missing binary read as present and the whole healthcheck audit reported every app
honest. Every observation here comes from `docker inspect`, `docker diff`, `/proc` and the host
filesystem, so a distroless or shell-less image is observed exactly like any other.
"""
import hashlib
import json
import os
import re
import secrets
import shutil
import stat
import subprocess
import sys
import tempfile
import time
from pathlib import Path
CLEAN, BROKEN, UNDETERMINED = "CLEAN", "BROKEN", "UNDETERMINED"
LIFECYCLE_RE = re.compile(r"""^lifecycle:\s*["']?([a-z]+)""", re.MULTILINE)
VAR_RE = re.compile(r"\$\{([A-Z0-9_]+)\}")
DIFF_RE = re.compile(r"^([ACD])\s+(.*)$")
PORT_RE = re.compile(r"loadbalancer\.server\.port=(\d+)")
# Where the controller resolves the felhom path variables to at deploy time
# (felhom-controller `internal/stacks/deploy.go:567-582`). Any host path here is scratch.
SCRATCH_HDD = "/srv/felhom-gate/hdd"
SCRATCH_IMPORT = "/srv/felhom-gate/import"
# ---------------------------------------------------------------------------- data vs noise
#
# `docker diff` is noisy. These three rules decide what counts as the customer's data, and they
# are the part of this gate most likely to need judgement — they are deliberately explicit rather
# than buried in a heuristic.
# 1. NOISE — content that is never customer data. Losing it costs a restart, nothing more.
NOISE_PREFIX = (
"/tmp/", "/var/tmp/", "/run/", "/var/run/", "/proc/", "/sys/", "/dev/",
"/var/log/", "/var/cache/", "/var/lib/apt/", "/var/lib/dpkg/", "/var/spool/",
"/usr/share/", "/usr/lib/", "/usr/local/lib/", "/usr/local/share/", "/lib/", "/bin/", "/sbin/",
"/var/lib/nginx/", "/var/lib/php/", "/etc/ssl/", "/etc/nginx/", "/etc/apache2/",
"/var/lib/misc/", "/var/backups/", "/root/.cache/", "/root/.npm/", "/root/.local/share/",
"/home/node/.npm/", "/var/lib/systemd/",
)
NOISE_SEGMENT = ("/__pycache__/", "/.cache/", "/node_modules/", "/.git/", "/.next/cache/",
"/tmp/", "/temp/", "/.pytest_cache/")
NOISE_SUFFIX = (".pid", ".sock", ".log", ".pyc", ".pyo", ".swp")
NOISE_EXACT = ("/etc/hosts", "/etc/hostname", "/etc/resolv.conf", "/etc/passwd", "/etc/group",
"/etc/shadow", "/etc/localtime", "/etc/timezone", "/etc/mtab", "/etc/machine-id")
# 2. DB SIGNATURE — a filename that PROVES a database lives in that directory. This is the
# strongest signal available and needs no path heuristics at all.
#
# `postgresql.conf` was in this list and has been REMOVED: it is a CONFIG file, not a data
# file, and the postgres entrypoint writes one to /etc/postgresql at init. That called immich
# BROKEN while its database sat correctly in its volume with 1831 files. `PG_VERSION` and
# `pg_control` are the real markers of a PGDATA directory, so removing it opens no blind spot —
# a genuinely misplaced PGDATA still trips both of those.
DB_FILE_RE = re.compile(
r"(\.sqlite3?$|\.sqlite\d*$|\.db$|\.db3$|\.db-wal$|\.db-shm$|-wal$|-shm$"
r"|^PG_VERSION$|^pg_control$|^ib_logfile|^ibdata|\.frm$|\.ibd$|\.MYD$|\.MYI$"
r"|^dump\.rdb$|\.aof$|\.rdb$|^data\.mdb$|^lock\.mdb$|^CURRENT$|^MANIFEST-|\.ldb$|\.sst$"
r"|^data\.ms$|^index\.bleve|\.duckdb$|\.bolt$|\.badger$|\.leveldb$)", re.I)
# 3. DATA TOKEN — a path that says "app state". THIS RULE DOES NOT CONVICT ON ITS OWN; it is
# reported for judgement. That demotion is evidence-driven: across the sweep its true positives
# were all also caught by rule 2, while it produced false positives on three separate apps —
# calibre-web (`cps/static/css/images/**`), crafty-controller (`…/crafty/config/__pycache__`)
# and onlyoffice, which unpacks its OWN static assets into the writable layer at first boot
# (plugin icons, slide-theme `media/`, `web-apps/apps/api/documents/api.js` — 2560 added
# entries) while its real data mount received data normally. Vocabulary is not evidence: a
# directory called `media` holds customer photos in one app and shipped clip-art in the next.
# A gate that cries wolf gets ignored, and then it protects nothing — the resolvability gate's
# own recorded lesson.
DATA_TOKEN_RE = re.compile(
r"(^|/)(data|database|db|storage|store|upload|uploads|media|library|libraries|config|"
r"appdata|app-data|documents?|photos?|images?|files?|backups?|vault|repositor(y|ies)|"
r"attachments?|state|var/lib/(postgresql|mysql|mariadb|redis|mongodb|influxdb)|"
r"conf|settings|sessions?|index|search|metadata|thumbnails?)(/|$)", re.I)
def is_noise(path: str) -> bool:
"""A trailing slash is appended before the segment test ON PURPOSE.
`docker diff` reports directories as well as files, so a bytecode cache appears as the bare
entry `A /routes/api/crafty/config/__pycache__` while its `.pyc` children are filtered by
suffix. Matching `/__pycache__/` against the un-terminated path misses the directory itself,
leaving it as the only surviving entry under `/config` which scored as data and called
crafty-controller BROKEN four times over, on an app whose data had landed correctly.
"""
p = ("/" + path.lstrip("/")).lower()
return (p in NOISE_EXACT
or any(p.startswith(x) for x in NOISE_PREFIX)
or any(x in p + "/" for x in NOISE_SEGMENT)
or any(p.endswith(x) for x in NOISE_SUFFIX))
def is_noise_dir(path: str) -> bool:
"""`is_noise` for a MOUNT TARGET rather than a file path.
The prefixes are written with a trailing slash (`/run/`), so a bare `/run` does not match one.
That gap made every mount rule below blind to runtime directories: privatebin's image declares
`VOLUME /run`, docker made it an anonymous volume, and 14 entries `nginx.pid`,
`php-fpm.sock`, s6 supervision fifos read as unbacked-up customer data. Losing /run costs a
restart and nothing else.
"""
return is_noise((path or "").rstrip("/") + "/_")
def rollup_diff(entries):
"""Group writable-layer entries by directory and split DATA from everything else.
`docker diff` reports changes to the container's writable layer and EXCLUDES every mounted
path, so a file appearing here is definitionally in no volume and no bind: it does not
survive a redeploy and no backup can ever contain it.
`A` VERSUS `C` IS THE WHOLE DIFFICULTY, and getting it wrong in either direction is fatal:
A (added) the app CREATED this file. Unambiguous: it exists only in the writable layer.
papra's `/app/app-data/db/db.sqlite` is an `A`.
C (changed) a file that SHIPS IN THE IMAGE was touched. Usually a chown/chmod sweep and
completely benign: linuxserver.io entrypoints re-own the whole application
tree, which made calibre-web report 1305 `C` entries including
`cps/static/css/images/**` 92 PNGs of static UI furniture. Treating those as
customer data called a clean app BROKEN on the first pass of this sweep.
So DATA is decided from `A` entries only, and `C` on a database-signature file is held back
as SUSPECT genuinely ambiguous, because an app writing into a DB that ships in its image
produces exactly the same verb. `adjudicate_suspects()` settles those by comparing bytes.
"""
dirs = {}
for kind, path in entries:
if kind == "D":
continue
p = "/" + path.lstrip("/")
if is_noise(p):
continue
d = os.path.dirname(p) or "/"
base = os.path.basename(p)
e = dirs.setdefault(d, {"added": [], "changed": [], "db_added": False, "db_changed": []})
if kind == "A":
if len(e["added"]) < 40:
e["added"].append(base)
if base and DB_FILE_RE.search(base):
e["db_added"] = True
else:
if len(e["changed"]) < 40:
e["changed"].append(base)
if base and DB_FILE_RE.search(base):
e["db_changed"].append(p)
data, token, suspect, other = [], [], [], []
for d, e in sorted(dirs.items()):
rec = {"dir": d, "files": e["added"] or e["changed"], "added": e["added"],
"changed_count": len(e["changed"]), "db_signature": e["db_added"]}
# NOTE: there is deliberately no second "are all this directory's children noise?" filter
# here. One was written and removed: `e["added"]` can only ever contain entries that
# already passed `is_noise` above, so the check is always False — dead code wearing the
# costume of a safeguard. The single entry-level filter is the whole mechanism, and
# `test_a_bytecode_cache_DIRECTORY_is_noise` is what pins it.
if e["added"] and e["db_added"]:
data.append(rec) # rule 2 — CONVICTS
elif e["added"] and DATA_TOKEN_RE.search(d):
token.append(rec) # rule 3 — reported for judgement, never convicts
elif e["db_changed"]:
suspect.append({"dir": d, "paths": e["db_changed"], "db_signature": True,
"files": [os.path.basename(x) for x in e["db_changed"]]})
else:
other.append(rec)
return data, token, suspect, other
def adjudicate_suspects(cid, image, suspects):
"""Settle a `C` on a database-signature file by BYTES, not by guessing.
A chown leaves the content identical; an app writing into a shipped database does not. So the
file is copied out of the running container and out of a pristine container made from the same
image, and the two are compared. Identical benign, the app's data is not here. Different →
the app IS writing into an image-layer file, which is the same defect as papra's with a
different verb.
Returns (confirmed, benign, unresolved) `unresolved` is never folded into either.
"""
confirmed, benign, unresolved = [], [], []
if not suspects:
return confirmed, benign, unresolved
ref = _sh(["docker", "create", image], timeout=300)
refid = ref.stdout.strip().splitlines()[-1] if ref.returncode == 0 and ref.stdout.strip() else ""
tmp = tempfile.mkdtemp(prefix="volgate-adj-")
try:
for s in suspects:
for path in s["paths"]:
live = os.path.join(tmp, "live")
orig = os.path.join(tmp, "orig")
a = _sh(["docker", "cp", f"{cid}:{path}", live], timeout=300)
b = _sh(["docker", "cp", f"{refid}:{path}", orig], timeout=300) if refid else None
if a.returncode != 0 or not refid or b.returncode != 0:
unresolved.append({**s, "why": "could not read both copies"})
continue
try:
lb, ob = open(live, "rb").read(), open(orig, "rb").read()
except OSError:
unresolved.append({**s, "why": "unreadable"})
continue
finally:
for f in (live, orig):
if os.path.exists(f):
os.remove(f)
if lb == ob:
benign.append({**s, "why": "byte-identical to the image copy — a chown/chmod "
"sweep, not a write"})
else:
confirmed.append({**s, "why": f"DIFFERS from the image copy "
f"({len(ob)} B -> {len(lb)} B) — the app is "
f"writing into an image-layer file"})
finally:
if refid:
_sh(["docker", "rm", "-f", refid], timeout=120)
shutil.rmtree(tmp, ignore_errors=True)
return confirmed, benign, unresolved
# ---------------------------------------------------------------------------- the verdict (pure)
def classify(probe: dict):
"""PURE. Turn one probe into (status, [reasons]). Unit-tested without Docker.
BROKEN positive evidence that data does not land where the template preserves it:
(a) DATA in a container's writable layer — never persisted, never backed up
(b) an app-data mount its own uid cannot write R-156's second leg
(c) data in an ANONYMOUS volume survives a restart, but is absent
from `ResolveDockerVolumeNames` (felhom-controller `internal/appbackup/appdata.go`, which
only ever returns `<project>_<name>` for volumes DECLARED in the compose file), so it is
never backed up, and a `down` + `up` orphans it.
UNDETERMINED the question was not answered: a container never reached running/healthy, or
nothing was written anywhere so there is no data to locate. **Never folded into CLEAN.**
CLEAN something was written, all of it inside a declared named volume or a bind, nothing
data-classified in any writable layer, every mount writable by its app uid.
"""
broken, undet, notes, structural = [], [], [], []
if probe.get("error"):
return UNDETERMINED, [probe["error"]]
containers = probe.get("containers") or []
if not containers:
return UNDETERMINED, ["no containers were created"]
wrote_anything = False
# Structural check, independent of every path heuristic below. The rules that name data by
# its path can only ever recognise the shapes someone thought of: gramps-web writes its actual
# family tree to /root/.gramps/grampsdb/<uuid>/ as database.txt + name.txt, which carries no
# database-signature filename and no data token, so it was MISSED entirely on the first pass
# while a second defect in the same app was caught. This asks a question that needs no
# vocabulary — did ANY of what the app created land in ANY folder the template preserves?
#
# ASKED PER APP, NOT PER CONTAINER, and that is the whole difficulty. Per container it fired on
# docmost, immich and claper — three CORRECT apps, all the same shape: the app container's only
# volume is for user uploads and is legitimately empty on a fresh install, while every byte of
# real state sits in the sibling database container's volume (1540, 1833 and 1470 files
# respectively). Per app it stays silent on all three and still catches gramps-web, whose
# single container had every mount empty. The per-container observation is kept as a NOTE so
# nothing is silently dropped.
running = [c for c in containers if c.get("status") == "running"]
app_persisted = [m for c in running for m in (c.get("mounts") or [])
if m["class"] != "tmpfs" and not is_noise_dir(m.get("target"))]
app_outside = [(c["name"], d) for c in running
for d in (c.get("diff_other_dirs") or []) if d.get("added")]
for c in running:
mine = [m for m in (c.get("mounts") or [])
if m["class"] != "tmpfs" and not is_noise_dir(m.get("target"))]
outside = [d for d in (c.get("diff_other_dirs") or []) if d.get("added")]
if mine and outside and all(m.get("files", 0) == 0 for m in mine):
notes.append(
f"{c['name']}: this container's mounts are all empty while it created entries in "
f"{[d['dir'] for d in outside][:3]} — benign when a sibling container holds the "
f"state, worth a look when none does")
if app_persisted and app_outside and all(m.get("files", 0) == 0 for m in app_persisted):
structural.append(
f"NOTHING this app wrote landed in ANY folder the template preserves: all "
f"{len(app_persisted)} mount(s) across {len(running)} container(s) are empty, yet "
f"entries were created in {[d['dir'] for _, d in app_outside][:4]}. Needs adjudication.")
for c in containers:
nm = c["name"]
if c.get("status") != "running":
undet.append(f"{nm}: not running (status={c.get('status')} exit={c.get('exit')} "
f"restarts={c.get('restarts')})")
continue
if c.get("health") == "unhealthy":
undet.append(f"{nm}: unhealthy")
for m in c.get("mounts") or []:
if m["class"] == "tmpfs" or is_noise_dir(m.get("target")):
continue # /run, /tmp, /var/log … — runtime state, not customer data
if m.get("files", 0) > 0:
wrote_anything = True
if m["class"] == "anonymous":
broken.append(f"{nm}: {m['files']} file(s) in an ANONYMOUS volume at "
f"{m['target']} — not in the compose `volumes:` block, so it is "
f"never backed up and a redeploy orphans it")
if m.get("writable_by_app") == "NO" and m["class"] in ("named-declared", "bind",
"anonymous"):
broken.append(f"{nm}: mount {m['target']} is NOT writable by the app's own "
f"uid={c.get('uid')} gid={c.get('gid')}")
if m["class"] == "named-declared" and m.get("files", 0) == 0:
notes.append(f"{nm}: declared volume {m['target']} is EMPTY")
for d in c.get("diff_data_dirs") or []:
wrote_anything = True
broken.append(f"{nm}: DATA in the writable layer at {d['dir']} "
f"(db_signature={d.get('db_signature')}, e.g. {d['files'][:4]})"
+ (f" [{d['why']}]" if d.get("why") else ""))
for d in c.get("diff_token_dirs") or []:
# Rule 3 never convicts. It is surfaced so a human decides, and counted as evidence
# that the app wrote SOMETHING (so an app is not called idle when it plainly was not).
wrote_anything = True
notes.append(f"{nm}: writable-layer writes at {d['dir']} (path suggests state, no "
f"database signature — judgement needed): {d['added'][:4]}")
for d in c.get("diff_benign_db_touches") or []:
notes.append(f"{nm}: {d['dir']} — database file(s) touched but byte-identical to the "
f"image; a chown sweep, not a write")
for d in c.get("diff_unresolved") or []:
# Never fold an unresolved suspect into CLEAN.
undet.append(f"{nm}: could not decide whether {d['dir']} holds live data "
f"({d.get('why')})")
# `structural` is always shown, whatever the verdict, and on its own it is enough to withhold
# a clean bill of health.
if broken:
return BROKEN, broken + structural + notes
if undet or structural:
return UNDETERMINED, undet + structural + notes
if not wrote_anything:
return UNDETERMINED, ["nothing was written to any mount and nothing data-classified in any "
"writable layer — the app produced no data to locate. Health is not "
"data: this is UNDETERMINED, not CLEAN"] + notes
return CLEAN, notes
# ---------------------------------------------------------------------------- the prober (Docker)
def _sh(args, timeout=180):
try:
return subprocess.run(args, capture_output=True, text=True, timeout=timeout)
except (subprocess.TimeoutExpired, OSError) as e:
return subprocess.CompletedProcess(args, 124, "", f"{e}")
def parse_deploy_fields(text: str):
"""Minimal `deploy_fields:` reader — env_var / type / default / generate.
Line-based on purpose: this repo ships no requirements file and the two sibling gates parse
`.felhom.yml` the same way, so the gate keeps working with a bare python3.
"""
fields, cur, indent = [], None, None
in_block = False
for line in text.splitlines():
if re.match(r"^deploy_fields:\s*$", line):
in_block, cur = True, None
continue
if in_block:
if line.strip() and not line.startswith((" ", "\t")):
break # a new top-level key ends the block
m = re.match(r"^(\s*)-\s+env_var:\s*[\"']?([A-Za-z0-9_]+)", line)
if m:
if cur:
fields.append(cur)
indent, cur = len(m.group(1)), {"env_var": m.group(2)}
continue
if cur is not None:
m = re.match(r"^\s+(type|default|generate):\s*(.*?)\s*$", line)
if m:
cur[m.group(1)] = m.group(2).strip().strip('"').strip("'")
if cur:
fields.append(cur)
return fields
def _gen(spec, ftype):
"""Mirror `generateValue` in felhom-controller `internal/stacks/deploy.go:870`.
It has to be the SAME value shape the controller mints, or the gate measures an app the
customer never runs. `base64key` is the one that bites: the controller returns
`"base64:" + b64` (deploy.go:904) because Laravel's APP_KEY is invalid without the prefix —
dropping it leaves bookstack serving 500s, which reads as an app defect and is a harness bug.
"""
if spec:
kind, _, n = spec.partition(":")
n = int(n) if n.isdigit() else 32
if kind == "hex":
return secrets.token_hex(n)
if kind in ("password", "secret"):
alph = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
return "".join(secrets.choice(alph) for _ in range(n))
if kind == "base64key":
import base64
return "base64:" + base64.b64encode(secrets.token_bytes(n)).decode()
if kind == "static":
return spec.partition(":")[2]
if ftype in ("password", "secret", "secret_input"):
return "Gate" + secrets.token_hex(12)
return ""
def build_env(app: str, felhom_text: str, compose_text: str):
"""Resolve every `${VAR}` the compose uses. Generated values are never printed or written to
any artifact only the KEYS are, per the repo's no-secrets rule."""
sub = re.search(r"^subdomain:\s*[\"']?([a-z0-9-]+)", felhom_text, re.M)
env = {"DOMAIN": "gate.invalid", "SUBDOMAIN": sub.group(1) if sub else app,
"HDD_PATH": SCRATCH_HDD, "USERDATA_PATH": SCRATCH_HDD + "/userdata",
"IMPORT_PATH": SCRATCH_IMPORT, "TZ": "Europe/Budapest"}
for f in parse_deploy_fields(felhom_text):
var, t = f["env_var"], f.get("type", "text")
if t == "path":
env[var] = SCRATCH_HDD
elif f.get("default"):
env[var] = f["default"]
elif t == "domain":
env[var] = "gate.invalid"
elif t == "subdomain":
env[var] = env["SUBDOMAIN"]
else:
env[var] = _gen(f.get("generate"), t)
for var in set(VAR_RE.findall(compose_text)):
# A var that resolves to "" binds a bogus root-owned dir at the container root
# (felhom-controller deploy.go:571) — never leave one unset.
env.setdefault(var, "g" + hashlib.sha256(var.encode()).hexdigest()[:20])
return env
def _inspect(cid):
r = _sh(["docker", "inspect", cid])
try:
return json.loads(r.stdout)[0]
except Exception:
return None
def _uid(info):
"""The uid PID 1 ACTUALLY runs as, from /proc — `Config.User` is frequently empty even when
the image drops privileges inside its entrypoint, which is exactly papra's shape."""
pid = (info.get("State") or {}).get("Pid") or 0
if pid:
try:
uid = gid = None
for line in open(f"/proc/{pid}/status"):
if line.startswith("Uid:"):
uid = int(line.split()[1])
elif line.startswith("Gid:"):
gid = int(line.split()[1])
if uid is not None:
return uid, gid
except OSError:
pass
u = (info.get("Config") or {}).get("User") or ""
a, _, b = u.partition(":")
return (int(a) if a.isdigit() else None, int(b) if b.isdigit() else None)
def _classify_mount(m, project, declared):
t = m.get("Type")
if t in ("tmpfs", "bind"):
return t
if t == "volume":
name = m.get("Name") or ""
if re.fullmatch(r"[0-9a-f]{64}", name):
return "anonymous"
if name.startswith(project + "_") and name[len(project) + 1:] in declared:
return "named-declared"
return "named-external"
return t or "unknown"
def _walk(src, limit=4000):
if not src or not os.path.isdir(src):
return -1, []
n, sample = 0, []
for dp, _, fns in os.walk(src, onerror=lambda e: None):
for f in fns:
n += 1
if len(sample) < 40:
sample.append(os.path.relpath(os.path.join(dp, f), src))
if n > limit:
return n, sample
return n, sample
def _writable(src, uid, gid):
"""Can uid/gid create a file in src? Decided from the host-side owner/mode — the same
question R-156 answered with `touch` inside the container, without needing a shell there."""
if uid is None:
return "unknown-uid"
if not src or not os.path.isdir(src):
return "no-source"
if uid == 0:
return "yes"
try:
st = os.stat(src)
except OSError:
return "stat-error"
if st.st_uid == uid and st.st_mode & stat.S_IWUSR:
return "yes"
if gid is not None and st.st_gid == gid and st.st_mode & stat.S_IWGRP:
return "yes"
return "yes" if st.st_mode & stat.S_IWOTH else "NO"
PATHS_FIRST = ("/",)
PATHS_DEEP = ("/", "/login", "/setup", "/signup", "/register", "/install", "/admin",
"/api/health", "/health", "/healthz", "/status", "/web", "/index.php", "/dashboard")
def _exercise(cids, ports, deep=False):
"""Minimum exercise: an HTTP request the app's OWN router answers.
A container that has only started may have written nothing, and health-check-passing is not
data-writing conflating the two is precisely what let papra look fine. Any status code
(including 3xx/4xx/5xx) proves the request reached application code; `000` does not.
"""
hits = []
for cid in cids:
info = _inspect(cid) or {}
for net in ((info.get("NetworkSettings") or {}).get("Networks") or {}).values():
ip = net.get("IPAddress")
if not ip:
continue
for port in ports:
for path in (PATHS_DEEP if deep else PATHS_FIRST):
a = ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "20"]
if deep:
a += ["-L", "--max-redirs", "5"]
code = _sh(a + [f"http://{ip}:{port}{path}"], timeout=40).stdout.strip()
if code and code != "000":
hits.append(f"{ip}:{port}{path} -> {code}")
if not deep:
break
return hits
def docker_prober(app: str, app_dir: Path, settle: int = 45, wait: int = 300) -> dict:
"""Deploy the template, exercise it, and report WHERE the data landed. The Docker seam.
Everything Docker-touching lives here so `classify()` stays pure and testable.
"""
compose_src = app_dir / "docker-compose.yml"
felhom_src = app_dir / ".felhom.yml"
if not compose_src.is_file():
return {"app": app, "error": "no docker-compose.yml"}
compose_text = compose_src.read_text(encoding="utf-8")
felhom_text = felhom_src.read_text(encoding="utf-8") if felhom_src.is_file() else ""
work = Path(tempfile.mkdtemp(prefix=f"volgate-{app}-"))
project = "volgate-" + re.sub(r"[^a-z0-9]+", "", app.lower())
cf = work / "docker-compose.yml"
shutil.copy(compose_src, cf)
env = build_env(app, felhom_text, compose_text)
(work / ".env").write_text("".join(f"{k}={v}\n" for k, v in sorted(env.items())),
encoding="utf-8")
os.makedirs(SCRATCH_HDD + "/userdata", exist_ok=True)
os.makedirs(SCRATCH_IMPORT, exist_ok=True)
_sh(["docker", "network", "create", "traefik-public"], timeout=60) # templates expect it
base = ["docker", "compose", "-p", project, "--project-directory", str(work), "-f", str(cf)]
try:
cfg = _sh(base + ["config", "--format", "json"], timeout=180)
try:
resolved = json.loads(cfg.stdout)
except Exception:
return {"app": app, "error": f"compose config failed: "
f"{(cfg.stderr or cfg.stdout)[:300]}"}
declared = set((resolved.get("volumes") or {}).keys())
ports = sorted({int(m.group(1))
for svc in (resolved.get("services") or {}).values()
for lbl in ((svc.get("labels") or {}).values()
if isinstance(svc.get("labels"), dict)
else (svc.get("labels") or []))
for m in [PORT_RE.search(str(lbl))] if m})
up = _sh(base + ["up", "-d"], timeout=1800)
cids = [c for c in _sh(base + ["ps", "-aq"], timeout=120).stdout.split() if c]
if not cids:
return {"app": app, "error": f"no containers created (compose up rc={up.returncode}: "
f"{(up.stderr or '')[-300:]})"}
deadline = time.time() + wait
while time.time() < deadline:
pend = False
for cid in cids:
st = (_inspect(cid) or {}).get("State") or {}
if st.get("Status") in ("created", "restarting") or \
(st.get("Health") or {}).get("Status") == "starting":
pend = True
if not pend:
break
time.sleep(10)
running = [c for c in cids
if ((_inspect(c) or {}).get("State") or {}).get("Status") == "running"]
hits = _exercise(running, ports) if (running and ports) else []
time.sleep(settle)
def observe():
out = []
for cid in cids:
info = _inspect(cid)
if not info:
continue
uid, gid = _uid(info)
mounts = []
for m in (info.get("Mounts") or []):
n, sample = _walk(m.get("Source"))
mounts.append({"target": m.get("Destination"),
"class": _classify_mount(m, project, declared),
"name": m.get("Name"), "source": m.get("Source"),
"files": n, "sample": sample,
"writable_by_app": _writable(m.get("Source"), uid, gid)})
entries = [(g.group(1), g.group(2))
for line in _sh(["docker", "diff", cid], timeout=180).stdout.splitlines()
for g in [DIFF_RE.match(line)] if g]
data, token, suspect, other = rollup_diff(entries)
image = (info.get("Config") or {}).get("Image")
conf, benign, unres = adjudicate_suspects(cid, image, suspect)
st = info.get("State") or {}
out.append({"name": (info.get("Name") or cid).lstrip("/"),
"image": image,
"status": st.get("Status"),
"health": (st.get("Health") or {}).get("Status"),
"exit": st.get("ExitCode"), "restarts": st.get("RestartCount"),
"uid": uid, "gid": gid, "mounts": mounts,
"diff_total": len(entries),
"diff_added": sum(1 for k, _ in entries if k == "A"),
"diff_data_dirs": data + conf,
"diff_token_dirs": token,
"diff_benign_db_touches": benign,
"diff_unresolved": unres,
"diff_other_dirs": other})
return out
def nothing_written(cs):
return not any(c["diff_data_dirs"] or c["diff_token_dirs"]
or any(m["class"] != "tmpfs" and m["files"] > 0 for m in c["mounts"])
for c in cs)
containers = observe()
# Second chance before declaring the question unanswerable: walk a wider path list
# following redirects, so a first-run wizard is actually reached.
if nothing_written(containers) and running and ports:
hits += _exercise(running, ports, deep=True)
time.sleep(90)
containers = observe()
return {"app": app, "ports": ports, "exercise": hits,
"declared_volumes": sorted(declared), "containers": containers,
"env_keys": sorted(env)}
finally:
# `compose down -v` removes THIS project's volumes and nothing else. Deliberately NOT
# `docker volume prune -f`: that is a GLOBAL sweep of every unused volume on the host,
# which on any box also running real stacks would delete data this gate never created.
# The workspace CLAUDE.md bans exactly this class of global Docker cleanup.
_sh(base + ["down", "-v", "--remove-orphans"], timeout=900)
shutil.rmtree(work, ignore_errors=True)
# ---------------------------------------------------------------------------- canary self-test
# A purpose-built canary image reproducing papra's exact shape: `/app/data` exists and is
# root-owned, `/app/app-data` exists and belongs to the app's own non-root uid. A fresh named
# volume inherits the ownership of whatever directory it is mounted over, so mounting at
# /app/data yields a volume the app cannot write, and mounting at /app/app-data yields one it can.
# That single difference is the whole of R-156, which makes the canary pair a live demonstration
# of the defect AND of its fix on every run.
CANARY_IMAGE = "felhom-volgate-canary:1"
CANARY_DOCKERFILE = """FROM alpine:3.22
RUN mkdir -p /app/data /app/app-data \
&& adduser -D -u 4242 appuser \
&& chown 4242:4242 /app/app-data
"""
_CANARY_CMD = ('["sh", "-c", "mkdir -p /app/app-data/db && '
'echo canary > /app/app-data/db/db.sqlite && sleep 900"]')
# BROKEN: the volume is mounted where the app does NOT write, and cannot write.
CANARY_BROKEN = f"""services:
canary:
image: {CANARY_IMAGE}
user: "4242:4242"
command: {_CANARY_CMD}
volumes:
- canary_data:/app/data
volumes:
canary_data:
"""
# CLEAN: the same app, same uid, volume mounted where it actually writes.
CANARY_CLEAN = f"""services:
canary:
image: {CANARY_IMAGE}
user: "4242:4242"
command: {_CANARY_CMD}
volumes:
- canary_data:/app/app-data
volumes:
canary_data:
"""
def ensure_canary_image() -> bool:
if _sh(["docker", "image", "inspect", CANARY_IMAGE], timeout=120).returncode == 0:
return True
d = tempfile.mkdtemp(prefix="volgate-canary-build-")
try:
with open(os.path.join(d, "Dockerfile"), "w") as fh:
fh.write(CANARY_DOCKERFILE)
return _sh(["docker", "build", "-q", "-t", CANARY_IMAGE, d], timeout=900).returncode == 0
finally:
shutil.rmtree(d, ignore_errors=True)
def self_test(prober) -> tuple[bool, str]:
"""Prove the prober discriminates, on THIS run, in BOTH directions.
A detector that flags everything and a detector that flags nothing are both useless, and the
second is actively dangerous: it turns an unexamined catalog into a documented-clean one. So
the gate refuses to report a verdict at all unless it has just called a known-broken template
BROKEN and a known-good one CLEAN.
"""
if prober is docker_prober and not ensure_canary_image():
return False, f"could not build {CANARY_IMAGE}"
for name, body, want in (("canary-broken", CANARY_BROKEN, BROKEN),
("canary-clean", CANARY_CLEAN, CLEAN)):
d = Path(tempfile.mkdtemp(prefix=f"volgate-{name}-"))
try:
(d / "docker-compose.yml").write_text(body, encoding="utf-8")
got, why = classify(prober(name, d, settle=10, wait=90))
if got != want:
return False, f"{name}: expected {want}, got {got} ({'; '.join(why)[:200]})"
finally:
shutil.rmtree(d, ignore_errors=True)
return True, ""
# ---------------------------------------------------------------------------- driver
def app_lifecycle(app_dir: Path) -> str:
f = app_dir / ".felhom.yml"
if not f.is_file():
return "available"
m = LIFECYCLE_RE.search(f.read_text(encoding="utf-8"))
v = m.group(1) if m else "available"
return v if v in ("available", "hidden", "abandoned") else "available"
def collect_apps(root: Path, only=None, include_unavailable=False):
apps, skipped = [], []
for f in sorted(root.glob("templates/*/docker-compose.yml")):
app = f.parent.name
if only and app not in only:
continue
lc = app_lifecycle(f.parent)
if lc != "available" and not include_unavailable:
skipped.append(f"{app} ({lc})")
continue
apps.append((app, f.parent))
return apps, skipped
def check(root: Path, only=None, prober=docker_prober, include_unavailable=False,
evidence: Path | None = None, skip_self_test=False) -> int:
apps, skipped = collect_apps(root, only, include_unavailable)
if skipped:
print(f"skipping {len(skipped)} app(s) not offered for new installs: {', '.join(skipped)}")
if not apps:
if skipped:
print("nothing to check — every app in scope is out of circulation")
return 0
print(f"ERROR: no templates found under {root}/templates/", file=sys.stderr)
return 2
if not skip_self_test:
print("self-testing the prober (both directions)…")
ok, why = self_test(prober)
if not ok:
print(f"ERROR: the prober failed its own canary — {why}\n"
" refusing to report a verdict: a broken detector reporting CLEAN is worse "
"than no detector at all", file=sys.stderr)
return 2
print(" prober flags the R-156 signature and clears a correct template — trustworthy")
results = []
for app, d in apps:
probe = prober(app, d)
status, why = classify(probe)
results.append((app, status, why))
print(f"{app:<20} {status:<13} {'; '.join(why)[:150]}", flush=True)
if evidence:
out = evidence / app
out.mkdir(parents=True, exist_ok=True)
(out / "probe.json").write_text(
json.dumps({"verdict": status, "reasons": why, "probe": probe},
indent=2, sort_keys=True), encoding="utf-8")
broken = [a for a, s, _ in results if s == BROKEN]
undet = [(a, w) for a, s, w in results if s == UNDETERMINED]
clean = [a for a, s, _ in results if s == CLEAN]
if broken:
print("\nBROKEN — the app's data does NOT land where the template preserves it:")
for a, s, w in results:
if s == BROKEN:
print(f" {a}")
for line in w:
print(f" {line}")
if undet:
print("\nUNDETERMINED — NOT a pass. The question was not answered for these:")
for a, w in undet:
print(f" {a}: {'; '.join(w)[:200]}")
print(f"\n{len(clean)} clean · {len(broken)} broken · {len(undet)} undetermined "
f"(of {len(apps)} in scope)")
if broken:
print("volume-persistence gate REFUSED")
return 1
if undet:
print("INCOMPLETE — no broken template among those decided, but "
f"{len(undet)} were never decided. This is not a clean bill of health.")
return 2
print("volume-persistence gate OK")
return 0
if __name__ == "__main__":
argv = sys.argv[1:]
all_apps = "--all" in argv
no_self = "--no-self-test" in argv
ev = None
for i, a in enumerate(argv):
if a == "--evidence" and i + 1 < len(argv):
ev = Path(argv[i + 1])
argv = [a for i, a in enumerate(argv)
if a not in ("--all", "--no-self-test", "--evidence")
and not (i > 0 and argv[i - 1] == "--evidence")]
sys.exit(check(Path(__file__).resolve().parent.parent, only=argv or None,
include_unavailable=all_apps, evidence=ev, skip_self_test=no_self))

Some files were not shown because too many files have changed in this diff Show More