3d41758be0
gates / gates (push) Successful in 24s
Caught by the live proof on 9202, not by a test. The v0.262.0 guard fired exactly right and answered HTTP 500: router.go maps remove errors by grepping the error TEXT for "not deployed" / "still running" / "not found" / "protected", and the busy sentence contains none of them. A 500 tells the UI something broke; this is "wait a moment". Now a typed *stacks.RemoveBusyError matched with errors.As and answered 409, carrying both the Hungarian bytes and the bundle key. Its test asserts the sentence contains none of the words the text mapping greps for, so the type is load-bearing rather than decorative. Red-proof seen failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
192 lines
8.1 KiB
Go
192 lines
8.1 KiB
Go
package stacks
|
|
|
|
import (
|
|
"errors"
|
|
"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
|
|
}
|
|
|
|
// ── B — the busy refusal is a CONFLICT, by type (R-633) ──────────────────────────────────────────
|
|
//
|
|
// RED-PROOF: return `util.MsgError(KeyRemoveBusy)` instead of `&RemoveBusyError{}` and this fails —
|
|
// which is what the first live run did, answering **500** while refusing correctly. The status
|
|
// mapping greps the error TEXT for "not deployed"/"still running"; the busy sentence has neither.
|
|
|
|
func TestRemoveBusyError_IsTypedAndCarriesBothForms(t *testing.T) {
|
|
var err error = &RemoveBusyError{Why: "a backup or restore is running (single-flight held)"}
|
|
|
|
var busy *RemoveBusyError
|
|
if !errors.As(err, &busy) {
|
|
t.Fatal("the API must be able to recognise this by TYPE, not by matching prose")
|
|
}
|
|
if busy.Why == "" {
|
|
t.Error("the guard's own words must survive for the log")
|
|
}
|
|
if err.Error() != MsgRemoveBusyHU {
|
|
t.Errorf("the default-language bytes must be the bundle's Hungarian, got %q", err.Error())
|
|
}
|
|
if busy.Key() != KeyRemoveBusy {
|
|
t.Errorf("the key must travel so the API can localise it, got %q", busy.Key())
|
|
}
|
|
// The whole point: none of the words the status mapping greps for appear in this sentence, so a
|
|
// text-matching handler WOULD answer 500. This assertion is why the type exists.
|
|
for _, w := range []string{"not deployed", "still running", "not found", "protected"} {
|
|
if strings.Contains(err.Error(), w) {
|
|
t.Errorf("the sentence happens to contain %q — the text mapping would catch it and this "+
|
|
"test would stop proving anything", w)
|
|
}
|
|
}
|
|
}
|