Skip to content

fix(docker): repair data directory ownership per directory - #155

Merged
Valtora merged 3 commits into
mainfrom
fix/container-data-permissions
Jul 28, 2026
Merged

fix(docker): repair data directory ownership per directory#155
Valtora merged 3 commits into
mainfrom
fix/container-data-permissions

Conversation

@Valtora

@Valtora Valtora commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Pull Request

Description

A chunked import returned HTTP 500 before its first byte whenever /app/data/recordings was root-owned while /app/data itself was owned by uid 1000, and each failed attempt left another recording stranded in UPLOADING.

Both entrypoints decided whether to repair ownership by reading the data root alone:

[ "$(stat -c %u /app/data)" = "1000" ] || chown -R appuser:appuser /app/data

That guard was written to answer whether the recursive chown was needed, and was read as whether the tree was correctly owned. The two coincide only while the tree is always created as a whole, so a child recreated under an already-correct parent was skipped, and the preceding mkdir -p is a no-op on a directory that already exists.

The api service made that state reachable on its own. It declared a second bind of ./data/recordings at /app/recordings, which nothing has ever read; every storage path resolves under /app/data through RECORDINGS_DIR. Docker materialises each declared bind source independently and as root, so deleting the host recordings directory was enough to have it recreated root-owned beneath an appuser-owned parent, with no manual step involved.

Three further gaps, each fixed here:

  • PathManager.repair_data_permissions runs after the gosu drop, as appuser, so it can only log the condition and never fix it. That is the misleading Could not set permissions on directory /app/data/recordings line in the report.
  • No check anywhere asserted the data directory was writable. /api/health proves the process is running, which was never in doubt, so the container reported healthy while structurally unable to accept an upload.
  • No reaper existed for stale UPLOADING rows, and _has_active_live_capture reads any such row as a capture in flight. That suppresses the per-task model cache release, so orphaned rows pin the live ASR model in worker memory indefinitely.

Changes:

  • Ownership repair moves into backend/entrypoint_common.sh, sourced by both entrypoints, which had already begun to drift apart. Each write-critical directory is created if missing and checked on its own, and only the ones that are wrong are repaired. A mis-owned data root still takes a single recursive pass, since that is the first boot on a fresh ./data when the tree is empty; a correct root costs one stat per directory and never walks a large recordings library. Repair is non-fatal, because some supported bind-mount backends reject or ignore chown and a container that refused to boot there would hide the diagnostic it is trying to surface.
  • The vestigial ./data/recordings:/app/recordings mount is removed, along with the image directory it was paired with.
  • init_chunked_import rolls back the row it just committed and returns 503 naming the cause. New probe_recordings_storage creates and removes a real file rather than testing for existence, since a root-owned mount leaves every path present and every write refused.
  • Storage becomes a readiness component on the admin health surface, treated as blocking, plus an explicit API startup error.
  • A reaper clears rows already stranded, with criteria strict enough that an upload or live capture in flight can never be caught.

/api/health is deliberately unchanged. The frontend and nginx services both gate on the api container reporting healthy, so failing the liveness probe would take the UI down and leave an operator with no surface to read the diagnosis on.

No new dependencies.

Fixes #153

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that changes existing behaviour)
  • Documentation update

Checks run

  • Backend tests: source .venv/bin/activate && pytest (1134 passed)
  • Python quality: python scripts/check.py (lint, format, whitespace, filesize, heldpins, typecheck, docs, alembic, tests)
  • Frontend lint: cd frontend && npm run lint
  • Frontend unit tests: cd frontend && npm run test (320 passed, 52 files)
  • Frontend build: cd frontend && npm run build
  • Docs validation: python3 scripts/validate_docs.py
  • Alembic validation: python3 scripts/validate_alembic.py

Migration impact

  • No database migration in this PR.
  • Adds an Alembic migration.

Documentation impact

  • No documentation change required.
  • Updated the relevant guide(s) in the same PR.

docs/DEPLOYMENT.md gains a Data Directory Ownership section covering the per-directory repair, the best-effort behaviour on bind-mount backends that reject chown, how the condition is surfaced, and the removed mount for anyone carrying a Compose override derived from an older example. docs/DEVELOPMENT.md drops the same mount from its two example stacks.

Security impact

  • No security-sensitive change.
  • Touches auth, tokens, encryption, capture ownership, or exposure.

The root-to-appuser privilege boundary described in docs/SECURITY.md is preserved unchanged: the entrypoints still enter as root only to repair bind-mount ownership and immediately drop via gosu. Only the repair block between those two points was rewritten. Verified end-to-end below.

Manual verification

Container-level, against the real nojoin-api:local and nojoin-worker:local images in isolated throwaway containers:

  • Reproduced the reported state exactly (/app/data uid 1000, /app/data/recordings uid 0) and confirmed recording_upload_temp_dir(id, create=True) raised PermissionError: [Errno 13] ... 'data/recordings/temp', matching the traceback in the issue.
  • After the new repair, the same call succeeds and probe_recordings_storage() returns ok=True.
  • With repair deliberately skipped, probe_recordings_storage() returns ok=False with the actionable message, confirming the health component and startup log fire on the real failure.
  • Ran the real worker_entrypoint.sh end-to-end, as the release smoke test does: repair runs, then id reports uid=1000(appuser). The privilege drop is intact.

