From efa20e52bbd3bde54d21a803217a4a5cacafe5c0 Mon Sep 17 00:00:00 2001 From: Benjamin Leggett Date: Tue, 22 Sep 2026 18:30:17 -0400 Subject: [PATCH 1/3] fix: allow `parentIDTemplate` to use pod fields Signed-off-by: Benjamin Leggett --- api/v1alpha1/controllermanagerconfig_types.go | 7 ++- pkg/spireentry/entries.go | 5 +- pkg/spireentry/entries_test.go | 47 +++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/api/v1alpha1/controllermanagerconfig_types.go b/api/v1alpha1/controllermanagerconfig_types.go index 14a19b3f..9bf19143 100644 --- a/api/v1alpha1/controllermanagerconfig_types.go +++ b/api/v1alpha1/controllermanagerconfig_types.go @@ -172,7 +172,12 @@ type ControllerManagerConfigurationSpec struct { // +optional FilterByClassName bool `json:"filterByClassName,omitempty"` - // If specified, uses a different parent id template for linking pods to nodes + // If specified, uses a different parent id template for linking pods to nodes. + // The template can use the pod's fields as well as the node's. The parent + // decides which agent may issue a pod's identity, so use only pod fields + // that cannot change after the pod is admitted, such as the UID, + // namespace, service account, or node selector, and not labels or + // annotations. // +optional ParentIDTemplate string `json:"parentIDTemplate,omitempty"` diff --git a/pkg/spireentry/entries.go b/pkg/spireentry/entries.go index 2b533aa5..af5598a0 100644 --- a/pkg/spireentry/entries.go +++ b/pkg/spireentry/entries.go @@ -82,6 +82,8 @@ func renderPodEntry(spec *spirev1alpha1.ParsedClusterSPIFFEIDSpec, node *corev1. ClusterDomain: clusterDomain, NodeMeta: &node.ObjectMeta, NodeSpec: &node.Spec, + PodMeta: &pod.ObjectMeta, + PodSpec: &pod.Spec, } if parentIDTemplate == nil { @@ -93,9 +95,6 @@ func renderPodEntry(spec *spirev1alpha1.ParsedClusterSPIFFEIDSpec, node *corev1. return nil, fmt.Errorf("failed to render parent ID: %w", err) } - data.PodMeta = &pod.ObjectMeta - data.PodSpec = &pod.Spec - spiffeID, err := renderSPIFFEID(spec.SPIFFEIDTemplate, data, trustDomain) if err != nil { return nil, fmt.Errorf("failed to render SPIFFE ID: %w", err) diff --git a/pkg/spireentry/entries_test.go b/pkg/spireentry/entries_test.go index 98abb376..5a578a0f 100644 --- a/pkg/spireentry/entries_test.go +++ b/pkg/spireentry/entries_test.go @@ -171,3 +171,50 @@ func TestParentIDTemplateRenderPodEntry(t *testing.T) { require.Equal(t, entry.ParentID.String(), fmt.Sprintf("spiffe://%s/spire/agent/x509pop/test.example.org", td)) } + +func TestParentIDTemplateCanUsePodFields(t *testing.T) { + spec := &spirev1alpha1.ClusterSPIFFEIDSpec{ + SPIFFEIDTemplate: "spiffe://{{ .TrustDomain }}/ns/{{ .PodMeta.Namespace }}/sa/{{ .PodSpec.ServiceAccountName }}", + } + node := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + UID: "node-uid", + Name: "node-a", + }, + } + // Pods of a runtime whose agents attest per pod get a parent for each pod, + // and every other pod keeps the node's agent. The branch reads the node + // selector that the runtime class adds at admission, which cannot change + // afterwards. + parentIDTemplate, err := template.New("testParentIDTemplate").Parse( + `{{ if eq (index .PodSpec.NodeSelector "example.org/runtime") "per-pod-agent" }}` + + `spiffe://{{ .TrustDomain }}/spire/agent/per-pod/{{ .NodeMeta.Name }}/pod/{{ .PodMeta.UID }}` + + `{{ else }}spiffe://{{ .TrustDomain }}/spire/agent/k8s_psat/{{ .ClusterName }}/{{ .NodeMeta.UID }}{{ end }}`) + require.NoError(t, err) + + parsedSpec, err := spirev1alpha1.ParseClusterSPIFFEIDSpec(spec) + require.NoError(t, err) + td, err := spiffeid.TrustDomainFromString(trustDomain) + require.NoError(t, err) + + perPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "namespace", + UID: "pod-uid", + }, + Spec: corev1.PodSpec{ + ServiceAccountName: "test", + NodeSelector: map[string]string{"example.org/runtime": "per-pod-agent"}, + }, + } + entry, err := renderPodEntry(parsedSpec, node, perPod, &corev1.EndpointsList{}, td, clusterName, clusterDomain, parentIDTemplate) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("spiffe://%s/spire/agent/per-pod/node-a/pod/pod-uid", td), entry.ParentID.String()) + + plain := perPod.DeepCopy() + plain.Spec.NodeSelector = nil + entry, err = renderPodEntry(parsedSpec, node, plain, &corev1.EndpointsList{}, td, clusterName, clusterDomain, parentIDTemplate) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("spiffe://%s/spire/agent/k8s_psat/%s/node-uid", td, clusterName), entry.ParentID.String()) +} From 5b56bf9f4e16a68abb37148b71f6d5b10a76911c Mon Sep 17 00:00:00 2001 From: Benjamin Leggett Date: Wed, 23 Sep 2026 15:41:53 -0400 Subject: [PATCH 2/3] support additive ClusterSPIFFEIDs Signed-off-by: Benjamin Leggett --- api/v1alpha1/clusterspiffeid_types.go | 9 ++ api/v1alpha1/clusterspiffeid_webhook.go | 8 ++ docs/clusterspiffeid-crd.md | 6 ++ pkg/spireentry/fallback_test.go | 104 ++++++++++++++++++++++++ pkg/spireentry/reconciler.go | 2 +- 5 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 pkg/spireentry/fallback_test.go diff --git a/api/v1alpha1/clusterspiffeid_types.go b/api/v1alpha1/clusterspiffeid_types.go index afb71132..abcf8c40 100644 --- a/api/v1alpha1/clusterspiffeid_types.go +++ b/api/v1alpha1/clusterspiffeid_types.go @@ -149,6 +149,15 @@ type ClusterSPIFFEID struct { Status ClusterSPIFFEIDStatus `json:"status"` } +// AdditiveAnnotation set to "true" applies the ID without keeping fallback +// ClusterSPIFFEIDs from applying to the same pods. It is an annotation so that +// the stock CRD, which prunes unknown spec fields, keeps it. +const AdditiveAnnotation = "spire-controller-manager.edera.dev/additive" + +func (r *ClusterSPIFFEID) IsAdditive() bool { + return r.Annotations[AdditiveAnnotation] == "true" +} + //+kubebuilder:object:root=true // ClusterSPIFFEIDList contains a list of ClusterSPIFFEID diff --git a/api/v1alpha1/clusterspiffeid_webhook.go b/api/v1alpha1/clusterspiffeid_webhook.go index 34d1532c..6b770068 100644 --- a/api/v1alpha1/clusterspiffeid_webhook.go +++ b/api/v1alpha1/clusterspiffeid_webhook.go @@ -76,6 +76,14 @@ func (r *ClusterSPIFFEIDCustomValidator) ValidateDelete(context.Context, *Cluste } func (r *ClusterSPIFFEIDCustomValidator) validate(o *ClusterSPIFFEID) (admission.Warnings, error) { + if value, ok := o.Annotations[AdditiveAnnotation]; ok { + if value != "true" && value != "false" { + return nil, fmt.Errorf("%s must be \"true\" or \"false\", not %q", AdditiveAnnotation, value) + } + if value == "true" && o.Spec.Fallback { + return nil, fmt.Errorf("fallback and %s cannot both be set", AdditiveAnnotation) + } + } _, err := ParseClusterSPIFFEIDSpec(&o.Spec) return nil, err } diff --git a/docs/clusterspiffeid-crd.md b/docs/clusterspiffeid-crd.md index dc8b633f..fd0a3c0f 100644 --- a/docs/clusterspiffeid-crd.md +++ b/docs/clusterspiffeid-crd.md @@ -30,6 +30,12 @@ The definition can be found [here](../api/v1alpha1/clusterspiffeid_types.go). | `fallback` | OPTIONAL | Apply this ID only if there are no other matching non fallback ClusterSPIFFEIDs. | | `className` | OPTIONAL | The class name of the SPIRE controller manager. | +## Annotations + +| Annotation | Description | +| ---------- | ----------- | +| `spire-controller-manager.edera.dev/additive` | `"true"` applies this ID without keeping fallback ClusterSPIFFEIDs from applying to the same pods, for an identity that sits beside a workload's own, such as a delegate's. Cannot be combined with `fallback`. | + ## ClusterSPIFFEIDStatus | Field | Description | diff --git a/pkg/spireentry/fallback_test.go b/pkg/spireentry/fallback_test.go new file mode 100644 index 00000000..82e9b9fd --- /dev/null +++ b/pkg/spireentry/fallback_test.go @@ -0,0 +1,104 @@ +package spireentry + +import ( + "context" + "strconv" + "testing" + + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + spirev1alpha1 "github.com/spiffe/spire-controller-manager/api/v1alpha1" +) + +func TestAdditiveClusterSPIFFEIDKeepsTheFallback(t *testing.T) { + for name, tc := range map[string]struct { + additive bool + wantFallback bool + }{ + "additive": {additive: true, wantFallback: true}, + "not additive": {additive: false, wantFallback: false}, + } { + t.Run(name, func(t *testing.T) { + declared := declaredSPIFFEIDs(t, + clusterSPIFFEID("fallback", "spiffe://{{ .TrustDomain }}/fallback", true, nil), + clusterSPIFFEID("beside", "spiffe://{{ .TrustDomain }}/beside", false, + map[string]string{spirev1alpha1.AdditiveAnnotation: strconv.FormatBool(tc.additive)}), + ) + require.Contains(t, declared, "spiffe://example.org/beside") + if tc.wantFallback { + require.Contains(t, declared, "spiffe://example.org/fallback") + } else { + require.NotContains(t, declared, "spiffe://example.org/fallback") + } + }) + } +} + +func TestAdditiveAnnotationIsValidated(t *testing.T) { + for name, tc := range map[string]struct { + value string + fallback bool + wantErr string + }{ + "additive": {value: "true"}, + "not additive": {value: "false", fallback: true}, + "additive fallback": {value: "true", fallback: true, wantErr: "fallback and"}, + "not a boolean": {value: "yes", wantErr: `not "yes"`}, + } { + t.Run(name, func(t *testing.T) { + c := clusterSPIFFEID("c", "spiffe://{{ .TrustDomain }}/c", tc.fallback, + map[string]string{spirev1alpha1.AdditiveAnnotation: tc.value}) + _, err := (&spirev1alpha1.ClusterSPIFFEIDCustomValidator{}).ValidateCreate(context.Background(), &c.ClusterSPIFFEID) + if tc.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.wantErr) + } + }) + } +} + +func clusterSPIFFEID(name, template string, fallback bool, annotations map[string]string) *ClusterSPIFFEID { + return &ClusterSPIFFEID{ClusterSPIFFEID: spirev1alpha1.ClusterSPIFFEID{ + ObjectMeta: metav1.ObjectMeta{Name: name, Annotations: annotations}, + Spec: spirev1alpha1.ClusterSPIFFEIDSpec{SPIFFEIDTemplate: template, Fallback: fallback}, + }} +} + +// declaredSPIFFEIDs renders the given ClusterSPIFFEIDs for one scheduled pod +// and returns the SPIFFE IDs they declare for it. +func declaredSPIFFEIDs(t *testing.T, clusterSPIFFEIDs ...*ClusterSPIFFEID) []string { + t.Helper() + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + node := &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "node-a", UID: "node-uid"}} + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "default", UID: "pod-uid"}, + Spec: corev1.PodSpec{NodeName: node.Name}, + }, + node, + ).Build() + + reconciler := &entryReconciler{config: ReconcilerConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + ClusterName: "cluster", + K8sClient: k8sClient, + }} + state := make(entriesState) + reconciler.addClusterSPIFFEIDEntriesState(context.Background(), state, clusterSPIFFEIDs, map[string]*corev1.Node{node.Name: node}) + + var declared []string + for _, entry := range state { + for _, d := range entry.Declared { + declared = append(declared, d.Entry.SPIFFEID.String()) + } + } + return declared +} diff --git a/pkg/spireentry/reconciler.go b/pkg/spireentry/reconciler.go index b28a0c3e..4196f71b 100644 --- a/pkg/spireentry/reconciler.go +++ b/pkg/spireentry/reconciler.go @@ -535,7 +535,7 @@ func (r *entryReconciler) addClusterSPIFFEIDEntriesState(ctx context.Context, st // renderPodEntry will return a nil entry if requisite k8s // objects disappeared from underneath. state.AddDeclared(*entry, clusterSPIFFEID) - if !clusterSPIFFEID.Spec.Fallback { + if !clusterSPIFFEID.Spec.Fallback && !clusterSPIFFEID.IsAdditive() { podsWithNonFallbackApplied[pods[i].UID] = struct{}{} } } From afee36f3c05839e2955874fca9f90f01112dee09 Mon Sep 17 00:00:00 2001 From: Benjamin Leggett Date: Wed, 23 Sep 2026 17:48:40 -0400 Subject: [PATCH 3/3] fix(ci): add support for building x.y.z-edera.q images Signed-off-by: Benjamin Leggett --- .github/workflows/pr_build.yaml | 2 +- .github/workflows/release_build.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr_build.yaml b/.github/workflows/pr_build.yaml index ce843a54..d80a3770 100644 --- a/.github/workflows/pr_build.yaml +++ b/.github/workflows/pr_build.yaml @@ -85,7 +85,7 @@ jobs: make load-images - name: Test image run: | - docker tag ghcr.io/spiffe/spire-controller-manager:devel ghcr.io/spiffe/spire-controller-manager:nightly + docker tag "ghcr.io/${{ github.repository_owner }}/spire-controller-manager:devel" ghcr.io/spiffe/spire-controller-manager:devel (cd demo; ./test.sh) success: diff --git a/.github/workflows/release_build.yaml b/.github/workflows/release_build.yaml index 5c02b748..788aceec 100644 --- a/.github/workflows/release_build.yaml +++ b/.github/workflows/release_build.yaml @@ -3,6 +3,7 @@ on: push: tags: - 'v[0-9].[0-9]+.[0-9]+' + - 'v[0-9].[0-9]+.[0-9]+-edera.[0-9]+' jobs: build-image: runs-on: ubuntu-22.04