Files
felhom-controller/controller/internal/stacks/r630_r634_v0262_test.go
T
admin b793638484
gates / gates (push) Successful in 26s
v0.262.0: six defects two drill nights found in the update, remove and hold paths
R-630 (P1): waitUpdateHealthy kept the probe inside `if hc != nil && len(hc.Checks) > 0`, and when
findProbeContainer returned "" its else set last="no probe container" and LOOPED - the settle path
sat in the outer else, unreachable. So verifying could only time out and failAndHold then stopped a
working app. Measured on paperless-ngx: three containers healthy, failed at +313.0s, front door 404
after. It now falls through to the same settle path with a WARN naming the candidates.

The probe target is decidable now: HealthCheckConfig.Container plus findProbeContainerMeta resolve
by exact stack name -> explicit container -> a UNIQUE prefix -> nothing with the candidates
returned. The old rule took the FIRST prefix match. A skipped stack records why instead of silence.

R-634 (half): RemoveStack refused on the !Deployed FLAG while the machine had containers, a compose
file and an app.yaml. It now asks whether anything EXISTS. The mechanism producing the bad record is
still not diagnosed and R-634 stays open for it.

R-633/R-626: RemoveStack consults UpdateGuards.Busy and IsUpdating and refuses with the app's own
sentence - the product already refused this clash for update and for restore. And because `down`
returning 0 is a request not a result, the project is watched for 25s afterwards, anything carrying
its label is removed by name with its labels logged, and the answer carries `verified`.

R-621: failAndHold writes compose logs --tail 400 into <stackdir>/hold-logs/<ts>/ BEFORE the down
that destroys them. Two existing tests pin the compose sequence and correctly caught the new step;
their expectations are updated with the reason that the ORDER is the assertion.

R-614: RemoveStack calls ClearUpdateState.

NOT in this release: R-625 (a held app still renders an Update button). Named, not half-done.

Three new sentences, each born as a key in both bundles. Four red-proofs seen failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-22 20:46:50 +02:00

159 lines
6.5 KiB
Go

