Skip to content

Commit f47420a

Browse files
authored
fix(resources): resolve built-in workloads addressed by their API group (#797)
1 parent dc086b6 commit f47420a

6 files changed

Lines changed: 251 additions & 36 deletions

File tree

internal/k8s/builtin_group_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package k8s
2+
3+
import (
4+
"testing"
5+
6+
"k8s.io/apimachinery/pkg/runtime/schema"
7+
)
8+
9+
// TestTypedKindOwnsGroup pins the typed-vs-dynamic routing contract used by the
10+
// resource GET/LIST handlers (REST + AI). The bug this guards: the SPA threads
11+
// the real apiGroup for built-in workloads (deployments?group=apps), and the
12+
// handlers' "explicit group ⇒ dynamic cache" dispatch sent those to the dynamic
13+
// cache — which has no informer for built-ins — yielding a 400 "unknown
14+
// resource kind: deployments (group: apps)" and an empty resource drawer.
15+
//
16+
// A built-in kind addressed by its OWN group must resolve via the typed cache
17+
// (TypedKindOwnsGroup == true); a CRD whose plural shadows a core kind must NOT
18+
// (so it still routes to the dynamic cache).
19+
func TestTypedKindOwnsGroup(t *testing.T) {
20+
cases := []struct {
21+
kind string
22+
group string
23+
want bool
24+
}{
25+
// Built-in workloads addressed by their real group — the regression.
26+
{"deployments", "apps", true},
27+
{"deployment", "apps", true},
28+
{"statefulsets", "apps", true},
29+
{"daemonsets", "apps", true},
30+
{"replicasets", "apps", true},
31+
{"jobs", "batch", true},
32+
{"cronjobs", "batch", true},
33+
{"horizontalpodautoscalers", "autoscaling", true},
34+
{"hpa", "autoscaling", true},
35+
{"ingresses", "networking.k8s.io", true},
36+
{"networkpolicies", "networking.k8s.io", true},
37+
{"netpols", "networking.k8s.io", true},
38+
{"poddisruptionbudgets", "policy", true},
39+
{"storageclasses", "storage.k8s.io", true},
40+
{"clusterroles", "rbac.authorization.k8s.io", true},
41+
42+
// Core kinds: own group is "" — typed, with or without an explicit "".
43+
{"pods", "", true},
44+
{"services", "", true},
45+
{"sa", "", true},
46+
{"secrets", "", true},
47+
48+
// CRD whose plural shadows a core/built-in kind — must stay dynamic.
49+
{"services", "serving.knative.dev", false},
50+
{"deployments", "argoproj.io", false},
51+
52+
// A built-in addressed with the WRONG group — not owned, routes dynamic
53+
// (and discovery will reject it).
54+
{"deployments", "batch", false},
55+
56+
// Genuine CRD kinds — never typed.
57+
{"widgets", "example.com", false},
58+
{"applications", "argoproj.io", false},
59+
}
60+
for _, tc := range cases {
61+
if got := TypedKindOwnsGroup(tc.kind, tc.group); got != tc.want {
62+
t.Errorf("TypedKindOwnsGroup(%q, %q) = %v, want %v", tc.kind, tc.group, got, tc.want)
63+
}
64+
}
65+
}
66+
67+
// TestBuiltinGVR pins the static GVR fallback used when API discovery can't
68+
// resolve a built-in (partial discovery) — the safety net that keeps GitOps
69+
// drift's live last-applied GET from silently returning nil for built-in
70+
// managed resources. The GVR must carry the canonical plural + GA version even
71+
// when addressed by a singular/abbreviation form, and must reject CRD-group
72+
// collisions.
73+
func TestBuiltinGVR(t *testing.T) {
74+
cases := []struct {
75+
kind, group string
76+
want schema.GroupVersionResource
77+
ok bool
78+
}{
79+
{"deployment", "apps", schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, true},
80+
{"Deployment", "apps", schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, true},
81+
{"pdb", "policy", schema.GroupVersionResource{Group: "policy", Version: "v1", Resource: "poddisruptionbudgets"}, true},
82+
{"hpa", "autoscaling", schema.GroupVersionResource{Group: "autoscaling", Version: "v2", Resource: "horizontalpodautoscalers"}, true},
83+
{"pods", "", schema.GroupVersionResource{Version: "v1", Resource: "pods"}, true},
84+
{"sa", "", schema.GroupVersionResource{Version: "v1", Resource: "serviceaccounts"}, true},
85+
{"netpols", "networking.k8s.io", schema.GroupVersionResource{Group: "networking.k8s.io", Version: "v1", Resource: "networkpolicies"}, true},
86+
{"services", "serving.knative.dev", schema.GroupVersionResource{}, false}, // CRD collision
87+
{"widgets", "example.com", schema.GroupVersionResource{}, false}, // genuine CRD
88+
}
89+
for _, tc := range cases {
90+
got, ok := BuiltinGVR(tc.kind, tc.group)
91+
if ok != tc.ok || got != tc.want {
92+
t.Errorf("BuiltinGVR(%q, %q) = (%v, %v), want (%v, %v)", tc.kind, tc.group, got, ok, tc.want, tc.ok)
93+
}
94+
}
95+
}

internal/k8s/cache.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,10 @@ func (c *ResourceCache) ListDynamicWithGroup(ctx context.Context, kind string, n
722722
gvr, ok = discovery.GetGVR(kind)
723723
}
724724

725+
if !ok {
726+
gvr, ok = builtinGVRFallback(kind, group)
727+
}
728+
725729
if !ok {
726730
if group != "" {
727731
return nil, fmt.Errorf("%w: %s (group: %s)", ErrUnknownDynamicKind, kind, group)
@@ -737,6 +741,17 @@ func (c *ResourceCache) ListDynamicWithGroup(ctx context.Context, kind string, n
737741
return dynamicCache.List(gvr, namespace)
738742
}
739743

744+
// builtinGVRFallback resolves a built-in kind's GVR from the static table when
745+
// API discovery couldn't (partial discovery under restricted RBAC, or a
746+
// transient refresh miss). It only resolves built-ins addressed by their own
747+
// group, so a CRD whose plural shadows a built-in still falls through as
748+
// unknown. Live/dynamic fetch paths (notably the GitOps drift last-applied GET,
749+
// which can't use the typed cache) rely on this so they don't silently fail
750+
// when discovery is incomplete.
751+
func builtinGVRFallback(kind, group string) (schema.GroupVersionResource, bool) {
752+
return BuiltinGVR(kind, group)
753+
}
754+
740755
// ErrUnknownDynamicKind is returned by ListDynamic / GetDynamicWithGroup when
741756
// the requested kind has no registered GVR in API discovery. Wrapped with
742757
// fmt.Errorf("%w: ..."), so callers should match with errors.Is. The HTTP
@@ -775,6 +790,10 @@ func (c *ResourceCache) getDynamicWithGroup(ctx context.Context, kind string, na
775790
gvr, ok = discovery.GetGVR(kind)
776791
}
777792

793+
if !ok {
794+
gvr, ok = builtinGVRFallback(kind, group)
795+
}
796+
778797
if !ok {
779798
if group != "" {
780799
return nil, fmt.Errorf("%w: %s (group: %s)", ErrUnknownDynamicKind, kind, group)

internal/k8s/fetch.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package k8s
22

33
import (
44
"fmt"
5+
"strings"
56

67
appsv1 "k8s.io/api/apps/v1"
78
autoscalingv2 "k8s.io/api/autoscaling/v2"
@@ -13,12 +14,102 @@ import (
1314
storagev1 "k8s.io/api/storage/v1"
1415
"k8s.io/apimachinery/pkg/labels"
1516
"k8s.io/apimachinery/pkg/runtime"
17+
"k8s.io/apimachinery/pkg/runtime/schema"
1618
)
1719

1820
// ErrUnknownKind is returned by FetchResourceList/FetchResource when the kind
1921
// is not a built-in typed resource. The caller should fall through to the dynamic cache.
2022
var ErrUnknownKind = fmt.Errorf("unknown typed kind")
2123

24+
// builtinGVRs maps every lowercase kind form the typed informer cache serves
25+
// (plural, singular, and abbreviations — the union of what
26+
// FetchResource/FetchResourceList and the REST switch in internal/server
27+
// accept) to its canonical GroupVersionResource. Versions are the GA
28+
// group/versions every supported cluster serves.
29+
//
30+
// One table, two jobs:
31+
// - TypedKindOwnsGroup decides typed-vs-dynamic routing for the resource
32+
// GET/LIST handlers (a built-in addressed by its own group must use the
33+
// typed cache, not the dynamic/CRD cache).
34+
// - BuiltinGVR is a static fallback for live/dynamic fetches when API
35+
// discovery can't resolve a built-in's GVR (partial discovery under
36+
// restricted RBAC, or a transient refresh miss). The drift/insights live
37+
// GET (GetDynamicWithGroupPreserveLastApplied) can't use the typed cache —
38+
// it needs last-applied, which the typed cache strips — so without this it
39+
// would silently return nil and drop drift for built-in managed resources.
40+
//
41+
// Keep in sync with the typed switches in this file and internal/server.
42+
var builtinGVRs = func() map[string]schema.GroupVersionResource {
43+
defs := []struct {
44+
forms []string
45+
group string
46+
version string
47+
resource string
48+
}{
49+
{[]string{"pod", "pods"}, "", "v1", "pods"},
50+
{[]string{"service", "services"}, "", "v1", "services"},
51+
{[]string{"configmap", "configmaps"}, "", "v1", "configmaps"},
52+
{[]string{"secret", "secrets"}, "", "v1", "secrets"},
53+
{[]string{"event", "events"}, "", "v1", "events"},
54+
{[]string{"persistentvolumeclaim", "persistentvolumeclaims", "pvc", "pvcs"}, "", "v1", "persistentvolumeclaims"},
55+
{[]string{"node", "nodes"}, "", "v1", "nodes"},
56+
{[]string{"namespace", "namespaces"}, "", "v1", "namespaces"},
57+
{[]string{"persistentvolume", "persistentvolumes", "pv", "pvs"}, "", "v1", "persistentvolumes"},
58+
{[]string{"serviceaccount", "serviceaccounts", "sa"}, "", "v1", "serviceaccounts"},
59+
{[]string{"limitrange", "limitranges"}, "", "v1", "limitranges"},
60+
{[]string{"resourcequota", "resourcequotas"}, "", "v1", "resourcequotas"},
61+
{[]string{"deployment", "deployments"}, "apps", "v1", "deployments"},
62+
{[]string{"daemonset", "daemonsets"}, "apps", "v1", "daemonsets"},
63+
{[]string{"statefulset", "statefulsets"}, "apps", "v1", "statefulsets"},
64+
{[]string{"replicaset", "replicasets"}, "apps", "v1", "replicasets"},
65+
{[]string{"job", "jobs"}, "batch", "v1", "jobs"},
66+
{[]string{"cronjob", "cronjobs"}, "batch", "v1", "cronjobs"},
67+
{[]string{"hpa", "hpas", "horizontalpodautoscaler", "horizontalpodautoscalers"}, "autoscaling", "v2", "horizontalpodautoscalers"},
68+
{[]string{"ingress", "ingresses"}, "networking.k8s.io", "v1", "ingresses"},
69+
{[]string{"networkpolicy", "networkpolicies", "netpol", "netpols"}, "networking.k8s.io", "v1", "networkpolicies"},
70+
{[]string{"ingressclass", "ingressclasses"}, "networking.k8s.io", "v1", "ingressclasses"},
71+
{[]string{"poddisruptionbudget", "poddisruptionbudgets", "pdb", "pdbs"}, "policy", "v1", "poddisruptionbudgets"},
72+
{[]string{"storageclass", "storageclasses", "sc"}, "storage.k8s.io", "v1", "storageclasses"},
73+
{[]string{"role", "roles"}, "rbac.authorization.k8s.io", "v1", "roles"},
74+
{[]string{"clusterrole", "clusterroles"}, "rbac.authorization.k8s.io", "v1", "clusterroles"},
75+
{[]string{"rolebinding", "rolebindings"}, "rbac.authorization.k8s.io", "v1", "rolebindings"},
76+
{[]string{"clusterrolebinding", "clusterrolebindings"}, "rbac.authorization.k8s.io", "v1", "clusterrolebindings"},
77+
}
78+
m := make(map[string]schema.GroupVersionResource)
79+
for _, d := range defs {
80+
gvr := schema.GroupVersionResource{Group: d.group, Version: d.version, Resource: d.resource}
81+
for _, f := range d.forms {
82+
m[f] = gvr
83+
}
84+
}
85+
return m
86+
}()
87+
88+
// TypedKindOwnsGroup reports whether (kind, group) names a built-in kind
89+
// addressed by its own API group — i.e. it must resolve via the typed cache,
90+
// not the dynamic/CRD cache. `deployments`+`apps` is a typed lookup;
91+
// `services`+`serving.knative.dev` is a CRD (dynamic) lookup; `services` with
92+
// an empty group is the core typed Service. Handlers use this to gate the "explicit group ⇒
93+
// dynamic cache" dispatch so built-in workloads addressed with their real group
94+
// don't fall through to the dynamic cache (which has no informer for them).
95+
func TypedKindOwnsGroup(kind, group string) bool {
96+
gvr, ok := builtinGVRs[strings.ToLower(kind)]
97+
return ok && gvr.Group == group
98+
}
99+
100+
// BuiltinGVR returns the canonical GroupVersionResource for a built-in kind in
101+
// the given group, for use as a static fallback when API discovery can't
102+
// resolve it. group must match the kind's canonical group ("" for core kinds);
103+
// a mismatch (e.g. a CRD whose plural shadows a built-in) returns ok=false so
104+
// the caller keeps treating it as unknown rather than mis-resolving.
105+
func BuiltinGVR(kind, group string) (schema.GroupVersionResource, bool) {
106+
gvr, ok := builtinGVRs[strings.ToLower(kind)]
107+
if !ok || gvr.Group != group {
108+
return schema.GroupVersionResource{}, false
109+
}
110+
return gvr, true
111+
}
112+
22113
// ToRuntimeObjects converts a typed slice to []runtime.Object using generics.
23114
func ToRuntimeObjects[T runtime.Object](items []T) []runtime.Object {
24115
out := make([]runtime.Object, len(items))

internal/mcp/tools.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -673,14 +673,15 @@ func handleListResources(ctx context.Context, req *mcp.CallToolRequest, input li
673673
listScope = nil
674674
}
675675

676-
// When a group is specified, route straight to the dynamic cache so
677-
// CRDs whose plural collides with a core kind (e.g. Knative
678-
// serving.knative.dev/Service vs corev1 ""/Service) reach the right
679-
// resource. FetchResourceList is group-blind — it would silently
680-
// return the core typed list, dropping the caller's group filter on
681-
// the floor. Mirrors the group-aware short-circuit in REST
682-
// handleAIListResources and handleGetResource (PR #721).
683-
if group != "" {
676+
// When a group is specified, route to the dynamic cache so CRDs whose
677+
// plural collides with a core kind (e.g. Knative serving.knative.dev/Service
678+
// vs corev1 ""/Service) reach the right resource. FetchResourceList is
679+
// group-blind — it would silently return the core typed list, dropping the
680+
// caller's group filter. But a built-in addressed by its own group
681+
// (deployments?group=apps) is a typed lookup — the dynamic cache has no
682+
// informer for built-ins — so only true CRDs / plural collisions go dynamic.
683+
// Mirrors the group-aware dispatch in the REST handlers.
684+
if group != "" && !k8s.TypedKindOwnsGroup(kind, group) {
684685
return listDynamicResources(ctx, cache, kind, group, listScope, clusterScoped, input.Context)
685686
}
686687

@@ -828,7 +829,7 @@ func handleGetResource(ctx context.Context, req *mcp.CallToolRequest, input getR
828829
// Service would silently leak the wrong object.
829830
var resourceData any
830831
var rawObj runtime.Object
831-
if group != "" {
832+
if group != "" && !k8s.TypedKindOwnsGroup(kind, group) {
832833
u, dynErr := cache.GetDynamicWithGroup(ctx, kind, namespace, name, group)
833834
if dynErr != nil {
834835
return nil, nil, fmt.Errorf("resource not found: %w", dynErr)

internal/server/ai_handlers.go

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -143,14 +143,15 @@ func (s *Server) handleAIListResources(w http.ResponseWriter, r *http.Request) {
143143
return
144144
}
145145

146-
// When a group is specified, route straight to the dynamic cache so
147-
// CRDs whose plural collides with a core kind (e.g. Knative
148-
// serving.knative.dev/Service vs corev1 ""/Service, KEDA's HPA-like
149-
// kinds) reach the right resource. FetchResourceList is group-blind
150-
// — it would silently return the core typed list, dropping the
151-
// query's group filter on the floor. Mirrors the same group-aware
152-
// short-circuit in handleGetResource (PR #721).
153-
if group != "" {
146+
// When a group is specified, route to the dynamic cache so CRDs whose
147+
// plural collides with a core kind (e.g. Knative serving.knative.dev/Service
148+
// vs corev1 ""/Service, KEDA's HPA-like kinds) reach the right resource.
149+
// FetchResourceList is group-blind — it would silently return the core typed
150+
// list, dropping the query's group filter. EXCEPT a built-in kind addressed
151+
// by its own group (deployments?group=apps) is a typed lookup: the dynamic
152+
// cache has no informer for built-ins, so it would 400. Mirrors the
153+
// group-aware dispatch in handleGetResource.
154+
if group != "" && !k8s.TypedKindOwnsGroup(kind, group) {
154155
s.aiListDynamic(w, r, cache, kind, namespaces, group, level, skipContext)
155156
return
156157
}
@@ -338,16 +339,16 @@ func (s *Server) handleAIGetResource(w http.ResponseWriter, r *http.Request) {
338339
// fetchAIResource resolves the resource from the typed cache or dynamic cache.
339340
// The bool reports whether the returned object is an unstructured (CRD) value.
340341
//
341-
// When a group is provided, the typed cache is skipped entirely and the
342-
// dynamic cache is consulted with the group qualifier. This prevents kind
343-
// collisions where a CRD plural shadows a core kind (e.g., Knative
344-
// serving.knative.dev/Service vs core/v1 Service): without this branch,
345-
// FetchResource("services", ...) would return the core Service from the
346-
// typed informer and the requested group would never be consulted, leaking
347-
// the wrong object via the AI surface. Mirrors handleGetResource's
348-
// group-first dispatch in server.go.
342+
// When a group is provided, the typed cache is skipped and the dynamic cache is
343+
// consulted with the group qualifier. This prevents kind collisions where a CRD
344+
// plural shadows a core kind (e.g., Knative serving.knative.dev/Service vs
345+
// core/v1 Service): without this branch, FetchResource("services", ...) would
346+
// return the core Service and the requested group would never be consulted,
347+
// leaking the wrong object via the AI surface. The exception is a built-in kind
348+
// addressed by its OWN group (deployments?group=apps) — that's a typed lookup;
349+
// the dynamic cache has no informer for built-ins. Mirrors handleGetResource.
349350
func (s *Server) fetchAIResource(ctx context.Context, cache *k8s.ResourceCache, kind, namespace, name, group string) (runtime.Object, bool, error) {
350-
if group != "" {
351+
if group != "" && !k8s.TypedKindOwnsGroup(kind, group) {
351352
u, err := cache.GetDynamicWithGroup(ctx, kind, namespace, name, group)
352353
if err != nil {
353354
return nil, false, err

internal/server/server.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,11 +1188,14 @@ func (s *Server) handleListResources(w http.ResponseWriter, r *http.Request) {
11881188
forbiddenMsg(resourceKind)
11891189
}
11901190

1191-
// When a group is specified, skip the typed cache and use the dynamic cache
1192-
// directly. This handles CRDs whose plural name collides with core resources
1193-
// (e.g., KNative "services" vs core "services"). Cluster-scoped gating for
1194-
// these is already done at the top of this handler via k8s.ClassifyKindScope.
1195-
if group != "" {
1191+
// A non-empty group routes to the dynamic/CRD cache so CRDs whose plural
1192+
// collides with a core kind (e.g. KNative "services" vs core "services")
1193+
// reach the right resource. Built-in workloads addressed by their real group
1194+
// (e.g. deployments?group=apps) live in the typed cache, so they must fall
1195+
// through to the typed switch below — TypedKindOwnsGroup keeps them off the
1196+
// dynamic path (which has no informer for built-ins). Cluster-scoped gating
1197+
// is already done at the top of this handler via k8s.ClassifyKindScope.
1198+
if group != "" && !k8s.TypedKindOwnsGroup(kind, group) {
11961199
if len(namespaces) > 0 {
11971200
var merged []any
11981201
for _, ns := range namespaces {
@@ -1648,11 +1651,16 @@ func (s *Server) handleGetResource(w http.ResponseWriter, r *http.Request) {
16481651
forbiddenGet(resourceKind)
16491652
}
16501653

1651-
// When a group is specified, skip the typed cache and use the dynamic cache
1652-
// directly. This handles CRDs whose plural name collides with core resources
1653-
// (e.g., KNative "services" vs core "services"). Cluster-scoped gating for
1654-
// these is already done at the top of this handler via k8s.ClassifyKindScope.
1655-
if group != "" {
1654+
// A non-empty group routes to the dynamic/CRD cache so CRDs whose plural
1655+
// collides with a core kind (e.g. KNative serving.knative.dev/services vs
1656+
// core "services") reach the right resource. But the SPA also threads the
1657+
// real apiGroup for BUILT-IN workloads (e.g. apps/Deployment), and those
1658+
// live in the typed cache, not the dynamic one — so a built-in addressed by
1659+
// its own group must still take the typed path below. Without this guard,
1660+
// deployments?group=apps fell through to the dynamic cache and 400'd with
1661+
// "unknown resource kind: deployments (group: apps)". Cluster-scoped gating
1662+
// is already done at the top of this handler via k8s.ClassifyKindScope.
1663+
if group != "" && !k8s.TypedKindOwnsGroup(kind, group) {
16561664
resource, err = cache.GetDynamicWithGroup(r.Context(), kind, namespace, name, group)
16571665
if err != nil {
16581666
if strings.Contains(err.Error(), "unknown resource kind") {

0 commit comments

Comments
 (0)