Skip to content

Commit 987016b

Browse files
committed
fix: drop Force on selfupgrade patch; wire MCP issues event-opt-in + total_matched
Three reviewer findings on PR #658. CRITICAL — selfupgrade: Force on StrategicMergePatch always fails K8s apimachinery rejects PatchOptions.Force on non-Apply patches (apimachinery/pkg/apis/meta/v1/validation: "may not be specified for non-apply patch"). The previous "fix" set Force=true on a StrategicMergePatch, so self-upgrade has been 422'ing on every call since that commit landed — masking itself as IsInvalid → HTTP 400 in our error path. Drop Force; keep FieldManager:"helm". The FieldManager alone is what fixes the original bug (apiserver no longer derives "radar" from User-Agent as the .image owner), and Force was never necessary for that. Server-side apply with ApplyPatchType + Force would also work, but that's a bigger surface change for no additional win in the single-field-patch case. Tripwire test inverts: Force MUST be nil now, not non-nil. If a refactor flips it back the test catches it before the binary ships. IMPORTANT — MCP issues: event source silently empty post-default-off After events became opt-in, the MCP issues tool kept the audit opt-in loop but didn't add the parallel IncludeEvents wiring. ComposeWithStats requires BOTH wantSource(event) AND IncludeEvents, so an MCP caller passing source:"event" got zero event rows while HTTP /api/issues was honoring it correctly. Loop now sets IncludeEvents for SourceEvent (mirroring the audit case) and defaults Since=1h when events are enabled and no window was given — matches the HTTP handler's behavior. Updated the source + since jsonschema descriptions to make the opt-in rules discoverable to LLM callers. IMPORTANT — MCP issues: total_matched missing from response HTTP /api/issues returns total_matched (uncapped count) so the caller can tell "200 returned" from "200 of 1000". The MCP response dropped this field, leaving agents blind to truncation. Add total_matched to the response map; field is already on ComposeStats so no compose-layer change needed.
1 parent 371175f commit 987016b

3 files changed

Lines changed: 49 additions & 27 deletions

File tree

