b93c1543da
gates / gates (push) Successful in 14s
Operator ruling 2026-09-13. The update precondition walks Tier 2, Tier 1 (own recovery unit, "helyi") and Tier 3 (off-site, 15 s bound; unreachable counts as absent with a WARN) and leans on the first FRESH copy; the backup_max_age rule applies to whichever tier is chosen. No copy anywhere: back up first. Refused only when nothing exists and no backup can be taken. RunAppBackupNow tolerates a Tier-2 failure (WARN) and marks the captured unit proven current. The hold names the tier (második meghajtó / saját meghajtó / távoli mentés) and the date; pre-v0.239.0 holds keep their text. A successful off-site restore now lifts an update hold. The backups page still uses Tier2UnitRestorePoint unchanged. Scenarios G-M tested; red-proofs M, L, the tail and the off-site clear in felhom.eu documentation/audits/rulings-r472-r475-2026-09-13/.
173 lines
6.8 KiB
Go
173 lines
6.8 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) 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, "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")
|
|
}
|