Skip to content

Commit 4be4a8d

Browse files
authored
Connect certificates to cached issuers (#1979)
Certificates can reference a working Issuer or ClusterIssuer that has no owner-connected topology node. Join built-in issuer references to exact cached objects so the existing Depends On / Required By views and REST/MCP context can navigate the issuance dependency. The resolver applies cert-manager's omitted-kind/group defaults, lists each target kind once and materializes only observed referenced issuers. Missing targets and same-name objects in another namespace or API group do not create links. ClusterIssuer nodes carry their exact cluster authorization tuple, and already-seeded issuers retain their metadata owner without duplicate nodes or edges. No new endpoint or permission grant is introduced. Validation: the complete Go, type-check and frontend matrix passes at the final head. A fresh embedded build and live main/PR REST/MCP comparisons with cert-manager 1.20.2 verify both issuer directions, missing-target rejection and preservation of the Issuer's Deployment owner. Browser captures of both drawers are below. ![Certificate issuer dependency](https://github.com/user-attachments/assets/2683a822-c364-4a1f-b277-8d7ebb9fb7b1) ![Issuer: Required By the Certificate](https://github.com/user-attachments/assets/14c9a799-9eca-457e-a330-ae2b5c7e5b87) A cold target kind may initialize an informer; the resolver performs no per-certificate object Gets. External issuer groups remain out of scope.
1 parent 6519719 commit 4be4a8d

5 files changed

Lines changed: 251 additions & 47 deletions

File tree

‎docs/integrations.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ The legacy `traefik.containo.us` API group (pre-v2.11) is warm-listed alongside
447447

448448
### What Radar Shows
449449

450-
**Topology:** Certificate → Issuer/ClusterIssuer edges show which issuer manages each certificate. The full provisioning chain (Certificate → CertificateRequest → Order → Challenge) is connected via owner references.
450+
**Topology:** Certificate → Issuer/ClusterIssuer edges connect each certificate to its observed built-in issuer, including ownerless issuers. Omitted `issuerRef.kind` and `issuerRef.group` use cert-manager defaults. Only referenced issuers are added; external issuer groups are not inferred. ClusterIssuer visibility uses its exact cluster-scoped read permission. Resource details and MCP context distinguish issuer dependencies from autoscaling. The full provisioning chain (Certificate → CertificateRequest → Order → Challenge) is connected via owner references.
451451

452452
<p align="center">
453453
<img src="screenshots/integrations/certmanager-topology.png" alt="cert-manager Topology" width="800">

‎pkg/topology/builder.go‎

Lines changed: 32 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5121,38 +5121,8 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) {
51215121
}
51225122
}
51235123

5124-
// 15c. Create cert-manager Certificate → Issuer/ClusterIssuer edges (via spec.issuerRef)
5125-
// Build a lookup of existing node IDs for matching
5126-
existingNodeIDs := make(map[string]bool, len(nodes))
5127-
for _, node := range nodes {
5128-
existingNodeIDs[node.ID] = true
5129-
}
5130-
for _, cert := range certificateResources {
5131-
ns := cert.GetNamespace()
5132-
certID := fmt.Sprintf("certificate/%s/%s", ns, cert.GetName())
5133-
5134-
issuerKind, _, _ := unstructured.NestedString(cert.Object, "spec", "issuerRef", "kind")
5135-
issuerName, _, _ := unstructured.NestedString(cert.Object, "spec", "issuerRef", "name")
5136-
if issuerKind == "" || issuerName == "" {
5137-
continue
5138-
}
5139-
5140-
var issuerID string
5141-
switch issuerKind {
5142-
case "ClusterIssuer":
5143-
issuerID = fmt.Sprintf("clusterissuer//%s", issuerName)
5144-
case "Issuer":
5145-
issuerID = fmt.Sprintf("issuer/%s/%s", ns, issuerName)
5146-
}
5147-
if issuerID != "" && existingNodeIDs[issuerID] {
5148-
edges = append(edges, Edge{
5149-
ID: fmt.Sprintf("%s-to-%s", certID, issuerID),
5150-
Source: certID,
5151-
Target: issuerID,
5152-
Type: EdgeUses,
5153-
})
5154-
}
5155-
}
5124+
nodes, edges, issuerWarnings := addCertificateIssuerEdges(nodes, edges, certificateResources, dynamicCache, opts)
5125+
warnings = append(warnings, issuerWarnings...)
51565126