package stacks
import (
"os"
"path/filepath"
"strings"
"testing"
)
// ── A / A2 — the probe target (R-630) ────────────────────────────────────────────────────────────
//
// RED-PROOF: revert findProbeContainerMeta to "exact, else FIRST prefix, else empty" and
// TestProbeTarget_ImmichPrefixIsAmbiguous and TestProbeTarget_ExplicitContainer both fail.
func running(names ...string) []ContainerInfo {
out := make([]ContainerInfo, 0, len(names))
for _, n := range names {
out = append(out, ContainerInfo{Name: n, State: StateRunning})
}
return out
}
func TestProbeTarget_ExactNameWins(t *testing.T) {
got, cand := findProbeContainerMeta("romm", nil, running("romm-db", "romm", "romm-redis"))
if got != "romm" {
t.Fatalf("exact match must win: got %q (candidates %v)", got, cand)
}
}
func TestProbeTarget_ExplicitContainer(t *testing.T) {
// paperless-ngx: no container is called `paperless-ngx`, and none even has it as a prefix.
cs := running("paperless-webserver", "paperless-postgres", "paperless-redis")
if got, _ := findProbeContainerMeta("paperless-ngx", nil, cs); got != "" {
t.Fatalf("without the explicit field there is nothing to probe, got %q", got)
}
meta := &Metadata{HealthCheck: &HealthCheckConfig{
Container: "paperless-webserver",
Checks: []HealthCheckItem{{Type: "http", Port: 8000}},
}}
got, _ := findProbeContainerMeta("paperless-ngx", meta, cs)
if got != "paperless-webserver" {
t.Fatalf("the explicit container must be used: got %q", got)
}
}
func TestProbeTarget_ImmichPrefixIsAmbiguous(t *testing.T) {
// Four `immich-*` containers and no exact match. The OLD rule returned the first one — which is
// whichever the container list happened to yield. Ambiguity must resolve to "nothing", with the
// candidates named, not to a guess.
cs := running("immich-server", "immich-machine-learning", "immich-postgres", "immich-redis")
got, cand := findProbeContainerMeta("immich", nil, cs)
if got != "" {
t.Fatalf("four candidates must not be resolved by guessing: got %q", got)
}
if len(cand) != 4 {
t.Fatalf("the candidates must come back so the log can name them: %v", cand)
}
meta := &Metadata{HealthCheck: &HealthCheckConfig{Container: "immich-server"}}
if got, _ := findProbeContainerMeta("immich", meta, cs); got != "immich-server" {
t.Fatalf("the explicit field must settle it: got %q", got)
}
}
func TestProbeTarget_UniquePrefixStillWorks(t *testing.T) {
// One running candidate is not ambiguous — the fallback stays useful.
got, _ := findProbeContainerMeta("gitea", nil, running("gitea-server"))
if got != "gitea-server" {
t.Fatalf("a single prefix match must still resolve: got %q", got)
}
}
func TestProbeTarget_StoppedContainersAreNotProbeTargets(t *testing.T) {
cs := []ContainerInfo{{Name: "wger", State: StateStopped}}
if got, _ := findProbeContainerMeta("wger", nil, cs); got != "" {
t.Fatalf("a stopped container is not probeable: got %q", got)
}
}
// ── A — the settle reason (R-630) ────────────────────────────────────────────────────────────────
//
// RED-PROOF: this is the sentence that distinguishes "declares no check" from "declares one that
// resolves to nothing". Before v0.262.0 the second case never reached a settle at all — it looped
// until the health timeout and the app was STOPPED.
func TestSettleReason_NamesWhyTheProbeWasNotUsed(t *testing.T) {
none := settleReason(Metadata{})
if !strings.Contains(none, "no health check declared") {
t.Fatalf("an app with no check must say so: %q", none)
}
declared := settleReason(Metadata{HealthCheck: &HealthCheckConfig{
Checks: []HealthCheckItem{{Type: "http", Port: 80}},
}})
if !strings.Contains(declared, "no probe container") {
t.Fatalf("an app whose check resolves to nothing must say THAT, not the other thing: %q", declared)
}
if none == declared {
t.Fatal("the two reasons must be distinguishable in the journal without reading the log")
}
}
// ── C — remove accepts the half-state (R-634) ────────────────────────────────────────────────────
//
// RED-PROOF: delete the halfStateEvidence branch in RemoveStack and this fails — which is exactly
// the state three apps were measured in, with no button that worked.
func TestHalfStateEvidence(t *testing.T) {
m := &Manager{}
if ok, _ := m.halfStateEvidence("ghost", &Stack{}); ok {
t.Fatal("nothing on disk and no containers is genuinely 'not deployed'")
}
ok, why := m.halfStateEvidence("outline", &Stack{Containers: running("outline", "outline-postgres")})
if !ok || !strings.Contains(why, "container") {
t.Fatalf("containers exist: the household must be able to remove them (%v, %q)", ok, why)
}
dir := t.TempDir()
compose := filepath.Join(dir, "docker-compose.yml")
if err := os.WriteFile(compose, []byte("services: {}\n"), 0o644); err != nil {
t.Fatal(err)
}
ok, why = m.halfStateEvidence("sparkyfitness", &Stack{ComposePath: compose})
if !ok || !strings.Contains(why, "compose file") {
t.Fatalf("a compose file on disk is something to remove (%v, %q)", ok, why)
}
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: x\n"), 0o644); err != nil {
t.Fatal(err)
}
ok, why = m.halfStateEvidence("sparkyfitness", &Stack{ComposePath: compose})
if !ok || !strings.Contains(why, "app.yaml") {
t.Fatalf("this is sparkyfitness's exact measured shape (%v, %q)", ok, why)
}
}
// ── F — a remove clears the update record (R-614) ────────────────────────────────────────────────
//
// RED-PROOF: drop the ClearUpdateState call from RemoveStack and a redeploy inherits „Frissítve".
func TestClearUpdateState(t *testing.T) {
m := &Manager{stacks: map[string]*Stack{
"komga": {
Updating: true,
UpdatePhase: "failed",
UpdatePhaseLabel: "A frissítés nem sikerült",
UpdateError: "held",
updateHeld: true,
HealthProbe: &HealthProbeResult{Healthy: false},
},
}}
m.ClearUpdateState("komga")
s := m.stacks["komga"]
if s.Updating || s.UpdatePhase != "" || s.UpdatePhaseLabel != "" || s.UpdateError != "" || s.updateHeld || s.HealthProbe != nil {
t.Fatalf("every field the next install could inherit must be cleared: %+v", s)
}
m.ClearUpdateState("does-not-exist") // must not panic
}