controller v0.269.0: whole restore from the second drive; crash loops stopped; exact image digests; steps judged by their own .felhom.yml (decisions 26-28, R-661 R-666 R-667 R-668 R-664 R-665 R-662, 09 6.4 part 6)
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// v0.269.0 (`09` §3 decision 27, R-666) — while a held app's page says support is informed, the remove
|
||||
// API allows only "remove the app, keep my data". Through the production wiring (NewManager + ScanStacks,
|
||||
// the hold read through UpdateGuards as main.go wires it).
|
||||
type heldGuards struct{ noWhole bool }
|
||||
|
||||
func (g heldGuards) HoldFor(string) (bool, string) { return true, "HELD" }
|
||||
func (g heldGuards) HoldNoWholeCopy(string) bool { return g.noWhole }
|
||||
func (g heldGuards) Busy(string) (bool, string) { return false, "" }
|
||||
func (g heldGuards) CanBackUp(string) (bool, string) { return true, "" }
|
||||
func (g heldGuards) BackupNow(context.Context, string) error { return nil }
|
||||
func (g heldGuards) SafetyDump(context.Context, string) ([]string, error) { return nil, nil }
|
||||
func (g heldGuards) RestorePoints(context.Context, string, func(stacks.UpdateRestorePoint) bool) (stacks.UpdateRestorePoint, bool, []stacks.UpdateRestorePoint) {
|
||||
return stacks.UpdateRestorePoint{}, false, nil
|
||||
}
|
||||
func (g heldGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// COMPANION RED-PROOF (REPORT): delete the decision-27 block in removeStack — the data-deleting remove
|
||||
// then reaches RemoveStack and this test fails at "was not refused".
|
||||
func TestR666_HeldWithNoWholeCopyRemovesOnlyKeepingData(t *testing.T) {
|
||||
for _, body := range []string{`{"remove_hdd_data":true,"remove_backups":false}`, `{"remove_hdd_data":false,"remove_backups":true}`} {
|
||||
r, dir := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n")
|
||||
r.stackMgr.SetUpdateGuards(heldGuards{noWhole: true})
|
||||
code, resp := postRemove(t, r, body)
|
||||
if code != http.StatusConflict || resp.OK {
|
||||
t.Fatalf("%s: HTTP %d ok=%v — a data-deleting remove of a stranded app was not refused", body, code, resp.OK)
|
||||
}
|
||||
if !strings.Contains(resp.Error, "ügyfélszolgálat") {
|
||||
t.Fatalf("error = %q, want the support sentence", resp.Error)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil {
|
||||
t.Fatal("app.yaml gone — something was removed by a refused request")
|
||||
}
|
||||
}
|
||||
// The dialog is told to offer keep-data only …
|
||||
r, _ := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n")
|
||||
r.stackMgr.SetUpdateGuards(heldGuards{noWhole: true})
|
||||
w := httptest.NewRecorder()
|
||||
r.getStackHDDData(w, httptest.NewRequest(http.MethodGet, "/api/stacks/app/hdd-data", nil), "app")
|
||||
if !strings.Contains(w.Body.String(), `"keep_data_only":true`) {
|
||||
t.Fatalf("hdd-data does not tell the dialog: %s", w.Body.String())
|
||||
}
|
||||
// … and a hold that NAMES a whole copy keeps the full dialog (positive control).
|
||||
r2, _ := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n")
|
||||
r2.stackMgr.SetUpdateGuards(heldGuards{noWhole: false})
|
||||
w2 := httptest.NewRecorder()
|
||||
r2.getStackHDDData(w2, httptest.NewRequest(http.MethodGet, "/api/stacks/app/hdd-data", nil), "app")
|
||||
if strings.Contains(w2.Body.String(), "keep_data_only") {
|
||||
t.Fatalf("a hold with a whole copy must keep the full dialog: %s", w2.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -607,6 +607,14 @@ func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, n
|
||||
// R-439 (slice 4): `update` is in this list. It was not until v0.237.0, so a held app could be
|
||||
// updated — the one action most likely to make a held app's data worse. Pinned by
|
||||
// TestR439_UpdateOfAHeldAppIsRefused.
|
||||
// v0.269.0 (`09` §3 decision 28): an app the BOX stopped for a crash loop / OOM storm is started
|
||||
// again by the household's Start — the hold is lifted and the app gets one more try. Restart and
|
||||
// Update stay refused while it holds; any other hold kind is untouched.
|
||||
if action == "start" && r.backupMgr != nil && r.backupMgr.HoldKind(name) == settings.HoldReasonUnhealthyStop {
|
||||
if r.backupMgr.LiftUnhealthyStop(name) {
|
||||
r.logger.Printf("[INFO] [api] %s: the unhealthy stop is LIFTED by Start — one more try (decision 28)", name)
|
||||
}
|
||||
}
|
||||
if action == "start" || action == "restart" || action == "update" {
|
||||
if held, why := r.restoreHoldFor(name); held {
|
||||
// `reason` (v0.261.0) — THIS LINE FIRES BEFORE UpdatePreflight, so without it a held app
|
||||
@@ -862,6 +870,7 @@ func (r *Router) getStackHDDData(w http.ResponseWriter, req *http.Request, name
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
resp.KeepDataOnly = r.stackMgr.RemoveKeepsDataOnly(name) // decision 27: the dialog offers keep-data only
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
||||
}
|
||||
|
||||
@@ -908,6 +917,14 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
}
|
||||
r.dbg("removeStack: name=%s removeHDDData=%v removeBackups=%v", name, body.RemoveHDDData, body.RemoveBackups)
|
||||
|
||||
// v0.269.0 (`09` §3 decision 27, R-666): while a held app's page says support is informed, only
|
||||
// "remove the app, keep my data" is allowed. Refused BEFORE anything is removed (live-probes rule).
|
||||
if (body.RemoveHDDData || body.RemoveBackups) && r.stackMgr.RemoveKeepsDataOnly(name) {
|
||||
r.logger.Printf("[WARN] [api] Remove of %s with data REFUSED: the app is held with no whole copy (support is informed) — only keep-data is allowed (decision 27)", name)
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, stacks.ErrRemoveKeepDataWhileSupport)})
|
||||
return
|
||||
}
|
||||
|
||||
// Compute backup paths to remove if requested. Disk-tier (cross-drive rsync)
|
||||
// backup has moved to the host agent; only the app-data DB-dump path is removed here.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user