772ef36f5f
gates / gates (push) Successful in 48s
FileBrowser config carries server.internalUrl http://filebrowser:80 (OnlyOffice fetched the public files address and got 403); Nextcloud's DocumentServerInternalUrl gets its trailing slash (every save was lost: http://onlyoffice:80cache/...); RestartStack("filebrowser") runs the R-1126 gate at once (files.<domain> was 502 for up to 5 min after the link switched). Each red-proofed. MinAgent 0.158.1.
133 lines
4.8 KiB
Go
133 lines
4.8 KiB
Go
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.<domain> 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.<domain> stays 502 until a base-stack round")
|
|
}
|
|
}
|
|
}
|