v0.244.0: the backup page stops promising what it does not hold (R-537/R-538/R-536)
gates / gates (push) Successful in 17s

R-537 — the contents label is now PER TIER. One string computed from the app's
shape was rendered on all three tier rows; a Tier-1 unit has no file-copy step, so
for the four class-A apps it was claiming „Adatok" for files it does not hold.

R-538 — a unit restore REFUSES before anything is touched when the unit cannot
return the app's drive-side files, and names the route that can. It runs before the
stack is stopped because the measured harm included the app's own wastebasket going
unreachable, which still held every byte.

R-536 — „Alkalmazás telepítve" moved from the deploy's acceptance to its completion,
with app_deploy_started and app_deploy_failed as the honest pair.

Each fix red-proofed: seen failing with its own sentence, passing when restored.
Requires hub v0.116.0 for the two new event types. MinAgent unchanged (0.131.0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-16 16:55:55 +02:00
parent 383a30b3c0
commit 2f8ff2414c
16 changed files with 640 additions and 50 deletions
+31
View File
@@ -401,6 +401,15 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
// runComposeDeploy executes docker compose up -d in background.
// On success it refreshes status; on failure it reverts the deploy state.
// SetDeployDoneHook registers the callback fired (in the deploy goroutine) when an async deploy
// ENDS — successfully or not. It is the seam R-536 needed: the deploy's outcome is known here and
// nowhere else, and the hub event that asserts an app is installed must hang off the outcome rather
// than off the acceptance.
//
// Same shape as SetMigrationDoneHook, deliberately: the policy (which event, what wording) lives in
// the caller, and this package only reports what happened.
func (m *Manager) SetDeployDoneHook(fn func(name string, ok bool, detail string)) { m.deployDoneHook = fn }
func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string, appCfg *AppConfig) {
start := time.Now()
_, composeErr := m.composeExecWithEnv(stackDir, env, "up", "-d")
@@ -421,6 +430,15 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string,
// Save reverted state to disk with encryption (H05 fix)
meta := LoadMetadata(stackDir)
_ = SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta))
// R-536: the deploy ended, and it ended badly. Say so — the alternative is the silence that
// let an accept-time „Alkalmazás telepítve" stand as the last word on an app that never ran.
//
// The app.yaml is deliberately NOT deleted here: it is the crash-safe record written with
// Deployed:false, it carries the settings the customer typed, and a redeploy reuses them. The
// state the surfaces read is `not_deployed`, which is the fact that matters.
if m.deployDoneHook != nil {
m.deployDoneHook(name, false, composeErr.Error())
}
return
}
@@ -472,6 +490,19 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string,
m.logPostStartStatus(name, stackDir, deployEnv)
_ = m.RefreshStatus()
// R-536: ONLY NOW is „Alkalmazás telepítve" a true sentence — the compose up succeeded, the
// durable record says deployed, the images are recorded and the status has been refreshed. The
// observed state rides along rather than being asserted: a stack that is still `starting` is
// installed, and the detail says which it is instead of the event implying health it has not
// measured.
if m.deployDoneHook != nil {
state := ""
if s, ok := m.GetStack(name); ok {
state = string(s.State)
}
m.deployDoneHook(name, true, state)
}
}
// UpdateStackConfig updates non-locked fields for a deployed stack.
+5
View File
@@ -205,6 +205,11 @@ type Manager struct {
sysDataPath string
backupRunning func() bool // mutual exclusion with the backup orchestrator (Change 3)
migDoneHook func(*MigrationJob) // fired on successful completion (decommission policy lives in caller)
// deployDoneHook (R-536) fires when an ASYNC deploy reaches its end — ok=true with the observed
// state, or ok=false with the reason. The hub event that says „Alkalmazás telepítve" hangs off
// this and nothing else: it used to be sent beside the 202 that merely accepted the request, so
// an install interrupted five seconds later stayed on the timeline as a completed one.
deployDoneHook func(name string, ok bool, detail string)
testSeams *migSeams // nil in production; tests inject fakes
// R-51: docker restart policies for DOWN members of mixed stacks. Keyed by
// containerName+"|"+state so a transitioned or recreated container re-reads rather than
@@ -0,0 +1,81 @@
package stacks
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// withFakeComposeExit is withFakeCompose with a chosen exit code, so the FAILING deploy can be
// driven through the same process boundary as the succeeding one.
func withFakeComposeExit(t *testing.T, m *Manager, code int) {
t.Helper()
if runtime.GOOS != "linux" {
t.Skip("the stub compose binary is a shell script")
}
bin := t.TempDir()
script := "#!/bin/sh\nexit " + string(rune('0'+code)) + "\n"
if err := os.WriteFile(filepath.Join(bin, "docker-compose"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
m.composeCmd = "docker-compose"
m.execFn = func(string, ...string) (string, error) { return "", nil }
}
// R-536 — „Alkalmazás telepítve" must be said at the END of a deploy, and a deploy that ends badly
// must say THAT rather than nothing.
//
// The defect this pins, measured live on 2026-09-16: the deploy of `mealie` was accepted at
// 12:31:36 CEST and the hub logged „Alkalmazás telepítve: Mealie" in the same second; the controller
// was killed five seconds later, and after the agent restarted it the stack read
// `not_deployed / deployed=false / deploying=false`. Nothing ever corrected the event.
//
// Red-proof: remove the deployDoneHook call from the success path → the "installed" case fails;
// remove it from the failure branch → the "failed" case fails.
func TestDeployDoneHook_FiresAtTheEndAndSaysWhichEndItWas(t *testing.T) {
t.Run("a deploy that succeeds reports installed", func(t *testing.T) {
m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n")
withFakeCompose(t, m)
var gotName, gotDetail string
var gotOK, fired bool
m.SetDeployDoneHook(func(name string, ok bool, detail string) {
fired, gotName, gotOK, gotDetail = true, name, ok, detail
})
m.runComposeDeploy("bookstack", dir, map[string]string{}, &AppConfig{Deployed: true})
if !fired {
t.Fatal("the deploy ended and nothing was told about it")
}
if gotName != "bookstack" || !gotOK {
t.Fatalf("a successful deploy must report ok for its own app: name=%q ok=%v detail=%q", gotName, gotOK, gotDetail)
}
})
t.Run("a deploy that fails says so", func(t *testing.T) {
m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n")
withFakeComposeExit(t, m, 1)
var gotOK, fired bool
var gotDetail string
m.SetDeployDoneHook(func(_ string, ok bool, detail string) {
fired, gotOK, gotDetail = true, ok, detail
})
m.runComposeDeploy("bookstack", dir, map[string]string{}, &AppConfig{Deployed: true})
if !fired {
t.Fatal("a failed deploy must be reported, not be silence — silence is what left a completed-install record for an app that never ran")
}
if gotOK {
t.Fatalf("a failed deploy must not report ok (detail=%q)", gotDetail)
}
// And the durable record must read not-deployed, which is the fact every surface reads.
if cfg := LoadAppConfig(dir); cfg != nil && cfg.Deployed {
t.Fatal("a failed deploy must leave the durable record NOT deployed")
}
})
}