From d424473b2e2a1066d8d7adc29d88969e4983f788 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 26 May 2026 15:04:38 +0100 Subject: [PATCH] feat(backup): auto-discover container-side capture uid:gid (drop the literal) The hardcoded uid:gid in libreportal.backup.files labels was brittle: matched the default PUID in the compose, but a PUID change (or new image version) would drift silently and the next restore would chown to a stale owner. Make it impossible to drift by letting the engine learn the uid at capture time. backup_files.sh: - After a successful tar capture, run `stat -c '%u:%g'` inside the container and write the result to a .lp-owner sidecar in staging. The sidecar rides in the snapshot alongside the captured tree. - Restore reads it back when the descriptor doesn't pin uid:gid; falls back to 0:0 with a clear notice if missing. - The 5-field form (with explicit uid:gid) is still supported as an override; it wins and skips the sidecar write entirely. Update all 4 current labels to the new 3-field form "::" (nextcloud, bookstack, gitea, owncloud). Engine handles both formats during the transition. Verified with stubs: 3-field capture writes the sidecar with the discovered 33:33; restore reads it back; 5-field override correctly skips the sidecar write. backup_files.sh parses. Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- containers/bookstack/docker-compose.yml | 2 +- containers/gitea/docker-compose.yml | 2 +- containers/nextcloud/docker-compose.yml | 2 +- containers/owncloud/docker-compose.yml | 2 +- scripts/backup/files/backup_files.sh | 46 ++++++++++++++++++++++--- 5 files changed, 46 insertions(+), 8 deletions(-) diff --git a/containers/bookstack/docker-compose.yml b/containers/bookstack/docker-compose.yml index 8f0f880..619cc96 100755 --- a/containers/bookstack/docker-compose.yml +++ b/containers/bookstack/docker-compose.yml @@ -33,7 +33,7 @@ services: libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA libreportal.backup.db: "mariadb:bookstack_db:db:" - libreportal.backup.files: "bookstack:/config:data:1000:1000" + libreportal.backup.files: "bookstack:/config:data" traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA # TRAEFIK_PORT_1_BEGIN traefik.http.routers.bookstack-service.entrypoints: web,websecure diff --git a/containers/gitea/docker-compose.yml b/containers/gitea/docker-compose.yml index b7c1fa8..f95db17 100755 --- a/containers/gitea/docker-compose.yml +++ b/containers/gitea/docker-compose.yml @@ -65,7 +65,7 @@ services: libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA libreportal.backup.db: "sqlite:::data/gitea/gitea/gitea.db" - libreportal.backup.files: "gitea-service:/data:data/gitea:1000:1000" + libreportal.backup.files: "gitea-service:/data:data/gitea" traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA # TRAEFIK_PORT_1_BEGIN traefik.http.routers.gitea-service.entrypoints: web,websecure diff --git a/containers/nextcloud/docker-compose.yml b/containers/nextcloud/docker-compose.yml index 2c9b6d8..4ca8212 100644 --- a/containers/nextcloud/docker-compose.yml +++ b/containers/nextcloud/docker-compose.yml @@ -33,7 +33,7 @@ services: libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA libreportal.backup.db: "mariadb:nextcloud-db:db_data:" - libreportal.backup.files: "nextcloud-service:/var/www/html:html:33:33" + libreportal.backup.files: "nextcloud-service:/var/www/html:html" # GLUETUN_OFF_BEGIN networks: DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA diff --git a/containers/owncloud/docker-compose.yml b/containers/owncloud/docker-compose.yml index 43eb06c..05fb00e 100755 --- a/containers/owncloud/docker-compose.yml +++ b/containers/owncloud/docker-compose.yml @@ -33,7 +33,7 @@ services: libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA libreportal.backup.db: "mariadb:owncloud-mariadb:mysql:" - libreportal.backup.files: "owncloud-service:/mnt/data:files:33:33" + libreportal.backup.files: "owncloud-service:/mnt/data:files" traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA # TRAEFIK_PORT_1_BEGIN traefik.http.routers.owncloud-service.entrypoints: web,websecure diff --git a/scripts/backup/files/backup_files.sh b/scripts/backup/files/backup_files.sh index 7cd9fb7..bc24fe7 100644 --- a/scripts/backup/files/backup_files.sh +++ b/scripts/backup/files/backup_files.sh @@ -20,15 +20,24 @@ # Declared per app as a compose label (multiple allowed): # # labels: -# libreportal.backup.files: "::::" +# libreportal.backup.files: "::" # # container service to exec/read through # container_path path inside the container to capture (a bind-mount target) # host_subdir app-dir-relative dir it maps to (excluded from the snapshot) -# uid:gid ownership to restore the files as (the app's runtime user) +# +# Ownership for restore is **auto-discovered** at capture time: the engine runs +# `stat -c '%u:%g'` inside the container and writes the result to a sidecar +# `.lp-owner` in staging. Restore reads it back — so a PUID/UID +# change on the running container is reflected on the next backup with no label +# edit needed. +# +# Legacy 5-field form is still supported as an explicit override: +# "::::" +# If uid:gid are present, they override the discovered value. # # Example (Nextcloud): -# "nextcloud-service:/var/www/html:html:33:33" +# "nextcloud-service:/var/www/html:html" # Staging lives at the app root (never inside an excluded path) so it rides in # the snapshot alongside the DB dumps under .lp-backup/. @@ -82,8 +91,24 @@ backupFilesCapture() # which the backup user still couldn't read. Hand the staging tree to # the backup user so restic can read it; modes are unchanged, so the # owner can now read everything. Real ownership is reapplied from the - # descriptor on restore. + # discovered-or-overridden uid:gid on restore. runFileOp chown -R "$docker_install_user":"$docker_install_user" "$stage" 2>/dev/null + + # Auto-discover the in-container uid:gid and write a sidecar that + # rides in the snapshot beside the staging dir. Restore reads this + # back, so a PUID/UID change is picked up on the next backup without + # any label edit. An explicit uid:gid in the descriptor wins; this + # only writes when the descriptor didn't pin them. + if [[ -z "$uid" || -z "$gid" ]]; then + local meta_file="$app_dir/$backup_files_stage_subdir/$subdir.lp-owner" + local discovered + discovered=$(docker exec "$container" stat -c '%u:%g' "$cpath" 2>/dev/null) + if [[ -n "$discovered" ]]; then + echo "$discovered" | runFileWrite "$meta_file" 2>/dev/null + runFileOp chown "$docker_install_user":"$docker_install_user" "$meta_file" 2>/dev/null + fi + fi + isSuccessful "captured $subdir ($(du -sh "$stage" 2>/dev/null | cut -f1))" else isError "capture of $subdir from $container failed" @@ -125,6 +150,19 @@ restoreFilesRehydratePreStart() IFS=':' read -r container cpath subdir uid gid <<< "$desc" stage="$app_dir/$backup_files_stage_subdir/$subdir" [[ -d "$stage" ]] || { isNotice "No captured files for $subdir — skipping"; continue; } + + # If the descriptor didn't pin uid:gid, read the auto-discovered sidecar + # written at capture time. Falls back to 0:0 (container root) if the + # sidecar is missing — safe but worth a notice so misconfig is visible. + if [[ -z "$uid" || -z "$gid" ]]; then + local meta_file="$app_dir/$backup_files_stage_subdir/$subdir.lp-owner" + if [[ -f "$meta_file" ]]; then + local meta; meta=$(cat "$meta_file" 2>/dev/null) + IFS=':' read -r uid gid <<< "$meta" + else + isNotice "No owner sidecar for $subdir — restoring as 0:0 (set uid:gid explicitly or re-capture to fix)" + fi + fi uid="${uid:-0}"; gid="${gid:-0}" isNotice "Restoring $subdir as $uid:$gid — via container"