From cb4b072e24f62e7a0465dbbbb52eb86508a3c406 Mon Sep 17 00:00:00 2001 From: rabi Date: Thu, 9 Apr 2026 12:47:14 +0530 Subject: [PATCH] Auto-detect sigstore ClusterImagePolicy for EDPM signature verification When a disconnected environment has a ClusterImagePolicy configured with sigstore (cosign) signature verification for a mirror registry, the openstack-operator now auto-detects it and passes the necessary ansible variables to edpm-ansible for configuring signature verification on EDPM data plane nodes. if ClusterImagePolicy CRD is not installed or no relevant policy exists, the operator continues without enabling signature verification. This maintains backward compatibility. Requires: OCP 4.20+ (sigstore GA) and oc-mirror v2. There would be a follow-up edpm-ansible patch to use these ansible vars. jira: OSPRH-28852 Change-Id: I2cbc4e83884562bd17065ee7158e00e5c9b12160 Signed-off-by: rabi --- cmd/main.go | 2 + internal/dataplane/inventory.go | 23 ++ internal/dataplane/util/image_registry.go | 179 ++++++++++++++++ .../dataplane/util/image_registry_test.go | 197 ++++++++++++++++++ 4 files changed, 401 insertions(+) diff --git a/cmd/main.go b/cmd/main.go index 6df1bb0aed..18ae67d28d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -50,6 +50,7 @@ import ( certmgrv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" k8s_networkv1 "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" ocp_configv1 "github.com/openshift/api/config/v1" + ocp_configv1alpha1 "github.com/openshift/api/config/v1alpha1" machineconfig "github.com/openshift/api/machineconfiguration/v1" ocp_image "github.com/openshift/api/operator/v1alpha1" routev1 "github.com/openshift/api/route/v1" @@ -124,6 +125,7 @@ func init() { utilruntime.Must(certmgrv1.AddToScheme(scheme)) utilruntime.Must(barbicanv1.AddToScheme(scheme)) utilruntime.Must(ocp_configv1.AddToScheme(scheme)) + utilruntime.Must(ocp_configv1alpha1.Install(scheme)) utilruntime.Must(ocp_image.AddToScheme(scheme)) utilruntime.Must(machineconfig.AddToScheme(scheme)) utilruntime.Must(k8s_networkv1.AddToScheme(scheme)) diff --git a/internal/dataplane/inventory.go b/internal/dataplane/inventory.go index 6d78a18de8..7ecd3dc130 100644 --- a/internal/dataplane/inventory.go +++ b/internal/dataplane/inventory.go @@ -168,6 +168,29 @@ func GenerateNodeSetInventory(ctx context.Context, helper *helper.Helper, } } + // Propagate sigstore verification settings from ClusterImagePolicy to EDPM. + if hasMirrorRegistries { + mirrorScopes, err := util.GetMirrorRegistryScopes(ctx, helper) + if err != nil { + return "", fmt.Errorf("failed to get mirror registries for sigstore verification: %w", err) + } + + sigstorePolicy, err := util.GetSigstoreImagePolicy(ctx, helper, mirrorScopes) + if err != nil { + return "", fmt.Errorf("failed to get ClusterImagePolicy for sigstore verification: %w", err) + } else if sigstorePolicy != nil { + nodeSetGroup.Vars["edpm_container_signature_verification"] = true + nodeSetGroup.Vars["edpm_container_signature_mirror_registry"] = sigstorePolicy.MirrorRegistry + nodeSetGroup.Vars["edpm_container_signature_cosign_key_data"] = sigstorePolicy.CosignKeyData + if sigstorePolicy.SignedPrefix != "" { + nodeSetGroup.Vars["edpm_container_signature_signed_prefix"] = sigstorePolicy.SignedPrefix + } + } else { + helper.GetLogger().Info("No sigstore ClusterImagePolicy found for mirror registries. " + + "Continuing without signature verification on dataplane nodes.") + } + } + // add TLS ansible variable nodeSetGroup.Vars["edpm_tls_certs_enabled"] = instance.Spec.TLSEnabled if instance.Spec.Tags != nil { diff --git a/internal/dataplane/util/image_registry.go b/internal/dataplane/util/image_registry.go index cc006fc828..bacc815982 100644 --- a/internal/dataplane/util/image_registry.go +++ b/internal/dataplane/util/image_registry.go @@ -5,9 +5,11 @@ import ( "encoding/base64" "encoding/json" "fmt" + "sort" "strings" ocpconfigv1 "github.com/openshift/api/config/v1" + ocpconfigv1alpha1 "github.com/openshift/api/config/v1alpha1" mc "github.com/openshift/api/machineconfiguration/v1" ocpicsp "github.com/openshift/api/operator/v1alpha1" "github.com/openstack-k8s-operators/lib-common/modules/common/helper" @@ -65,6 +67,70 @@ func HasMirrorRegistries(ctx context.Context, helper *helper.Helper) (bool, erro return false, nil } +func collectMirrorScopes(scopes map[string]struct{}, values []string) []string { + for _, value := range values { + scope := normalizeImageScope(value) + if scope != "" { + scopes[scope] = struct{}{} + } + } + + if len(scopes) == 0 { + return nil + } + + result := make([]string, 0, len(scopes)) + for scope := range scopes { + result = append(result, scope) + } + sort.Strings(result) + + return result +} + +// GetMirrorRegistryScopes returns the configured mirror scopes, preferring IDMS +// and falling back to ICSP only when no IDMS mirror scopes are present. +// The returned values are normalized and de-duplicated for policy matching. +func GetMirrorRegistryScopes(ctx context.Context, helper *helper.Helper) ([]string, error) { + idmsList := &ocpconfigv1.ImageDigestMirrorSetList{} + if err := helper.GetClient().List(ctx, idmsList); err != nil { + if !IsNoMatchError(err) { + return nil, err + } + } else { + scopes := map[string]struct{}{} + for _, idms := range idmsList.Items { + for _, mirrorSet := range idms.Spec.ImageDigestMirrors { + mirrorValues := make([]string, 0, len(mirrorSet.Mirrors)) + for _, mirror := range mirrorSet.Mirrors { + mirrorValues = append(mirrorValues, string(mirror)) + } + result := collectMirrorScopes(scopes, mirrorValues) + if len(result) > 0 { + return result, nil + } + } + } + } + + icspList := &ocpicsp.ImageContentSourcePolicyList{} + if err := helper.GetClient().List(ctx, icspList); err != nil { + if !IsNoMatchError(err) { + return nil, err + } + } else { + scopes := map[string]struct{}{} + for _, icsp := range icspList.Items { + for _, mirrorSet := range icsp.Spec.RepositoryDigestMirrors { + _ = collectMirrorScopes(scopes, mirrorSet.Mirrors) + } + } + return collectMirrorScopes(scopes, nil), nil + } + + return nil, nil +} + // IsNoMatchError checks if the error indicates that a CRD/resource type doesn't exist func IsNoMatchError(err error) bool { errStr := err.Error() @@ -151,6 +217,119 @@ func getMachineConfig(ctx context.Context, helper *helper.Helper) (mc.MachineCon return masterMachineConfig, nil } +// SigstorePolicyInfo contains the EDPM-relevant parts of a ClusterImagePolicy. +type SigstorePolicyInfo struct { + MirrorRegistry string + CosignKeyData string + SignedPrefix string +} + +func normalizeImageScope(scope string) string { + return strings.TrimSuffix(strings.TrimSpace(scope), "/") +} + +func clusterImagePolicyScopeMatchesMirror(policyScope string, mirrorScope string) bool { + policyScope = normalizeImageScope(policyScope) + mirrorScope = normalizeImageScope(mirrorScope) + + if policyScope == "" || mirrorScope == "" { + return false + } + + if strings.HasPrefix(policyScope, "*.") { + mirrorHost := strings.SplitN(mirrorScope, "/", 2)[0] + suffix := strings.TrimPrefix(policyScope, "*") + return strings.HasSuffix(mirrorHost, suffix) + } + + return mirrorScope == policyScope || strings.HasPrefix(mirrorScope, policyScope+"/") +} + +// GetSigstoreImagePolicy checks if OCP has a ClusterImagePolicy configured +// with sigstore signature verification for one of the mirror registries in use. +// Returns policy info if a relevant policy is found, nil if no policy exists. +// Returns nil without error if the ClusterImagePolicy CRD is not installed. +func GetSigstoreImagePolicy(ctx context.Context, helper *helper.Helper, mirrorScopes []string) (*SigstorePolicyInfo, error) { + if len(mirrorScopes) == 0 { + return nil, nil + } + + policyList := &ocpconfigv1alpha1.ClusterImagePolicyList{} + if err := helper.GetClient().List(ctx, policyList); err != nil { + if IsNoMatchError(err) { + return nil, nil + } + return nil, err + } + + var matches []string + var match *SigstorePolicyInfo + + for _, policy := range policyList.Items { + if policy.Name == "openshift" { + continue + } + + if policy.Spec.Policy.RootOfTrust.PolicyType != ocpconfigv1alpha1.PublicKeyRootOfTrust { + continue + } + + if policy.Spec.Policy.RootOfTrust.PublicKey == nil { + continue + } + + keyData := policy.Spec.Policy.RootOfTrust.PublicKey.KeyData + if len(keyData) == 0 { + continue + } + + if len(policy.Spec.Scopes) == 0 { + continue + } + + signedPrefix := "" + if policy.Spec.Policy.SignedIdentity.MatchPolicy == ocpconfigv1alpha1.IdentityMatchPolicyRemapIdentity && + policy.Spec.Policy.SignedIdentity.PolicyMatchRemapIdentity != nil { + signedPrefix = string(policy.Spec.Policy.SignedIdentity.PolicyMatchRemapIdentity.SignedPrefix) + } + + for _, scope := range policy.Spec.Scopes { + policyScope := normalizeImageScope(string(scope)) + if policyScope == "" { + continue + } + + matchesMirror := false + for _, mirrorScope := range mirrorScopes { + if clusterImagePolicyScopeMatchesMirror(policyScope, mirrorScope) { + matchesMirror = true + break + } + } + if !matchesMirror { + continue + } + + matches = append(matches, fmt.Sprintf("%s (%s)", policy.Name, policyScope)) + match = &SigstorePolicyInfo{ + MirrorRegistry: policyScope, + CosignKeyData: base64.StdEncoding.EncodeToString(keyData), + SignedPrefix: signedPrefix, + } + } + } + + if len(matches) > 1 { + sort.Strings(matches) + return nil, fmt.Errorf( + "expected exactly one ClusterImagePolicy matching mirror registries, found %d: %s", + len(matches), strings.Join(matches, ", "), + ) + } + + return match, nil +} + // GetMirrorRegistryCACerts retrieves CA certificates from image.config.openshift.io/cluster. // Returns nil without error if: // - not on OpenShift (Image CRD doesn't exist) diff --git a/internal/dataplane/util/image_registry_test.go b/internal/dataplane/util/image_registry_test.go index 21155078fb..4a5d9206bb 100644 --- a/internal/dataplane/util/image_registry_test.go +++ b/internal/dataplane/util/image_registry_test.go @@ -25,6 +25,7 @@ import ( . "github.com/onsi/gomega" //revive:disable:dot-imports ocpidms "github.com/openshift/api/config/v1" + ocpconfigv1alpha1 "github.com/openshift/api/config/v1alpha1" mc "github.com/openshift/api/machineconfiguration/v1" ocpicsp "github.com/openshift/api/operator/v1alpha1" "github.com/openstack-k8s-operators/lib-common/modules/common/helper" @@ -50,6 +51,7 @@ func setupTestHelper(includeOpenShiftCRDs bool, objects ...client.Object) *helpe if includeOpenShiftCRDs { _ = ocpicsp.AddToScheme(s) _ = ocpidms.AddToScheme(s) + _ = ocpconfigv1alpha1.Install(s) _ = mc.AddToScheme(s) } @@ -237,6 +239,72 @@ func TestHasMirrorRegistries_CRDsNotInstalled(t *testing.T) { g.Expect(hasMirrors).To(BeFalse(), "Should return false when CRDs don't exist (graceful degradation)") } +func TestGetMirrorRegistryScopes(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + idms := &ocpidms.ImageDigestMirrorSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-idms", + }, + Spec: ocpidms.ImageDigestMirrorSetSpec{ + ImageDigestMirrors: []ocpidms.ImageDigestMirrors{ + { + Source: "registry.redhat.io/rhosp-dev-preview", + Mirrors: []ocpidms.ImageMirror{ + "mirror.example.com:5000/rhosp-dev-preview", + "mirror.example.com:5000/rhosp-dev-preview", + }, + }, + }, + }, + } + icsp := &ocpicsp.ImageContentSourcePolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-icsp", + }, + Spec: ocpicsp.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []ocpicsp.RepositoryDigestMirrors{ + { + Source: "quay.io/openstack-k8s-operators", + Mirrors: []string{"mirror.example.com:5000/openstack-k8s-operators/"}, + }, + }, + }, + } + + h := setupTestHelper(true, idms, icsp) + + scopes, err := GetMirrorRegistryScopes(ctx, h) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(scopes).To(Equal([]string{"mirror.example.com:5000/rhosp-dev-preview"})) +} + +func TestGetMirrorRegistryScopes_FallsBackToICSP(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + icsp := &ocpicsp.ImageContentSourcePolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-icsp", + }, + Spec: ocpicsp.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []ocpicsp.RepositoryDigestMirrors{ + { + Source: "quay.io/openstack-k8s-operators", + Mirrors: []string{"mirror.example.com:5000/openstack-k8s-operators/"}, + }, + }, + }, + } + + h := setupTestHelper(true, icsp) + + scopes, err := GetMirrorRegistryScopes(ctx, h) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(scopes).To(Equal([]string{"mirror.example.com:5000/openstack-k8s-operators"})) +} + // Test GetMCRegistryConf scenarios func TestGetMCRegistryConf_Success(t *testing.T) { g := NewWithT(t) @@ -544,3 +612,132 @@ func TestGetMirrorRegistryCACerts_ConfigMapNotFound(t *testing.T) { g.Expect(err).ToNot(HaveOccurred()) g.Expect(caCerts).To(BeNil()) } + +func newSigstorePolicy( + name string, + scopes []string, + keyData string, + matchPolicy ocpconfigv1alpha1.IdentityMatchPolicy, + signedPrefix string, +) *ocpconfigv1alpha1.ClusterImagePolicy { + policy := &ocpconfigv1alpha1.ClusterImagePolicy{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: ocpconfigv1alpha1.ClusterImagePolicySpec{ + Scopes: make([]ocpconfigv1alpha1.ImageScope, 0, len(scopes)), + Policy: ocpconfigv1alpha1.Policy{ + RootOfTrust: ocpconfigv1alpha1.PolicyRootOfTrust{ + PolicyType: ocpconfigv1alpha1.PublicKeyRootOfTrust, + PublicKey: &ocpconfigv1alpha1.PublicKey{ + KeyData: []byte(keyData), + }, + }, + SignedIdentity: ocpconfigv1alpha1.PolicyIdentity{ + MatchPolicy: matchPolicy, + }, + }, + }, + } + + for _, scope := range scopes { + policy.Spec.Scopes = append(policy.Spec.Scopes, ocpconfigv1alpha1.ImageScope(scope)) + } + + if matchPolicy == ocpconfigv1alpha1.IdentityMatchPolicyRemapIdentity { + policy.Spec.Policy.SignedIdentity.PolicyMatchRemapIdentity = &ocpconfigv1alpha1.PolicyMatchRemapIdentity{ + Prefix: ocpconfigv1alpha1.IdentityRepositoryPrefix(scopes[0]), + SignedPrefix: ocpconfigv1alpha1.IdentityRepositoryPrefix(signedPrefix), + } + } + + return policy +} + +func TestGetSigstoreImagePolicy_WithRemapIdentity(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + policy := newSigstorePolicy( + "test-policy", + []string{"local-registry.example.com:5000"}, + "test-public-key", + ocpconfigv1alpha1.IdentityMatchPolicyRemapIdentity, + "registry.example.com/vendor", + ) + + h := setupTestHelper(true, policy) + + result, err := GetSigstoreImagePolicy(ctx, h, []string{"local-registry.example.com:5000"}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.MirrorRegistry).To(Equal("local-registry.example.com:5000")) + g.Expect(result.CosignKeyData).To(Equal(base64.StdEncoding.EncodeToString([]byte("test-public-key")))) + g.Expect(result.SignedPrefix).To(Equal("registry.example.com/vendor")) +} + +func TestGetSigstoreImagePolicy(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + policy := newSigstorePolicy( + "test-policy", + []string{"local-registry.example.com:5000"}, + "test-public-key", + ocpconfigv1alpha1.IdentityMatchPolicyMatchRepoDigestOrExact, + "", + ) + + h := setupTestHelper(true, policy) + + result, err := GetSigstoreImagePolicy(ctx, h, []string{"local-registry.example.com:5000"}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.MirrorRegistry).To(Equal("local-registry.example.com:5000")) + g.Expect(result.CosignKeyData).To(Equal(base64.StdEncoding.EncodeToString([]byte("test-public-key")))) + g.Expect(result.SignedPrefix).To(BeEmpty()) +} + +func TestGetSigstoreImagePolicy_IgnoresNonMatchingPolicies(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + policy := newSigstorePolicy( + "other-policy", + []string{"other-registry.example.com:5000"}, + "test-public-key", + ocpconfigv1alpha1.IdentityMatchPolicyMatchRepoDigestOrExact, + "", + ) + + h := setupTestHelper(true, policy) + + result, err := GetSigstoreImagePolicy(ctx, h, []string{"mirror.example.com:5000/openstack-k8s-operators"}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(BeNil()) +} + +func TestGetSigstoreImagePolicy_ReturnsErrorForAmbiguousPolicies(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + policy1 := newSigstorePolicy( + "policy-one", + []string{"mirror.example.com:5000/openstack-k8s-operators"}, + "key-one", + ocpconfigv1alpha1.IdentityMatchPolicyMatchRepoDigestOrExact, + "", + ) + policy2 := newSigstorePolicy( + "policy-two", + []string{"mirror.example.com:5000/openstack-k8s-operators"}, + "key-two", + ocpconfigv1alpha1.IdentityMatchPolicyMatchRepoDigestOrExact, + "", + ) + + h := setupTestHelper(true, policy1, policy2) + + result, err := GetSigstoreImagePolicy(ctx, h, []string{"mirror.example.com:5000/openstack-k8s-operators"}) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("expected exactly one ClusterImagePolicy matching mirror registries")) + g.Expect(result).To(BeNil()) +}