From 4fb2eb099afe447fbda6b612db48a9ceb575d17c Mon Sep 17 00:00:00 2001 From: Andrei Pohilko Date: Tue, 17 Mar 2026 15:55:48 +0000 Subject: [PATCH] fix: use apiVersion to disambiguate CRDs with same kind (#504) When multiple CRDs share the same kind but different API groups (e.g. Traefik's Middleware under traefik.io and traefik.containo.us), the dashboard failed to look up the correct resource. Thread apiVersion through the resource fetch chain and use group-qualified kind (e.g. Widget.new.example.com) for kubectl lookups. Closes #504 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/API/releases.ts | 9 +++++-- .../components/revision/RevisionResource.tsx | 3 ++- pkg/dashboard/handlers/helmHandlers.go | 3 ++- pkg/dashboard/handlers/kubeHandlers.go | 6 +++-- pkg/dashboard/utils/utils.go | 24 +++++++++++++++++-- pkg/dashboard/utils/utils_test.go | 24 +++++++++++++++++++ 6 files changed, 61 insertions(+), 8 deletions(-) diff --git a/frontend/src/API/releases.ts b/frontend/src/API/releases.ts index 5bc57ce..0fa605f 100644 --- a/frontend/src/API/releases.ts +++ b/frontend/src/API/releases.ts @@ -117,13 +117,18 @@ export function useGetResourceDescription( type: string, ns: string, name: string, + apiVersion?: string, options?: UseQueryOptions ) { + const params = new URLSearchParams({ name, namespace: ns }); + if (apiVersion) { + params.set("apiVersion", apiVersion); + } return useQuery({ - queryKey: ["describe", type, ns, name], + queryKey: ["describe", type, ns, name, apiVersion], queryFn: () => apiService.fetchWithDefaults( - `/api/k8s/${type}/describe?name=${name}&namespace=${ns}`, + `/api/k8s/${type}/describe?${params.toString()}`, { headers: { "Content-Type": "text/plain; charset=utf-8" }, } diff --git a/frontend/src/components/revision/RevisionResource.tsx b/frontend/src/components/revision/RevisionResource.tsx index 520a82a..cfb1636 100644 --- a/frontend/src/components/revision/RevisionResource.tsx +++ b/frontend/src/components/revision/RevisionResource.tsx @@ -154,7 +154,8 @@ const DescribeResource = ({ const { data, isLoading } = useGetResourceDescription( resource.kind, namespace, - name + name, + resource.apiVersion ); const yamlFormattedData = useMemo( diff --git a/pkg/dashboard/handlers/helmHandlers.go b/pkg/dashboard/handlers/helmHandlers.go index e3a328d..0645f51 100644 --- a/pkg/dashboard/handlers/helmHandlers.go +++ b/pkg/dashboard/handlers/helmHandlers.go @@ -159,7 +159,8 @@ func (h *HelmHandler) Resources(c *gin.Context) { if ns == "" { ns = c.Param("ns") } - info, err := app.K8s.GetResourceInfo(obj.Kind, ns, obj.Name) + kind := utils.QualifiedKind(obj.Kind, obj.APIVersion) + info, err := app.K8s.GetResourceInfo(kind, ns, obj.Name) if err != nil { log.Warnf("Failed to get resource info for %s %s/%s: %+v", obj.Name, ns, obj.Name, err) info = &v1.Carp{} diff --git a/pkg/dashboard/handlers/kubeHandlers.go b/pkg/dashboard/handlers/kubeHandlers.go index 7300d96..d060f1b 100644 --- a/pkg/dashboard/handlers/kubeHandlers.go +++ b/pkg/dashboard/handlers/kubeHandlers.go @@ -47,7 +47,8 @@ func (h *KubeHandler) GetResourceInfo(c *gin.Context) { return // sets error inside } - res, err := app.K8s.GetResourceInfo(c.Param("kind"), qp.Namespace, qp.Name) + kind := utils.QualifiedKind(c.Param("kind"), qp.APIVersion) + res, err := app.K8s.GetResourceInfo(kind, qp.Namespace, qp.Name) if errors.IsNotFound(err) { res = &v12.Carp{Status: v12.CarpStatus{Phase: "NotFound", Message: err.Error()}} //_ = c.AbortWithError(http.StatusNotFound, err) @@ -160,7 +161,8 @@ func (h *KubeHandler) Describe(c *gin.Context) { return // sets error inside } - res, err := app.K8s.DescribeResource(c.Param("kind"), qp.Namespace, qp.Name) + kind := utils.QualifiedKind(c.Param("kind"), qp.APIVersion) + res, err := app.K8s.DescribeResource(kind, qp.Namespace, qp.Name) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return diff --git a/pkg/dashboard/utils/utils.go b/pkg/dashboard/utils/utils.go index eef3c4a..5ceff49 100644 --- a/pkg/dashboard/utils/utils.go +++ b/pkg/dashboard/utils/utils.go @@ -102,8 +102,9 @@ func RunCommand(cmd []string, env map[string]string) (string, error) { } type QueryProps struct { - Namespace string - Name string + Namespace string + Name string + APIVersion string } func GetQueryProps(c *gin.Context) (*QueryProps, error) { @@ -111,6 +112,7 @@ func GetQueryProps(c *gin.Context) (*QueryProps, error) { qp.Namespace = c.Query("namespace") qp.Name = c.Query("name") + qp.APIVersion = c.Query("apiVersion") if qp.Name == "" { return nil, errors.New("missing required query string parameter: name") } @@ -118,6 +120,24 @@ func GetQueryProps(c *gin.Context) (*QueryProps, error) { return &qp, nil } +// QualifiedKind returns a group-qualified kind (e.g. "Widget.new.example.com") +// when apiVersion contains a group, allowing kubectl to disambiguate CRDs +// that share the same kind across different API groups. +func QualifiedKind(kind string, apiVersion string) string { + if apiVersion == "" { + return kind + } + group := apiVersion + if idx := strings.Index(apiVersion, "/"); idx >= 0 { + group = apiVersion[:idx] + } + // Core API group (e.g. "v1") has no group prefix + if !strings.Contains(group, ".") { + return kind + } + return kind + "." + group +} + func EnvAsBool(envKey string, envDef bool) bool { validSettableValues := []string{"false", "true", "0", "1"} envValue := os.Getenv(envKey) diff --git a/pkg/dashboard/utils/utils_test.go b/pkg/dashboard/utils/utils_test.go index 6f1f289..fde6381 100644 --- a/pkg/dashboard/utils/utils_test.go +++ b/pkg/dashboard/utils/utils_test.go @@ -108,6 +108,30 @@ func TestChartAndVersion(t *testing.T) { } } +func TestQualifiedKind(t *testing.T) { + tests := []struct { + kind string + apiVersion string + want string + }{ + {"Widget", "new.example.com/v1alpha1", "Widget.new.example.com"}, + {"Widget", "old.example.com/v1alpha1", "Widget.old.example.com"}, + {"Deployment", "apps/v1", "Deployment"}, + {"ConfigMap", "v1", "ConfigMap"}, + {"ConfigMap", "", "ConfigMap"}, + {"Middleware", "traefik.io/v1alpha1", "Middleware.traefik.io"}, + {"Middleware", "traefik.containo.us/v1alpha1", "Middleware.traefik.containo.us"}, + } + for _, tt := range tests { + t.Run(tt.kind+"/"+tt.apiVersion, func(t *testing.T) { + got := QualifiedKind(tt.kind, tt.apiVersion) + if got != tt.want { + t.Errorf("QualifiedKind(%q, %q) = %q, want %q", tt.kind, tt.apiVersion, got, tt.want) + } + }) + } +} + func TestEnvAsBool(t *testing.T) { // value: "true" | "1", default: false -> expect true t.Setenv("TEST", "true")