diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b5b1f3..4332ce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## v0.331.0 — OnlyOffice opens and SAVES from FileBrowser and Nextcloud; FileBrowser is back at once after a restart (R-1018) (2026-10-18) + +**MinAgent: 0.158.1** — the floor's, carried (not above the vouched agent 0.159.2). Nothing here calls a new agent route. +No golden (`09` §3 decision 250). Task: the evening brief of 2026-10-18 (felhom.eu `REPORT-evening-2026-10-18.md`). +Found by the first live walk of the OnlyOffice links since the move to Proxmox (scratch 9202, headless Chrome through the +box's public names; felhom.eu `audits/onlyoffice-2026-10-18/`). + +- **FileBrowser → OnlyOffice could not open a document.** FileBrowser built the download and save URLs it hands + OnlyOffice from the request's host, `https://files.` — a trip out through the tunnel and back (on demo-hp it + reached another box: 403). FileBrowser's config now carries `server.internalUrl: "http://filebrowser:80"` (used by + integrations only, gtstef/filebrowser:1.5.6-stable `onlyOffice.go`); OnlyOffice reaches it by name on traefik-public, + where the R-1126 gate puts it. Every box's FileBrowser config changes once, so FileBrowser is re-created once. + `TestFileBrowserConfig_R1018_InternalURLIsTheContainer`. +- **Nextcloud → OnlyOffice lost every edit.** `DocumentServerInternalUrl` was `http://onlyoffice:80` with no slash, and + Nextcloud's connector appends paths: it fetched `http://onlyoffice:80cache/files/…` and logged „Track: … status 2 + error", while the editor said „All changes saved". Now `http://onlyoffice:80/` (as `StorageUrl` already was). The occ + list is a function (`nextcloudApplyCommands`), so the test reads the values the apply really sends. + `TestNextcloudApply_R1018_InternalAddressesTakeAPath`. A box with the link already on gets the value at OnlyOffice's + or Nextcloud's next start (the link re-applies then). +- **files. answered 502 for up to 5 minutes after the link switched on or off** (its Apply and Revoke restart + FileBrowser, and a re-created FileBrowser starts off traefik-public — R-1126 — until the next base-stack round; + measured 4 min 8 s and 3 min 38 s). `RestartStack("filebrowser")` now runs the gate at once, in the background, like + the mounts sync. `TestRestartStack_R1018_FileBrowserPublishedAgainAtOnce`. +- Red-proofs: each fix removed → its test fails with the wrong value (felhom.eu `audits/onlyoffice-2026-10-18/redproof-fix{1,2,3}.txt`). +- No page changed; every fixture byte-identical. + ## Unreleased (2026-10-18) — CI workflow: the failure mail links the public run page, by run id (R-1135); no code change `.gitea/workflows/gates.yml`, the alarm step: the mail's run link is now `https://gitea.dooplex.hu//actions/runs/`. diff --git a/CONTEXT.md b/CONTEXT.md index 94d0cb4..06fe580 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,8 @@ # CONTEXT.md — Project Memory +> **2026-10-18 (evening) — v0.331.0: OnlyOffice opens and saves from FileBrowser (`server.internalUrl`) and Nextcloud +> (`DocumentServerInternalUrl` with its slash); a FileBrowser restart runs the R-1126 gate at once (R-1018).** MinAgent 0.158.1. > **2026-10-18 (day) — v0.330.0: „anyway" asks the agent's disk list first and starts again the apps it stopped when the > agent refuses (R-1144).** MinAgent 0.158.1. > **2026-10-18 (night) — v0.329.0: each app reaches only its own `appdata/` (R-1142, compiled-in folder map: diff --git a/REPORT.md b/REPORT.md index 09d9df1..cf38ebc 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,10 +1,21 @@ -# REPORT — controller v0.330.0 (2026-10-18, day) +# REPORT — controller v0.331.0 (2026-10-18 evening): the OnlyOffice links, live-walked and fixed (R-1018) -**In plain words.** When a household presses „retire anyway" and the host says no, the drive's apps start again, and -the household is told so. Where the host's own disk list already says no, nothing is stopped at all. +Task: the evening brief of 2026-10-18, Part 2 (felhom.eu `REPORT-evening-2026-10-18.md`). Evidence: felhom.eu +`documentation/audits/onlyoffice-2026-10-18/`. -| Row | Change | Pinned by | Red-proof | -|---|---|---|---| -| R-1144 | precheck from the agent's disk list; restart exactly the apps that were running after a refusal | `TestR1144_*` (3) | no restart → starts = []; no precheck → 3 stops before the refusal | +**The walk** (scratch 9202 on 0.330.0, OnlyOffice 9.4.0 + Nextcloud 34.0.4 installed through the dashboard API, headless +Chrome through the box's public names mapped to 9202): -Gates: green gate rc=0; `controller_gates.py --fast` OK. Release path and box walk: felhom.eu `REPORT-day-2026-10-18.md`. +| Path | Before the fix | After | +|---|---|---| +| FileBrowser → open a .docx | editor frame loads, the document never does: OnlyOffice's fetch of `https://files./…` → 403 (the public name leads to another box) | opens, edits, saves: the file on the drive reads „R1018 BEFORE EDITED-FB-1849" | +| Nextcloud → open a .docx | opens and edits; „All changes saved" — but the save is lost: Nextcloud fetches `http://onlyoffice:80cache/…` | the file in Nextcloud reads „… EDITED-NC-1847" | +| switch the link on / off | files. 502 for 4 min 8 s / 3 min 38 s | the gate runs at the restart | +| OnlyOffice stopped | FileBrowser: no editor; Nextcloud: `/apps/onlyoffice/` 404 — the revoke-on-stop rule holds | — | + +**Fixes** (each red-proofed — the fix removed, the test failed with the wrong value, restored): +1. FileBrowser config: `server.internalUrl: "http://filebrowser:80"` — `TestFileBrowserConfig_R1018_InternalURLIsTheContainer`. +2. Nextcloud: `DocumentServerInternalUrl = http://onlyoffice:80/` — `TestNextcloudApply_R1018_InternalAddressesTakeAPath`. +3. `RestartStack("filebrowser")` runs the R-1126 gate — `TestRestartStack_R1018_FileBrowserPublishedAgainAtOnce`. + +Gates: `go build/vet/test ./...` green; `controller_gates.py --fast` green. Fixtures: none changed. diff --git a/controller/README.md b/controller/README.md index 69960bf..4618c01 100644 --- a/controller/README.md +++ b/controller/README.md @@ -3893,7 +3893,7 @@ CRUD methods in settings.go: `GetIntegrationState`, `SetIntegrationState`, `Remo #### Built-in Handlers **OnlyOffice → FileBrowser** (`onlyoffice_filebrowser.go`): -- Apply: Reads `JWT_SECRET` + `SUBDOMAIN` from OnlyOffice app.yaml (decrypted), strips any existing `integrations:` block from FileBrowser `config.yaml` via `removeIntegrationsSection()`, appends new block with `url` (public HTTPS), `internalUrl` (`http://onlyoffice:80`), `secret`, `viewOnly: false`. Atomic write (`.tmp` + rename). Restarts FileBrowser +- Apply: Reads `JWT_SECRET` + `SUBDOMAIN` from OnlyOffice app.yaml (decrypted), strips any existing `integrations:` block from FileBrowser `config.yaml` via `removeIntegrationsSection()`, appends new block with `url` (public HTTPS), `internalUrl` (`http://onlyoffice:80`), `secret`, `viewOnly: false`. Atomic write (`.tmp` + rename). Restarts FileBrowser — and `RestartStack("filebrowser")` runs the R-1126 gate at once, so files. is back within seconds (v0.331.0, R-1018). OnlyOffice fetches and saves the document at FileBrowser's `server.internalUrl` (`http://filebrowser:80`, rendered in every FileBrowser config since v0.331.0) — never through the public address - Revoke: Strips `integrations:` block from config.yaml, restarts FileBrowser **OnlyOffice → Nextcloud** (`onlyoffice_nextcloud.go`): @@ -3901,7 +3901,7 @@ CRUD methods in settings.go: `GetIntegrationState`, `SetIntegrationState`, `Remo 1. `app:install onlyoffice` (tolerates "already installed") 2. `app:enable onlyoffice` 3. `config:app:set onlyoffice DocumentServerUrl --value=https://{subdomain}.{domain}` - 4. `config:app:set onlyoffice DocumentServerInternalUrl --value=http://onlyoffice:80` + 4. `config:app:set onlyoffice DocumentServerInternalUrl --value=http://onlyoffice:80/` (the trailing slash is load-bearing: the connector appends paths — without it every save failed, v0.331.0, R-1018) 5. `config:app:set onlyoffice jwt_secret --value={JWT_SECRET}` 6. `config:app:set onlyoffice StorageUrl --value=http://nextcloud` (internal callback URL) - Revoke: Runs `occ app:disable onlyoffice` (tolerates container not running / app not enabled) diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index 2e9871c..4beea48 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -459,6 +459,11 @@ func RenderFileBrowserConfigKept(paths []settings.StoragePath, importSource bool server: port: 80 baseURL: "/" + # R-1018: the address OnlyOffice's server uses to fetch and save a document (FileBrowser's onlyOffice.go builds both + # URLs from it at gtstef/filebrowser:1.5.6-stable; it is used by integrations only). Without it FileBrowser hands + # OnlyOffice https://files. — a trip out through the tunnel and back, which failed (403) on demo-hp. + # Pinned by TestFileBrowserConfig_R1018_InternalURLIsTheContainer. + internalUrl: "http://filebrowser:80" # The box updates FileBrowser (the pinned image); a household cannot, so its „An update is available!" is noise. # backend/common/settings/structs.go:51 at gtstef/filebrowser:1.5.6-stable (v0.321.0). disableUpdateCheck: true diff --git a/controller/internal/infra/infra_test.go b/controller/internal/infra/infra_test.go index bba5bca..8add926 100644 --- a/controller/internal/infra/infra_test.go +++ b/controller/internal/infra/infra_test.go @@ -1,6 +1,8 @@ package infra import ( + "net/url" + "strconv" "strings" "testing" @@ -248,6 +250,33 @@ func TestFileBrowserRender(t *testing.T) { } } +// R-1018: OnlyOffice's server fetches and saves a FileBrowser document at server.internalUrl. The consequence pinned: +// that URL names the FileBrowser container on port 80 — the name OnlyOffice resolves on traefik-public — and the +// container really is called that in the compose. RED-PROOF: drop the config line (FileBrowser then hands OnlyOffice +// the public https://files., 403 on demo-hp 2026-10-18) or rename the container → this fails. +func TestFileBrowserConfig_R1018_InternalURLIsTheContainer(t *testing.T) { + var cfg struct { + Server struct { + InternalURL string `yaml:"internalUrl"` + Port int `yaml:"port"` + } `yaml:"server"` + } + if err := yaml.Unmarshal([]byte(RenderFileBrowserConfig([]settings.StoragePath{{Path: "/mnt/hdd_1"}}, true)), &cfg); err != nil { + t.Fatalf("config does not parse: %v", err) + } + u, err := url.Parse(cfg.Server.InternalURL) + if err != nil || u.Scheme != "http" || u.Hostname() == "" { + t.Fatalf("server.internalUrl = %q — OnlyOffice would be handed the public files address", cfg.Server.InternalURL) + } + if u.Port() != strconv.Itoa(cfg.Server.Port) { + t.Errorf("server.internalUrl port %q, FileBrowser listens on %d", u.Port(), cfg.Server.Port) + } + compose := RenderFileBrowserCompose("demo-felhom.eu", nil) + if !strings.Contains(compose, "container_name: "+u.Hostname()+"\n") { + t.Errorf("server.internalUrl names %q, which is not the FileBrowser container:\n%s", u.Hostname(), compose) + } +} + // R-1126 (D11 = A): FileBrowser starts OFF traefik-public — the box joins it there only after this run's default login // is checked — and on its own network, which the controller joins for that check. v0.327.1: NO Traefik labels — the // route is the file-provider route by NAME (a label route kept the address Traefik chose at container start, on the diff --git a/controller/internal/integrations/onlyoffice_nextcloud.go b/controller/internal/integrations/onlyoffice_nextcloud.go index 821766c..5a958f6 100644 --- a/controller/internal/integrations/onlyoffice_nextcloud.go +++ b/controller/internal/integrations/onlyoffice_nextcloud.go @@ -29,41 +29,7 @@ func (h *OnlyOfficeNextcloudHandler) Apply(ac *ApplyContext) error { } publicURL := fmt.Sprintf("https://%s.%s", subdomain, ac.Domain) - internalURL := "http://onlyoffice:80" - - // Install and configure OnlyOffice app in Nextcloud - commands := []struct { - args []string - tolerate string // substring in output to tolerate as success - }{ - { - args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:install", "onlyoffice"}, - tolerate: "already installed", - }, - { - args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:enable", "onlyoffice"}, - }, - { - args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "DocumentServerUrl", "--value=" + publicURL}, - }, - { - args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "DocumentServerInternalUrl", "--value=" + internalURL}, - }, - { - args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "jwt_secret", "--value=" + jwtSecret}, - }, - { - // StorageUrl: tells OO Document Server where to reach Nextcloud internally for file callbacks. - // Trailing slash is critical — NC's OO connector does string replacement of the server URL - // (which ends with /) with StorageUrl. Without trailing slash, "/apps/..." merges into hostname. - args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "StorageUrl", "--value=http://nextcloud/"}, - }, - { - // Add internal hostname to trusted_domains so OO Document Server callbacks - // (arriving with Host: nextcloud) are not rejected by Nextcloud. - args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:system:set", "trusted_domains", "10", "--value=nextcloud"}, - }, - } + commands := nextcloudApplyCommands(publicURL, jwtSecret) for _, cmd := range commands { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) @@ -107,3 +73,52 @@ func (h *OnlyOfficeNextcloudHandler) Revoke(ac *ApplyContext) error { ac.Logger.Printf("[INFO] [integrations] OnlyOffice integration revoked from Nextcloud") return nil } + +// occCommand is one occ call of the apply; tolerate is a substring of its output that counts as success. +type occCommand struct { + args []string + tolerate string +} + +// The two addresses OnlyOffice and Nextcloud use to reach each other inside the box. BOTH end with "/": Nextcloud's +// OnlyOffice connector appends paths to them („converter", „cache/files/…", „apps/onlyoffice/track"). R-1018, +// measured on demo-hp 2026-10-18: DocumentServerInternalUrl without the slash made Nextcloud fetch +// http://onlyoffice:80cache/files/… — every save failed („Track: … status 2 error") while the editor said +// „All changes saved". Pinned by TestNextcloudApply_R1018_InternalAddressesTakeAPath. +const ( + ncDocServerInternalURL = "http://onlyoffice:80/" + ncStorageURL = "http://nextcloud/" +) + +// nextcloudApplyCommands installs and configures the OnlyOffice app in Nextcloud. +func nextcloudApplyCommands(publicURL, jwtSecret string) []occCommand { + return []occCommand{ + { + args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:install", "onlyoffice"}, + tolerate: "already installed", + }, + { + args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:enable", "onlyoffice"}, + }, + { + args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "DocumentServerUrl", "--value=" + publicURL}, + }, + { + args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "DocumentServerInternalUrl", "--value=" + ncDocServerInternalURL}, + }, + { + args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "jwt_secret", "--value=" + jwtSecret}, + }, + { + // StorageUrl: tells OO Document Server where to reach Nextcloud internally for file callbacks. + // Trailing slash is critical — NC's OO connector does string replacement of the server URL + // (which ends with /) with StorageUrl. Without trailing slash, "/apps/..." merges into hostname. + args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "StorageUrl", "--value=" + ncStorageURL}, + }, + { + // Add internal hostname to trusted_domains so OO Document Server callbacks + // (arriving with Host: nextcloud) are not rejected by Nextcloud. + args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:system:set", "trusted_domains", "10", "--value=nextcloud"}, + }, + } +} diff --git a/controller/internal/integrations/onlyoffice_nextcloud_test.go b/controller/internal/integrations/onlyoffice_nextcloud_test.go new file mode 100644 index 0000000..40478af --- /dev/null +++ b/controller/internal/integrations/onlyoffice_nextcloud_test.go @@ -0,0 +1,44 @@ +package integrations + +import ( + "net/url" + "strings" + "testing" +) + +// R-1018: Nextcloud's OnlyOffice connector builds its server-to-server requests by APPENDING a path to the two inside +// addresses the apply sets. The consequence pinned: the address the apply really sends, plus such a path, is a request +// to the right container. Measured on demo-hp 2026-10-18: without the slash Nextcloud asked +// http://onlyoffice:80cache/files/… and every save was lost. RED-PROOF: drop the slash from ncDocServerInternalURL → +// the cache URL no longer parses (port "80cache") and this fails. +func TestNextcloudApply_R1018_InternalAddressesTakeAPath(t *testing.T) { + values := map[string]string{} + for _, c := range nextcloudApplyCommands("https://office.example.hu", "secret") { + if len(c.args) >= 11 && c.args[7] == "config:app:set" { + values[c.args[9]] = strings.TrimPrefix(c.args[10], "--value=") + } + } + for _, tc := range []struct { + key, path, host string + }{ + {"DocumentServerInternalUrl", "cache/files/data/2153553391_5872/output.docx/output.docx", "onlyoffice:80"}, + {"DocumentServerInternalUrl", "converter?shardkey=thumb_1", "onlyoffice:80"}, + {"StorageUrl", "apps/onlyoffice/track?doc=x", "nextcloud"}, + } { + base, ok := values[tc.key] + if !ok { + t.Fatalf("the apply does not set %s (set: %v)", tc.key, values) + } + u, err := url.Parse(base + tc.path) + if err != nil { + t.Errorf("%s %q + %q is not a URL: %v", tc.key, base, tc.path, err) + continue + } + if u.Host != tc.host || !strings.HasPrefix(u.Path, "/"+strings.SplitN(tc.path, "?", 2)[0][:4]) { + t.Errorf("%s %q + %q reaches host %q path %q, want host %q", tc.key, base, tc.path, u.Host, u.Path, tc.host) + } + } + if values["DocumentServerUrl"] != "https://office.example.hu" { + t.Errorf("DocumentServerUrl = %q, want the public office address", values["DocumentServerUrl"]) + } +} diff --git a/controller/internal/stacks/filebrowser_restart_gate_test.go b/controller/internal/stacks/filebrowser_restart_gate_test.go new file mode 100644 index 0000000..824674a --- /dev/null +++ b/controller/internal/stacks/filebrowser_restart_gate_test.go @@ -0,0 +1,132 @@ +package stacks + +import ( + "context" + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" +) + +// R-1018: the OnlyOffice link restarts FileBrowser (Apply and Revoke), and a re-created FileBrowser starts off +// traefik-public (R-1126). The consequence pinned: after RestartStack("filebrowser") the NEW run is checked and joined to +// traefik-public by itself, within seconds — not at the next base-stack round (files. answered 502 for 4 min 8 s +// on demo-hp, 2026-10-18). Another app's restart publishes nothing. RED-PROOF: remove the gate call from RestartStack → +// "not published again" (nothing connects until a round runs, and no round runs here). +func TestRestartStack_R1018_FileBrowserPublishedAgainAtOnce(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("the stub compose binary is a shell script") + } + fb := &fakeFB{password: "admin"} + srv := httptest.NewServer(fb.handler()) + defer srv.Close() + m, _, _ := fbManager(t, srv.URL) + + root := t.TempDir() + cfg := &config.Config{} + cfg.Paths.StacksDir = root + m.cfg = cfg + m.stacks = map[string]*Stack{} + for _, name := range []string{"filebrowser", "bookstack"} { + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services:\n web:\n image: nginx:1.27\n"), 0o644); err != nil { + t.Fatal(err) + } + m.stacks[name] = &Stack{Name: name, ComposePath: filepath.Join(dir, "docker-compose.yml"), Deployed: true} + } + + // compose: a stub that records the recreate; docker inspect: the start time moves after it (RestartStack's own check). + bin := t.TempDir() + marker := filepath.Join(t.TempDir(), "recreated") + script := fmt.Sprintf("#!/bin/sh\nfor a in \"$@\"; do [ \"$a\" = \"--force-recreate\" ] && touch %q; done\nexit 0\n", marker) + if err := os.WriteFile(filepath.Join(bin, "docker-compose"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + m.composeCmd = "docker-compose" + m.execFn = func(string, ...string) (string, error) { return "", nil } + recreated := func() bool { _, err := os.Stat(marker); return err == nil } + m.installedExecFn = func(_ context.Context, _ string, _ []string, name string, args ...string) (string, error) { + if name == "docker" && len(args) > 0 && args[0] == "compose" { + name, args = "docker-compose", args[1:] + } + started := "2026-10-18T18:00:00Z" + if recreated() { + started = "2026-10-18T18:32:14Z" + } + switch { + case name == "docker-compose" && len(args) > 0 && args[0] == "ps": + return `{"ID":"aaa111bbb222","Name":"web-1","Service":"web"}`, nil + case name == "docker" && len(args) > 0 && args[0] == "inspect" && strings.Contains(strings.Join(args, " "), "State.StartedAt"): + return "aaa111bbb222ccc333" + inspectSep + "started" + inspectSep + started + "\n", nil + case name == "docker" && len(args) > 0 && args[0] == "inspect": + return "aaa111bbb222" + inspectSep + "nginx:1.27" + inspectSep + "sha256:i\n", nil + case name == "docker" && len(args) > 1 && args[0] == "image": + return "sha256:i" + inspectSep + "nginx@sha256:d\n", nil + } + return "", nil + } + + // The gate's view of FileBrowser: run 1 was checked and public; a recreate makes run 2, off traefik-public. + var mu sync.Mutex + nets := map[string]bool{"filebrowser@traefik-public": true, "felhom-controller@felhom-filebrowser": true} + connected := make(chan string, 8) + m.fbStarted = func() string { + if recreated() { + return "run-2" + } + return "run-1" + } + m.fbProbedRun = "run-1" + m.fbNetOnFn = func(c, n string) bool { mu.Lock(); defer mu.Unlock(); return nets[c+"@"+n] } + m.fbNetConnectFn = func(c, n string) error { + mu.Lock() + nets[c+"@"+n] = true + mu.Unlock() + connected <- c + "@" + n + return nil + } + + // Another app's restart publishes nothing. + if err := m.RestartStack("bookstack"); err != nil { + t.Fatalf("bookstack restart: %v", err) + } + select { + case c := <-connected: + t.Fatalf("a bookstack restart touched FileBrowser's networks: %s", c) + case <-time.After(300 * time.Millisecond): + } + os.Remove(marker) + + // FileBrowser's restart: the recreate drops it from traefik-public; the gate must put run 2 back by itself. + mu.Lock() + delete(nets, "filebrowser@traefik-public") + mu.Unlock() + if err := m.RestartStack("filebrowser"); err != nil { + t.Fatalf("filebrowser restart: %v", err) + } + deadline := time.After(10 * time.Second) + for { + select { + case c := <-connected: + if c == "filebrowser@traefik-public" { + if m.fbProbedRun != "run-2" { + t.Fatalf("published, but the checked run is %q, not the new run-2", m.fbProbedRun) + } + return + } + case <-deadline: + t.Fatal("FileBrowser was not published again after its restart — files. stays 502 until a base-stack round") + } + } +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index f6ec1ab..a177858 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -1501,6 +1501,15 @@ func (m *Manager) RestartStack(name string) error { } m.mu.Unlock() + // R-1018: a re-created FileBrowser starts off traefik-public (R-1126), so files. answered 502 until the next + // base-stack round — up to 5 minutes, measured 4 min 8 s on demo-hp 2026-10-18 after the OnlyOffice link switched + // on (its Apply and Revoke restart FileBrowser). Check and publish this run now, in the background like the mounts + // sync does (handlers.go): the check may wait ~20 s for FileBrowser to answer, and it takes infraMu, which no caller + // of RestartStack holds. Pinned by TestRestartStack_R1018_FileBrowserPublishedAgainAtOnce. + if name == fileBrowserContainer { + go m.EnsureFileBrowserGate() + } + return m.RefreshStatus() }