v0.297.0: burn-down round 2 — 24 small rows (R-591 R-568 R-567 R-363 R-547 R-10 R-552 R-251 R-104 R-619 R-362 R-675 R-256 R-257 R-240 R-365 R-425 R-565 R-564 R-603 R-454 R-208 R-457-swept) + the banner countdown and deepCopyStack twins; MinAgent 0.131.0
gates / gates (push) Failing after 50s

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:
2026-10-05 20:12:56 +02:00
parent 114ff2761a
commit 1453cfc69b
68 changed files with 3953 additions and 116 deletions
@@ -0,0 +1,89 @@
package api
import (
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-552: an interrupted-restore notice was cleared only by a NEW restore of the same app, so a
// household that answered it by REMOVING the app kept a „Megszakadt visszaállítás" card about an app
// that no longer exists — for ever, because the notice is persisted. The consequence asserted: after
// the removal path's clear, the notice is gone from the page's list AND from the persisted record (a
// fresh Manager loading the file sees none), while another app's notice stays.
// COMPANION RED-PROOF: drop the clearInterruptedRestoreNotice call from removeStack → the AST check
// fails; make ClearInterruptedRestore a no-op → the notice is still listed.
func TestR552_RemoveClearsTheInterruptedRestoreNotice(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.Paths.DataDir = dir
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
recPath := filepath.Join(dir, "restore-status.json")
// The record two stops left behind: "otherapp" already carries a notice, "homebox" was running.
seed := map[string]any{
"running": true, "op": "restore", "stack": "homebox",
"interrupted": map[string]any{"otherapp": map[string]any{"op": "restore", "stack": "otherapp", "interrupted": true}},
}
b, _ := json.Marshal(seed)
if err := os.WriteFile(recPath, b, 0o600); err != nil {
t.Fatal(err)
}
load := func() *backup.Manager {
bm := backup.NewManager(cfg, sett, lg)
bm.SetRestoreRecordPath(recPath)
bm.LoadRestoreRecord()
return bm
}
bm := load()
if _, ok := bm.InterruptedRestore("homebox"); !ok {
t.Fatal("setup: no interrupted notice for homebox")
}
r := &Router{backupMgr: bm, logger: lg}
r.clearInterruptedRestoreNotice("homebox")
if _, ok := bm.InterruptedRestore("homebox"); ok {
t.Errorf("R-552: the removed app's interrupted-restore notice is still listed")
}
if _, ok := bm.InterruptedRestore("otherapp"); !ok {
t.Errorf("another app's notice was cleared by this app's removal")
}
again := load()
if _, ok := again.InterruptedRestore("homebox"); ok {
t.Errorf("R-552: the notice comes back after a restart — the clear was not persisted")
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
called := false
for _, d := range f.Decls {
if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "removeStack" && fn.Body != nil {
ast.Inspect(fn.Body, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "clearInterruptedRestoreNotice" {
called = true
}
return true
})
}
}
if !called {
t.Errorf("R-552: removeStack does not call clearInterruptedRestoreNotice")
}
}
@@ -0,0 +1,89 @@
package api
import (
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-619: GET /api/stacks/<n>/deploy-fields served a `type: password` field as `required:false` (as the
// template declares), while the deploy refuses 400 without it — so a caller that trusted the contract
// was refused (measured on grafana, 2026-09-21). The consequence asserted, on the wire through the real
// handler: the password field arrives `required:true`; a `secret` field (which the box DOES generate)
// keeps its declared `required:false`; and the stack's own metadata is not changed for the next reader.
func TestR619_PasswordFieldIsServedAsRequired(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Stacks.ComposeCommand = "docker compose"
app := filepath.Join(cfg.Paths.StacksDir, "grafana")
if err := os.MkdirAll(app, 0o755); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n grafana:\n image: busybox\n"), 0o644)
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte(`display_name: Grafana
deploy_fields:
- env_var: GF_SECURITY_ADMIN_PASSWORD
label: Admin jelszo
type: password
generate: "password:16"
required: false
- env_var: GF_SECRET_KEY
label: Titkos kulcs
type: secret
generate: "hex:32"
required: false
`), 0o644)
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
r := &Router{stackMgr: m, cfg: cfg, logger: log.New(io.Discard, "", 0)}
read := func() map[string]bool {
w := httptest.NewRecorder()
r.getDeployFields(w, httptest.NewRequest(http.MethodGet, "/api/stacks/grafana/deploy-fields", nil), "grafana")
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var body struct {
Data struct {
Metadata struct {
DeployFields []struct {
EnvVar string `json:"env_var"`
Required bool `json:"required"`
} `json:"deploy_fields"`
} `json:"metadata"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
out := map[string]bool{}
for _, f := range body.Data.Metadata.DeployFields {
out[f.EnvVar] = f.Required
}
return out
}
got := read()
if !got["GF_SECURITY_ADMIN_PASSWORD"] {
t.Errorf("R-619: the password field reaches the wire as required:false, but the deploy refuses without it")
}
if req, ok := got["GF_SECRET_KEY"]; !ok || req {
t.Errorf("a secret field (the box generates it) must keep its declared required:false; got present=%v required=%v", ok, req)
}
if meta, _, _ := m.GetDeployFields("grafana"); meta.DeployFields[0].Required {
t.Errorf("the derivation leaked into the stack's metadata — it must be applied to the answer only")
}
}
+31
View File
@@ -391,6 +391,23 @@ func (r *Router) getStack(w http.ResponseWriter, req *http.Request, name string)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
}
// markPasswordFieldsRequired (R-619) serves the deploy refusal's real rule on the wire: a
// `type: password` field is MANDATORY however the template's `required` reads, because the deploy
// never generates a password the household has not seen (stacks/deploy.go, the "password" case).
// Served `required:false`, a caller that trusts the contract was refused 400. Derived here, not stored,
// so templates need no edit. meta is the handler's own fresh LoadMetadata copy, so changing it touches
// no shared state. Pinned by TestR619_PasswordFieldIsServedAsRequired.
func markPasswordFieldsRequired(meta *stacks.Metadata) {
if meta == nil {
return
}
for i := range meta.DeployFields {
if meta.DeployFields[i].Type == "password" {
meta.DeployFields[i].Required = true
}
}
}
func (r *Router) getDeployFields(w http.ResponseWriter, req *http.Request, name string) {
meta, appCfg, err := r.stackMgr.GetDeployFields(name)
if err != nil {
@@ -398,6 +415,7 @@ func (r *Router) getDeployFields(w http.ResponseWriter, req *http.Request, name
return
}
markPasswordFieldsRequired(meta)
data := map[string]interface{}{
"metadata": meta,
"app_config": appCfg,
@@ -929,6 +947,18 @@ func (r *Router) dropLeftoverHold(name, why string) {
}
}
// clearInterruptedRestoreNotice (R-552) drops the removed app's „Megszakadt visszaállítás" notice. The
// notice tells the household to run the restore again; once the app is removed that advice has no
// subject, and nothing else would ever clear it. Pinned by TestR552_RemoveClearsTheInterruptedRestoreNotice.
func (r *Router) clearInterruptedRestoreNotice(name string) {
if r.backupMgr == nil {
return
}
if r.backupMgr.ClearInterruptedRestore(name) {
r.logger.Printf("[INFO] [api] remove %s: its interrupted-restore notice is cleared with it (R-552)", name)
}
}
// removeVerificationCopy (R-706, v0.279.0) deletes the app's off-site VERIFICATION copy
// (`backups/offsite-restore/<app>`, left by a full off-site restore for the household to inspect) when the
// app is removed "with its backups". Measured 2026-09-28 on demo-hp: ~1 GB stayed after such a removal, and
@@ -1057,6 +1087,7 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
r.logger.Printf("[INFO] [api] remove %s: its update hold is cleared with it (R-491)", name)
}
}
r.clearInterruptedRestoreNotice(name)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"})
@@ -87,8 +87,8 @@ func TestR609_EveryRefusalCarriesItsReason(t *testing.T) {
wantReason: "downgrade", wantCode: http.StatusConflict,
},
{
name: "not_found — an app that exists nowhere",
arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {},
name: "not_found — an app that exists nowhere",
arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {},
wantReason: "not_found", wantCode: http.StatusNotFound,
},
}