Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions internal/server/resource_observation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ package server

import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"sync/atomic"
"testing"
"time"

authorizationv1 "k8s.io/api/authorization/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
dynamicfake "k8s.io/client-go/dynamic/fake"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"

"github.com/skyhook-io/radar/internal/auth"
"github.com/skyhook-io/radar/internal/k8s"
Expand Down Expand Up @@ -145,3 +152,115 @@ func TestEmptyViewerScopeClearsRetainedObservationEvidence(t *testing.T) {
}
}
}

func TestAPIResourcesDefinitionNamesRequireObservedReadableCRDs(t *testing.T) {
k8s.ResetTestDynamicState()
t.Cleanup(k8s.ResetTestDynamicState)
widget := k8s.APIResource{Group: "plants.example.io", Version: "v1", Kind: "Cactus", Name: "cacti", Namespaced: true, IsCRD: true, Verbs: []string{"get", "list", "watch"}}
definitions := k8s.APIResource{Group: crdGVR.Group, Version: crdGVR.Version, Kind: "CustomResourceDefinition", Name: crdGVR.Resource, Verbs: []string{"get", "list", "watch"}}
metrics := k8s.APIResource{Group: "metrics.k8s.io", Version: "v1beta1", Kind: "PodMetrics", Name: "pods", Namespaced: true, IsCRD: true, Verbs: []string{"get", "list"}}
native := k8s.APIResource{Group: "resource.k8s.io", Version: "v1", Kind: "DeviceClass", Name: "deviceclasses", IsCRD: true, Verbs: []string{"get", "list", "watch"}}
definition := &unstructured.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.k8s.io/v1", "kind": "CustomResourceDefinition", "metadata": map[string]any{"name": "cacti.plants.example.io"}, "spec": map[string]any{"group": "plants.example.io", "names": map[string]any{"kind": "Cactus", "plural": "cacti"}}}}
dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{crdGVR: "CustomResourceDefinitionList"}, definition)
if err := k8s.InitTestDynamicResourceCache(dyn, []k8s.APIResource{widget, definitions, metrics, native}); err != nil {
t.Fatal(err)
}
env := newAuthTestServer(t)
permissions := &auth.UserPermissions{AllowedNamespaces: []string{"team"}}
permissions.SetCanI("list", crdGVR.Group, crdGVR.Resource, "", true)
permissions.SetCanI("get", crdGVR.Group, crdGVR.Resource, "", true)
env.srv.permCache.Set("viewer", nil, permissions)
before := len(dyn.Actions())
var coldResources []apiResourceResponse
assertOK(t, env.authGet(t, "/api/api-resources", "viewer", ""), &coldResources)
for _, resource := range coldResources {
if resource.DefinitionName != "" {
t.Fatalf("unwatched inventory confirmed a definition: %+v", resource)
}
if resource.Group == crdGVR.Group && resource.Name == crdGVR.Resource && (resource.Observation == nil || resource.Observation.State != k8score.DynamicObservationUnwatched) {
t.Fatalf("cold definition inventory omitted its state: %+v", resource)
}
}
if len(dyn.Actions()) != before {
t.Fatal("cold discovery performed object reads")
}
cache := k8s.GetDynamicResourceCache()
if err := cache.EnsureWatching(crdGVR); err != nil {
t.Fatal(err)
}
if !cache.WaitForSync(crdGVR, 3*time.Second) {
t.Fatal("CRD cache did not sync")
}
for _, access := range []struct{ list, get bool }{{true, true}, {true, false}, {false, true}, {false, false}} {
permissions := &auth.UserPermissions{AllowedNamespaces: []string{"team"}}
permissions.SetCanI("list", crdGVR.Group, crdGVR.Resource, "", access.list)
permissions.SetCanI("get", crdGVR.Group, crdGVR.Resource, "", access.get)
env.srv.permCache.Set("viewer", nil, permissions)
before := len(dyn.Actions())
var resources []apiResourceResponse
assertOK(t, env.authGet(t, "/api/api-resources", "viewer", ""), &resources)
found := false
for _, resource := range resources {
if resource.Group == widget.Group && resource.Name == widget.Name {
found = true
want := ""
if access.list && access.get {
want = definition.GetName()
}
if resource.DefinitionName != want {
t.Fatalf("access=%+v, definition=%q want=%q", access, resource.DefinitionName, want)
}
} else if resource.DefinitionName != "" {
t.Fatalf("non-CRD inferred definition: %+v", resource)
}
if resource.Group == crdGVR.Group && resource.Name == crdGVR.Resource {
if access.list && access.get {
if resource.Observation == nil || resource.Observation.State != k8score.DynamicObservationSynced {
t.Fatalf("readable inventory omitted its sync state: %+v", resource)
}
} else if resource.Observation != nil {
t.Fatalf("unreadable definition inventory exposed state: %+v", resource)
}
}
}
if !found {
t.Fatal("custom API missing")
}
if len(dyn.Actions()) != before {
t.Fatal("discovery performed live object reads")
}
}
}

