Skip to content

Commit 1e5a5f6

Browse files
authored
Redesign application and workload detail scope (#1106)
1 parent c69bd70 commit 1e5a5f6

41 files changed

Lines changed: 5795 additions & 1046 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

internal/server/applications.go

Lines changed: 442 additions & 15 deletions
Large diffs are not rendered by default.

internal/server/applications_identity.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,172 @@ func argoManagedWorkloads(item *unstructured.Unstructured) []workloadRef {
10501050
return out
10511051
}
10521052

1053+
func setStrictSourceRef(r *appRow, ins []appWorkloadInput) {
1054+
if len(ins) == 0 {
1055+
return
1056+
}
1057+
var ref *appSourceRef
1058+
for _, in := range ins {
1059+
if in.source == nil {
1060+
return
1061+
}
1062+
if ref == nil {
1063+
cp := *in.source
1064+
ref = &cp
1065+
continue
1066+
}
1067+
if !sameSourceRef(ref, in.source) {
1068+
r.sourceConflict = true
1069+
return
1070+
}
1071+
}
1072+
r.SourceRef = ref
1073+
r.sourceStrict = true
1074+
}
1075+
1076+
func enrichRowsWithManagedSourceRefs(ctx context.Context, cache resourceLister, rows []appRow, argoItems []*unstructured.Unstructured) {
1077+
sources := map[string][]appSourceRef{}
1078+
addArgoManagedSourceRefs(sources, argoItems)
1079+
addFluxKustomizationManagedSourceRefs(ctx, cache, sources)
1080+
if len(sources) == 0 {
1081+
return
1082+
}
1083+
for i := range rows {
1084+
ref := commonManagedSourceRef(rows[i].Workloads, sources)
1085+
if ref == nil {
1086+
continue
1087+
}
1088+
mergeSourceRef(&rows[i], ref)
1089+
}
1090+
}
1091+
1092+
func addArgoManagedSourceRefs(out map[string][]appSourceRef, items []*unstructured.Unstructured) {
1093+
for _, item := range items {
1094+
if item == nil || item.GetName() == "" || item.GetNamespace() == "" {
1095+
continue
1096+
}
1097+
ref := appSourceRef{Type: "gitops", Tool: "argocd", Group: "argoproj.io", Kind: "Application", Namespace: item.GetNamespace(), Name: item.GetName()}
1098+
destNamespace := ""
1099+
if spec, _ := item.Object["spec"].(map[string]any); spec != nil {
1100+
if dest, _ := spec["destination"].(map[string]any); dest != nil {
1101+
destNamespace, _ = dest["namespace"].(string)
1102+
}
1103+
}
1104+
resources, _, _ := unstructured.NestedSlice(item.Object, "status", "resources")
1105+
for _, res := range resources {
1106+
resMap, _ := res.(map[string]any)
1107+
if resMap == nil {
1108+
continue
1109+
}
1110+
kind, _ := resMap["kind"].(string)
1111+
if !argoWorkloadKinds[kind] {
1112+
continue
1113+
}
1114+
name, _ := resMap["name"].(string)
1115+
ns, _ := resMap["namespace"].(string)
1116+
if ns == "" {
1117+
ns = destNamespace
1118+
}
1119+
addManagedSourceRef(out, kind, ns, name, ref)
1120+
}
1121+
}
1122+
}
1123+
1124+
func addFluxKustomizationManagedSourceRefs(ctx context.Context, cache resourceLister, out map[string][]appSourceRef) {
1125+
items, err := cache.ListDynamicWithGroup(ctx, "Kustomization", "", "kustomize.toolkit.fluxcd.io")
1126+
if err != nil {
1127+
return
1128+
}
1129+
for _, item := range items {
1130+
if item == nil || item.GetName() == "" || item.GetNamespace() == "" {
1131+
continue
1132+
}
1133+
ref := appSourceRef{Type: "gitops", Tool: "fluxcd", Group: "kustomize.toolkit.fluxcd.io", Kind: "Kustomization", Namespace: item.GetNamespace(), Name: item.GetName()}
1134+
entries, _, _ := unstructured.NestedSlice(item.Object, "status", "inventory", "entries")
1135+
for _, entry := range entries {
1136+
entryMap, _ := entry.(map[string]any)
1137+
if entryMap == nil {
1138+
continue
1139+
}
1140+
id, _ := entryMap["id"].(string)
1141+
kind, namespace, name, ok := parseFluxInventoryWorkloadID(id)
1142+
if !ok {
1143+
continue
1144+
}
1145+
if !argoWorkloadKinds[kind] {
1146+
continue
1147+
}
1148+
addManagedSourceRef(out, kind, namespace, name, ref)
1149+
}
1150+
}
1151+
}
1152+
1153+
func parseFluxInventoryWorkloadID(id string) (kind, namespace, name string, ok bool) {
1154+
parts := strings.Split(id, "_")
1155+
if len(parts) < 4 {
1156+
return "", "", "", false
1157+
}
1158+
kind = parts[len(parts)-1]
1159+
namespace = parts[0]
1160+
name = strings.Join(parts[1:len(parts)-2], "_")
1161+
if kind == "" || namespace == "" || name == "" {
1162+
return "", "", "", false
1163+
}
1164+
return kind, namespace, name, true
1165+
}
1166+
1167+
func addManagedSourceRef(out map[string][]appSourceRef, kind, namespace, name string, ref appSourceRef) {
1168+
if kind == "" || namespace == "" || name == "" {
1169+
return
1170+
}
1171+
key := managedWorkloadKey(kind, namespace, name)
1172+
for _, existing := range out[key] {
1173+
if sameSourceRef(&existing, &ref) {
1174+
return
1175+
}
1176+
}
1177+
out[key] = append(out[key], ref)
1178+
}
1179+
1180+
func commonManagedSourceRef(workloads []appWorkload, sources map[string][]appSourceRef) *appSourceRef {
1181+
if len(workloads) == 0 {
1182+
return nil
1183+
}
1184+
var common []appSourceRef
1185+
for i, w := range workloads {
1186+
refs := sources[managedWorkloadKey(w.Kind, w.Namespace, w.Name)]
1187+
if len(refs) == 0 {
1188+
return nil
1189+
}
1190+
if i == 0 {
1191+
common = append(common, refs...)
1192+
continue
1193+
}
1194+
next := make([]appSourceRef, 0, len(common))
1195+
for _, a := range common {
1196+
for _, b := range refs {
1197+
if sameSourceRef(&a, &b) {
1198+
next = append(next, a)
1199+
break
1200+
}
1201+
}
1202+
}
1203+
common = next
1204+
if len(common) == 0 {
1205+
return nil
1206+
}
1207+
}
1208+
if len(common) != 1 {
1209+
return nil
1210+
}
1211+
cp := common[0]
1212+
return &cp
1213+
}
1214+
1215+
func managedWorkloadKey(kind, namespace, name string) string {
1216+
return kind + "/" + namespace + "/" + name
1217+
}
1218+
10531219
// appSetFanout is one child's declared identity within a single-app fan-out set.
10541220
type appSetFanout struct {
10551221
stem string

internal/server/applications_identity_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,3 +917,22 @@ func TestFluxKustomizationFacts(t *testing.T) {
917917
t.Fatalf("env-less path should be skipped: %v", got)
918918
}
919919
}
920+
921+
func TestAddFluxKustomizationManagedSourceRefsParsesInventoryIDFromRight(t *testing.T) {
922+
ks := &unstructured.Unstructured{Object: map[string]any{
923+
"metadata": map[string]any{"namespace": "flux-system", "name": "platform"},
924+
"status": map[string]any{"inventory": map[string]any{"entries": []any{
925+
map[string]any{"id": "team_api_worker_apps_Deployment"},
926+
}}},
927+
}}
928+
got := map[string][]appSourceRef{}
929+
addFluxKustomizationManagedSourceRefs(context.Background(), &stubLister{items: []*unstructured.Unstructured{ks}}, got)
930+
931+
refs := got[managedWorkloadKey("Deployment", "team", "api_worker")]
932+
if len(refs) != 1 {
933+
t.Fatalf("managed source refs = %#v, want one ref keyed by full workload name", got)
934+
}
935+
if refs[0].Name != "platform" || refs[0].Namespace != "flux-system" || refs[0].Tool != "fluxcd" {
936+
t.Fatalf("source ref = %+v, want flux-system/platform flux ref", refs[0])
937+
}
938+
}

internal/server/applications_test.go

Lines changed: 165 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/skyhook-io/radar/pkg/subject"
88
"github.com/skyhook-io/radar/pkg/topology"
99
corev1 "k8s.io/api/core/v1"
10+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1011
)
1112

