fix(docker): normalise data-volume ownership at startup via gosu entrypoint

Two related failure modes have been biting Docker users repeatedly,
  most recently in #1211:

    1. Docker named volumes are created by the daemon as root:root, and
       the previous `chmod 777 /app/data` Dockerfile workaround only
       covered the named-volume root — so subdirs Bambuddy creates at
       runtime (virtual_printer/uploads, virtual_printer/certs, etc.)
       inherited wrong ownership when the container ran as 1000:1000.

    2. The shipped docker-compose.yml ships
       `./virtual_printer:/app/data/virtual_printer` uncommented, and
       dockerd creates a missing bind-mount source on the host as root
       before the container starts — leaving the host directory
       unwritable by uid 1000 inside the container even though the named
       volume above it had the chmod-777 workaround.

  Symptom either way: [Errno 13] Permission denied:
  '/app/data/virtual_printer/uploads', no virtual printer ever starts,
  "VP doesn't work" support reports follow.

  Replace the chmod-777 hack with a proper entrypoint:

    - deploy/docker-entrypoint.sh runs as root, chowns /app/data and
      /app/logs (and /app/data/virtual_printer when bind-mounted) to
      PUID:PGID, then drops to that uid via gosu before exec'ing the
      app. The chown is gated behind a top-level ownership check so
      subsequent restarts skip the recursive traversal — no multi-
      second startup penalty on multi-GB archive directories.

    - A sentinel .bambuddy file in each data path prevents Docker from
      re-syncing image directory metadata on every mount (otherwise
      empty volumes have their ownership reverted from the image on
      each restart, defeating the idempotency).

    - When the container is started with an explicit `user:` directive
      or `--user` flag the entrypoint detects it isn't root and falls
      through to direct exec — preserving compatibility for users who
      pin a specific uid.

  Compose template changes:

    - Remove `user: "${PUID:-1000}:${PGID:-1000}"` (entrypoint owns
      privilege drop now).
    - Add PUID / PGID env vars with the same defaults.
    - Comment out the ./virtual_printer:/app/data/virtual_printer
      bind mount by default, with explicit "only needed if you also
      run a native install of Bambuddy on the same host and want both
      to share the VP CA cert" guidance. The entrypoint chowns the
      host-side dir through the bind mount the first time it sees
      wrong ownership, so existing uncomented installs continue to
      work and #1211 specifically gets fixed.
This commit is contained in:
maziggy 2026-05-05 17:22:08 +02:00
parent d5280ce21f
commit 3407afc0c7
4 changed files with 114 additions and 9 deletions

View file

