Skip to content

Commit 3ff2b10

Browse files
authored
Resources API: include=summary body-verbosity param, over a shared prune mechanism (#1096)
1 parent c5a666c commit 3ff2b10

14 files changed

Lines changed: 1658 additions & 177 deletions
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package server
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
8+
9+
"github.com/skyhook-io/radar/pkg/prune"
10+
)
11+
12+
// Summary strip profiles for ?include=summary on the resource list endpoint.
13+
// GitOps CRs carry heavy subtrees (Argo status.resources / history /
14+
// operationState.syncResult, Flux status.inventory) that dominate wire size on
15+
// large fleets but that list consumers (the fleet GitOps board) never read.
16+
// Each profile deletes only those subtrees; every field the board normalizers
17+
// read must survive intact — resource_summary_test.go pins that contract.
18+
// Kinds without a profile pass through unchanged, so summary is best-effort
19+
// and the response stays a bare array of full-shaped objects.
20+
// Profiles are DATA over pkg/prune's shared mechanism — the keep-list
21+
// policy lives here (validated by the contract tests below/in *_test.go);
22+
// the tree surgery, deep-copy discipline, and tail-trim semantics live in
23+
// pkg/prune. pkg/ai/context prunes the same way for a different policy
24+
// (token budget); see that package before inventing a third mechanism.
25+
var summaryStripProfiles = map[string]prune.Profile{
26+
"argoproj.io/Application": {
27+
Drop: [][]string{
28+
{"metadata", "managedFields"},
29+
{"status", "resources"},
30+
{"status", "operationState", "syncResult"},
31+
},
32+
TailTrims: []prune.TailTrim{{Path: []string{"status", "history"}, KeepField: "deployedAt"}},
33+
},
34+
"kustomize.toolkit.fluxcd.io/Kustomization": {
35+
Drop: [][]string{{"metadata", "managedFields"}, {"status", "inventory"}},
36+
},
37+
"helm.toolkit.fluxcd.io/HelmRelease": {
38+
Drop: [][]string{{"metadata", "managedFields"}, {"status", "inventory"}},
39+
},
40+
}
41+
42+
// Profiles must target CRD kinds (group contains a dot): the summary strip
43+
// only runs on the dynamic (unstructured) list path — a typed-kind profile
44+
// would be accepted and silently never apply. Fail loudly at init instead.
45+
func init() {
46+
for key := range summaryStripProfiles {
47+
if !strings.Contains(key, ".") {
48+
panic("resource summary profile for a non-CRD kind will silently never apply: " + key)
49+
}
50+
}
51+
}
52+
53+
// parseResourcesInclude maps the resource list endpoint's include values.
54+
// Default (absent) is raw; unknown values are a caller bug and 400, matching
55+
// /api/search's validation posture — but NOT its semantics: here summary is
56+
// a same-schema strip (heavy subtrees removed, object shape intact), whereas
57+
// search's summary/raw are transformed ai/context representations. Same
58+
// word, different shape contract; don't assume one from the other.
59+
func parseResourcesInclude(v string) (summary bool, err error) {
60+
switch v {
61+
case "", "raw":
62+
return false, nil
63+
case "summary":
64+
return true, nil
65+
default:
66+
return false, fmt.Errorf("unknown include=%q (want: summary, raw)", v)
67+
}
68+
}
69+
70+
// applySummaryStrip summarizes every unstructured item in a dynamic list
71+
// IN PLACE. Its only callers are the two handleListResources dynamic-list
72+
// exits, and every item there is already an owned deep copy — the dynamic
73+
// cache returns StripUnstructuredFields(u) results (List + ListDirect both
74+
// DeepCopy). Mutating in place avoids a redundant second copy of objects
75+
// we're about to shrink, on the heaviest payload path. The informer cache
76+
// is never touched (proven by the handler e2e test); do NOT call this with
77+
// objects you don't own.
78+
//
79+
// Typed-cache lists (the default: arm) bypass summary by construction —
80+
// profiled kinds are all CRDs, guaranteed by the init check above. Dynamic
81+
// informers preserve apiVersion/kind, so each item keys on its own GVK; an
82+
// item that lacks a profile is left untouched (fail open).
83+
func applySummaryStrip(result any) any {
84+
switch items := result.(type) {
85+
case []*unstructured.Unstructured:
86+
for _, item := range items {
87+
summarizeUnstructuredInPlace(item)
88+
}
89+
case []any:
90+
for _, item := range items {
91+
if u, ok := item.(*unstructured.Unstructured); ok {
92+
summarizeUnstructuredInPlace(u)
93+
}
94+
}
95+
}
96+
return result
97+
}
98+
99+
func summarizeUnstructuredInPlace(obj *unstructured.Unstructured) {
100+
if obj == nil {
101+
return
102+
}
103+
gvk := obj.GroupVersionKind()
104+
if profile, ok := summaryStripProfiles[gvk.Group+"/"+gvk.Kind]; ok {
105+
prune.ApplyInPlace(obj.Object, profile)
106+
}
107+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"testing"
9+
"time"
10+
11+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
12+
"k8s.io/apimachinery/pkg/runtime"
13+
"k8s.io/apimachinery/pkg/runtime/schema"
14+
dynamicfake "k8s.io/client-go/dynamic/fake"
15+
16+
"github.com/skyhook-io/radar/internal/k8s"
17+
)
18+
19+
// End-to-end coverage of include=summary through the real handler path:
20+
// HTTP request → handleListResources → dynamic cache → applySummaryStrip →
21+
// JSON response. The unit tests in resource_summary_test.go pin the strip
22+
// profiles; this pins the wiring — the query-param parse, the strip actually
23+
// being applied at the writeJSON exit, and the informer-cache object
24+
// surviving unmutated.
25+
26+
func argoAppE2EFixture() *unstructured.Unstructured {
27+
return &unstructured.Unstructured{Object: map[string]any{
28+
"apiVersion": "argoproj.io/v1alpha1",
29+
"kind": "Application",
30+
"metadata": map[string]any{
31+
"name": "checkout-service-production-us-east1",
32+
"namespace": "argocd",
33+
"labels": map[string]any{"team": "payments-platform"},
34+
"annotations": map[string]any{
35+
"argocd.argoproj.io/refresh": "normal",
36+
},
37+
},
38+
"spec": map[string]any{
39+
"project": "shop-backend",
40+
"source": map[string]any{
41+
"repoURL": "https://github.com/example-org/shop-backend-gitops",
42+
"targetRevision": "main",
43+
"path": "environments/production/checkout-service",
44+
},
45+
"destination": map[string]any{
46+
"server": "https://kubernetes.default.svc",
47+
"namespace": "shop-backend-production",
48+
},
49+
"syncPolicy": map[string]any{"automated": map[string]any{"prune": true}},
50+
},
51+
"status": map[string]any{
52+
"sync": map[string]any{"status": "Synced", "revision": "f3c9a1d7"},
53+
"health": map[string]any{"status": "Healthy"},
54+
"operationState": map[string]any{
55+
"phase": "Succeeded",
56+
"finishedAt": "2026-06-10T10:00:12Z",
57+
"syncResult": map[string]any{
58+
"resources": []any{map[string]any{"kind": "Deployment", "name": "checkout-service", "status": "Synced"}},
59+
},
60+
},
61+
"reconciledAt": "2026-06-10T10:05:00Z",
62+
"history": []any{
63+
map[string]any{"id": int64(0), "revision": "aaaa", "deployedAt": "2026-06-01T10:00:00Z"},
64+
map[string]any{"id": int64(1), "revision": "bbbb", "deployedAt": "2026-06-10T10:00:00Z"},
65+
},
66+
"resources": []any{
67+
map[string]any{"kind": "Deployment", "name": "checkout-service", "status": "Synced", "health": map[string]any{"status": "Healthy"}},
68+
},
69+
},
70+
}}
71+
}
72+
73+
func setupArgoApplicationsDynamicCache(t *testing.T) {
74+
t.Helper()
75+
gvr := schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"}
76+
dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(
77+
runtime.NewScheme(),
78+
map[schema.GroupVersionResource]string{gvr: "ApplicationList"},
79+
argoAppE2EFixture(),
80+
)
81+
if err := k8s.InitTestDynamicResourceCache(dyn, []k8s.APIResource{{
82+
Group: "argoproj.io",
83+
Version: "v1alpha1",
84+
Kind: "Application",
85+
Name: "applications",
86+
Namespaced: true,
87+
Verbs: []string{"get", "list", "watch"},
88+
}}); err != nil {
89+
t.Fatalf("InitTestDynamicResourceCache: %v", err)
90+
}
91+
t.Cleanup(k8s.ResetTestDynamicState)
92+
93+
// The dynamic cache's List is non-blocking; poll until the informer has
94+
// synced the fixture so the HTTP assertions below see deterministic data.
95+
deadline := time.Now().Add(5 * time.Second)
96+
for {
97+
cached, err := k8s.GetResourceCache().ListDynamicWithGroup(context.Background(), "applications", "", "argoproj.io")
98+
if err == nil && len(cached) == 1 {
99+
return
100+
}
101+
if time.Now().After(deadline) {
102+
t.Fatalf("dynamic informer never synced: items=%d err=%v", len(cached), err)
103+
}
104+
time.Sleep(10 * time.Millisecond)
105+
}
106+
}
107+
108+
func listApplications(t *testing.T, query string) []map[string]any {
109+
t.Helper()
110+
var items []map[string]any
111+
assertOK(t, get(t, "/api/resources/applications?group=argoproj.io"+query), &items)
112+
if len(items) != 1 {
113+
t.Fatalf("got %d applications, want 1", len(items))
114+
}
115+
return items
116+
}
117+
118+
func TestListResourcesIncludeSummaryE2E(t *testing.T) {
119+
setupArgoApplicationsDynamicCache(t)
120+
121+
summarized := listApplications(t, "&include=summary")[0]
122+
123+
assertPathAbsent(t, summarized, "status", "resources")
124+
assertPathAbsent(t, summarized, "status", "operationState", "syncResult")
125+
if got := mustNested(t, summarized, "status", "sync", "status"); got != "Synced" {
126+
t.Errorf("status.sync.status = %v, want Synced", got)
127+
}
128+
if got := mustNested(t, summarized, "status", "health", "status"); got != "Healthy" {
129+
t.Errorf("status.health.status = %v, want Healthy", got)
130+
}
131+
if got := mustNested(t, summarized, "status", "operationState", "phase"); got != "Succeeded" {
132+
t.Errorf("status.operationState.phase = %v, want Succeeded", got)
133+
}
134+
history := mustNested(t, summarized, "status", "history").([]any)
135+
if len(history) != 1 {
136+
t.Fatalf("history should be trimmed to tail, got %d entries", len(history))
137+
}
138+
tail, _ := history[0].(map[string]any)
139+
if len(tail) != 1 || tail["deployedAt"] != "2026-06-10T10:00:00Z" {
140+
t.Errorf("history tail should keep only deployedAt: %v", tail)
141+
}
142+
143+
// The summary strip must have operated on a copy: the informer-cache
144+
// object behind the handler must still carry the heavy subtrees.
145+
cached, err := k8s.GetResourceCache().ListDynamicWithGroup(context.Background(), "applications", "", "argoproj.io")
146+
if err != nil {
147+
t.Fatalf("ListDynamicWithGroup: %v", err)
148+
}
149+
if len(cached) != 1 {
150+
t.Fatalf("got %d cached applications, want 1", len(cached))
151+
}
152+
if _, found, _ := unstructured.NestedFieldNoCopy(cached[0].Object, "status", "resources"); !found {
153+
t.Fatal("cache object lost status.resources — summary request mutated the informer cache")
154+
}
155+
if hist, _, _ := unstructured.NestedSlice(cached[0].Object, "status", "history"); len(hist) != 2 {
156+
t.Fatalf("cache object history trimmed to %d entries — summary request mutated the informer cache", len(hist))
157+
}
158+
159+
// Without include the same GET returns the full objects.
160+
full := listApplications(t, "")[0]
161+
if _, found, _ := unstructured.NestedFieldNoCopy(full, "status", "resources"); !found {
162+
t.Error("raw response missing status.resources")
163+
}
164+
if _, found, _ := unstructured.NestedFieldNoCopy(full, "status", "operationState", "syncResult"); !found {
165+
t.Error("raw response missing status.operationState.syncResult")
166+
}
167+
if fullHistory := mustNested(t, full, "status", "history").([]any); len(fullHistory) != 2 {
168+
t.Errorf("raw response history = %d entries, want 2", len(fullHistory))
169+
}
170+
}
171+
172+
func TestListResourcesUnknownIncludeRejected(t *testing.T) {
173+
setupArgoApplicationsDynamicCache(t)
174+
175+
resp := get(t, "/api/resources/applications?group=argoproj.io&include=bogus")
176+
defer resp.Body.Close()
177+
if resp.StatusCode != http.StatusBadRequest {
178+
t.Fatalf("status = %d, want 400", resp.StatusCode)
179+
}
180+
body, _ := io.ReadAll(resp.Body)
181+
var payload map[string]any
182+
if err := json.Unmarshal(body, &payload); err != nil {
183+
t.Fatalf("non-JSON error body: %s", body)
184+
}
185+
msg, _ := payload["error"].(string)
186+
if msg != `unknown include="bogus" (want: summary, raw)` {
187+
t.Errorf("error = %q, want the accepted values named", msg)
188+
}
189+
}

0 commit comments

Comments
 (0)