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
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user