v0.82.0: gate FileBrowser recreate on actual change (F2); drop unused restic binary (F1)
syncFileBrowserMounts no longer force-recreates FileBrowser unconditionally: captures config.yaml+compose before writes, re-reads final content after, and recreates only when they actually changed (new pure helper fbNeedsRecreate). Controller restarts / no-op storage syncs now issue a plain up -d and do NOT bounce the customer's file UI. Restore-mode DB reset still forces a recreate. Dockerfile: removed the unused restic apt package (disk-tier restic moved to the host agent; no controller code execs the binary). ResticSchedule/migrateResticToRsync config+settings paths untouched (still live in the dashboard). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpBYrZCt9sFDqLgbG5GRGD
This commit is contained in:
@@ -39,7 +39,6 @@ FROM debian:bookworm-slim
|
||||
# Install runtime dependencies:
|
||||
# - docker-cli: for "docker compose" commands
|
||||
# - ca-certificates: for HTTPS (healthchecks pings, git)
|
||||
# - restic: for backup operations
|
||||
# - postgresql-client: for pg_dump
|
||||
# - default-mysql-client: for mysqldump
|
||||
# - sqlite3: for SQLite backup
|
||||
@@ -55,7 +54,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
gnupg \
|
||||
git \
|
||||
restic \
|
||||
postgresql-client \
|
||||
default-mysql-client \
|
||||
sqlite3 \
|
||||
|
||||
@@ -866,7 +866,7 @@ After migration, the deploy page detects leftover data on previous storage paths
|
||||
|
||||
#### FileBrowser Mount Sync
|
||||
|
||||
When storage paths are added or removed, `syncFileBrowserMounts()` auto-regenerates FileBrowser's `docker-compose.yml` with volume mounts for all registered paths, then recreates the container.
|
||||
When storage paths are added or removed, `syncFileBrowserMounts()` auto-regenerates FileBrowser's `docker-compose.yml` with volume mounts for all registered paths. It then **recreates the container only when the generated `config.yaml` or compose actually changed** (v0.82.0, F2) — gated by the pure helper `fbNeedsRecreate(oldCfg,newCfg,oldCompose,newCompose)`, which compares the on-disk content captured before the writes against the final content read after them (so the integrations' `ReapplyConfigForTarget` edits count). When nothing changed (a controller restart, a no-op sync) it issues a plain `up -d --remove-orphans` that does **not** bounce the running FileBrowser. The restore-mode DB reset (`down -v`) still forces a recreate.
|
||||
|
||||
#### Storage Watchdog (`internal/monitor/watchdog.go`)
|
||||
|
||||
@@ -1683,9 +1683,9 @@ CRUD methods in settings.go: `GetIntegrationState`, `SetIntegrationState`, `Remo
|
||||
3. **Provider/target stops**: `OnStackStop` → calls `Handler.Revoke()` → sets status to `"provider_stopped"` or `"target_unavailable"` (keeps `enabled=true`)
|
||||
4. **Provider/target starts**: `OnStackStart` (5s delay) → finds enabled integrations with non-active status → re-applies if both sides running/starting
|
||||
5. **Provider/target removed**: `OnStackRemove` → revokes and deletes integration state permanently
|
||||
6. **FileBrowser config regen**: `SyncFileBrowserMounts` regenerates `config.yaml` from scratch → `ReapplyConfigForTarget("filebrowser")` patches integration config synchronously before `docker compose up -d --force-recreate`
|
||||
6. **FileBrowser config regen**: `SyncFileBrowserMounts` regenerates `config.yaml` from scratch → `ReapplyConfigForTarget("filebrowser")` patches integration config synchronously → recreates the container **only when the final `config.yaml`/compose differ from the pre-sync content** (`fbNeedsRecreate` gate, v0.82.0)
|
||||
|
||||
**Important**: `SyncFileBrowserMounts` uses `--force-recreate` because `config.yaml` is a bind mount — without it, `docker compose up -d` won't recreate the container when only the config file changes (compose only detects compose file changes). `ReapplyConfigForTarget` calls each handler's `Apply` with a no-op `RestartStack` since the caller handles the restart.
|
||||
**Important**: `SyncFileBrowserMounts` uses `--force-recreate` (rather than a plain `up -d`) **when something changed**, because `config.yaml` is a bind mount — without `--force-recreate`, `docker compose up -d` won't recreate the container when only the config file changes (compose only detects compose-file changes). The recreate is now **gated on an actual change** (v0.82.0, F2): a controller restart or no-op sync where the generated config+compose are byte-identical issues a plain `up -d --remove-orphans` and does **not** bounce the customer's file UI. `ReapplyConfigForTarget` calls each handler's `Apply` with a no-op `RestartStack` since the caller handles the restart.
|
||||
|
||||
#### Built-in Handlers
|
||||
|
||||
|
||||
@@ -23,3 +23,29 @@ func TestSkipFileBrowserPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// F2: fbNeedsRecreate gates the FileBrowser --force-recreate so a no-op controller restart / storage
|
||||
// sync no longer bounces the customer's file UI. Recreate only when config OR compose actually changed;
|
||||
// the first-ever run (empty old files) differs from the generated content → recreate (creates it).
|
||||
// Companion red-proof: hard-wiring the helper to always return true (the OLD unconditional behaviour)
|
||||
// makes the "unchanged → no recreate" case fail — restoring the byte-equality gate turns it green.
|
||||
func TestFbNeedsRecreate(t *testing.T) {
|
||||
cfg := []byte("sources:\n - /srv/usb\n")
|
||||
compose := []byte("services:\n filebrowser:\n image: x\n")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
oldCfg, newCfg, oldCmp, newCmp []byte
|
||||
want bool
|
||||
}{
|
||||
{"unchanged → no recreate", cfg, cfg, compose, compose, false},
|
||||
{"config differs → recreate", cfg, []byte("sources:\n - /srv/hdd\n"), compose, compose, true},
|
||||
{"compose differs → recreate", cfg, cfg, compose, []byte("services:\n filebrowser:\n image: y\n"), true},
|
||||
{"first run (no old files) → recreate", nil, cfg, nil, compose, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := fbNeedsRecreate(c.oldCfg, c.newCfg, c.oldCmp, c.newCmp); got != c.want {
|
||||
t.Errorf("%s: fbNeedsRecreate = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -1499,12 +1500,15 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
configPath := stackDir + "/config.yaml"
|
||||
fbConfig := generateFileBrowserConfig(paths)
|
||||
|
||||
// Capture the current on-disk content BEFORE any writes, so we can detect whether this sync
|
||||
// actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml
|
||||
// after we write it, so the recreate decision is made AFTER the writes against the final files.
|
||||
oldConfig, _ := os.ReadFile(configPath)
|
||||
oldCompose, _ := os.ReadFile(composePath)
|
||||
|
||||
// Detect if sources changed — if so, the database must be reset so
|
||||
// FileBrowser picks up the new source list (user prefs cache old sources).
|
||||
sourcesChanged := true
|
||||
if oldConfig, err := os.ReadFile(configPath); err == nil {
|
||||
sourcesChanged = string(oldConfig) != fbConfig
|
||||
}
|
||||
sourcesChanged := string(oldConfig) != fbConfig
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(fbConfig), 0644); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Failed to write FileBrowser config: %v", err)
|
||||
@@ -1523,6 +1527,13 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
return
|
||||
}
|
||||
|
||||
// Read back the FINAL content (post-integrations) to decide whether a recreate is warranted (F2):
|
||||
// a controller restart or a no-op storage sync must NOT bounce the customer's file UI when nothing
|
||||
// actually changed. The recreate only happens when config.yaml or the compose file truly differ.
|
||||
finalConfig, _ := os.ReadFile(configPath)
|
||||
finalCompose, _ := os.ReadFile(composePath)
|
||||
changed := fbNeedsRecreate(oldConfig, finalConfig, oldCompose, finalCompose)
|
||||
|
||||
// If sources changed and caller requested a DB reset (restore flow),
|
||||
// nuke the data volume so FileBrowser re-reads config.yaml from scratch.
|
||||
// Normal operations skip this to preserve user accounts, permissions, and share links.
|
||||
@@ -1535,20 +1546,36 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
if out, err := stop.CombinedOutput(); err != nil {
|
||||
s.logger.Printf("[WARN] [web] FileBrowser down -v: %s — %v", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
changed = true // a DB reset removed the container — it must be recreated
|
||||
}
|
||||
|
||||
// Recreate container — H16: use 60s timeout to prevent hanging indefinitely.
|
||||
// Bring FileBrowser up. H16: 60s timeout to prevent hanging indefinitely. Only force-recreate when
|
||||
// something actually changed; otherwise a plain `up -d` just ensures it's running without a bounce.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "docker", "compose", "up", "-d", "--force-recreate", "--remove-orphans")
|
||||
args := []string{"compose", "up", "-d", "--remove-orphans"}
|
||||
if changed {
|
||||
args = []string{"compose", "up", "-d", "--force-recreate", "--remove-orphans"}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd.Dir = stackDir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Failed to recreate FileBrowser: %s — %v", string(out), err)
|
||||
s.logger.Printf("[ERROR] [web] Failed to bring up FileBrowser: %s — %v", string(out), err)
|
||||
} else if changed {
|
||||
s.logger.Printf("[INFO] [web] FileBrowser mounts synced (recreated) — %d storage path(s), config updated", len(paths))
|
||||
} else {
|
||||
s.logger.Printf("[INFO] [web] FileBrowser mounts synced — %d storage path(s), config updated", len(paths))
|
||||
s.logger.Printf("[INFO] [web] FileBrowser sync — no config/compose change, ensured running without recreate (%d storage path(s))", len(paths))
|
||||
}
|
||||
}
|
||||
|
||||
// fbNeedsRecreate reports whether the FileBrowser container must be force-recreated: true when either
|
||||
// the config.yaml or the compose file content changed between the pre-sync and post-sync state. On the
|
||||
// first-ever run the old files are empty → differs from the freshly generated content → true (creates
|
||||
// it). Pure, so syncFileBrowserMounts' recreate decision is unit-testable without shelling to docker.
|
||||
func fbNeedsRecreate(oldConfig, newConfig, oldCompose, newCompose []byte) bool {
|
||||
return !bytes.Equal(oldConfig, newConfig) || !bytes.Equal(oldCompose, newCompose)
|
||||
}
|
||||
|
||||
// generateFileBrowserCompose returns a FileBrowser docker-compose.yml string with the given domain
|
||||
// and storage volume-mount lines. Delegates to internal/infra (the single source of truth — so the
|
||||
// pinned image and the base-infra bring-up path can never diverge).
|
||||
|
||||
Reference in New Issue
Block a user