1213
// rawInput builds a workload with no label overlay and its own structural root
@@ -141,6 +142,161 @@ func TestGroupApplications_StructuralManagerRoot(t *testing.T) {
141142
}
142143
}
143144

145+
func TestGroupApplications_SourceRefRequiresExactSource(t *testing.T) {
146+
helmApp := overlayInput("Deployment", "prod", "api", "1.0", "healthy", subject.TierHelmRelease, "prod/HelmRelease/checkout", subject.ConfidenceMedium)
147+
helmApp.overlay.Winner.Ref = subject.Ref{Kind: "HelmRelease", Namespace: "prod", Name: "checkout"}
148+
helmApp.source = sourceRefForInput(helmApp.overlay, helmApp.rootKind, helmApp.rootKey)
149+
150+
labelApp := overlayInput("Deployment", "prod", "payments-worker", "1.0", "healthy", subject.TierPartOf, "prod/app/payments", subject.ConfidenceMedium)
151+
labelApp.overlay.Winner.Ref = subject.Ref{Kind: "app", Namespace: "prod", Name: "payments"}
152+
labelApp.source = sourceRefForInput(labelApp.overlay, labelApp.rootKind, labelApp.rootKey)
153+
154+
structuralApp := rawInput("Deployment", "prod", "admin", "1.0", "healthy")
155+
structuralApp.rootKey, structuralApp.rootKind = "argocd/Application/admin", "Application"
156+
structuralApp.source = sourceRefForInput(structuralApp.overlay, structuralApp.rootKind, structuralApp.rootKey)
157+
158+
rows := groupApplications([]appWorkloadInput{helmApp, labelApp, structuralApp})
159+
160+
helmRow := rowByName(rows, "checkout")
161+
if helmRow == nil || helmRow.SourceRef == nil || helmRow.SourceRef.Type != "helm" || helmRow.SourceRef.Name != "checkout" {
162+
t.Fatalf("native Helm exact source ref missing: %+v", helmRow)
163+
}
164+
labelRow := rowByName(rows, "payments")
165+
if labelRow == nil || labelRow.SourceRef != nil {
166+
t.Fatalf("label-inferred app should not expose source ref, got %+v", labelRow)
167+
}
168+
structuralRow := rowByName(rows, "admin")
169+
if structuralRow == nil || structuralRow.SourceRef == nil || structuralRow.SourceRef.Type != "gitops" || structuralRow.SourceRef.Tool != "argocd" {
170+
t.Fatalf("structural GitOps source ref missing: %+v", structuralRow)
171+
}
172+
}
173+
174+
func TestGroupApplications_SourceRefRequiresEveryWorkload(t *testing.T) {
175+
api := overlayInput("Deployment", "prod", "checkout-api", "1.0", "healthy", subject.TierHelmRelease, "prod/HelmRelease/checkout", subject.ConfidenceMedium)
176+
api.overlay.Winner.Ref = subject.Ref{Kind: "HelmRelease", Namespace: "prod", Name: "checkout"}
177+
api.source = sourceRefForInput(api.overlay, api.rootKind, api.rootKey)
178+
179+
worker := overlayInput("Deployment", "prod", "checkout-worker", "1.0", "healthy", subject.TierHelmRelease, "prod/HelmRelease/checkout", subject.ConfidenceMedium)
180+
181+
rows := groupApplications([]appWorkloadInput{api, worker})
182+
checkout := rowByName(rows, "checkout")
183+
if checkout == nil || len(checkout.Workloads) != 2 {
184+
t.Fatalf("checkout app missing or malformed: %+v", rows)
185+
}
186+
if checkout.SourceRef != nil {
187+
t.Fatalf("partial source evidence should not expose an app-level source ref: %+v", checkout.SourceRef)
188+
}
189+
}
190+
191+
func TestSetStrictSourceRefMarksConflictingSources(t *testing.T) {
192+
row := appRow{}
193+
setStrictSourceRef(&row, []appWorkloadInput{
194+
{source: &appSourceRef{Type: "helm", Tool: "helm", Kind: "HelmRelease", Namespace: "prod", Name: "checkout"}},
195+
{source: &appSourceRef{Type: "gitops", Tool: "argocd", Group: "argoproj.io", Kind: "Application", Namespace: "argocd", Name: "checkout"}},
196+
})
197+
if row.SourceRef != nil || row.sourceStrict || !row.sourceConflict {
198+
t.Fatalf("conflicting strict sources should mark conflict only: ref=%+v strict=%v conflict=%v", row.SourceRef, row.sourceStrict, row.sourceConflict)
199+
}
200+
mergeSourceRef(&row, &appSourceRef{Type: "gitops", Tool: "argocd", Group: "argoproj.io", Kind: "Application", Namespace: "argocd", Name: "checkout"})
201+
if row.SourceRef != nil {
202+
t.Fatalf("managed source should not attach after strict source conflict: %+v", row.SourceRef)
203+
}
204+
}
205+
206+
func TestManagedSourceRefs_CrossNamespaceArgoApplication(t *testing.T) {
207+
app := &unstructured.Unstructured{Object: map[string]any{
208+
"metadata": map[string]any{"namespace": "argocd", "name": "billing"},
209+
"spec": map[string]any{
210+
"destination": map[string]any{"namespace": "team-a"},
211+
},
212+
"status": map[string]any{"resources": []any{
213+
map[string]any{"kind": "Deployment", "name": "api"},
214+
map[string]any{"kind": "Deployment", "namespace": "team-a", "name": "worker"},
215+
}},
216+
}}
217+
sources := map[string][]appSourceRef{}
218+
addArgoManagedSourceRefs(sources, []*unstructured.Unstructured{app})
219+
ref := commonManagedSourceRef([]appWorkload{
220+
{Kind: "Deployment", Namespace: "team-a", Name: "api"},
221+
{Kind: "Deployment", Namespace: "team-a", Name: "worker"},
222+
}, sources)
223+
if ref == nil || ref.Tool != "argocd" || ref.Namespace != "argocd" || ref.Name != "billing" {
224+
t.Fatalf("cross-namespace Argo source ref = %+v, want argocd/Application/billing", ref)
225+
}
226+
}
227+
228+
func TestMergeSourceRefPreservesStrictSourceRefOnManagedConflict(t *testing.T) {
229+
row := appRow{
230+
SourceRef: &appSourceRef{Type: "helm", Tool: "helm", Kind: "HelmRelease", Namespace: "prod", Name: "checkout"},
231+
sourceStrict: true,
232+
}
233+
mergeSourceRef(&row, &appSourceRef{Type: "gitops", Tool: "argocd", Group: "argoproj.io", Kind: "Application", Namespace: "argocd", Name: "checkout"})
234+
if row.SourceRef == nil || row.SourceRef.Type != "helm" || row.SourceRef.Name != "checkout" {
235+
t.Fatalf("strict source ref should survive managed conflict: %+v", row.SourceRef)
236+
}
237+
if row.sourceConflict {
238+
t.Fatalf("strict source ref conflict should not mark row conflicted")
239+
}
240+
}
241+
242+
func TestMergeSourceRefClearsWeakConflict(t *testing.T) {
243+
row := appRow{SourceRef: &appSourceRef{Type: "helm", Tool: "helm", Kind: "HelmRelease", Namespace: "prod", Name: "checkout"}}
244+
mergeSourceRef(&row, &appSourceRef{Type: "gitops", Tool: "argocd", Group: "argoproj.io", Kind: "Application", Namespace: "argocd", Name: "checkout"})
245+
if row.SourceRef != nil || !row.sourceConflict {
246+
t.Fatalf("weak source conflict should clear source ref and mark conflict: ref=%+v conflict=%v", row.SourceRef, row.sourceConflict)
247+
}
248+
}
249+
250+
func TestHistorySummaryPrioritizesCurrentIncidents(t *testing.T) {
251+
summary := historySummary(
252+
[]appHistoryAnchor{{Title: "Helm revision 3", Status: "deployed", Revision: "3", Timestamp: "2026-07-08T10:00:00Z"}},
253+
[]appHistoryIncident{{Title: "FailedScheduling on Pod/api", Message: "0/9 nodes are available", LastSeen: "2026-07-08T11:00:00Z"}},
254+
)
255+
if summary == nil || summary.State != "incident" || summary.Title != "Current incident: FailedScheduling on Pod/api" || summary.Detail != "0/9 nodes are available" {
256+
t.Fatalf("incident summary = %+v", summary)
257+
}
258+
259+
summary = historySummary(
260+
[]appHistoryAnchor{{Title: "Helm revision 3", Status: "deployed", Revision: "3", Message: "Upgrade complete", Timestamp: "2026-07-08T10:00:00Z"}},
261+
nil,
262+
)
263+
if summary == nil || summary.State != "change" || summary.Detail != "deployed · 3 · Upgrade complete" {
264+
t.Fatalf("change summary = %+v", summary)
265+
}
266+
267+
summary = historySummary(nil, nil)
268+
if summary == nil || summary.State != "none" {
269+
t.Fatalf("empty summary = %+v", summary)
270+
}
271+
}
272+
273+
func TestHistoryIncidentsSortAndTimestampFallbacks(t *testing.T) {
274+
events := []appEvent{
275+
{Reason: "Older", Object: "Pod/old", Message: "old", Count: 1, LastSeen: "2026-07-08T10:00:00Z"},
276+
{Reason: "Newer", Object: "Pod/new", Message: "new", Count: 3, FirstSeen: "2026-07-08T09:00:00Z", LastSeen: "2026-07-08T11:00:00Z"},
277+
}
278+
incidents := historyIncidents(events)
279+
if len(incidents) != 2 || incidents[0].Title != "Newer on Pod/new" || incidents[0].Count != 3 || incidents[0].FirstSeen == "" {
280+
t.Fatalf("incidents = %+v", incidents)
281+
}
282+
}
283+
284+
func TestGitOpsPluralKind(t *testing.T) {
285+
cases := map[string]string{
286+
"Application": "applications",
287+
"ApplicationSet": "applicationsets",
288+
"AppProject": "appprojects",
289+
"Kustomization": "kustomizations",
290+
"HelmRelease": "helmreleases",
291+
"Deployment": "",
292+
}
293+
for kind, want := range cases {
294+
if got := gitOpsPluralKind(kind); got != want {
295+
t.Fatalf("gitOpsPluralKind(%q) = %q, want %q", kind, got, want)
296+
}
297+
}
298+
}
299+
144300
// Over-merge guardrail: two distinct apps that share a satellite Service must
145301
// NOT fuse. Satellites are attached, never used to partition.
146302
func TestGroupApplications_SharedSatelliteDoesNotMerge(t *testing.T) {
@@ -166,23 +322,25 @@ func TestGroupApplications_RelationshipCountsDeduplicateSharedRefs(t *testing.T)
166322
}
167323
a := overlayInput("Deployment", "prod", "api", "1.0", "healthy", subject.TierPartOf, "prod/app/checkout", subject.ConfidenceMedium)
168324
a.rels = &appRelationships{
169-
configRefs: map[string]struct{}{refKey(ref("ConfigMap", "prod", "shared-config")): {}},
170-
scalerRefs: map[string]struct{}{refKey(ref("HorizontalPodAutoscaler", "prod", "checkout")): {}},
171-
pdbRefs: map[string]struct{}{refKey(ref("PodDisruptionBudget", "prod", "checkout")): {}},
325+
configRefs: map[string]struct{}{refKey(ref("ConfigMap", "prod", "shared-config")): {}},
326+
scalerRefs: map[string]struct{}{refKey(ref("HorizontalPodAutoscaler", "prod", "checkout")): {}},
327+
storageRefs: map[string]struct{}{refKey(ref("PersistentVolumeClaim", "prod", "checkout-data")): {}},
328+
pdbRefs: map[string]struct{}{refKey(ref("PodDisruptionBudget", "prod", "checkout")): {}},
172329
}
173330
b := overlayInput("Deployment", "prod", "worker", "1.0", "healthy", subject.TierPartOf, "prod/app/checkout", subject.ConfidenceMedium)
174331
b.rels = &appRelationships{
175-
configRefs: map[string]struct{}{refKey(ref("ConfigMap", "prod", "shared-config")): {}},
176-
scalerRefs: map[string]struct{}{refKey(ref("HorizontalPodAutoscaler", "prod", "checkout")): {}},
177-
pdbRefs: map[string]struct{}{refKey(ref("PodDisruptionBudget", "prod", "checkout")): {}},
332+
configRefs: map[string]struct{}{refKey(ref("ConfigMap", "prod", "shared-config")): {}},
333+
scalerRefs: map[string]struct{}{refKey(ref("HorizontalPodAutoscaler", "prod", "checkout")): {}},
334+
storageRefs: map[string]struct{}{refKey(ref("PersistentVolumeClaim", "prod", "checkout-data")): {}},
335+
pdbRefs: map[string]struct{}{refKey(ref("PodDisruptionBudget", "prod", "checkout")): {}},
178336
}
179337

180338
rows := groupApplications([]appWorkloadInput{a, b})
181339
r := rowByName(rows, "checkout")
182340
if r == nil || r.Relationships == nil {
183341
t.Fatalf("checkout relationships missing: %+v", rows)
184342
}
185-
if r.Relationships.Configs != 1 || r.Relationships.Scalers != 1 || r.Relationships.PDBs != 1 {
343+
if r.Relationships.Configs != 1 || r.Relationships.Scalers != 1 || r.Relationships.Storage != 1 || r.Relationships.PDBs != 1 {
186344
t.Fatalf("relationship counts = %+v, want each shared ref counted once", r.Relationships)
187345
}
188346
}

0 commit comments

Comments
 (0)