Skip to content

Commit 4300d4a

Browse files
author
Aamod
committed
feat: structured error classification for apply conflicts
- Add ErrorKind enum (VALIDATION, CONFLICT, APPLY_FAILED) with ApplyError struct - Classify Kubernetes errors and return human-readable suggestions - Conflict errors (409) now show 'Resource already exists — delete it first or use a different name' - Validation errors (422) suggest using 'Retry with error context' - Extract resource name from YAML for precise error reporting
1 parent 3a679b8 commit 4300d4a

2 files changed

Lines changed: 125 additions & 1 deletion

File tree

internal/k8s/errors.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package k8s
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
)
7+
8+
// ErrorKind categorizes the type of error returned by the apply pipeline.
9+
type ErrorKind string
10+
11+
const (
12+
// ErrValidation means the YAML is syntactically invalid or fails server-side dry-run.
13+
ErrValidation ErrorKind = "VALIDATION"
14+
// ErrConflict means the resource already exists or there is a version conflict.
15+
ErrConflict ErrorKind = "CONFLICT"
16+
// ErrApplyFailed means an unexpected server error occurred during apply.
17+
ErrApplyFailed ErrorKind = "APPLY_FAILED"
18+
)
19+
20+
// ApplyError is a structured error returned by the apply pipeline.
21+
type ApplyError struct {
22+
Kind ErrorKind `json:"kind"`
23+
Resource string `json:"resource,omitempty"`
24+
Message string `json:"message"`
25+
Suggestion string `json:"suggestion,omitempty"`
26+
}
27+
28+
func (e *ApplyError) Error() string {
29+
return e.Message
30+
}
31+
32+
// ClassifyError inspects a raw Kubernetes error message and returns a structured ApplyError.
33+
func ClassifyError(err error, resource string) *ApplyError {
34+
if err == nil {
35+
return nil
36+
}
37+
38+
msg := err.Error()
39+
40+
// Conflict / already exists
41+
if strings.Contains(msg, "already exists") {
42+
return &ApplyError{
43+
Kind: ErrConflict,
44+
Resource: resource,
45+
Message: msg,
46+
Suggestion: "Resource already exists. Delete it first with 'kubectl delete " + resource + "' or use a different name in your prompt.",
47+
}
48+
}
49+
50+
// Version conflict / modified
51+
if strings.Contains(msg, "the object has been modified") {
52+
return &ApplyError{
53+
Kind: ErrConflict,
54+
Resource: resource,
55+
Message: msg,
56+
Suggestion: "The resource was modified concurrently. Try again.",
57+
}
58+
}
59+
60+
// Validation / dry-run failures
61+
if strings.Contains(msg, "invalid") || strings.Contains(msg, "required field") ||
62+
strings.Contains(msg, "unknown field") || strings.Contains(msg, "expected") ||
63+
strings.Contains(msg, "DryRun") {
64+
return &ApplyError{
65+
Kind: ErrValidation,
66+
Resource: resource,
67+
Message: msg,
68+
Suggestion: "Fix the YAML and try again. Use 'Retry with error context' to let the LLM correct it.",
69+
}
70+
}
71+
72+
// Generic apply failure
73+
return &ApplyError{
74+
Kind: ErrApplyFailed,
75+
Resource: resource,
76+
Message: msg,
77+
Suggestion: "Check cluster connectivity and RBAC permissions.",
78+
}
79+
}
80+
81+
// MarshalJSON implements json.Marshaler for ApplyError to include the error kind in responses.
82+
func (e *ApplyError) MarshalJSON() ([]byte, error) {
83+
return json.Marshal(map[string]any{
84+
"kind": e.Kind,
85+
"resource": e.Resource,
86+
"message": e.Message,
87+
"suggestion": e.Suggestion,
88+
})
89+
}

internal/server/apply.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"context"
55
"encoding/json"
66
"net/http"
7+
"strings"
78
"time"
89

910
"github.com/gorilla/mux"
1011
"go.uber.org/zap"
12+
"github.com/aamod/llm-infra-assistant/internal/k8s"
1113
)
1214

1315
type applyRequest struct {
@@ -28,7 +30,21 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
2830

2931
res, err := s.clusterManager.Apply(r.Context(), req.YAML)
3032
if err != nil {
31-
http.Error(w, err.Error(), http.StatusInternalServerError)
33+
// Extract resource name from error for better classification
34+
resource := extractResourceFromError(req.YAML)
35+
applyErr := k8s.ClassifyError(err, resource)
36+
37+
statusCode := http.StatusInternalServerError
38+
switch applyErr.Kind {
39+
case k8s.ErrConflict:
40+
statusCode = http.StatusConflict
41+
case k8s.ErrValidation:
42+
statusCode = http.StatusUnprocessableEntity
43+
}
44+
45+
w.Header().Set("Content-Type", "application/json")
46+
w.WriteHeader(statusCode)
47+
json.NewEncoder(w).Encode(applyErr)
3248
return
3349
}
3450

@@ -90,3 +106,22 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
90106
"status": "ok",
91107
})
92108
}
109+
110+
// extractResourceFromError parses the YAML to find the resource name for error classification.
111+
func extractResourceFromError(yamlData string) string {
112+
lines := strings.Split(yamlData, "\n")
113+
var kind, name string
114+
for _, line := range lines {
115+
trimmed := strings.TrimSpace(line)
116+
if strings.HasPrefix(trimmed, "kind:") {
117+
kind = strings.TrimSpace(strings.TrimPrefix(trimmed, "kind:"))
118+
}
119+
if strings.HasPrefix(trimmed, "name:") && name == "" {
120+
name = strings.TrimSpace(strings.TrimPrefix(trimmed, "name:"))
121+
}
122+
}
123+
if kind != "" && name != "" {
124+
return kind + "/" + name
125+
}
126+
return "unknown"
127+
}

0 commit comments

Comments
 (0)