Files
admin 5a3437669f
gates / gates (push) Successful in 27s
v0.284.0 — a box deletes old app images (decision 53, R-736); an after_install app is held until its known login is replaced (R-741)
Image retention: after a done/undone guarded Update and at remove, an app's images older than its running
and previous one are deleted — never an image any container, installed compose or installed/previous record
names (box-wide keep set read at delete time); exact id, never forced or pruned; paused while any update runs;
a one-time sweep of catalog app images at the first start. Install hold: an after_install app is installed
behind the setup gate's door and opens when after_install succeeds or the household says it changed the login.
Tests TestImageRetention_* and TestInstallHold_* with red-proofs; parity fixture for the held card.

MinAgent: 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 22:29:33 +02:00

199 lines
8.0 KiB
Go

package stacks
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// R-741 (decision 45): an after_install app is installed HELD — the gate's door in front of it — until its
// known first login is replaced. Nothing here reaches Docker (gateManager's stub + the composeExecFn/afterLoadFn seams).
const heldYml = "display_name: Held App\n" +
"after_install:\n service: gapp\n env: [ADMIN_PASSWORD]\n command: [\"set-pw\", \"admin:${ADMIN_PASSWORD}\"]\n success: \"changed\"\n" +
"app_info:\n default_creds: \"admin / admin123\"\n" +
"deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n" +
" - env_var: ADMIN_PASSWORD\n type: password\n generate: \"password:24\"\n"
func deployHeld(t *testing.T, m *Manager) (existedAtUp bool, atUp string) {
t.Helper()
p := m.installHoldPath("gapp")
m.composeExecFn = func(_ string, _ map[string]string, args ...string) (string, error) {
if len(args) > 0 && args[0] == "up" {
b, err := os.ReadFile(p)
existedAtUp, atUp = err == nil, string(b)
}
return "", nil
}
done := make(chan bool, 1)
m.SetDeployDoneHook(func(_ string, ok bool, _ string) { done <- ok })
if _, err := m.DeployStack(DeployRequest{StackName: "gapp", Values: map[string]string{"ADMIN_PASSWORD": "Gen-Pw-123456789"}}); err != nil {
t.Fatal(err)
}
select {
case <-done:
case <-time.After(20 * time.Second):
t.Fatal("the deploy never ended")
}
return existedAtUp, atUp
}
// The hold stands BEFORE the first start, and it is the gate's door (forwardAuth), above the gate's priority.
// COMPANION RED-PROOF: drop the prepareInstallHold block in DeployStack → "the hold file did not exist" fails.
func TestInstallHold_WrittenBeforeTheFirstStart(t *testing.T) {
m := gateManager(t, heldYml)
existed, atUp := deployHeld(t, m)
if !existed {
t.Fatal("the hold file did not exist when the app was first started — its known default login was reachable (R-741)")
}
for _, want := range []string{"Host(`gapp.example.hu`)", `service: "gapp@docker"`, setupGateAuthURL, "felhom-install-hold-gapp@file"} {
if !strings.Contains(atUp, want) {
t.Errorf("the hold file lacks %q:\n%s", want, atUp)
}
}
if !strings.Contains(atUp, "priority: 3000") {
t.Errorf("the hold must outrank the setup gate and the sign-up block:\n%s", atUp)
}
cfg := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp"))
if cfg == nil || !cfg.InstallHold.Closed() || strings.Join(cfg.InstallHold.Hosts, ",") != "gapp.example.hu" {
t.Fatalf("app.yaml hold record: %+v", cfg)
}
if app, closed, found := m.SetupGateHost("gapp.example.hu"); !found || !closed || app != "gapp" {
t.Fatalf("the door must see the held host as closed: %q %v %v", app, closed, found)
}
}
// A template without after_install is never held (no change for 40-odd apps).
func TestInstallHold_OnlyForAfterInstallTemplates(t *testing.T) {
m := gateManager(t, "display_name: Plain\ndeploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n")
existed, _ := deployHeld(t, m)
if existed {
t.Fatal("an app without after_install was held")
}
}
// after_install succeeding OPENS the hold: record first, file gone, the door lets everyone through.
// COMPANION RED-PROOF: drop the OpenInstallHold call in runAfterInstallNow → "still held after the login was replaced".
func TestInstallHold_OpensWhenAfterInstallSucceeds(t *testing.T) {
m := gateManager(t, heldYml)
deployHeld(t, m)
m.afterLoadFn = func(string, ...string) (string, error) { return "Password for user 'admin' changed", nil }
st, _ := m.GetStack("gapp")
dir := filepath.Dir(st.ComposePath)
rec := func(ok bool, d string) {
m.mutateAppConfig("gapp", dir, "after_install", func(c *AppConfig) bool {
c.AfterInstall = &AfterInstallRecord{At: "x", OK: ok, Detail: d}
return true
})
}
if err := m.runAfterInstallNow("gapp", st.Meta.AfterInstall, []string{"set-pw", "admin:x"}, rec); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(m.installHoldPath("gapp")); !os.IsNotExist(err) {
t.Fatal("the hold file is still there after the login was replaced")
}
cfg := LoadAppConfig(dir)
if cfg.InstallHold.Closed() || cfg.InstallHold.OpenedBy != InstallHoldByAfterInstall {
t.Fatalf("still held after the login was replaced: %+v", cfg.InstallHold)
}
if _, closed, _ := m.SetupGateHost("gapp.example.hu"); closed {
t.Fatal("the door still refuses strangers after the hold opened")
}
}
// A failed after_install keeps the hold (the app is NOT published with its known login); the household's
// "I changed it" opens it.
// COMPANION RED-PROOF: drop the OpenInstallHold call in MarkDefaultLoginChanged → "the household's word did not open".
func TestInstallHold_FailureKeepsItTheHouseholdOpensIt(t *testing.T) {
m := gateManager(t, heldYml)
deployHeld(t, m)
st, _ := m.GetStack("gapp")
dir := filepath.Dir(st.ComposePath)
m.mutateAppConfig("gapp", dir, "after_install", func(c *AppConfig) bool {
c.AfterInstall = &AfterInstallRecord{At: "x", OK: false, Detail: "no marker"}
return true
})
must(t, m.ScanStacks())
m.installHoldTick()
if _, err := os.Stat(m.installHoldPath("gapp")); err != nil {
t.Fatal("a failed after_install dropped the hold — the known default login would be public")
}
must(t, m.ScanStacks())
if err := m.MarkDefaultLoginChanged("gapp", "household"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(m.installHoldPath("gapp")); !os.IsNotExist(err) {
t.Fatal("the household's word did not open the hold")
}
}
// The loop: a record that says the login was replaced (a restore, a crash between record and removal) opens; a
// stale file of an app that is not held goes; a closed hold's file is (re)written.
func TestInstallHold_LoopReconciles(t *testing.T) {
m := gateManager(t, heldYml)
deployHeld(t, m)
st, _ := m.GetStack("gapp")
dir := filepath.Dir(st.ComposePath)
must(t, os.Remove(m.installHoldPath("gapp")))
must(t, m.ScanStacks())
m.installHoldTick()
if _, err := os.Stat(m.installHoldPath("gapp")); err != nil {
t.Fatal("a closed hold's missing file was not rewritten")
}
must(t, os.WriteFile(m.installHoldPath("ghost"), []byte("x"), 0o644))
m.mutateAppConfig("gapp", dir, "after_install", func(c *AppConfig) bool {
c.AfterInstall = &AfterInstallRecord{At: "x", OK: true}
return true
})
must(t, m.ScanStacks())
m.installHoldTick()
if _, err := os.Stat(m.installHoldPath("gapp")); !os.IsNotExist(err) {
t.Fatal("a hold whose after_install succeeded was not opened by the loop")
}
if _, err := os.Stat(m.installHoldPath("ghost")); !os.IsNotExist(err) {
t.Fatal("a stale hold file of an app that is not held was kept")
}
}
// An install made by THIS process is never re-run by the loop (its hook is running after_install already); one
// made before the process started, with no record, is re-run once.
// COMPANION RED-PROOF: drop the DeployedAt-before-process-start check → "re-ran an install this process made".
func TestInstallHold_ReRunsOnlyAnInstallTheRestartCutOff(t *testing.T) {
m := gateManager(t, heldYml)
deployHeld(t, m)
calls := 0
prev := installHoldAfterInstall
installHoldAfterInstall = func(*Manager, string) { calls++ }
defer func() { installHoldAfterInstall = prev }()
installHoldRetried.Delete("gapp")
defer installHoldRetried.Delete("gapp")
setRunning := func() {
m.mu.Lock()
m.stacks["gapp"].State = StateRunning
m.stacks["gapp"].Deploying = false
m.mu.Unlock()
}
must(t, m.ScanStacks())
setRunning()
m.installHoldTick()
time.Sleep(20 * time.Millisecond)
if calls != 0 {
t.Fatal("the loop re-ran after_install for an install this process made — twice at once")
}
st, _ := m.GetStack("gapp")
m.mutateAppConfig("gapp", filepath.Dir(st.ComposePath), "deployed_at", func(c *AppConfig) bool {
c.DeployedAt = installHoldProcessStart.Add(-time.Hour).UTC().Format(time.RFC3339)
return true
})
must(t, m.ScanStacks())
setRunning()
m.installHoldTick()
m.installHoldTick()
time.Sleep(20 * time.Millisecond)
if calls != 1 {
t.Fatalf("an install the restart cut off was re-run %d times, want once", calls)
}
}