51575127
// Build Certificate lookup by secretName (for IngressRoute → Certificate edges)
51585128
certBySecret := make(map[string]string) // "ns/secretName" → certificateID
@@ -8867,6 +8837,14 @@ func (b *Builder) addGenericCRDNodes(nodes []Node, edges []Edge, opts BuildOptio
88678837
}
88688838
}
88698839

8840+
type ownerPair struct{ source, target string }
8841+
existingOwnerEdges := make(map[ownerPair]bool)
8842+
for _, edge := range edges {
8843+
if edge.Type == EdgeManages {
8844+
existingOwnerEdges[ownerPair{edge.Source, edge.Target}] = true
8845+
}
8846+
}
8847+
88708848
processedTypes := make(map[string]bool)
88718849
for _, node := range nodes {
88728850
if node.Kind != KindNodeClass {
@@ -8888,6 +8866,7 @@ func (b *Builder) addGenericCRDNodes(nodes []Node, edges []Edge, opts BuildOptio
88888866
ownerRefs []ResourceRef
88898867
resourceKey string
88908868
typeKey string
8869+
existing bool
88918870
}
88928871
var candidates []candidate
88938872

@@ -8956,14 +8935,12 @@ func (b *Builder) addGenericCRDNodes(nodes []Node, edges []Edge, opts BuildOptio
89568935

89578936
name := resource.GetName()
89588937
resourceKey := resourceid.ResourceKey(gvr.Group, kind, ns, name)
8959-
if _, exists := existingResourceIDs[resourceKey]; exists {
8960-
continue
8961-
}
8962-
nodeID := fmt.Sprintf("%s/%s/%s/%s", strings.ToLower(kind), ns, name, gvr.Group)
8963-
8964-
// Skip if already in topology
8965-
if existingIDs[nodeID] {
8966-
continue
8938+
nodeID, existing := existingResourceIDs[resourceKey]
8939+
if !existing {
8940+
nodeID = fmt.Sprintf("%s/%s/%s/%s", strings.ToLower(kind), ns, name, gvr.Group)
8941+
if existingIDs[nodeID] {
8942+
continue
8943+
}
89678944
}
89688945

89698946
var ownerResources []ResourceRef
@@ -8994,6 +8971,7 @@ func (b *Builder) addGenericCRDNodes(nodes []Node, edges []Edge, opts BuildOptio
89948971
ownerRefs: ownerResources,
89958972
resourceKey: resourceKey,
89968973
typeKey: typeKey,
8974+
existing: existing,
89978975
})
89988976
}
89998977
}
@@ -9003,7 +8981,7 @@ func (b *Builder) addGenericCRDNodes(nodes []Node, edges []Edge, opts BuildOptio
90038981
added := 0
90048982
remaining := candidates[:0] // reuse slice
90058983
for _, c := range candidates {
9006-
if crdCounts[c.typeKey] >= maxPerKind {
8984+
if !c.existing && crdCounts[c.typeKey] >= maxPerKind {
90078985
continue // drop — kind at capacity
90088986
}
90098987

@@ -9024,11 +9002,19 @@ func (b *Builder) addGenericCRDNodes(nodes []Node, edges []Edge, opts BuildOptio
90249002
}
90259003

90269004
if len(ownerEdges) > 0 {
9027-
nodes = append(nodes, c.node)
9028-
edges = append(edges, ownerEdges...)
9029-
existingIDs[c.nodeID] = true
9030-
existingResourceIDs[c.resourceKey] = c.nodeID
9031-
crdCounts[c.typeKey]++
9005+
if !c.existing {
9006+
nodes = append(nodes, c.node)
9007+
existingIDs[c.nodeID] = true
9008+
existingResourceIDs[c.resourceKey] = c.nodeID
9009+
crdCounts[c.typeKey]++
9010+
}
9011+
for _, edge := range ownerEdges {
9012+
key := ownerPair{edge.Source, edge.Target}
9013+
if !existingOwnerEdges[key] {
9014+
edges = append(edges, edge)
9015+
existingOwnerEdges[key] = true
9016+
}
9017+
}
90329018
added++
90339019
} else {
90349020
remaining = append(remaining, c)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package topology
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/skyhook-io/radar/pkg/resourceid"
8+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
9+
)
10+
11+
func addCertificateIssuerEdges(nodes []Node, edges []Edge, certificates []unstructured.Unstructured, provider DynamicProvider, opts BuildOptions) ([]Node, []Edge, []string) {
12+
if provider == nil || len(certificates) == 0 {
13+
return nodes, edges, nil
14+
}
15+
byResource := make(map[string]string, len(nodes))
16+
for i := range nodes {
17+
for _, key := range nodeResourceKeys(&nodes[i]) {
18+
byResource[key] = nodes[i].ID
19+
}
20+
}
21+
issuers := map[string]*unstructured.Unstructured{}
22+
resources := map[string]string{}
23+
var warnings []string
24+
for _, kind := range []string{"Issuer", "ClusterIssuer"} {
25+
gvr, ok := provider.GetGVRWithGroup(kind, "cert-manager.io")
26+
if !ok {
27+
continue
28+
}
29+
namespaces := opts.Namespaces
30+
if kind == "ClusterIssuer" {
31+
namespaces = nil
32+
}
33+
objects, err := provider.ListNamespaces(gvr, namespaces)
34+
if err != nil {
35+
warnings = append(warnings, fmt.Sprintf("Failed to list cert-manager %s resources: %v", kind, err))
36+
continue
37+
}
38+
resources[kind] = gvr.Resource
39+
for _, obj := range objects {
40+
if obj.GetNamespace() != "" && !opts.MatchesNamespaceFilter(obj.GetNamespace()) {
41+
continue
42+
}
43+
key := resourceid.ResourceKey(gvr.Group, kind, obj.GetNamespace(), obj.GetName())
44+
issuers[key] = obj
45+
}
46+
}
47+
for i := range certificates {
48+
cert := &certificates[i]
49+
kind, _, _ := unstructured.NestedString(cert.Object, "spec", "issuerRef", "kind")
50+
group, _, _ := unstructured.NestedString(cert.Object, "spec", "issuerRef", "group")
51+
name, _, _ := unstructured.NestedString(cert.Object, "spec", "issuerRef", "name")
52+
if kind == "" {
53+
kind = "Issuer"
54+
}
55+
if group == "" {
56+
group = "cert-manager.io"
57+
}
58+
if name == "" || group != "cert-manager.io" || (kind != "Issuer" && kind != "ClusterIssuer") {
59+
continue
60+
}
61+
namespace := cert.GetNamespace()
62+
if kind == "ClusterIssuer" {
63+
namespace = ""
64+
}
65+
key := resourceid.ResourceKey(group, kind, namespace, name)
66+
issuer := issuers[key]
67+
if issuer == nil {
68+
continue
69+
}
70+
source := byResource[resourceid.ResourceKey("cert-manager.io", "Certificate", cert.GetNamespace(), cert.GetName())]
71+
if source == "" {
72+
continue
73+
}
74+
target := byResource[key]
75+
if target == "" {
76+
target = fmt.Sprintf("%s/%s/%s/%s", strings.ToLower(kind), namespace, name, group)
77+
data := map[string]any{"namespace": namespace, "labels": issuer.GetLabels(), "apiVersion": issuer.GetAPIVersion()}
78+
if namespace == "" {
79+
data[clusterScopedGroupKey] = group
80+
data[clusterScopedResourceKey] = resources[kind]
81+
}
82+
nodes = append(nodes, Node{ID: target, Kind: NodeKind(kind), Name: name, Status: extractGenericStatus(issuer), Data: data})
83+
byResource[key] = target
84+
}
85+
edges = append(edges, Edge{ID: source + "-to-" + target, Source: source, Target: target, Type: EdgeUses, Label: "issuer"})
86+
}
87+
return nodes, edges, warnings
88+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package topology
2+
3+
import (
4+
"fmt"
5+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
6+
"testing"
7+
8+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
9+
"k8s.io/apimachinery/pkg/runtime/schema"
10+
)
11+
12+
type issuerTestProvider struct {
13+
monitorDynamicProvider
14+
getCalls int
15+
listCalls map[string]int
16+
listError error
17+
}
18+
19+
func (p *issuerTestProvider) Get(schema.GroupVersionResource, string, string) (*unstructured.Unstructured, error) {
20+
p.getCalls++
21+
return nil, fmt.Errorf("unexpected object fetch")
22+
}
23+
24+
func (p *issuerTestProvider) ListNamespaces(gvr schema.GroupVersionResource, namespaces []string) ([]*unstructured.Unstructured, error) {
25+
p.listCalls[gvr.Resource]++
26+
if p.listError != nil {
27+
return nil, p.listError
28+
}
29+
return p.monitorDynamicProvider.ListNamespaces(gvr, namespaces)
30+
}
31+
32+
func TestCertificateIssuerEdgesDefaultsScopeAndCachedResolution(t *testing.T) {
33+
issuerGVR := schema.GroupVersionResource{Group: "cert-manager.io", Version: "v1", Resource: "issuers"}
34+
clusterGVR := schema.GroupVersionResource{Group: "cert-manager.io", Version: "v1", Resource: "clusterissuers"}
35+
makeIssuer := func(kind, namespace, name string) *unstructured.Unstructured {
36+
return &unstructured.Unstructured{Object: map[string]any{"apiVersion": "cert-manager.io/v1", "kind": kind, "metadata": map[string]any{"namespace": namespace, "name": name}}}
37+
}
38+
p := &issuerTestProvider{monitorDynamicProvider: monitorDynamicProvider{
39+
gvrs: map[string]schema.GroupVersionResource{"Issuer": issuerGVR, "ClusterIssuer": clusterGVR},
40+
resources: map[schema.GroupVersionResource][]*unstructured.Unstructured{
41+
issuerGVR: {makeIssuer("Issuer", "team", "ca"), makeIssuer("Issuer", "other", "ca"), makeIssuer("Issuer", "team", "unused")},
42+
clusterGVR: {makeIssuer("ClusterIssuer", "", "ca")},
43+
}}, listCalls: map[string]int{}}
44+
var certs []unstructured.Unstructured
45+
var nodes []Node
46+
for i, issuerRef := range []map[string]any{
47+
{"name": "ca"},
48+
{"name": "ca", "kind": "ClusterIssuer"},
49+
{"name": "ca", "kind": "Issuer", "group": "external.example.com"},
50+
{"name": "absent"},
51+
{"name": "ca"},
52+
} {
53+
name := fmt.Sprintf("cert-%d", i)
54+
certs = append(certs, unstructured.Unstructured{Object: map[string]any{"apiVersion": "cert-manager.io/v1", "kind": "Certificate", "metadata": map[string]any{"namespace": "team", "name": name}, "spec": map[string]any{"issuerRef": issuerRef}}})
55+
nodes = append(nodes, Node{ID: "certificate/team/" + name, Kind: KindCertificate, Name: name, Data: map[string]any{"namespace": "team", "apiVersion": "cert-manager.io/v1"}})
56+
}
57+
opts := DefaultBuildOptions()
58+
opts.Namespaces = []string{"team"}
59+
nodes, edges, warnings := addCertificateIssuerEdges(nodes, nil, certs, p, opts)
60+
if len(warnings) != 0 || len(nodes) != 7 || len(edges) != 3 {
61+
t.Fatalf("nodes=%d edges=%+v warnings=%v; want two referenced issuers and three joins", len(nodes), edges, warnings)
62+
}
63+
if p.getCalls != 0 || p.listCalls["issuers"] != 1 || p.listCalls["clusterissuers"] != 1 {
64+
t.Fatalf("lookup fan-out: gets=%d lists=%v", p.getCalls, p.listCalls)
65+
}
66+
topo := &Topology{Nodes: nodes, Edges: edges}
67+
rel := GetRelationships("Certificate", "team", "cert-0", topo, nil, p)
68+
if rel == nil || len(rel.Dependencies) != 1 || rel.Dependencies[0] != (ResourceRef{Kind: "Issuer", Group: "cert-manager.io", Namespace: "team", Name: "ca"}) || rel.ScaleTarget != nil {
69+
t.Fatalf("default issuer relationship = %+v", rel)
70+
}
71+
if tuples := topo.ClusterScopedDynamicRBACTuples(); len(tuples) != 1 || tuples[0].Group != "cert-manager.io" || tuples[0].Resource != "clusterissuers" {
72+
t.Fatalf("cluster issuer authorization tuples = %+v", tuples)
73+
}
74+
topo.StripClusterScopedDynamicExcept(nil)
75+
if len(topo.Nodes) != 6 || len(topo.Edges) != 2 {
76+
t.Fatalf("denied cluster issuer retained: nodes=%d edges=%d", len(topo.Nodes), len(topo.Edges))
77+
}
78+
}
79+
80+
func TestCertificateIssuerEdgesReportsUnavailableLookup(t *testing.T) {
81+
p := &issuerTestProvider{monitorDynamicProvider: monitorDynamicProvider{
82+
gvrs: map[string]schema.GroupVersionResource{
83+
"Issuer": {Group: "cert-manager.io", Version: "v1", Resource: "issuers"},
84+
"ClusterIssuer": {Group: "cert-manager.io", Version: "v1", Resource: "clusterissuers"},
85+
},
86+
}, listCalls: map[string]int{}, listError: fmt.Errorf("cache unavailable")}
87+
cert := unstructured.Unstructured{Object: map[string]any{
88+
"apiVersion": "cert-manager.io/v1", "kind": "Certificate",
89+
"metadata": map[string]any{"name": "tls", "namespace": "team"},
90+
"spec": map[string]any{"issuerRef": map[string]any{"name": "ca"}},
91+
}}
92+
nodes, edges, warnings := addCertificateIssuerEdges([]Node{{ID: "certificate/team/tls", Kind: KindCertificate, Name: "tls", Data: map[string]any{"apiVersion": "cert-manager.io/v1"}}}, nil, []unstructured.Unstructured{cert}, p, DefaultBuildOptions())
93+
if len(nodes) != 1 || len(edges) != 0 || len(warnings) != 2 || p.getCalls != 0 {
94+
t.Fatalf("unavailable lookup: nodes=%d edges=%d warnings=%v gets=%d", len(nodes), len(edges), warnings, p.getCalls)
95+
}
96+
}
97+
98+
func TestReferencedIssuerRetainsGenericOwnerWithoutDuplicateNodesOrEdges(t *testing.T) {
99+
issuerGVR := schema.GroupVersionResource{Group: "cert-manager.io", Version: "v1", Resource: "issuers"}
100+
issuer := genericIdentityObject(issuerGVR, "Issuer", "team", "ca", metav1.OwnerReference{APIVersion: "apps/v1", Kind: "Deployment", Name: "operator"})
101+
p := &genericIdentityDynamic{watched: []schema.GroupVersionResource{issuerGVR}, kinds: map[schema.GroupVersionResource]string{issuerGVR: "Issuer"}, resources: map[schema.GroupVersionResource][]*unstructured.Unstructured{issuerGVR: {issuer}}, listCalls: map[schema.GroupVersionResource]int{}}
102+
cert := genericIdentityObject(schema.GroupVersionResource{Group: "cert-manager.io", Version: "v1"}, "Certificate", "team", "tls")
103+
cert.Object["spec"] = map[string]any{"issuerRef": map[string]any{"name": "ca"}}
104+
nodes := []Node{
105+
{ID: "certificate/team/tls", Kind: KindCertificate, Name: "tls", Data: map[string]any{"namespace": "team", "apiVersion": "cert-manager.io/v1"}},
106+
{ID: "deployment/team/operator", Kind: KindDeployment, Name: "operator", Data: map[string]any{"namespace": "team"}},
107+
}
108+
nodes, edges, warnings := addCertificateIssuerEdges(nodes, nil, []unstructured.Unstructured{*cert}, p, DefaultBuildOptions())
109+
if len(warnings) != 0 {
110+
t.Fatal(warnings)
111+
}
112+
builder := &Builder{dynamic: p}
113+
for i := 0; i < 2; i++ {
114+
nodes, edges = builder.addGenericCRDNodes(nodes, edges, DefaultBuildOptions())
115+
if len(nodes) != 3 || len(edges) != 2 {
116+
t.Fatalf("generic pass %d nodes=%d edges=%+v, want seeded issuer and unique dependency/owner", i, len(nodes), edges)
117+
}
118+
}
119+
rel := GetRelationshipsWithObject("Issuer", "team", "ca", issuer, &Topology{Nodes: nodes, Edges: edges}, nil, p, nil)
120+
if rel == nil || rel.Owner == nil || rel.Owner.Kind != "Deployment" || rel.Owner.Name != "operator" || len(rel.Dependents) != 1 {
121+
t.Fatalf("referenced issuer owner/dependent = %+v", rel)
122+
}
123+
}

‎pkg/topology/generic_crd_identity_test.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,13 @@ func TestGenericCRDReusesSpecializedResourceIdentityAndPseudoOwner(t *testing.T)
240240
if buckets != 1 || nodeByID(nodes, "bucket/infra/logs/s3.aws.upbound.io") != nil {
241241
t.Fatalf("specialized Bucket was duplicated: %+v", nodes)
242242
}
243+
ownerFound := false
244+
for _, edge := range edges {
245+
ownerFound = ownerFound || edge.Source == "deployment/infra/operator" && edge.Target == "crossplane/infra/logs" && edge.Type == EdgeManages
246+
}
247+
if !ownerFound {
248+
t.Fatalf("specialized resource lost its generic owner edge: %+v", edges)
249+
}
243250
childID := "child/infra/leaf/infra.example.io"
244251
found := false
245252
for _, edge := range edges {

0 commit comments

Comments
 (0)