5a3437669f
gates / gates (push) Successful in 27s
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
199 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|