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) <noreply@anthropic.com>
This commit is contained in:
Andrei Pohilko
2026-03-17 15:55:48 +00:00
parent 62cf1dfc3e
commit 4fb2eb099a
6 changed files with 61 additions and 8 deletions

View File

@@ -117,13 +117,18 @@ export function useGetResourceDescription(
type: string,
ns: string,
name: string,
apiVersion?: string,
options?: UseQueryOptions<string>
) {
const params = new URLSearchParams({ name, namespace: ns });
if (apiVersion) {
params.set("apiVersion", apiVersion);
}
return useQuery<string>({
queryKey: ["describe", type, ns, name],
queryKey: ["describe", type, ns, name, apiVersion],
queryFn: () =>
apiService.fetchWithDefaults<string>(
`/api/k8s/${type}/describe?name=${name}&namespace=${ns}`,
`/api/k8s/${type}/describe?${params.toString()}`,
{
headers: { "Content-Type": "text/plain; charset=utf-8" },
}

View File

@@ -154,7 +154,8 @@ const DescribeResource = ({
const { data, isLoading } = useGetResourceDescription(
resource.kind,
namespace,
name
name,
resource.apiVersion
);
const yamlFormattedData = useMemo(

View File

@@ -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{}

View File

@@ -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

View File

@@ -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)

View File

@@ -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")