v0.262.0: six defects two drill nights found in the update, remove and hold paths
gates / gates (push) Successful in 26s
gates / gates (push) Successful in 26s
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
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user