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)
gates / gates (push) Successful in 27s
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
This commit is contained in:
@@ -181,6 +181,9 @@ func (m *Manager) runAfterInstallNow(name string, ai *AfterInstallCommand, cmd [
|
||||
m.logger.Printf("[INFO] [stacks] after_install %s: %s %v done in %s (try %d) — the app's known first login is replaced by its generated one",
|
||||
name, ai.Service, ai.Command, time.Since(t0).Round(time.Millisecond), try)
|
||||
record(true, "")
|
||||
if err := m.OpenInstallHold(name, InstallHoldByAfterInstall); err != nil {
|
||||
m.logger.Printf("[ERROR] [stacks] %s: %v — the loop retries", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
last = fmt.Sprintf("try %d: err=%v, no %q in the output", try, err, ai.Success)
|
||||
|
||||
@@ -285,6 +285,9 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
|
||||
}
|
||||
}
|
||||
|
||||
// decision 53 (R-736): the app's image repositories, read BEFORE the remove (its compose and records go).
|
||||
removedRepos := appImageRepos(stackDir, LoadAppConfig(stackDir))
|
||||
|
||||
// Step 2: Run docker compose down --rmi local --volumes
|
||||
// H14: Return error if docker compose down fails — continuing would leave orphaned containers.
|
||||
env := m.stackEnv(stackDir)
|
||||
@@ -364,6 +367,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
m.logger.Printf("[WARN] Rescan after delete failed: %v", err)
|
||||
}
|
||||
go m.RetainImagesAfterRemove(name, removedRepos) // decision 53 (R-736): the removed app's images, if nothing keeps them
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -184,6 +184,12 @@ type AppConfig struct {
|
||||
// SetupGate (v0.280.0, decision 46) is the app's setup gate: closed from a fresh install until the first
|
||||
// setup is done. A life record (carried across a restore). See setup_gate.go.
|
||||
SetupGate *SetupGateRecord `yaml:"setup_gate,omitempty" json:"setup_gate,omitempty"`
|
||||
// InstallHold (R-741, decision 45): an after_install app is held (the gate's door) from its fresh install until
|
||||
// its known first login is replaced. Same record shape as SetupGate. See install_hold.go.
|
||||
InstallHold *SetupGateRecord `yaml:"install_hold,omitempty" json:"install_hold,omitempty"`
|
||||
// PreviousImages (R-736, decision 53): per service, the image the app ran BEFORE its last done update — kept on
|
||||
// the box with the running one; older images of the app are deleted (image_retention.go).
|
||||
PreviousImages map[string]InstalledImage `yaml:"previous_images,omitempty" json:"previous_images,omitempty"`
|
||||
// DefaultLogin (v0.280.0, R-710) is the household's own word that it changed the template's known default
|
||||
// login by hand. The page stops naming the default. See internal/web/known_login.go.
|
||||
DefaultLogin *DefaultLoginRecord `yaml:"default_login,omitempty" json:"default_login,omitempty"`
|
||||
@@ -434,6 +440,20 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
}
|
||||
gate = g
|
||||
}
|
||||
// R-741: an after_install template is installed HELD, the file written before the first start, like the gate.
|
||||
var hold *SetupGateRecord
|
||||
if wantsInstallHold(&meta) {
|
||||
h, err := m.prepareInstallHold(req.StackName, stack.ComposePath, env)
|
||||
if err != nil {
|
||||
clearDeploying()
|
||||
if gate != nil {
|
||||
_ = m.removeSetupGateFile(req.StackName)
|
||||
}
|
||||
m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: the install hold could not be prepared: %v", req.StackName, err)
|
||||
return "", util.MsgError("err.stacks.setup_gate_failed", err.Error())
|
||||
}
|
||||
hold = h
|
||||
}
|
||||
|
||||
// Save app.yaml.
|
||||
// CTRL-T2-1: persist the env now, but mark the ON-DISK state Deployed:false
|
||||
@@ -455,6 +475,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
// reverts Deployed to false — so a failed deploy can never present as an app owed a restart.
|
||||
DesiredState: DesiredStateRunning,
|
||||
SetupGate: gate,
|
||||
InstallHold: hold,
|
||||
}
|
||||
|
||||
diskCfg := *appCfg
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
)
|
||||
|
||||
// ── Image retention (R-736, `09` §3 decision 53) ──────────────────────────────────────────────────────
|
||||
//
|
||||
// A remove ran `compose down --rmi local` (which never removes a registry-pulled image) and an update left the old
|
||||
// version's image behind; nothing else deleted any. On scratch guest 9202 that filled the Docker disk until the box
|
||||
// refused an install (2026-09-30: 84 images, 53 GB used by no container). The ruling: a box keeps, per app service,
|
||||
// the image it runs now and the image before it (the undo's); it deletes older images of that app by itself; it
|
||||
// NEVER deletes an image that any container (running or stopped) or any installed app's compose still names.
|
||||
// Removing an app deletes that app's images under the same rule. Kept data (decision 40) is data, not images.
|
||||
//
|
||||
// THE KEEP SET is box-wide and rebuilt at every pass, at delete time: every container's image ID, every image an
|
||||
// installed app's live compose names (by tag and by digest), and every installed app's installed_images and
|
||||
// previous_images. A CANDIDATE is an image whose repository is one of THIS app's service repositories and whose ID
|
||||
// is not kept. It is deleted by exact ID, never forced (Docker itself refuses an image a container uses) and never
|
||||
// by prune; an ID that carries several repositories' tags is left alone. Every deletion is logged with its size.
|
||||
// Pinned by internal/stacks/image_retention_test.go.
|
||||
|
||||
// imageDocker runs one docker command (a seam: tests never reach Docker).
|
||||
var imageDocker = func(args ...string) (string, error) {
|
||||
out, err := dockerexec.Command("docker", args...).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
var imageRetentionMu sync.Mutex
|
||||
|
||||
type localImage struct {
|
||||
ID, Repo, Tag, Digest, Size string
|
||||
}
|
||||
|
||||
func splitRepoTag(ref string) (repo, tag, digest string) {
|
||||
if i := strings.Index(ref, "@"); i >= 0 {
|
||||
ref, digest = ref[:i], ref[i+1:]
|
||||
}
|
||||
if c := strings.LastIndex(ref, ":"); c > strings.LastIndex(ref, "/") {
|
||||
return ref[:c], ref[c+1:], digest
|
||||
}
|
||||
return ref, "latest", digest
|
||||
}
|
||||
|
||||
// normRepo makes Docker Hub's short forms comparable: "library/postgres" and "docker.io/postgres" are "postgres".
|
||||
func normRepo(r string) string {
|
||||
r = strings.TrimPrefix(r, "docker.io/")
|
||||
return strings.TrimPrefix(r, "library/")
|
||||
}
|
||||
|
||||
func listLocalImages() ([]localImage, error) {
|
||||
out, err := imageDocker("image", "ls", "--digests", "--no-trunc", "--format", "{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Digest}}\t{{.Size}}")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker image ls: %v: %s", err, truncateStr(out, 200))
|
||||
}
|
||||
var imgs []localImage
|
||||
for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
f := strings.Split(l, "\t")
|
||||
if len(f) < 5 || f[0] == "" {
|
||||
continue
|
||||
}
|
||||
imgs = append(imgs, localImage{ID: f[0], Repo: normRepo(f[1]), Tag: f[2], Digest: f[3], Size: f[4]})
|
||||
}
|
||||
return imgs, nil
|
||||
}
|
||||
|
||||
func imagesUsedByContainers() (map[string]bool, error) {
|
||||
out, err := imageDocker("ps", "-a", "-q", "--no-trunc")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker ps: %v", err)
|
||||
}
|
||||
ids := strings.Fields(out)
|
||||
used := map[string]bool{}
|
||||
if len(ids) == 0 {
|
||||
return used, nil
|
||||
}
|
||||
out, err = imageDocker(append([]string{"inspect", "--format", "{{.Image}}"}, ids...)...)
|
||||
if err != nil {
|
||||
// a container removed between the two calls fails the inspect: FAIL CLOSED — nothing is deleted
|
||||
return nil, fmt.Errorf("docker inspect containers: %v", err)
|
||||
}
|
||||
for _, id := range strings.Fields(out) {
|
||||
used[id] = true
|
||||
}
|
||||
return used, nil
|
||||
}
|
||||
|
||||
// matchImages: the local image IDs a reference names — by repo+tag, or by repo+digest.
|
||||
func matchImages(imgs []localImage, ref, digest string) []string {
|
||||
repo, tag, d := splitRepoTag(ref)
|
||||
repo = normRepo(repo)
|
||||
if digest == "" {
|
||||
digest = d
|
||||
}
|
||||
var ids []string
|
||||
for _, im := range imgs {
|
||||
if im.Repo != repo {
|
||||
continue
|
||||
}
|
||||
if (tag != "" && im.Tag == tag) || (digest != "" && im.Digest == digest) {
|
||||
ids = append(ids, im.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// imageKeepSet is the box-wide keep set (see the header). except = an app being removed (its records do not keep).
|
||||
func (m *Manager) imageKeepSet(imgs []localImage, except string) (map[string]bool, error) {
|
||||
keep, err := imagesUsedByContainers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.mu.RLock()
|
||||
type app struct {
|
||||
dir string
|
||||
installed map[string]InstalledImage
|
||||
previous map[string]InstalledImage
|
||||
}
|
||||
var apps []app
|
||||
for n, st := range m.stacks {
|
||||
if n == except || !st.Deployed {
|
||||
continue
|
||||
}
|
||||
a := app{dir: filepath.Dir(st.ComposePath)}
|
||||
if st.AppConfig != nil {
|
||||
a.installed, a.previous = st.AppConfig.InstalledImages, st.AppConfig.PreviousImages
|
||||
}
|
||||
apps = append(apps, a)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
for _, a := range apps {
|
||||
if refs, err := ParseComposeImages(ComposePathIn(a.dir)); err == nil {
|
||||
for _, ref := range refs {
|
||||
for _, id := range matchImages(imgs, ref, "") {
|
||||
keep[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, set := range []map[string]InstalledImage{a.installed, a.previous} {
|
||||
for _, ii := range set {
|
||||
for _, id := range matchImages(imgs, ii.Ref, ii.Digest) {
|
||||
keep[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return keep, nil
|
||||
}
|
||||
|
||||
// appImageRepos: the repositories an app's services use (its live compose, its records).
|
||||
func appImageRepos(dir string, cfg *AppConfig) map[string]bool {
|
||||
repos := map[string]bool{}
|
||||
if refs, err := ParseComposeImages(ComposePathIn(dir)); err == nil {
|
||||
for _, r := range refs {
|
||||
rp, _, _ := splitRepoTag(r)
|
||||
repos[normRepo(rp)] = true
|
||||
}
|
||||
}
|
||||
if cfg != nil {
|
||||
for _, set := range []map[string]InstalledImage{cfg.InstalledImages, cfg.PreviousImages} {
|
||||
for _, ii := range set {
|
||||
rp, _, _ := splitRepoTag(ii.Ref)
|
||||
repos[normRepo(rp)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return repos
|
||||
}
|
||||
|
||||
// deleteUnkeptImages deletes every image of repos whose ID is not kept. Returns what it deleted.
|
||||
func (m *Manager) deleteUnkeptImages(why string, repos map[string]bool, except string) ([]string, error) {
|
||||
imageRetentionMu.Lock()
|
||||
defer imageRetentionMu.Unlock()
|
||||
// An update IN FLIGHT has already replaced its containers and its compose; the image its undo needs is then named
|
||||
// by nothing the keep set reads. So no pass runs while any update runs (the next pass catches up).
|
||||
m.mu.RLock()
|
||||
busy := ""
|
||||
for n, st := range m.stacks {
|
||||
if st.Updating {
|
||||
busy = n
|
||||
break
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
if busy != "" {
|
||||
m.logger.Printf("[INFO] [stacks] image retention (%s): skipped — %s is updating (its undo may need an image nothing else names)", why, busy)
|
||||
return nil, nil
|
||||
}
|
||||
imgs, err := listLocalImages()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keep, err := m.imageKeepSet(imgs, except)
|
||||
if err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] image retention (%s): the keep set could not be read (%v) — NOTHING is deleted", why, err)
|
||||
return nil, err
|
||||
}
|
||||
byID := map[string][]localImage{}
|
||||
for _, im := range imgs {
|
||||
byID[im.ID] = append(byID[im.ID], im)
|
||||
}
|
||||
ids := make([]string, 0, len(byID))
|
||||
for id := range byID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
var deleted []string
|
||||
for _, id := range ids {
|
||||
group := byID[id]
|
||||
if keep[id] {
|
||||
continue
|
||||
}
|
||||
inRepos, names := true, []string{}
|
||||
for _, im := range group {
|
||||
if !repos[im.Repo] || strings.Contains(im.Repo, "felhom-controller") {
|
||||
inRepos = false
|
||||
}
|
||||
names = append(names, im.Repo+":"+im.Tag)
|
||||
}
|
||||
if !inRepos {
|
||||
continue
|
||||
}
|
||||
if len(uniqueRepos(group)) > 1 {
|
||||
m.logger.Printf("[INFO] [stacks] image retention (%s): %s carries several repositories' names %v — left alone", why, shortID(id), names)
|
||||
continue
|
||||
}
|
||||
if out, err := imageDocker("rmi", id); err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] image retention (%s): docker refused to delete %v (%s): %s", why, names, shortID(id), truncateStr(strings.TrimSpace(out), 160))
|
||||
continue
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] image retention (%s): deleted %v (%s, %s) — no container, installed app or undo names it (decision 53)", why, names, shortID(id), group[0].Size)
|
||||
deleted = append(deleted, strings.Join(names, ","))
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func uniqueRepos(g []localImage) map[string]bool {
|
||||
r := map[string]bool{}
|
||||
for _, im := range g {
|
||||
r[im.Repo] = true
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func shortID(id string) string {
|
||||
id = strings.TrimPrefix(id, "sha256:")
|
||||
if len(id) > 12 {
|
||||
return id[:12]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// RetainImagesAfterUpdate is called when a guarded Update ends. previous = what the app ran BEFORE the update when it
|
||||
// ended done (the image before the new one); when it was undone, the images of the attempt (the app runs the old
|
||||
// ones again and the attempt is the most recent other image). It records previous_images, then deletes the app's
|
||||
// older images.
|
||||
func (m *Manager) RetainImagesAfterUpdate(name string, previous map[string]InstalledImage) {
|
||||
st, ok := m.GetStack(name)
|
||||
if !ok || !st.Deployed {
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(st.ComposePath)
|
||||
if len(previous) > 0 {
|
||||
m.mutateAppConfig(name, dir, "previous_images", func(cfg *AppConfig) bool {
|
||||
cfg.PreviousImages = previous
|
||||
return true
|
||||
})
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] image retention %s: rescan failed: %v", name, err)
|
||||
}
|
||||
}
|
||||
st, _ = m.GetStack(name)
|
||||
if _, err := m.deleteUnkeptImages("update of "+name, appImageRepos(dir, st.AppConfig), ""); err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] image retention after the update of %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// retainAfterUpdateFn runs the retention after an update ends (a seam: the update tests do not exercise it).
|
||||
var retainAfterUpdateFn = func(m *Manager, name string, previous map[string]InstalledImage) {
|
||||
go m.RetainImagesAfterUpdate(name, previous)
|
||||
}
|
||||
|
||||
func (m *Manager) retainAfterUpdate(name string, previous map[string]InstalledImage) {
|
||||
retainAfterUpdateFn(m, name, previous)
|
||||
}
|
||||
|
||||
// RetainImagesAfterRemove deletes a removed app's images (its repos, read BEFORE the remove) that nothing else keeps.
|
||||
func (m *Manager) RetainImagesAfterRemove(name string, repos map[string]bool) {
|
||||
if len(repos) == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := m.deleteUnkeptImages("remove of "+name, repos, name); err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] image retention after the remove of %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// catalogImageRepos: every repository any catalog template or step names (the one-time sweep's reach: app images
|
||||
// only — never the controller's, traefik's or another infrastructure image).
|
||||
func (m *Manager) catalogImageRepos() map[string]bool {
|
||||
repos := map[string]bool{}
|
||||
root := filepath.Join(m.cfg.Paths.DataDir, "catalog-cache", "templates")
|
||||
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !(strings.HasSuffix(p, "docker-compose.yml") || (strings.Contains(p, string(filepath.Separator)+"steps"+string(filepath.Separator)) && strings.HasSuffix(p, ".yml") && !strings.HasSuffix(p, ".felhom.yml"))) {
|
||||
return nil
|
||||
}
|
||||
if refs, err := ParseComposeImages(p); err == nil {
|
||||
for _, r := range refs {
|
||||
rp, _, _ := splitRepoTag(r)
|
||||
repos[normRepo(rp)] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return repos
|
||||
}
|
||||
|
||||
// imageRetentionMarker: the one-time sweep runs once per box (decision 53's clean-up for boxes older than it).
|
||||
func (m *Manager) imageRetentionMarker() string {
|
||||
return filepath.Join(m.cfg.Paths.DataDir, "image-retention-v1.done")
|
||||
}
|
||||
|
||||
// RunImageRetentionOnce is the one-time clean-up at the first start of this release: the same rule, applied to every
|
||||
// app image the catalog names (so the images of apps removed before this release go too). Logged; a marker file
|
||||
// keeps it to once. Returns what it deleted.
|
||||
func (m *Manager) RunImageRetentionOnce() []string {
|
||||
if _, err := os.Stat(m.imageRetentionMarker()); err == nil {
|
||||
return nil
|
||||
}
|
||||
repos := m.catalogImageRepos()
|
||||
if len(repos) == 0 {
|
||||
m.logger.Printf("[WARN] [stacks] image retention (one-time): no catalog read — skipped, tried again at the next start")
|
||||
return nil
|
||||
}
|
||||
before, _ := imageDocker("system", "df", "--format", "{{.Type}} {{.Size}} {{.Reclaimable}}")
|
||||
deleted, err := m.deleteUnkeptImages("one-time clean-up", repos, "")
|
||||
if err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] image retention (one-time): %v — tried again at the next start", err)
|
||||
return nil
|
||||
}
|
||||
after, _ := imageDocker("system", "df", "--format", "{{.Type}} {{.Size}} {{.Reclaimable}}")
|
||||
m.logger.Printf("[INFO] [stacks] image retention (one-time): deleted %d image(s). docker disk before: %s | after: %s",
|
||||
len(deleted), strings.Join(strings.Fields(firstLine(before)), " "), strings.Join(strings.Fields(firstLine(after)), " "))
|
||||
_ = os.MkdirAll(filepath.Dir(m.imageRetentionMarker()), 0o755)
|
||||
_ = os.WriteFile(m.imageRetentionMarker(), []byte(fmt.Sprintf("deleted %d\n%s\n", len(deleted), strings.Join(deleted, "\n"))), 0o644)
|
||||
return deleted
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-736 (decision 53): the box keeps each app service's running image and the one before it; it deletes older
|
||||
// images of that app; it never deletes an image a container or an installed compose names. Docker is the
|
||||
// imageDocker seam — nothing here reaches a daemon (and dockerexec refuses one under go test anyway, R-650).
|
||||
|
||||
type fakeImages struct {
|
||||
imgs []localImage
|
||||
containers map[string]string // container id -> image id
|
||||
rmi []string
|
||||
}
|
||||
|
||||
func (f *fakeImages) run(args ...string) (string, error) {
|
||||
switch {
|
||||
case args[0] == "image" && args[1] == "ls":
|
||||
var b strings.Builder
|
||||
for _, im := range f.imgs {
|
||||
fmt.Fprintf(&b, "%s\t%s\t%s\t%s\t%s\n", im.ID, im.Repo, im.Tag, im.Digest, im.Size)
|
||||
}
|
||||
return b.String(), nil
|
||||
case args[0] == "ps":
|
||||
var ids []string
|
||||
for c := range f.containers {
|
||||
ids = append(ids, c)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return strings.Join(ids, "\n"), nil
|
||||
case args[0] == "inspect":
|
||||
var out []string
|
||||
for _, c := range args[3:] {
|
||||
out = append(out, f.containers[c])
|
||||
}
|
||||
return strings.Join(out, "\n"), nil
|
||||
case args[0] == "rmi":
|
||||
f.rmi = append(f.rmi, args[1])
|
||||
var keep []localImage
|
||||
for _, im := range f.imgs {
|
||||
if im.ID != args[1] {
|
||||
keep = append(keep, im)
|
||||
}
|
||||
}
|
||||
f.imgs = keep
|
||||
return "Deleted", nil
|
||||
case args[0] == "system":
|
||||
return "Images 1GB 0B", nil
|
||||
}
|
||||
return "", fmt.Errorf("unexpected docker %v", args)
|
||||
}
|
||||
|
||||
func withFakeImages(t *testing.T, f *fakeImages) {
|
||||
t.Helper()
|
||||
prev := imageDocker
|
||||
imageDocker = f.run
|
||||
t.Cleanup(func() { imageDocker = prev })
|
||||
}
|
||||
|
||||
// retentionManager: two installed apps sharing postgres:18-alpine; app "web" at web:3 (previous web:2), web:1 older.
|
||||
func retentionManager(t *testing.T) *Manager {
|
||||
t.Helper()
|
||||
m := gateManager(t, "display_name: G\n")
|
||||
m.cfg.Paths.DataDir = filepath.Join(t.TempDir(), "data") // never the package folder
|
||||
root := m.cfg.Paths.StacksDir
|
||||
write := func(app, compose, appYaml string) {
|
||||
d := filepath.Join(root, app)
|
||||
must(t, os.MkdirAll(d, 0o755))
|
||||
must(t, os.WriteFile(filepath.Join(d, "docker-compose.yml"), []byte(compose), 0o644))
|
||||
must(t, os.WriteFile(filepath.Join(d, "app.yaml"), []byte(appYaml), 0o644))
|
||||
}
|
||||
write("web", "services:\n web:\n image: acme/web:3\n web-db:\n image: postgres:18-alpine\n",
|
||||
"deployed: true\nenv: {}\ninstalled_images:\n web:\n ref: acme/web:3\n digest: sha256:w3\n at: \"2026-09-30T00:00:00Z\"\n web-db:\n ref: postgres:18-alpine\n digest: sha256:p18\n at: \"2026-09-30T00:00:00Z\"\nprevious_images:\n web:\n ref: acme/web:2\n digest: sha256:w2\n at: \"2026-09-20T00:00:00Z\"\n")
|
||||
write("docs", "services:\n docs:\n image: acme/docs:1\n docs-db:\n image: postgres:18-alpine\n",
|
||||
"deployed: true\nenv: {}\ninstalled_images:\n docs:\n ref: acme/docs:1\n digest: sha256:d1\n at: \"2026-09-30T00:00:00Z\"\n")
|
||||
must(t, m.ScanStacks())
|
||||
return m
|
||||
}
|
||||
|
||||
func baseImages() *fakeImages {
|
||||
return &fakeImages{
|
||||
imgs: []localImage{
|
||||
{ID: "sha256:W3", Repo: "acme/web", Tag: "3", Digest: "sha256:w3", Size: "100MB"},
|
||||
{ID: "sha256:W2", Repo: "acme/web", Tag: "2", Digest: "sha256:w2", Size: "100MB"},
|
||||
{ID: "sha256:W1", Repo: "acme/web", Tag: "1", Digest: "sha256:w1", Size: "100MB"},
|
||||
{ID: "sha256:P18", Repo: "postgres", Tag: "18-alpine", Digest: "sha256:p18", Size: "300MB"},
|
||||
{ID: "sha256:P16", Repo: "postgres", Tag: "16-alpine", Digest: "sha256:p16", Size: "290MB"},
|
||||
{ID: "sha256:D1", Repo: "acme/docs", Tag: "1", Digest: "sha256:d1", Size: "50MB"},
|
||||
{ID: "sha256:CTL", Repo: "gitea.dooplex.hu/admin/felhom-controller", Tag: "0.283.0", Digest: "", Size: "400MB"},
|
||||
},
|
||||
containers: map[string]string{"c-web": "sha256:W3", "c-webdb": "sha256:P18", "c-docs": "sha256:D1", "c-docsdb": "sha256:P18"},
|
||||
}
|
||||
}
|
||||
|
||||
// After an update: the running image and the one before it stay; the older one goes; the shared engine stays.
|
||||
// COMPANION RED-PROOF: drop previous_images from imageKeepSet → "the undo's image (web:2) was deleted".
|
||||
func TestImageRetention_KeepsRunningAndPreviousDeletesOlder(t *testing.T) {
|
||||
m := retentionManager(t)
|
||||
f := baseImages()
|
||||
withFakeImages(t, f)
|
||||
st, _ := m.GetStack("web")
|
||||
if _, err := m.deleteUnkeptImages("test", appImageRepos(filepath.Dir(st.ComposePath), st.AppConfig), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := strings.Join(f.rmi, ",")
|
||||
if strings.Contains(got, "sha256:W2") {
|
||||
t.Fatal("the undo's image (web:2) was deleted")
|
||||
}
|
||||
if strings.Contains(got, "sha256:W3") || strings.Contains(got, "sha256:P18") {
|
||||
t.Fatalf("a running image was deleted: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "sha256:W1") {
|
||||
t.Fatalf("the older web:1 was not deleted: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "sha256:P16") {
|
||||
t.Fatalf("postgres:16-alpine is this app's repo and nothing keeps it — expected deleted: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "sha256:CTL") || strings.Contains(got, "sha256:D1") {
|
||||
t.Fatalf("another app's or the controller's image was touched: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A shared image survives the remove of one of its apps (another app's container and compose name it).
|
||||
// COMPANION RED-PROOF: drop the container half of the keep set (return an empty map from imagesUsedByContainers)
|
||||
// AND the compose half → "the shared postgres:18-alpine was deleted".
|
||||
func TestImageRetention_ASharedImageSurvivesTheRemoveOfOneApp(t *testing.T) {
|
||||
m := retentionManager(t)
|
||||
f := baseImages()
|
||||
withFakeImages(t, f)
|
||||
st, _ := m.GetStack("web")
|
||||
repos := appImageRepos(filepath.Dir(st.ComposePath), st.AppConfig)
|
||||
// the remove took web's containers away
|
||||
delete(f.containers, "c-web")
|
||||
delete(f.containers, "c-webdb")
|
||||
m.mu.Lock()
|
||||
m.stacks["web"].Deployed = false
|
||||
m.mu.Unlock()
|
||||
m.RetainImagesAfterRemove("web", repos)
|
||||
got := strings.Join(f.rmi, ",")
|
||||
if strings.Contains(got, "sha256:P18") {
|
||||
t.Fatal("the shared postgres:18-alpine was deleted while docs still runs it")
|
||||
}
|
||||
for _, id := range []string{"sha256:W3", "sha256:W2", "sha256:W1"} {
|
||||
if !strings.Contains(got, id) {
|
||||
t.Fatalf("the removed app's image %s was kept: %s", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The keep set is checked from the compose too, not only containers: an installed app whose containers are down
|
||||
// (stopped by the household, or mid-restart) keeps its images.
|
||||
// COMPANION RED-PROOF: drop the ParseComposeImages loop from imageKeepSet → docs' image goes.
|
||||
func TestImageRetention_AStoppedAppsComposeKeepsItsImage(t *testing.T) {
|
||||
m := retentionManager(t)
|
||||
f := baseImages()
|
||||
f.containers = map[string]string{} // nothing running anywhere
|
||||
withFakeImages(t, f)
|
||||
m.RunImageRetentionOnce() // catalog-cache is absent → skipped, no marker
|
||||
if len(f.rmi) != 0 {
|
||||
t.Fatalf("the one-time sweep ran without a catalog: %v", f.rmi)
|
||||
}
|
||||
st, _ := m.GetStack("docs")
|
||||
m.mu.Lock()
|
||||
m.stacks["docs"].AppConfig.InstalledImages = nil // only the compose names it now
|
||||
m.mu.Unlock()
|
||||
if _, err := m.deleteUnkeptImages("test", appImageRepos(filepath.Dir(st.ComposePath), nil), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(strings.Join(f.rmi, ","), "sha256:D1") {
|
||||
t.Fatal("a stopped app's image was deleted although its compose names it")
|
||||
}
|
||||
}
|
||||
|
||||
// A keep set that cannot be read deletes NOTHING (fail closed).
|
||||
func TestImageRetention_UnreadableKeepSetDeletesNothing(t *testing.T) {
|
||||
m := retentionManager(t)
|
||||
f := baseImages()
|
||||
withFakeImages(t, f)
|
||||
prev := imageDocker
|
||||
imageDocker = func(args ...string) (string, error) {
|
||||
if args[0] == "ps" {
|
||||
return "", fmt.Errorf("daemon hiccup")
|
||||
}
|
||||
return f.run(args...)
|
||||
}
|
||||
t.Cleanup(func() { imageDocker = prev })
|
||||
if _, err := m.deleteUnkeptImages("test", map[string]bool{"acme/web": true, "postgres": true}, ""); err == nil {
|
||||
t.Fatal("no error from an unreadable keep set")
|
||||
}
|
||||
if len(f.rmi) != 0 {
|
||||
t.Fatalf("deleted with no keep set: %v", f.rmi)
|
||||
}
|
||||
}
|
||||
|
||||
// The one-time sweep reaches only images the catalog names (app images) — never the controller's.
|
||||
func TestImageRetention_OneTimeSweepOnlyCatalogRepos(t *testing.T) {
|
||||
m := retentionManager(t)
|
||||
f := baseImages()
|
||||
f.imgs = append(f.imgs, localImage{ID: "sha256:OLD", Repo: "acme/gone", Tag: "5", Digest: "sha256:g5", Size: "70MB"})
|
||||
withFakeImages(t, f)
|
||||
cat := filepath.Join(m.cfg.Paths.DataDir, "catalog-cache", "templates", "gone")
|
||||
must(t, os.MkdirAll(cat, 0o755))
|
||||
must(t, os.WriteFile(filepath.Join(cat, "docker-compose.yml"), []byte("services:\n gone:\n image: acme/gone:6\n"), 0o644))
|
||||
deleted := m.RunImageRetentionOnce()
|
||||
got := strings.Join(f.rmi, ",")
|
||||
if !strings.Contains(got, "sha256:OLD") {
|
||||
t.Fatalf("an earlier-removed app's image was not swept: %v", deleted)
|
||||
}
|
||||
if strings.Contains(got, "sha256:CTL") || strings.Contains(got, "sha256:W1") {
|
||||
t.Fatalf("the sweep reached beyond the catalog's repos: %s", got)
|
||||
}
|
||||
if _, err := os.Stat(m.imageRetentionMarker()); err != nil {
|
||||
t.Fatal("no marker — the sweep would run at every start")
|
||||
}
|
||||
f.rmi = nil
|
||||
m.RunImageRetentionOnce()
|
||||
if len(f.rmi) != 0 {
|
||||
t.Fatal("the one-time sweep ran twice")
|
||||
}
|
||||
}
|
||||
|
||||
// An update in flight pauses every pass: its undo's image is named by nothing the keep set reads.
|
||||
// COMPANION RED-PROOF: drop the busy check in deleteUnkeptImages → "a pass ran while docs was updating".
|
||||
func TestImageRetention_NoPassWhileAnUpdateRuns(t *testing.T) {
|
||||
m := retentionManager(t)
|
||||
f := baseImages()
|
||||
withFakeImages(t, f)
|
||||
m.mu.Lock()
|
||||
m.stacks["docs"].Updating = true
|
||||
m.mu.Unlock()
|
||||
st, _ := m.GetStack("web")
|
||||
if _, err := m.deleteUnkeptImages("test", appImageRepos(filepath.Dir(st.ComposePath), st.AppConfig), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.rmi) != 0 {
|
||||
t.Fatalf("a pass ran while docs was updating: %v", f.rmi)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── The install hold (R-741, `09` §3 decision 45) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Measured 2026-09-30 on 9202 (calibre-web): an app whose template carries `after_install:` answered its PUBLIC
|
||||
// default login through traefik for 1–18 s — the app was published at its first start, and the box replaced the
|
||||
// login only after the app was up. So such an app is installed HELD: before its first start a traefik file puts
|
||||
// the setup gate's door (forwardAuth, internal/web/setup_gate.go) in front of every router it publishes. A
|
||||
// stranger is refused; the household (a dashboard session) still passes — so a failed after_install leaves the
|
||||
// household able to change the login by hand and say so ("I changed it"). The hold OPENS when after_install
|
||||
// succeeds (runAfterInstallNow) or when the household says it changed the login (MarkDefaultLoginChanged); opening
|
||||
// removes the file. The record (`install_hold:` in app.yaml, the gate's record shape) is reconciled by the gate's
|
||||
// loop: a closed hold keeps its file; a hold whose after_install already succeeded, or whose login the household
|
||||
// changed, opens; an absent after_install record (a controller restart cut the hook off) is run again once per
|
||||
// process. Its priority beats the setup gate and the sign-up block, so a gated app is held first.
|
||||
// Pinned by internal/stacks/install_hold_test.go.
|
||||
|
||||
const (
|
||||
InstallHoldByAfterInstall = "after_install"
|
||||
InstallHoldByHousehold = "household"
|
||||
)
|
||||
|
||||
func (m *Manager) installHoldPath(name string) string {
|
||||
return filepath.Join(m.setupGateDir(), "install-hold-"+name+".yml")
|
||||
}
|
||||
|
||||
// renderInstallHold is the traefik file: every router the app publishes, same rule, a priority above the gate's
|
||||
// and the sign-up block's, the gate's forwardAuth door, then the app's own docker service.
|
||||
func renderInstallHold(name string, rs []gateRouter) string {
|
||||
var b strings.Builder
|
||||
mw := "felhom-install-hold-" + name
|
||||
fmt.Fprintf(&b, "# Install hold for %s — managed by felhom-controller (R-741, `09` §3 decision 45).\n", name)
|
||||
b.WriteString("# Only the household reaches the app until its known first login has been replaced; then this file is removed.\n")
|
||||
b.WriteString("http:\n middlewares:\n")
|
||||
fmt.Fprintf(&b, " %s:\n forwardAuth:\n address: %q\n", mw, setupGateAuthURL)
|
||||
b.WriteString(" routers:\n")
|
||||
for _, r := range rs {
|
||||
fmt.Fprintf(&b, " %s-%s:\n", mw, r.Name)
|
||||
fmt.Fprintf(&b, " rule: %q\n", r.Rule)
|
||||
fmt.Fprintf(&b, " priority: %d\n", 3*setupGatePriority+len(r.Rule))
|
||||
b.WriteString(" entryPoints:\n - websecure\n")
|
||||
if r.CertResolver != "" {
|
||||
fmt.Fprintf(&b, " tls:\n certResolver: %s\n", r.CertResolver)
|
||||
} else {
|
||||
b.WriteString(" tls: {}\n")
|
||||
}
|
||||
fmt.Fprintf(&b, " middlewares:\n - %s@file\n", mw)
|
||||
fmt.Fprintf(&b, " service: %q\n", r.Service+"@docker")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *Manager) writeInstallHold(name, composePath string, env map[string]string) ([]string, error) {
|
||||
rs, err := gateRoutersFromCompose(composePath, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(m.setupGateDir(), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := renderInstallHold(name, rs)
|
||||
p := m.installHoldPath(name)
|
||||
if cur, err := os.ReadFile(p); err == nil && string(cur) == want {
|
||||
return gateHosts(rs), nil
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(want), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Rename(tmp, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gateHosts(rs), nil
|
||||
}
|
||||
|
||||
func (m *Manager) removeInstallHoldFile(name string) error {
|
||||
err := os.Remove(m.installHoldPath(name))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wantsInstallHold: the template replaces a known first login after the install.
|
||||
func wantsInstallHold(meta *Metadata) bool {
|
||||
ai := meta.AfterInstall
|
||||
return ai != nil && ai.Service != "" && len(ai.Command) > 0 && ai.Success != ""
|
||||
}
|
||||
|
||||
// prepareInstallHold is DeployStack's step for an after_install template on a FRESH install: the file first (it
|
||||
// must stand before the first start), then the record the caller saves with the app.
|
||||
func (m *Manager) prepareInstallHold(name, composePath string, env map[string]string) (*SetupGateRecord, error) {
|
||||
hosts, err := m.writeInstallHold(name, composePath, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] %s: install HOLD before the first start — only the household reaches %v until the known first login is replaced", name, hosts)
|
||||
return &SetupGateRecord{State: SetupGateClosed, Since: m.now().UTC().Format(time.RFC3339), Hosts: hosts}, nil
|
||||
}
|
||||
|
||||
// OpenInstallHold opens an app's hold: the record first, then the file (a failed removal is retried by the loop).
|
||||
// A hold that is not closed is not an error — after_install succeeding on an app never held (installed before
|
||||
// this release) opens nothing.
|
||||
func (m *Manager) OpenInstallHold(name, by string) error {
|
||||
st, ok := m.GetStack(name)
|
||||
if !ok || st.AppConfig == nil || !st.AppConfig.InstallHold.Closed() {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(st.ComposePath)
|
||||
now := m.now().UTC().Format(time.RFC3339)
|
||||
opened := false
|
||||
m.mutateAppConfig(name, dir, "install_hold", func(cfg *AppConfig) bool {
|
||||
if !cfg.InstallHold.Closed() {
|
||||
return false
|
||||
}
|
||||
cfg.InstallHold.State, cfg.InstallHold.OpenedAt, cfg.InstallHold.OpenedBy = SetupGateOpen, now, by
|
||||
opened = true
|
||||
return true
|
||||
})
|
||||
if !opened {
|
||||
return fmt.Errorf("install hold %s: the record could not be written", name)
|
||||
}
|
||||
if err := m.removeInstallHoldFile(name); err != nil {
|
||||
m.logger.Printf("[ERROR] [stacks] %s: install hold opened but its traefik file could not be removed (%v) — the loop retries", name, err)
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] %s: install hold OPENED by %s — the app is reached as without a hold", name, by)
|
||||
return nil
|
||||
}
|
||||
|
||||
// installHoldProcessStart: only an install made BEFORE this process started can have lost its hook (the hook runs
|
||||
// after_install in this process's own goroutine right after an install made now).
|
||||
var installHoldProcessStart = time.Now()
|
||||
|
||||
// installHoldRetried: apps whose absent after_install record this process already re-ran (once per process).
|
||||
var installHoldRetried sync.Map
|
||||
|
||||
// installHoldAfterInstall is RunAfterInstall, a seam for the tests.
|
||||
var installHoldAfterInstall = func(m *Manager, name string) { _, _ = m.RunAfterInstall(name, 10*time.Minute) }
|
||||
|
||||
// installHoldTick is the hold's part of SetupGateTick: stale files go, closed holds keep their file, and a hold
|
||||
// whose login is already replaced opens.
|
||||
func (m *Manager) installHoldTick() {
|
||||
type item struct {
|
||||
name, dir, compose string
|
||||
opened, rerun string
|
||||
}
|
||||
var items []item
|
||||
keep := map[string]bool{}
|
||||
m.mu.RLock()
|
||||
for n, st := range m.stacks {
|
||||
if !st.Deployed || st.AppConfig == nil || !st.AppConfig.InstallHold.Closed() {
|
||||
continue
|
||||
}
|
||||
it := item{name: n, dir: filepath.Dir(st.ComposePath), compose: st.ComposePath}
|
||||
switch {
|
||||
case st.AppConfig.AfterInstall != nil && st.AppConfig.AfterInstall.OK:
|
||||
it.opened = InstallHoldByAfterInstall
|
||||
case st.AppConfig.DefaultLogin != nil:
|
||||
it.opened = InstallHoldByHousehold
|
||||
case st.AppConfig.AfterInstall == nil && (st.State == StateRunning || st.State == StateUnhealthy) && !st.Deploying:
|
||||
if at, err := time.Parse(time.RFC3339, st.AppConfig.DeployedAt); err == nil && at.Before(installHoldProcessStart.Truncate(time.Second)) { // DeployedAt has whole seconds
|
||||
it.rerun = "yes"
|
||||
}
|
||||
}
|
||||
items = append(items, it)
|
||||
keep[n] = true
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
if ents, err := os.ReadDir(m.setupGateDir()); err == nil {
|
||||
for _, e := range ents {
|
||||
n := e.Name()
|
||||
if !strings.HasPrefix(n, "install-hold-") || !strings.HasSuffix(n, ".yml") {
|
||||
continue
|
||||
}
|
||||
app := strings.TrimSuffix(strings.TrimPrefix(n, "install-hold-"), ".yml")
|
||||
if !keep[app] {
|
||||
if err := m.removeInstallHoldFile(app); err == nil {
|
||||
m.logger.Printf("[INFO] [stacks] %s: removed an install-hold file for an app that is not held", app)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.opened != "" {
|
||||
if err := m.OpenInstallHold(it.name, it.opened); err != nil {
|
||||
m.logger.Printf("[ERROR] [stacks] %s: %v", it.name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cfg := LoadAppConfigDecrypted(it.dir, m.encKey); cfg != nil {
|
||||
if _, err := m.writeInstallHold(it.name, it.compose, cfg.Env); err != nil {
|
||||
m.logger.Printf("[ERROR] [stacks] %s: the install hold's traefik file could not be (re)written: %v", it.name, err)
|
||||
}
|
||||
}
|
||||
if it.rerun != "" {
|
||||
if _, done := installHoldRetried.LoadOrStore(it.name, true); !done {
|
||||
m.logger.Printf("[WARN] [stacks] %s: held, and its after_install never ran (a restart cut the install hook off) — running it now", it.name)
|
||||
go installHoldAfterInstall(m, it.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) {
|
||||
// opened; a gate that was still closed stays closed (its probe opens it if the restored data is set up).
|
||||
// No prior record (a removed app, kept data, a rebuilt guest) = no gate: the data comes back with its admin.
|
||||
cfg.SetupGate = prior.SetupGate
|
||||
cfg.InstallHold = prior.InstallHold // R-741: the loop opens it when the restored record says the login was replaced
|
||||
cfg.DefaultLogin = prior.DefaultLogin
|
||||
cfg.AfterSetup = prior.AfterSetup
|
||||
if n := len(prior.EarlierConversionCopies); prior.ConversionCopy != nil || n > 0 {
|
||||
|
||||
@@ -345,6 +345,16 @@ func (m *Manager) SetupGateHost(host string) (name string, closed bool, found bo
|
||||
host = strings.ToLower(host)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
// R-741: a closed install hold answers first (its routers outrank the gate's); an app with both is closed while
|
||||
// either is.
|
||||
for n, st := range m.stacks {
|
||||
if st.AppConfig == nil || !st.AppConfig.InstallHold.Closed() {
|
||||
continue
|
||||
}
|
||||
if containsStr(st.AppConfig.InstallHold.Hosts, host) {
|
||||
return n, true, true
|
||||
}
|
||||
}
|
||||
for n, st := range m.stacks {
|
||||
if st.AppConfig == nil || st.AppConfig.SetupGate == nil {
|
||||
continue
|
||||
@@ -486,6 +496,7 @@ func (m *Manager) SetupGateTick() {
|
||||
}
|
||||
}
|
||||
m.reconcileSignupBlocks()
|
||||
m.installHoldTick()
|
||||
}
|
||||
|
||||
// RunSetupGateLoop runs SetupGateTick every interval until ctx ends.
|
||||
@@ -521,5 +532,8 @@ func (m *Manager) MarkDefaultLoginChanged(name, by string) error {
|
||||
return fmt.Errorf("%s: app.yaml could not be read", name)
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] %s: the household says it changed the default login by hand — the page stops naming it", name)
|
||||
if err := m.OpenInstallHold(name, InstallHoldByHousehold); err != nil { // R-741
|
||||
m.logger.Printf("[ERROR] [stacks] %s: %v — the loop retries", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -564,6 +564,7 @@ func (m *Manager) tryUndo(ctx context.Context, name, dir, why string, entry *upd
|
||||
m.logger.Printf("[WARN] [stacks] update %s: rescan after the undo failed: %v", name, err)
|
||||
}
|
||||
m.finishUpdate(name, UpdatePhaseUndone, "")
|
||||
m.retainAfterUpdate(name, nil) // decision 53: the app runs its old image again; the attempt's is not kept
|
||||
m.emitUpdateEvent(UpdateEventUndone, name, entry, UpdateRestorePoint{}, true)
|
||||
m.logger.Printf("[INFO] [stacks] update %s: UNDONE in %s — the previous version is running on the data from before the update (%s)", name, m.now().Sub(start).Round(time.Second), detail)
|
||||
return ""
|
||||
|
||||
@@ -757,6 +757,13 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
dir := filepath.Dir(st.ComposePath)
|
||||
g := m.guards()
|
||||
entry := updateJournalEntry{StartedAt: start}
|
||||
// decision 53: what the app ran before this press — kept as the previous image if the update ends done.
|
||||
if st.AppConfig != nil && len(st.AppConfig.InstalledImages) > 0 {
|
||||
entry.BeforeImages = make(map[string]InstalledImage, len(st.AppConfig.InstalledImages))
|
||||
for k, v := range st.AppConfig.InstalledImages {
|
||||
entry.BeforeImages[k] = v
|
||||
}
|
||||
}
|
||||
fail := func(key, detail string, args ...interface{}) {
|
||||
m.logger.Printf("[ERROR] [stacks] update %s FAILED in phase %s after %s — nothing was moved: %s", name, entry.Phase, m.now().Sub(start).Round(time.Millisecond), detail)
|
||||
m.clearJournal(name)
|
||||
@@ -1031,6 +1038,7 @@ func (m *Manager) verifyAndConclude(ctx context.Context, name, dir string, env [
|
||||
}
|
||||
m.finishUpdate(name, UpdatePhaseDone, "")
|
||||
m.logger.Printf("[INFO] [stacks] update %s: DONE in %s", name, m.now().Sub(start).Round(time.Second))
|
||||
m.retainAfterUpdate(name, entry.BeforeImages) // decision 53 (R-736)
|
||||
}
|
||||
|
||||
// holdLogTailLines is how much of each service's log the hold keeps. 400 lines is enough to hold a
|
||||
@@ -1259,12 +1267,14 @@ func (m *Manager) waitUpdateHealthyMeta(ctx context.Context, name string, timeou
|
||||
// ── the journal ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type updateJournalEntry struct {
|
||||
Phase string `json:"phase"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
PrevPin map[string]string `json:"prev_pin,omitempty"`
|
||||
PrevCompose string `json:"prev_compose,omitempty"`
|
||||
PrevApplied string `json:"prev_applied,omitempty"`
|
||||
ProvenCopyAt string `json:"proven_copy_at,omitempty"`
|
||||
Phase string `json:"phase"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
PrevPin map[string]string `json:"prev_pin,omitempty"`
|
||||
// BeforeImages (decision 53): installed_images at the press — the previous image kept if the update ends done.
|
||||
BeforeImages map[string]InstalledImage `json:"before_images,omitempty"`
|
||||
PrevCompose string `json:"prev_compose,omitempty"`
|
||||
PrevApplied string `json:"prev_applied,omitempty"`
|
||||
ProvenCopyAt string `json:"proven_copy_at,omitempty"`
|
||||
// ProvenTier (R-475) — which tier ProvenCopyAt belongs to, so a resumed update that fails names
|
||||
// the right copy. 0 in a journal written by v0.238.1 or older.
|
||||
ProvenTier int `json:"proven_tier,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user