Files
felhom-controller/controller/internal/api/slice4_update_test.go
T
admin 8fc2b4a1a9
gates / gates (push) Successful in 26s
v0.263.0: a failed update puts the app back by itself (09 decision 15, R-637)
The guarded update gains a folder copy of the app's named volumes, taken
after the pull where the app stops anyway (decision 19, chosen by the
2026-09-23 bake-off). On a failed health check the box undoes: every copy
validated by its finished-marker first, volumes refilled, definition and pin
from the job's own pre-update copies, the old version checked with the OLD
.felhom.yml probe. It holds only if the undo fails, and the hold sentence
says so and what state the data is in. Bind-mounted folders are never
touched.

- R-637 built; R-638/R-640/R-641 do not arise with a folder copy; R-639
  (pre-update copies incl. .felhom.yml kept until the undo is over).
- journal phases copying/undoing with power-cut recovery.
- app.yaml last_update_undone + one line on the app page (hu/en).
- R-642: start/restart never answer "completed".
- Removal deletes kept undo copies.

MinAgent unchanged (0.131.0). Nine red-proofs in REPORT.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 11:12:49 +02:00

175 lines
6.9 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// Update arc slice 4 through the PRODUCTION handler: actionStack → the real stacks.Manager
// (NewManager + ScanStacks) and the real backup.Manager over real settings. No docker is reached:
// every path here refuses, or fails at the pin (the app's catalog template is absent on purpose).
type apiFakeGuards struct {
b *backup.Manager
points []stacks.UpdateRestorePoint
cannotBackUp bool
// blindToHolds makes the manager-side preflight NOT see holds, so a test can prove the ROUTER's
// own hold check refuses — the two layers are each pinned separately (the preflight's by
// TestSlice4_D_CheapRefusals/held). Without it, removing either layer passes inertly, because the
// other refuses with the same sentence (observed on the first run of red-proof 4, 2026-09-13).
blindToHolds bool
}
func (g *apiFakeGuards) HoldFor(n string) (bool, string) {
if g.blindToHolds {
return false, ""
}
return g.b.RestoreHoldFor(n)
}
func (g *apiFakeGuards) Busy(string) (bool, string) { return false, "" }
func (g *apiFakeGuards) RestorePoints(_ context.Context, _ string, accept func(stacks.UpdateRestorePoint) bool) (stacks.UpdateRestorePoint, bool, []stacks.UpdateRestorePoint) {
for _, p := range g.points {
if accept == nil || accept(p) {
return p, true, g.points
}
}
return stacks.UpdateRestorePoint{}, false, g.points
}
func (g *apiFakeGuards) CanBackUp(string) (bool, string) { return !g.cannotBackUp, "fake: no drive" }
func (g *apiFakeGuards) BackupNow(context.Context, string) error { return nil }
func (g *apiFakeGuards) SafetyDump(context.Context, string) ([]string, error) {
return nil, nil
}
func (g *apiFakeGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint, string) error {
return nil
}
const slice4AppYAML = "deployed: true\nenv: {}\npinned_images:\n app: nginx:1.27\n"
func newSlice4Router(t *testing.T) (*Router, *settings.Settings, *apiFakeGuards, string) {
t.Helper()
root := t.TempDir()
dir := filepath.Join(root, "stacks", "app")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services:\n app:\n image: nginx:1.27\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(slice4AppYAML), 0o600); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(root, "stacks")
cfg.Paths.DataDir = filepath.Join(root, "data")
cfg.Paths.SystemDataPath = filepath.Join(root, "sys")
cfg.Stacks.ComposeCommand = "docker compose"
lg := log.New(io.Discard, "", 0)
m, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatal(err)
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
sett, err := settings.Load(filepath.Join(root, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
b := backup.NewManager(cfg, sett, lg)
g := &apiFakeGuards{b: b, points: []stacks.UpdateRestorePoint{{Tier: stacks.UpdateTierSecondDrive, ProvenAt: time.Now().Add(-time.Hour)}}}
m.SetUpdateGuards(g)
return &Router{cfg: cfg, stackMgr: m, backupMgr: b, logger: lg}, sett, g, dir
}
func postUpdate(t *testing.T, r *Router) (int, apiResponse) {
t.Helper()
w := httptest.NewRecorder()
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", "app")
var resp apiResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
}
return w.Code, resp
}
// TestR439_UpdateOfAHeldAppIsRefused — R-439 closed.
//
// COMPANION RED-PROOF 4 (REPORT.md): remove `|| action == "update"` from actionStack's hold check. The
// preflight then refuses on its own grounds with a DIFFERENT sentence, and this test fails on the
// message — which is what proves the router line is the one doing it.
func TestR439_UpdateOfAHeldAppIsRefused(t *testing.T) {
r, sett, g, dir := newSlice4Router(t)
g.points, g.cannotBackUp = nil, true // the preflight's own refusal would say "no backup" — not the hold
g.blindToHolds = true // only the router's line can produce the hold's sentence
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "app", At: "2026-09-13T08:00:00Z", Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-13T01:30:00Z"}); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(filepath.Join(dir, "app.yaml"))
code, resp := postUpdate(t, r)
_, holdText := r.backupMgr.RestoreHoldFor("app")
if code != http.StatusConflict || resp.OK || resp.Error != holdText {
t.Fatalf("a HELD app's update must be refused with the hold's own sentence: code=%d ok=%v error=%q", code, resp.OK, resp.Error)
}
after, _ := os.ReadFile(filepath.Join(dir, "app.yaml"))
if string(before) != string(after) {
t.Error("a refused update must record no intent — app.yaml changed")
}
}
func TestSlice4_Router_NoBackupIs409AndRecordsNothing(t *testing.T) {
r, _, g, dir := newSlice4Router(t)
g.points, g.cannotBackUp = nil, true
before, _ := os.ReadFile(filepath.Join(dir, "app.yaml"))
code, resp := postUpdate(t, r)
if code != http.StatusConflict || resp.Error != fmt.Sprintf(stacks.MsgUpdateNoBackupFmt, "app") {
t.Fatalf("code=%d error=%q", code, resp.Error)
}
if after, _ := os.ReadFile(filepath.Join(dir, "app.yaml")); string(before) != string(after) {
t.Error("the preflight refusal must come BEFORE the intent write")
}
}
// TestR443_UpdateIsNeverReportedCompleteSynchronously — R-443 closed. The handler answers 202 with
// completed:false; the job then runs (and here fails at the pin, the catalog being absent), and the
// outcome exists ONLY on GET /api/stacks/{name}.
func TestR443_UpdateIsNeverReportedCompleteSynchronously(t *testing.T) {
r, _, _, _ := newSlice4Router(t)
code, resp := postUpdate(t, r)
if code != http.StatusAccepted {
t.Fatalf("an accepted update must answer 202, got %d (%+v)", code, resp)
}
data, _ := resp.Data.(map[string]interface{})
if data["completed"] != false || data["accepted"] != true {
t.Errorf("the body must say accepted and NOT completed, got %v", resp.Data)
}
if resp.Message == "Stack app update completed" {
t.Error("the synchronous response claimed completion — R-443")
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if st, ok := r.stackMgr.GetStack("app"); ok && !st.Updating {
if st.UpdatePhase != stacks.UpdatePhaseFailed || st.UpdateError != stacks.MsgUpdatePinFailed {
t.Errorf("the job's truth must be on the stack: phase=%q err=%q", st.UpdatePhase, st.UpdateError)
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("the job never finished")
}