R-569: stack-lifecycle handlers pick their status by error KIND, not English words
stop/start/restart/update, remove and delete matched "protected", "not found", "not deployed", "still running", "not orphaned" in err.Error(). New sentinels in internal/stacks (stack_errors.go) carried by the producers in manager.go/delete.go via util.KindErrorf (message bytes unchanged); api.stackOpStatusFor maps them. Tests: reworded-message table per family, wiring check over all three handlers, producers keep kind + words. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// R-569 — the stop/start/restart/update, remove and delete handlers pick their status by the
|
||||
// refusal's KIND. Every row passes a REWORDED message: the words the handlers used to match
|
||||
// ("protected", "not found", "not deployed", "still running", "not orphaned") are gone, only the
|
||||
// kind remains. RED-PROOF (REPORT): restore any strings.Contains chain and its family's reworded rows
|
||||
// answer 500.
|
||||
func TestR569_StackOpStatus_SurvivesRewording(t *testing.T) {
|
||||
cases := []struct {
|
||||
family, name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
// action family (start/stop/restart/update)
|
||||
{"action", "unknown app, reworded", util.KindErrorf(stacks.ErrStackNotFound, "no app called %q", "x"), http.StatusNotFound},
|
||||
{"action", "unknown app, wrapped by the caller", fmt.Errorf("restarting: %w",
|
||||
util.KindErrorf(stacks.ErrStackNotFound, "no app called %q", "x")), http.StatusNotFound},
|
||||
{"action", "protected, reworded", util.KindErrorf(stacks.ErrProtectedStack, "%q is infrastructure", "traefik"), http.StatusForbidden},
|
||||
{"action", "update refusal", &stacks.UpdateRefusal{Reason: "busy", Message: "foglalt"}, http.StatusConflict},
|
||||
{"action", "update refusal: unknown app", &stacks.UpdateRefusal{Reason: "not_found", Message: "nincs ilyen"}, http.StatusNotFound},
|
||||
// remove family
|
||||
{"remove", "not deployed, reworded", util.KindErrorf(stacks.ErrNotDeployed, "%q has nothing installed", "x"), http.StatusConflict},
|
||||
{"remove", "still running, reworded", util.KindErrorf(stacks.ErrStillRunning, "%q is up — stop it first", "x"), http.StatusConflict},
|
||||
{"remove", "protected, reworded", util.KindErrorf(stacks.ErrProtectedStack, "cannot take %q away", "traefik"), http.StatusForbidden},
|
||||
// delete family
|
||||
{"delete", "not orphaned, reworded", util.KindErrorf(stacks.ErrNotOrphaned, "%q still belongs to the catalog", "x"), http.StatusConflict},
|
||||
{"delete", "still running, reworded", util.KindErrorf(stacks.ErrStillRunning, "%q is up", "x"), http.StatusConflict},
|
||||
{"delete", "unknown, reworded", util.KindErrorf(stacks.ErrStackNotFound, "nothing named %q", "x"), http.StatusNotFound},
|
||||
// the negative half: the old WORDS without a kind are not a refusal
|
||||
{"any", "text that merely contains the old words", errors.New("docker: image not found; protected; still running"), http.StatusInternalServerError},
|
||||
{"any", "anything else", errors.New("compose exploded"), http.StatusInternalServerError},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := stackOpStatusFor(c.err); got != c.want {
|
||||
t.Errorf("%s / %s: stackOpStatusFor(%q) = %d, want %d", c.family, c.name, c.err.Error(), got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The seam must be WIRED in all three handlers, and none may decide by reading the error text again.
|
||||
func TestR569_HandlersUseTheKind(t *testing.T) {
|
||||
src, err := os.ReadFile("router.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(src)
|
||||
for _, fn := range []string{"func (r *Router) actionStack(", "func (r *Router) removeStack(", "func (r *Router) deleteStack("} {
|
||||
i := strings.Index(body, fn)
|
||||
if i < 0 {
|
||||
t.Fatalf("%s not found — this test no longer reads what it thinks it reads", fn)
|
||||
}
|
||||
h := body[i:]
|
||||
if j := strings.Index(h[10:], "\nfunc "); j > 0 {
|
||||
h = h[:j+10]
|
||||
}
|
||||
if !strings.Contains(h, "stackOpStatusFor(err)") {
|
||||
t.Errorf("%s no longer asks stackOpStatusFor for the status code", fn)
|
||||
}
|
||||
for _, line := range strings.Split(h, "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "//") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "strings.Contains(err.Error()") {
|
||||
t.Errorf("%s decides by reading the error text again (R-569): %s", fn, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -623,6 +623,36 @@ func desiredStateForAction(action string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// stackOpStatusFor maps a stack-lifecycle refusal (stop/start/restart/update, remove, delete) to its
|
||||
// HTTP status by the refusal's KIND (R-569) — the R-553 rule one handler family over.
|
||||
//
|
||||
// Until R-569 these three handlers matched "protected", "not found", "not deployed", "still running"
|
||||
// and "not orphaned" in err.Error(). Those strings are internal English, so localisation never moved
|
||||
// them; a reworded internal message would have, silently turning a 404/403/409 into a 500. The kinds
|
||||
// come from internal/stacks (stack_errors.go); the messages are unchanged.
|
||||
//
|
||||
// One function serves the three families because their kinds are disjoint: an action never returns
|
||||
// ErrNotOrphaned, a remove never returns an UpdateRefusal. Pinned by
|
||||
// TestR569_StackOpStatus_SurvivesRewording and TestR569_HandlersUseTheKind.
|
||||
func stackOpStatusFor(err error) int {
|
||||
var ref *stacks.UpdateRefusal
|
||||
switch {
|
||||
case errors.Is(err, stacks.ErrStackNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.As(err, &ref):
|
||||
if ref.Reason == "not_found" {
|
||||
return http.StatusNotFound
|
||||
}
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, stacks.ErrProtectedStack):
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, stacks.ErrNotDeployed), errors.Is(err, stacks.ErrStillRunning),
|
||||
errors.Is(err, stacks.ErrNotOrphaned):
|
||||
return http.StatusConflict
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, name string) {
|
||||
r.logger.Printf("[INFO] [api] %s requested for stack: %s", action, name)
|
||||
r.dbg("actionStack: action=%s name=%s", action, name)
|
||||
@@ -760,17 +790,7 @@ func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, n
|
||||
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] %s failed for %s: %v", action, name, err)
|
||||
status := http.StatusInternalServerError
|
||||
var ref *stacks.UpdateRefusal
|
||||
if errors.As(err, &ref) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
if strings.Contains(err.Error(), "protected") {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
@@ -1052,16 +1072,7 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
status := http.StatusInternalServerError
|
||||
if strings.Contains(err.Error(), "protected") {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
@@ -1137,16 +1148,7 @@ func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message})
|
||||
return
|
||||
}
|
||||
status := http.StatusInternalServerError
|
||||
if strings.Contains(err.Error(), "protected") {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user