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:
2026-10-05 21:36:54 +02:00
parent 9271f33359
commit d15ad105be
7 changed files with 203 additions and 47 deletions
+33 -31
View File
@@ -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
}