@ -22,6 +22,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Filament Track Switch (FTS) support — print modal filament dropdown is no longer empty when an X2D / H2D has the FTS accessory installed** ([#1162](https://github.com/maziggy/bambuddy/issues/1162), reported by @mkavalecz) — When the FTS accessory is installed the printer's MQTT changes one nibble of the per-AMS `info` bitmask: bits 8-11 flip from a fixed extruder ID (0x0 / 0x1) to `0xE` ("uninitialized"), because the AMS is no longer wired to a single nozzle — the FTS dynamically routes any slot to either extruder. Bambuddy's MQTT parser already skipped 0xE entries when building `ams_extruder_map` (matching BambuStudio's reading for boot-time transient state), so with the FTS installed the map ended up empty and the print modal's filament dropdown — which filters by `extruderId === nozzle_id` to prevent cross-nozzle assignment ("position of left hotend is abnormal" failures) — filtered out *every* loaded slot. Net effect: empty Filament Mapping dropdown on every dual-nozzle print with the FTS, even when the AMS was fully loaded with the right material. Detection comes from a new MQTT field — `print.device.fila_switch` — which is non-null only when the accessory is installed; it carries the routing topology as two arrays: `in[track] = currently fed slot (-1 = empty)` and `out[track] = extruder this track terminates at`. The fix surfaces this through a new `FilaSwitchState` dataclass on `PrinterState` (`installed`, `in_slots`, `out_extruders`, `stat`, `info`) and the equivalent `FilaSwitchResponse` Pydantic schema on the `GET /printers/{id}/status` route. Frontend (`useFilamentMapping.ts` + `FilamentMapping.tsx`) skips the per-extruder filter when `printerStatus.fila_switch?.installed === true` so any compatible AMS slot can satisfy any nozzle's filament requirement, since the FTS handles the routing. Slots currently fed into a track also get a routing badge in the dropdown — `[L]` or `[R]` — so the user can tell at a glance which slot the FTS is currently routing where (idle slots get no badge: they can be routed to either extruder on demand). The hard "no cross-nozzle assignment" filter on real dual-nozzle printers without the FTS stays untouched (still trips the same way it always has — `fila_switch == null` keeps the existing behaviour). 4 backend tests in `test_bambu_mqtt.py::TestFilamentTrackSwitchDetection` (default-not-installed, detect-from-MQTT-using-the-reporter's-bundle, no-fila_switch-field-stays-not-installed, missing-in-out-arrays-don't-crash) and 2 frontend tests in `useFilamentMapping.test.ts` (FTS-active drops the nozzle filter; explicit `fila_switch: null` keeps the filter applied). Upstream fila_switch payloads with anything other than the documented shape are tolerated — `installed` flips on the *presence* of the field, the routing arrays default to empty lists if missing, and the dropdown skips the badge for slots not currently in `in_slots`.
### Fixed
- **Docker permission errors on `/app/data/virtual_printer` and similar paths — root-owned volumes / bind-mount sources no longer break virtual printer setup** ([#1211](https://github.com/maziggy/bambuddy/issues/1211) follow-up; same shape as multiple previous user reports) — Two related failure modes have been biting Docker users repeatedly: (1) Docker named volumes are created by the daemon as `root:root` and the previous `chmod 777 /app/data` Dockerfile workaround only covered the named-volume root, so subdirs Bambuddy creates at runtime (`virtual_printer/uploads`, `virtual_printer/certs`, etc.) inherited the wrong ownership when the container ran as `1000:1000`; (2) the shipped `docker-compose.yml` ships `./virtual_printer:/app/data/virtual_printer` uncommented, and dockerd creates a missing bind-mount source on the host as root before the container starts — leaving the host directory unwritable by uid 1000 inside the container even though the named volume above it had the chmod-777 workaround. Symptom either way: `[Errno 13] Permission denied: '/app/data/virtual_printer/uploads'`, no virtual printer ever starts, "VP doesn't work" support reports follow. **Fix:** new `deploy/docker-entrypoint.sh` runs as root, normalises ownership of `/app/data` and `/app/logs` (and `/app/data/virtual_printer` when bind-mounted) to `PUID:PGID` (default `1000:1000`, overridable via env), then drops to that uid via `gosu` before exec'ing uvicorn. The chown is gated behind a top-level ownership check so subsequent restarts skip the recursive traversal entirely (no multi-second startup penalty on multi-GB archive dirs). A sentinel `.bambuddy` file in each data path prevents Docker from re-syncing image directory metadata on every mount (otherwise empty volumes have their ownership reverted from the image on each restart, defeating the idempotency). When the container is started with an explicit `user:` directive in compose or `--user` on `docker run`, the entrypoint detects it isn't running as root and falls through to direct exec without modifying ownership — preserving compatibility with users who pin a specific uid. **Compose template changes:** the `user: "${PUID:-1000}:${PGID:-1000}"` line is removed (entrypoint owns privilege drop now); `PUID` / `PGID` env vars added with the same defaults; `./virtual_printer:/app/data/virtual_printer` bind mount commented out by default with a clearer explanation of when it's actually needed (only when sharing the VP CA certificate with a co-located native install, which most Docker-only users don't have). Existing users with that bind mount uncommented continue to work — the entrypoint chowns the host-side directory through the bind mount the first time it sees the wrong ownership, fixing #1211 specifically. **Tested end-to-end** against four scenarios on a clean rebuild: (a) named volume only with default PUID/PGID; (b) explicit `--user 1000:1000` override (entrypoint falls through); (c) custom `PUID=1500`; (d) legacy stale root-owned volume contents from a pre-fix install (gets normalised on first start). Idempotency verified: chown messages appear on first start, subsequent starts are silent.
- **Backup restore silently lost most data — settings reverted to defaults, ~most printers/archive rows missing** ([#1211](https://github.com/maziggy/bambuddy/issues/1211), reported by @Carter3DP; same shape as previously-closed [#668](https://github.com/maziggy/bambuddy/issues/668)) — Restoring a settings backup ZIP appeared to succeed but the user found their `energy_cost_per_kwh` reverted to the `0.15` default (defined in `main.py:3457`), 7 of 8 printers gone, 1 GB of archive files on disk but only 1 archive row in the database. #668 was closed in March without an actual fix — that user happened to make it work by rolling back to a stable release, which masked the bug; same shape resurfaces here on a single (consistent) version. **Cause:** the live database runs in WAL mode (`PRAGMA journal_mode = WAL` in `database.py:19`). The original restore endpoint used `shutil.copy2(backup_db, db_path)` after `engine.dispose()`. Two things conspired to make this unsafe: (1) anything the fresh container wrote between startup and the restore call — `seed_default_groups`, `init_db()` migrations, background heartbeat writes — sits in `bambuddy.db-wal` with valid checksums, and `engine.dispose()` doesn't checkpoint it; (2) FastAPI's dependency injection keeps the route handler's own `db: AsyncSession = Depends(get_db)` session checked out across `engine.dispose()` (per SQLAlchemy docs, dispose only closes pooled — not checked-out — connections), so the WAL inode is held open through the whole restore. After `shutil.copy2` rewrote the main DB inode in place, SQLite's WAL recovery on the next `init_db()` happily re-applied the stale frames on top of the restored content, partially clobbering it with fresh-install state. Initial fix attempt of "delete the WAL/SHM/journal sidecars before the copy" turned out to be insufficient — verified experimentally that the still-open request session reads the unlinked sidecars via held fds and bleeds the WAL state back into the new file when it eventually closes. **Real fix:** replace the file copy with SQLite's online backup API (`src_conn.backup(dst_conn)`). The page-by-page protocol opens both DBs as proper SQLite connections, acquires the right locks, and routes new pages through the destination's own WAL — concurrent open sessions see their own transactional snapshot until they close (transaction isolation) but can't corrupt the restored state. Verified via 6 regression tests in `backend/tests/unit/test_restore_sqlite_wal_safety.py`: the buggy `shutil.copy2` path is pinned (the test asserts the bug *manifests* under the un-checkpointed-WAL condition, so a future "small simplification" can't silently re-introduce it); the production `src_conn.backup(dst_conn)` path returns the user's restored values exactly under the same bug condition; the no-WAL-frames case (fresh container, restore as the very first action) round-trips cleanly; and the page-protocol parametrised test runs at 1, 100, and 1000-page DB sizes so a regression at any one size surfaces. PostgreSQL path (`_import_sqlite_to_postgres`) is unchanged — that's row-by-row already and was never affected.
- **`formatTimeOnly` tests failed under non-`:`-separator locales** ([#1213](https://github.com/maziggy/bambuddy/issues/1213), reported by @maugsburger) — Running the frontend test suite under `LC_ALL=en_DK.UTF-8` (or any locale whose `toLocaleTimeString` uses a separator other than `:`) failed two tests in `frontend/src/__tests__/utils/date.test.ts`: `formats time with 12h format` (expected `02.30 pm` to match `/2:30|02:30/`) and `formats time with 24h format` (expected `14.30` to contain `14:30`). The implementation is correct — `formatTimeOnly` calls `date.toLocaleTimeString([], …)` which by design respects the user's locale, so a Danish-English user genuinely should see `02.30 pm` in the UI. The tests just hard-coded the `:` separator. **Fix:** test assertions now use `\D+` (any non-digit, one or more) for the separator: `expect(result).toMatch(/\b0?2\D+30\b/)` and `expect(result).toMatch(/\b14\D+30\b/)`. Tests the actual contract — "the function returns hours and minutes, separated somehow" — without coupling to a specific separator that varies by locale (en_DK uses `.`, some en_* locales use a narrow no-break space at U+202F, most others use `:`). Verified passing under `en_DK.UTF-8`, `en_US.UTF-8`, and `de_DE.UTF-8`. Audited every other `toLocaleTimeString`/`toLocaleString` call site in the test suite — no other places hard-code separator characters; `formatETA`, `formatDateInput` etc. assert via `toBeTruthy()` or check translated content.

View file

@ -24,6 +24,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ffmpeg \
gnupg \
gosu \
iproute2 \
libcap2-bin \
openssh-client \
@ -66,9 +67,29 @@ COPY .git/HEAD ./.git/HEAD
# Copy built frontend from builder stage
COPY --from=frontend-builder /app/static ./static
# Create data directory for persistent storage
# chmod 777 allows running as non-root user (e.g., with docker compose user: directive)
RUN mkdir -p /app/data /app/logs && chmod 777 /app/data /app/logs
# Create data directories. Ownership is normalised at startup by the
# entrypoint (chowns to PUID:PGID and drops privileges via gosu before
# exec'ing the app), so we don't need a chmod 777 hack here — that was
# the workaround for the previous compose `user: "1000:1000"` model and
# only worked when the volume's perms happened to survive (named volume
# first-create case; bind-mount-source case bit users in #1211 / #668).
#
# The sentinel file is needed so a freshly-created Docker named volume
# isn't "empty" from Docker's POV. On empty volumes Docker resyncs the
# directory metadata (incl. ownership) from the image on every mount,
# which would mean our entrypoint chown gets reverted on every restart
# and re-fired on every start (slow on multi-GB archive dirs). With a
# sentinel inside the volume on first mount, Docker considers the
# volume populated and stops resyncing, so the chown is genuinely
# one-shot.
RUN mkdir -p /app/data /app/logs && \
: >/app/data/.bambuddy && \
: >/app/logs/.bambuddy
# Entrypoint script: handles PUID/PGID + ownership normalisation +
# privilege drop. See deploy/docker-entrypoint.sh for the full rationale.
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Environment variables
ENV PYTHONUNBUFFERED=1
@ -103,4 +124,5 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
# Run the application
# Use standard asyncio loop (uvloop has permission issues in some Docker environments)
# Port is configurable via PORT environment variable (default: 8000)
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["sh", "-c", "uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000} --loop asyncio"]

68
deploy/docker-entrypoint.sh Executable file
View file

@ -0,0 +1,68 @@
#!/bin/sh
# Bambuddy container entrypoint.
#
# Runs as root (the image leaves USER unset, so containers start as
# root by default), chowns /app/data and /app/logs to PUID:PGID, then
# drops to PUID:PGID via gosu and execs the application. This fixes the
# class of "Permission denied" errors that bit users when:
#
# - a Docker named volume was first created with root ownership and
# the container was running with `user: 1000:1000` (named volumes
# created by the daemon take its ownership; Dockerfile chmod hacks
# cover the parent path but not subdirs created at runtime).
# - a bind-mount source path didn't exist on the host yet, so dockerd
# created it as root before the container started, leaving it
# unwritable by uid 1000 inside the container — see #1211 / #668
# for the virtual_printer bind-mount case the shipped compose
# template ships uncommented.
#
# If the container is started with an explicit `user:` directive
# (compose `user:` or `docker run --user`), the entrypoint runs as that
# user instead of root and chown isn't possible. The script falls
# through to direct exec without modifying ownership — preserving the
# previous behavior for users who pin a specific uid via compose.
set -eu
# Default to 1000:1000 to match the legacy `user: "1000:1000"` default
# in our previously-shipped compose template; overridable via env so
# users who run docker as a different uid can match their host without
# editing the compose user: directive.
PUID="${PUID:-1000}"
PGID="${PGID:-1000}"
# If we're not root, we can't chown anything. Exec the original command
# and trust that the user has set up host-side ownership themselves.
if [ "$(id -u)" -ne 0 ]; then
exec "$@"
fi
# `chown -R` is gated behind a top-level ownership check so a correctly-
# owned directory isn't traversed on every container start. A user with
# a multi-GB archive directory would otherwise pay seconds-to-minutes
# of chown traversal at every restart.
chown_if_needed() {
target="$1"
[ -d "$target" ] || mkdir -p "$target"
current="$(stat -c '%u:%g' "$target" 2>/dev/null || echo '')"
if [ "$current" != "$PUID:$PGID" ]; then
echo "[entrypoint] chown -R ${PUID}:${PGID} ${target}"
chown -R "${PUID}:${PGID}" "$target" || true
fi
}
chown_if_needed /app/data
chown_if_needed /app/logs
# Bind-mount-source path needs the same treatment when present. dockerd
# creates missing bind-mount sources as root on the host before the
# container starts; the chown here propagates through the bind mount to
# the host-side directory and fixes the issue once and for all.
if [ -d /app/data/virtual_printer ]; then
chown_if_needed /app/data/virtual_printer
fi
# Drop privileges and run the application. python's file capabilities
# (cap_net_bind_service=+ep, set in the Dockerfile) survive the uid
# switch, so binding to :322 / :990 still works post-drop.
exec gosu "${PUID}:${PGID}" "$@"

View file

@ -6,9 +6,11 @@ services:
# docker compose up -d → pulls pre-built image from ghcr.io
# docker compose up -d --build → builds locally from source
container_name: bambuddy
# Run as current user to avoid permission issues with mounted volumes
# Override with: PUID=$(id -u) PGID=$(id -g) docker compose up -d
user: "${PUID:-1000}:${PGID:-1000}"
# File ownership inside the data and logs volumes is normalised by the
# entrypoint at startup (chowns to PUID:PGID and drops privileges via
# gosu before running the app). Override PUID / PGID below to match
# your host user if needed — defaults to 1000:1000 to match the
# historical compose `user:` directive.
#
# Allow binding to privileged ports (322, 990) as non-root user — required
# for FTPS in every VP mode and for the RTSPS camera proxy in proxy mode +
@ -37,9 +39,14 @@ services:
- bambuddy_data:/app/data
- bambuddy_logs:/app/logs
#
# Share virtual printer certs with native installation
# This ensures the slicer only needs to trust one CA certificate.
- ./virtual_printer:/app/data/virtual_printer
# OPTIONAL — only needed if you ALSO run a native install of Bambuddy
# on the same host and want both installs to share the same Virtual
# Printer CA certificate (so the slicer only has to trust one CA).
# Most Docker-only users should leave this commented out — the
# entrypoint will keep the VP data inside the named volume above.
# If uncommented, the entrypoint chowns the host directory to
# PUID:PGID on first start so the container user can write to it.
#- ./virtual_printer:/app/data/virtual_printer
#
# Mount scheduled backup output to NAS or external storage
# Backups default to DATA_DIR/backups/ inside the data volume.
@ -57,6 +64,12 @@ services:
#- /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock
environment:
- TZ=${TZ:-Europe/Berlin}
# User/group the container drops to after the entrypoint normalises
# ownership on /app/data and /app/logs. Match your host user (run
# `id -u` / `id -g`) if you want files written by the container to
# show up as your user on the host. Defaults to 1000:1000.
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
# Port BamBuddy runs on (default: 8000)
# Usage: PORT=8080 docker compose up -d
- PORT=${PORT:-8000}