Shell-level, four ownership scenarios:

  • Issue Chunked import returns HTTP 500 when the recordings directory is root-owned #153 state: root-owned child under a correct parent is repaired.
  • First boot on a fresh, entirely root-owned ./data: single recursive pass, all directories created and owned correctly.
  • Steady state with everything already correct: no output and no work, confirming a large recordings library is not walked on restart.
  • Post-repair write as appuser into recordings/temp succeeds.

Not applicable to this change: browser capture flows (frontend/src/lib/capture/ untouched) and the recording context menu (RecordingCard.tsx / Sidebar.tsx untouched). The only frontend change is the new Storage readiness card in Settings > System, which renders through the existing health card component.

Valtora added 3 commits July 28, 2026 11:54
A chunked import returned 500 before its first byte, because
/app/data/recordings was root-owned while /app/data itself was owned by
uid 1000. Both entrypoints decided whether to repair ownership by
reading the data root alone:

    [ "$(stat -c %u /app/data)" = "1000" ] || chown -R appuser:appuser /app/data

That guard was written to answer whether the recursive chown was needed,
and was read as whether the tree was correctly owned. The two coincide
only while the tree is always created as a whole, so a child recreated
under an already-correct parent was skipped, and the preceding mkdir -p
is a no-op on a directory that already exists.

The api service made that state reachable on its own. It declared a
second bind of ./data/recordings at /app/recordings, which nothing has
ever read; every storage path resolves under /app/data through
RECORDINGS_DIR. Docker materialises each declared bind source
independently and as root, so deleting the host recordings directory was
enough to have it recreated root-owned beneath an appuser-owned parent,
with no manual step involved. Remove the mount and the image directory
it was paired with.

Repair moves into backend/entrypoint_common.sh, sourced by both
entrypoints, which had already begun to drift apart. Each write-critical
directory is created if missing and checked on its own, and only the
ones that are wrong are repaired. A mis-owned data root still takes a
single recursive pass, since that is the first boot on a fresh ./data
when the tree is empty; a correct root costs one stat per directory and
never walks a large recordings library.

Repair is also non-fatal now. Both entrypoints run under set -e, and
some supported bind-mount backends, notably a Windows-drive mount under
Docker Desktop, either reject chown or ignore it. A container that
refused to boot there would hide the diagnostic it is trying to
surface, so failures warn and continue.

Refs: #153, docs/DEVELOPMENT.md
init_chunked_import committed its recording row before creating the
upload temp directory, so a filesystem failure escaped as an unhandled
500 with the row already durable. Every retry added another recording
stuck in UPLOADING that had never received a byte; one report
accumulated ten of them against a single transcript.

Those rows are not only clutter. _has_active_live_capture reads any
UPLOADING recording as a capture in flight, which suppresses the
per-task model cache release, so orphans pin the live ASR model in
worker memory indefinitely. On a single-card host that is the whole
VRAM budget, held by rows representing uploads that never started.

Wrap the directory creation, delete the row it just created, and return
503 naming the cause rather than 500 naming nothing. The sibling upload
endpoints already write their file before inserting a row, so they
cannot orphan anything and are unchanged.

Add probe_recordings_storage, which creates and removes a real file
rather than testing for existence. A bind mount owned by root leaves
every path present and every write refused, so existence answers the
wrong question.

Add a reaper for rows already stranded, run by the daily cleanup task.
Its criteria are deliberately strict, so an upload or live capture in
flight can never be caught: older than 24 hours, no chunk rows, no file
on disk, and nothing in its temp directory. Anything that received a
single byte satisfies one of the last three. Rows are soft-deleted, in
line with every other deletion path.

Refs: #153
A mis-owned bind mount was invisible to every readiness check: the
database, queue, workers and ffmpeg were all fine, so the install
reported itself healthy right up to the first failed upload. The
container healthcheck agreed, because /api/health proves only that the
process is running, which was never in doubt.

Add storage as a readiness component, backed by the write probe, and
treat it as blocking in the pipeline summary. The API also logs the
condition explicitly at startup, so the cause appears in the log at boot
rather than in a traceback hours later.

/api/health is deliberately left alone. The frontend and nginx services
both gate on the api container reporting healthy, so failing the
liveness probe would take the UI down and leave an operator with no
surface to read the diagnosis on. The condition belongs on the readiness
surface, which already models blocking reasons, while the container
keeps serving.

Settings > System renders its health cards from an explicit list rather
than from whatever the API returns, so the card and the shared type are
added alongside.

Refs: #153, docs/DEPLOYMENT.md
@Valtora
Valtora merged commit b8dcda0 into main Jul 28, 2026
19 of 20 checks passed
@Valtora
Valtora deleted the fix/container-data-permissions branch July 28, 2026 12:37
@Valtora Valtora mentioned this pull request Jul 28, 2026
15 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chunked import returns HTTP 500 when the recordings directory is root-owned

1 participant