A review of the backup path found several ways a backup was not one, and a
restore could destroy data it did not contain.
Data loss:
- Restore with overwrite rmtree'd the whole stack directory, including
folders the backup had left out (over 2 GiB, deselected, an NFS share
mounted inside). It now clears only what the backup holds, keeps the
excluded paths (recorded in the manifest) and never crosses into another
filesystem.
- The stack was only stopped when named volumes were selected, so data
folders inside the stack directory were archived while in use. Any
selected data now stops it.
- Retention matched by prefix: pruning "app" deleted "app-db"'s backups.
It now matches backup-<id>-(full|config)-<date>.tar.gz exactly.
- A restore under a new name wrote into the original's volumes when they
had an explicit name, and into its folders outside the stack directory.
Both are skipped now; a volume owned by another stack or mounted by a
running container is never overwritten.
- A truncated archive was only noticed after the old stack was gone. The
whole archive is verified first, and the stack directory is unpacked
next to the old one and swapped in.
Remote storage:
- Bind folders on a mounted share (fstab/autofs), bind-type volumes
pointing at one and shares mounted inside the stack folder are detected
by filesystem type and not selected; a folder du cannot measure in 20s
is no longer selected by default either.
Reliability:
- After a backup exactly the containers that ran are started again with
compose start (falling back to the Docker API), instead of up -d, which
started services stopped on purpose and applied undeployed edits. A
stack that cannot be stopped is not backed up.
- Backup and restore hold the stack lock; a scheduled backup that finds
the stack busy retries after 10 minutes. Restore stops the stack while
replacing its data and brings it back up.
- external: true volumes are backed up; anonymous ones are listed, not
selected. Restored volumes get their original driver and options.
- A backup with uncaptured data or a stack that did not restart is
reported as incomplete: in the dialog, and for schedules as a warning
with a notification and without rotating older backups out.
- Archive writing and restore run off the event loop; downloads delete
their temp file; helper containers use the long-timeout client; S3
listing is paginated; a file changing mid-read no longer corrupts the
archive.
Manifest format 3 records compose_binds/excluded and volume
driver/options/external; format 1/2 backups still restore.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uploading a backup extracted the tar straight into the NFS mount via
put_archive, and the daemon chowns every entry while extracting — an export
with root_squash refuses that ("failed to Lchown ... for UID 0, GID 0:
operation not permitted"), so the upload died with a docker 500 even though
plain writes to the share work (which is why the destination test passed).
The helper container now unpacks into its own filesystem and copies the file
into the mount with cat, which never chowns. Restores hit the same wall when a
volume or bind folder lives on a squashed mount, so import_path/import_volume
fall back to a copy-through-staging when (and only when) the failure is a chown
denial — local restores keep preserving ownership. NFS file names are validated
against the same safe charset as the subdir parts, since both are interpolated
into the helper's shell commands.
Verified against a real root_squash NFS export: test/upload/list/download/delete
round trip, byte-identical download, restore into an NFS-backed volume via the
fallback, and ownership still preserved (1000:1000, 0600) on a local volume.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_ensure_helper_image moved to stack_assets_service, but backup_destination_service
imports it lazily inside _nfs_run/_nfs_helper, so nothing failed at import time —
every NFS destination operation raised ImportError at runtime instead. The helper
is now a public ensure_helper_image() and the NFS helpers import it from its new
home. Verified: every services/ and routers/ module imports, and both NFS helper
paths run through to a Docker call instead of ImportError.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stack's real state lives in its bind-mounted config directories, and those
were never captured: the backup only tarred the stack folder as this container
sees it. When STACKS_HOST_DIR differs from the container's STACKS_DIR, compose
resolves ./config against the container path and the daemon creates it at that
path on the *host* — invisible here, so the archive held little more than
compose.yaml and .env.
New services/stack_assets_service.py inventories a stack's data (bind sources
merged from container mounts + the compose file, named volumes) and does all
data I/O through a throwaway helper container, i.e. by host path, so unseen
directories are captured anyway. It also detects the host/container stacks-path
mismatch and reports it.
- manifest v2: full inventory, per-asset capture result, skip reasons (v1 still
restores)
- NFS/CIFS-backed volumes are skipped by default and never wiped on restore
- deselected data inside the stack folder no longer sneaks in via compose/
- volume/bind archives stream through temp files instead of RAM
- restore preserves mode, ownership, mtime and symlinks, and writes bind folders
back to their host paths (rewritten when the stack is renamed)
- backup dialog shows the inventory with sizes and per-item checkboxes; restore
gained a "restore bind folders" toggle
- new GET /api/stacks/{id}/backup/inventory (+ agent + proxy), backup endpoints
take include_binds/binds/volumes, restore takes restore_binds
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>