8fc2b4a1a9
gates / gates (push) Successful in 26s
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
175 lines
6.9 KiB
Go
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")
|
|
}
|