internal/mcp/tools.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -337,9 +337,9 @@ type searchInput struct {
337337
type issuesInput struct {
338338
Namespace string `json:"namespace,omitempty" jsonschema:"filter to one namespace"`
339339
Severity string `json:"severity,omitempty" jsonschema:"comma-separated: critical,warning"`
340-
Source string `json:"source,omitempty" jsonschema:"comma-separated: problem,audit,event,condition. audit excluded by default"`
340+
Source string `json:"source,omitempty" jsonschema:"comma-separated: problem,audit,event,condition. Defaults to problem+condition only. Pass 'event' to opt in K8s Warning events (off by default — they flood thousands per cluster and mostly duplicate problem-source rows). Pass 'audit' to opt in best-practice findings (off by default — 50–200 per cluster)."`
341341
Kind string `json:"kind,omitempty" jsonschema:"comma-separated kind filter (e.g. Deployment,Pod)"`
342-
Since string `json:"since,omitempty" jsonschema:"event lookback window, e.g. 15m or 1h"`
342+
Since string `json:"since,omitempty" jsonschema:"event lookback window, e.g. 15m or 1h. Only affects the event source; when events are enabled and since is omitted, defaults to 1h to avoid pulling the full event-cache backlog."`
343343
Limit int `json:"limit,omitempty" jsonschema:"max issues returned (default 200, max 1000)"`
344344
Filter string `json:"filter,omitempty" jsonschema:"optional CEL boolean expression run against each composed Issue. Bindings: severity, source, kind, group, ns (the namespace — note: use 'ns' not 'namespace' because the latter is a CEL reserved word), name, reason, message, count (int), cluster, last_seen (unix seconds). Examples: 'severity == \"critical\" && count > 5', 'source == \"condition\" && ns.startsWith(\"prod-\")'"`
345345
}
@@ -1492,19 +1492,32 @@ func handleIssuesTool(_ context.Context, _ *mcp.CallToolRequest, input issuesInp
14921492
}
14931493
filters.Since = d
14941494
}
1495-
// audit-source opt-in via either source list OR an explicit flag
1496-
// — honor the source list here since the input doesn't expose a
1497-
// separate include_audit knob.
1495+
// Audit + event sources are both opt-in (default off). The
1496+
// MCP input doesn't surface separate include_* knobs, so the
1497+
// source list IS the opt-in. Mirror the HTTP handler's
1498+
// behavior — including the 1h since-default when events are
1499+
// enabled with no explicit window, so an MCP caller doesn't
1500+
// silently inherit the full event-cache backlog.
14981501
for _, s := range filters.Sources {
1499-
if s == issues.SourceAudit {
1502+
switch s {
1503+
case issues.SourceAudit:
15001504
filters.IncludeAudit = true
1501-
break
1505+
case issues.SourceEvent:
1506+
filters.IncludeEvents = true
15021507
}
15031508
}
1509+
if filters.IncludeEvents && filters.Since == 0 {
1510+
filters.Since = time.Hour
1511+
}
15041512
out, stats := issues.ComposeWithStats(provider, filters)
15051513
resp := map[string]any{
15061514
"issues": out,
15071515
"total": len(out),
1516+
// total_matched is the uncapped count — tells the caller
1517+
// whether the response is windowed or the whole set. Without
1518+
// it, an MCP agent can't distinguish "200 returned" from
1519+
// "200 of 1000". Mirrors the HTTP /api/issues response shape.
1520+
"total_matched": stats.TotalMatched,
15081521
}
15091522
if stats.FilterErrors > 0 {
15101523
resp["filter_errors"] = stats.FilterErrors

internal/server/selfupgrade.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,24 @@ import (
1616
)
1717

1818
// selfUpgradePatchOptions returns the PatchOptions used by the self-upgrade
19-
// endpoint. Field manager "helm" plus Force is what keeps `.image` ownership
20-
// stable across self-upgrade and `helm upgrade` cycles. With an empty
21-
// FieldManager the apiserver derives one from User-Agent → "radar", which
22-
// then permanently owns .image and breaks every subsequent helm upgrade.
19+
// endpoint. FieldManager "helm" is what keeps `.image` ownership stable
20+
// across self-upgrade and `helm upgrade` cycles: with an empty FieldManager
21+
// the apiserver derives one from User-Agent → "radar", which then
22+
// permanently owns .image and breaks every subsequent helm upgrade with a
23+
// server-side-apply conflict.
2324
//
24-
// Extracted for tripwire test; if a refactor reverts these values, the test
25-
// in selfupgrade_test.go fails before the bug ships.
25+
// We deliberately do NOT set Force here. K8s apimachinery rejects Force on
26+
// non-Apply patches (apimachinery/pkg/apis/meta/v1/validation:
27+
// `field.Forbidden("force", "may not be specified for non-apply patch")`)
28+
// so a StrategicMergePatch with Force=true returns 422 Invalid and the
29+
// upgrade never runs. If we ever need to reclaim conflicting ownership,
30+
// the route is to switch the request to ApplyPatchType with a full apply
31+
// object — not to flip Force back on a strategic merge.
32+
//
33+
// Extracted for tripwire test; if a refactor reverts these values, the
34+
// test in selfupgrade_test.go fails before the bug ships.
2635
func selfUpgradePatchOptions() metav1.PatchOptions {
27-
force := true
28-
return metav1.PatchOptions{FieldManager: "helm", Force: &force}
36+
return metav1.PatchOptions{FieldManager: "helm"}
2937
}
3038

3139
// handleSelfUpgrade patches this Radar Deployment's container image so the
@@ -94,8 +102,10 @@ func (s *Server) handleSelfUpgrade(w http.ResponseWriter, r *http.Request) {
94102
case apierrors.IsForbidden(err):
95103
s.writeError(w, http.StatusForbidden, "SA lacks patch permission on this Deployment (rbac.selfUpgrade=true?)")
96104
case apierrors.IsConflict(err):
97-
// Force=true reclaims field ownership, but a concurrent helm
98-
// upgrade can still race. Retryable on the caller's side.
105+
// A concurrent helm upgrade or apply can race the patch.
106+
// Retryable on the caller's side. (We could reclaim
107+
// ownership via server-side apply, but the StrategicMerge
108+
// path keeps the request small and a retry is cheap.)
99109
s.writeError(w, http.StatusConflict, "concurrent modification, retry")
100110
case apierrors.IsTooManyRequests(err) || apierrors.IsServerTimeout(err):
101111
s.writeError(w, http.StatusServiceUnavailable, "apiserver throttled, retry")

internal/server/selfupgrade_test.go

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,19 @@ package server
33
import "testing"
44

55
// TestSelfUpgradePatchOptions is a tripwire: it pins the PatchOptions used
6-
// by handleSelfUpgrade. Field manager "helm" + Force=true is what prevents
7-
// the apiserver from recording "radar" as the owner of .image (derived from
8-
// User-Agent when FieldManager is empty), which would break every later
9-
// `helm upgrade` with a server-side-apply conflict. See selfupgrade.go for
10-
// the full rationale.
6+
// by handleSelfUpgrade. FieldManager "helm" is what prevents the apiserver
7+
// from recording "radar" as the owner of .image (derived from User-Agent
8+
// when FieldManager is empty), which would break every later `helm
9+
// upgrade` with a server-side-apply conflict. Force MUST stay unset on a
10+
// StrategicMergePatch — apimachinery rejects it with a 422 Invalid so
11+
// flipping Force back on regresses self-upgrade to "always fails."
12+
// See selfupgrade.go for the full rationale.
1113
func TestSelfUpgradePatchOptions(t *testing.T) {
1214
opts := selfUpgradePatchOptions()
1315
if opts.FieldManager != "helm" {
1416
t.Errorf("FieldManager = %q, want %q", opts.FieldManager, "helm")
1517
}
16-
if opts.Force == nil {
17-
t.Fatal("Force is nil, want *true")
18-
}
19-
if !*opts.Force {
20-
t.Errorf("Force = false, want true")
18+
if opts.Force != nil {
19+
t.Errorf("Force = %v, want nil (Force is forbidden on StrategicMergePatch and a non-nil value 422s the request)", opts.Force)
2120
}
2221
}

0 commit comments

Comments
 (0)