b793638484
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
159 lines
6.5 KiB
Go
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
|
|
}
|