func TestAPIResourcesRejectsDiscoveryReplacedDuringAssembly(t *testing.T) {
k8s.ResetTestDynamicState()
t.Cleanup(k8s.ResetTestDynamicState)
var replaced atomic.Bool
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
k8s.ResetResourceDiscovery()
replaced.Store(true)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(&authorizationv1.SubjectAccessReview{Status: authorizationv1.SubjectAccessReviewStatus{Allowed: true}})
}))
t.Cleanup(api.Close)
client, err := kubernetes.NewForConfig(&rest.Config{Host: api.URL})
if err != nil {
t.Fatal(err)
}
previous := k8s.SetTestClient(client)
t.Cleanup(func() { k8s.SetTestClient(previous) })
dyn := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme())
if err := k8s.InitTestDynamicResourceCache(dyn, nil); err != nil {
t.Fatal(err)
}
env := newAuthTestServer(t)
env.srv.permCache.Set("viewer", nil, &auth.UserPermissions{AllowedNamespaces: []string{"team"}})
response := env.authGet(t, "/api/api-resources", "viewer", "")
defer response.Body.Close()
if !replaced.Load() {
t.Fatal("discovery was not replaced during authorization")
}
if response.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("discovery replacement returned %d, want 503", response.StatusCode)
}
}
34 changes: 29 additions & 5 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2037,8 +2037,9 @@ func (s *Server) handleNamespaces(w http.ResponseWriter, r *http.Request) {

type apiResourceResponse struct {
k8score.APIResource
Featured bool `json:"featured,omitempty"`
Observation *k8score.DynamicResourceObservation `json:"observation,omitempty"`
DefinitionName string `json:"definitionName,omitempty"`
Featured bool `json:"featured,omitempty"`
Observation *k8score.DynamicResourceObservation `json:"observation,omitempty"`
}

func filterDynamicObservationNamespaces(observation k8score.DynamicResourceObservation, allowed []string) k8score.DynamicResourceObservation {
Expand Down Expand Up @@ -2083,14 +2084,32 @@ func (s *Server) handleAPIResources(w http.ResponseWriter, r *http.Request) {

result := make([]apiResourceResponse, 0, len(resources))
dynamicCache := k8s.GetDynamicResourceCache()
// IsCRD is a discovery/category heuristic (also true for aggregated APIs).
// Definition links require an observed CRD, read under the existing list
// permission. Use only watched inventory: discovery never starts per-kind
// object reads or a new CRD informer for this optional navigation metadata.
definitionNames := make(map[string]string)
canReadDefinitions := dynamicCache != nil && s.canRead(r, crdGVR.Group, crdGVR.Resource, "", "list") && s.canRead(r, crdGVR.Group, crdGVR.Resource, "", "get")
if canReadDefinitions {
if definitions, err := dynamicCache.ListWatchedReadOnly(crdGVR); err == nil {
for _, definition := range definitions {
group, _, _ := unstructured.NestedString(definition.Object, "spec", "group")
kind, _, _ := unstructured.NestedString(definition.Object, "spec", "names", "kind")
plural, _, _ := unstructured.NestedString(definition.Object, "spec", "names", "plural")
definitionNames[group+"/"+kind+"/"+plural] = definition.GetName()
}
}
}
var visibleNamespaces []string
visibleNamespacesResolved := false
for _, resource := range resources {
response := apiResourceResponse{
APIResource: resource,
Featured: isFeaturedKubernetesAPI(resource.Group, resource.Kind),
APIResource: resource,
Featured: isFeaturedKubernetesAPI(resource.Group, resource.Kind),
DefinitionName: definitionNames[resource.Group+"/"+resource.Kind+"/"+resource.Name],
}
if resource.IsCRD && dynamicCache != nil {
isDefinitionsInventory := resource.Group == crdGVR.Group && resource.Name == crdGVR.Resource
if dynamicCache != nil && ((resource.IsCRD && !isDefinitionsInventory) || (isDefinitionsInventory && canReadDefinitions)) {
observation := dynamicCache.Observation(schema.GroupVersionResource{
Group: resource.Group,
Version: resource.Version,
Expand All @@ -2107,6 +2126,11 @@ func (s *Server) handleAPIResources(w http.ResponseWriter, r *http.Request) {
}
result = append(result, response)
}
if discovery != k8s.GetResourceDiscovery() {
s.writeError(w, http.StatusServiceUnavailable, "Resource discovery changed, please retry shortly")
return
}

s.writeJSON(w, result)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -800,7 +800,7 @@ export function ResourceRendererDispatch({
{kind !== 'events' && <EventsSection fullscreen={!!renderSidebar} events={events || []} updates={updates || []} isLoading={eventsLoading ?? false} eventsError={eventsError ?? null} updatesError={updatesError ?? null} hint={eventsHint} />}
<LabelsSection data={data} />
<AnnotationsSection data={data} />
<MetadataSection data={data} />
<MetadataSection data={data} onNavigate={onNavigate} />
</>
)

Expand Down
160 changes: 160 additions & 0 deletions packages/k8s-ui/src/components/ui/crd-definition-navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, expect, it, vi } from "vitest";
import { MetadataSection } from "./drawer-components";
import {
customResourceDefinitionRef,
initNavigationMap,
resetNavigationMap,
} from "../../utils/navigation";

Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
afterEach(resetNavigationMap);
const resources = [
{
group: "plants.example.io",
version: "v1",
kind: "Cactus",
name: "cacti",
definitionName: "cacti.plants.example.io",
namespaced: true,
isCrd: true,
verbs: ["get"],
},
{
group: "other.example.io",
version: "v1",
kind: "Cactus",
name: "cactuses",
definitionName: "cactuses.other.example.io",
namespaced: false,
isCrd: true,
verbs: ["get"],
},
{
group: "apps",
version: "v1",
kind: "Deployment",
name: "deployments",
namespaced: true,
isCrd: false,
verbs: ["get"],
},
];

it("uses exact discovered CRD group and plural instead of guessing from the Kind", () => {
expect(
customResourceDefinitionRef("plants.example.io/v1", "Cactus"),
).toBeNull();
initNavigationMap(resources);
expect(customResourceDefinitionRef("plants.example.io/v1", "Cactus")).toEqual(
{
kind: "CustomResourceDefinition",
group: "apiextensions.k8s.io",
namespace: "",
name: "cacti.plants.example.io",
},
);
expect(
customResourceDefinitionRef("other.example.io/v1", "Cactus")?.name,
).toBe("cactuses.other.example.io");
expect(customResourceDefinitionRef("apps/v1", "Deployment")).toBeNull();
initNavigationMap([
...resources,
{
group: "metrics.k8s.io",
version: "v1beta1",
kind: "PodMetrics",
name: "pods",
namespaced: true,
isCrd: true,
verbs: ["get"],
},
]);
expect(
customResourceDefinitionRef("metrics.k8s.io/v1beta1", "PodMetrics"),
).toBeNull();
resetNavigationMap();
expect(
customResourceDefinitionRef("plants.example.io/v1", "Cactus"),
).toBeNull();
initNavigationMap([
{
...resources[0],
name: "cactuses",
definitionName: "cactuses.plants.example.io",
},
]);
expect(
customResourceDefinitionRef("plants.example.io/v1", "Cactus")?.name,
).toBe("cactuses.plants.example.io");
expect(
customResourceDefinitionRef("unknown.example.io/v1", "Cactus"),
).toBeNull();
expect(customResourceDefinitionRef(undefined, "Cactus")).toBeNull();
initNavigationMap([]);
expect(
customResourceDefinitionRef("plants.example.io/v1", "Cactus"),
).toBeNull();
});

it("navigates from instance metadata to its cluster-scoped definition", async () => {
initNavigationMap(resources);
const element = document.createElement("div");
const root = createRoot(element);
const onNavigate = vi.fn();
try {
await act(async () =>
root.render(
<MetadataSection
data={{
apiVersion: "plants.example.io/v1",
kind: "Cactus",
metadata: { name: "live", namespace: "team" },
}}
onNavigate={onNavigate}
/>,
),
);
const button = [...element.querySelectorAll("button")].find(
(b) => b.textContent === "cacti.plants.example.io",
);
expect(button).toBeDefined();
await act(async () => button!.click());
expect(onNavigate).toHaveBeenCalledWith({
kind: "CustomResourceDefinition",
group: "apiextensions.k8s.io",
namespace: "",
name: "cacti.plants.example.io",
});
} finally {
await act(async () => root.unmount());
}
});

it("keeps exact Kind casing within one API group", () => {
initNavigationMap([
...resources,
{ ...resources[0], kind: "CACTUS", name: "uppercacti", definitionName: "uppercacti.plants.example.io" },
]);
expect(customResourceDefinitionRef("plants.example.io/v1", "Cactus")?.name).toBe("cacti.plants.example.io");
expect(customResourceDefinitionRef("plants.example.io/v1", "CACTUS")?.name).toBe("uppercacti.plants.example.io");
expect(customResourceDefinitionRef("plants.example.io/v1", "cactus")).toBeNull();
});

it("does not add a guessed definition to built-in or undiscovered metadata", () => {
initNavigationMap(resources);
const html = renderToStaticMarkup(
<MetadataSection
data={{
apiVersion: "apps/v1",
kind: "Deployment",
metadata: { name: "live", uid: "native" },
}}
/>,
);
expect(html).toContain("native");
expect(html).not.toContain("Definition");
});
8 changes: 6 additions & 2 deletions packages/k8s-ui/src/components/ui/drawer-components.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { dedupeResourceRefs, omitResourceRefs } from '../../utils/resource-refs'
import { customResourceDefinitionRef, getCRDDefinitionsSnapshot, subscribeCRDDefinitions } from '../../utils/navigation'
import { Disclosure } from './Disclosure'
import { createContext, useContext, useState } from 'react'
import { createContext, useContext, useState, useSyncExternalStore } from 'react'
import { Copy, Check, Tag, AlertTriangle, CheckCircle, ExternalLink, Layers, X, Minus } from 'lucide-react'
import { clsx } from 'clsx'
import { formatDuration, formatResources } from '../resources/resource-utils'
Expand Down Expand Up @@ -541,13 +542,16 @@ export function AnnotationsSection({ data }: { data: any }) {
)
}

export function MetadataSection({ data }: { data: any }) {
export function MetadataSection({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
useSyncExternalStore(subscribeCRDDefinitions, getCRDDefinitionsSnapshot, getCRDDefinitionsSnapshot)
const meta = data.metadata
if (!meta) return null
const definition = customResourceDefinitionRef(data.apiVersion, data.kind)

return (
<Section title="Metadata" defaultExpanded>
<PropertyList>
{definition && <Property label="Definition" value={<ResourceLink {...definition} onNavigate={onNavigate} />} />}
<Property label="UID" value={meta.uid} />
<Property label="Resource Version" value={meta.resourceVersion} />
<Property label="Generation" value={meta.generation} />
Expand Down
Loading
Loading