From 922466ff7a657ba7bc2ddf4c51790584dda02c94 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 --- bindata/rbac/rbac.yaml | 1 + config/rbac/role.yaml | 1 + .../openstackdataplanenodeset_controller.go | 1 + internal/dataplane/inventory.go | 31 +- internal/dataplane/util/image_registry.go | 287 +++++++++++++ .../dataplane/util/image_registry_test.go | 399 ++++++++++++++++++ 6 files changed, 710 insertions(+), 10 deletions(-) diff --git a/bindata/rbac/rbac.yaml b/bindata/rbac/rbac.yaml index 35260f1f09..6480aa0e4b 100644 --- a/bindata/rbac/rbac.yaml +++ b/bindata/rbac/rbac.yaml @@ -304,6 +304,7 @@ rules: - apiGroups: - config.openshift.io resources: + - clusterimagepolicies - imagedigestmirrorsets - images - networks diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 1c68d8089b..5eb5ab7f75 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -255,6 +255,7 @@ rules: - apiGroups: - config.openshift.io resources: + - clusterimagepolicies - imagedigestmirrorsets - images - networks diff --git a/internal/controller/dataplane/openstackdataplanenodeset_controller.go b/internal/controller/dataplane/openstackdataplanenodeset_controller.go index c625a93359..7a268e98e8 100644 --- a/internal/controller/dataplane/openstackdataplanenodeset_controller.go +++ b/internal/controller/dataplane/openstackdataplanenodeset_controller.go @@ -133,6 +133,7 @@ func (r *OpenStackDataPlaneNodeSetReconciler) GetLogger(ctx context.Context) log // RBAC for ImageContentSourcePolicy and MachineConfig // +kubebuilder:rbac:groups="operator.openshift.io",resources=imagecontentsourcepolicies,verbs=get;list;watch +// +kubebuilder:rbac:groups="config.openshift.io",resources=clusterimagepolicies,verbs=get;list;watch // +kubebuilder:rbac:groups="config.openshift.io",resources=imagedigestmirrorsets,verbs=get;list;watch // +kubebuilder:rbac:groups="config.openshift.io",resources=images,verbs=get;list;watch // +kubebuilder:rbac:groups="machineconfiguration.openshift.io",resources=machineconfigs,verbs=get;list;watch diff --git a/internal/dataplane/inventory.go b/internal/dataplane/inventory.go index 5e16ace54d..e81bf5dde2 100644 --- a/internal/dataplane/inventory.go +++ b/internal/dataplane/inventory.go @@ -145,27 +145,38 @@ func GenerateNodeSetInventory(ctx context.Context, helper *helper.Helper, registryConfig, err := util.GetMCRegistryConf(ctx, helper) if err != nil { // CRD not installed (non-OpenShift or no MCO) - log warning and continue. - // This allows graceful degradation when running on non-OpenShift clusters. // Users can manually configure registries.conf via ansibleVars. if util.IsNoMatchError(err) { - helper.GetLogger().Info("Disconnected environment detected but MachineConfig CRD not available. "+ - "Registry configuration will not be propagated to dataplane nodes. "+ - "You may need to configure registries.conf manually using ansibleVars "+ - "(edpm_podman_disconnected_ocp and edpm_podman_registries_conf).", + helper.GetLogger().Info("MachineConfig CRD not available; registry config will not be propagated", "error", err.Error()) } else { - // CRD exists but resource not found, or other errors (network issues, - // permissions, etc.) - return the error. If MCO is installed but the - // registry MachineConfig doesn't exist, this indicates a misconfiguration. return "", fmt.Errorf("failed to get MachineConfig registry configuration: %w", err) } } else { helper.GetLogger().Info("Mirror registries detected via IDMS/ICSP. Using OCP registry configuration.") - - // Use OCP registries.conf for mirror registry deployments nodeSetGroup.Vars["edpm_podman_registries_conf"] = registryConfig nodeSetGroup.Vars["edpm_podman_disconnected_ocp"] = hasMirrorRegistries } + + mirrorScopes, sourceByMirror, 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, sourceByMirror) + if err != nil { + return "", fmt.Errorf("failed to get ClusterImagePolicy for sigstore verification: %w", err) + } + if sigstorePolicy != nil { + nodeSetGroup.Vars["edpm_container_signature_verification"] = true + nodeSetGroup.Vars["edpm_container_signature_registry_mappings"] = sigstorePolicy.RegistryMappings + 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 matching ClusterImagePolicy found; skipping sigstore verification") + } } // add TLS ansible variable diff --git a/internal/dataplane/util/image_registry.go b/internal/dataplane/util/image_registry.go index cc006fc828..5de520075b 100644 --- a/internal/dataplane/util/image_registry.go +++ b/internal/dataplane/util/image_registry.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "sort" "strings" ocpconfigv1 "github.com/openshift/api/config/v1" @@ -12,8 +13,11 @@ import ( ocpicsp "github.com/openshift/api/operator/v1alpha1" "github.com/openstack-k8s-operators/lib-common/modules/common/helper" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" k8s_errors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" ) @@ -65,6 +69,78 @@ func HasMirrorRegistries(ctx context.Context, helper *helper.Helper) (bool, erro return false, nil } +func sortedSetKeys(set map[string]struct{}) []string { + if len(set) == 0 { + return nil + } + result := make([]string, 0, len(set)) + for k := range set { + result = append(result, k) + } + sort.Strings(result) + return result +} + +// GetMirrorRegistryScopes returns the configured mirror scopes and their +// source registry mapping, preferring IDMS and falling back to ICSP. +// The returned scopes are normalized and de-duplicated for policy matching. +// The sourceByMirror map links each mirror scope back to its IDMS/ICSP source. +func GetMirrorRegistryScopes(ctx context.Context, helper *helper.Helper) ([]string, map[string]string, error) { + idmsList := &ocpconfigv1.ImageDigestMirrorSetList{} + if err := helper.GetClient().List(ctx, idmsList); err != nil { + if !IsNoMatchError(err) { + return nil, nil, err + } + } else { + scopes := map[string]struct{}{} + sourceByMirror := map[string]string{} + for _, idms := range idmsList.Items { + for _, mirrorSet := range idms.Spec.ImageDigestMirrors { + source := normalizeImageScope(string(mirrorSet.Source)) + for _, mirror := range mirrorSet.Mirrors { + m := normalizeImageScope(string(mirror)) + if m != "" { + scopes[m] = struct{}{} + if source != "" { + sourceByMirror[m] = source + } + } + } + } + } + if result := sortedSetKeys(scopes); len(result) > 0 { + return result, sourceByMirror, nil + } + } + + icspList := &ocpicsp.ImageContentSourcePolicyList{} + if err := helper.GetClient().List(ctx, icspList); err != nil { + if !IsNoMatchError(err) { + return nil, nil, err + } + } else { + scopes := map[string]struct{}{} + sourceByMirror := map[string]string{} + for _, icsp := range icspList.Items { + for _, mirrorSet := range icsp.Spec.RepositoryDigestMirrors { + source := normalizeImageScope(mirrorSet.Source) + for _, mirror := range mirrorSet.Mirrors { + m := normalizeImageScope(mirror) + if m != "" { + scopes[m] = struct{}{} + if source != "" { + sourceByMirror[m] = source + } + } + } + } + } + return sortedSetKeys(scopes), sourceByMirror, nil + } + + return nil, 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 +227,217 @@ func getMachineConfig(ctx context.Context, helper *helper.Helper) (mc.MachineCon return masterMachineConfig, nil } +// RegistryMapping pairs a mirror registry with its upstream source from IDMS/ICSP. +type RegistryMapping struct { + Mirror string `json:"mirror"` + Source string `json:"source"` +} + +// SigstorePolicyInfo contains the EDPM-relevant parts of a ClusterImagePolicy. +// A single RemapIdentity signedPrefix covers all mirrors under the same registry +// root — the container runtime replaces only the prefix, preserving namespace paths. +type SigstorePolicyInfo struct { + RegistryMappings []RegistryMapping + CosignKeyData string + SignedPrefix string +} + +const ( + clusterImagePolicyCRDName = "clusterimagepolicies.config.openshift.io" + clusterImagePolicyGroup = "config.openshift.io" + clusterImagePolicyKind = "ClusterImagePolicy" + clusterImagePolicyV1 = "v1" + clusterImagePolicyV1Alpha1 = "v1alpha1" + publicKeyRootOfTrustPolicyType = "PublicKey" + remapIdentityMatchPolicy = "RemapIdentity" +) + +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, "*.") { + mirrorHostPort := strings.SplitN(mirrorScope, "/", 2)[0] + mirrorHost := strings.SplitN(mirrorHostPort, ":", 2)[0] + suffix := strings.TrimPrefix(policyScope, "*") + return strings.HasSuffix(mirrorHost, suffix) + } + + return mirrorScope == policyScope || strings.HasPrefix(mirrorScope, policyScope+"/") +} + +func getServedClusterImagePolicyVersion(ctx context.Context, helper *helper.Helper) (string, error) { + crd := &apiextensionsv1.CustomResourceDefinition{} + if err := helper.GetClient().Get(ctx, types.NamespacedName{Name: clusterImagePolicyCRDName}, crd); err != nil { + if k8s_errors.IsNotFound(err) || IsNoMatchError(err) { + return "", nil + } + return "", err + } + + for _, preferredVersion := range []string{clusterImagePolicyV1, clusterImagePolicyV1Alpha1} { + for _, version := range crd.Spec.Versions { + if version.Name == preferredVersion && version.Served { + return preferredVersion, nil + } + } + } + + return "", nil +} + +func listClusterImagePolicies( + ctx context.Context, + helper *helper.Helper, + version string, +) (*unstructured.UnstructuredList, error) { + // Use an unstructured client here because ClusterImagePolicy may be served as + // either v1 or v1alpha1 depending on the cluster; binding to a typed v1 API + // would fail on clusters that do not serve v1 yet. + policyList := &unstructured.UnstructuredList{} + policyList.SetGroupVersionKind(schema.GroupVersionKind{ + Group: clusterImagePolicyGroup, + Version: version, + Kind: clusterImagePolicyKind + "List", + }) + + if err := helper.GetClient().List(ctx, policyList); err != nil { + if IsNoMatchError(err) { + return nil, nil + } + return nil, err + } + + return policyList, nil +} + +// GetSigstoreImagePolicy checks if OCP has a ClusterImagePolicy configured +// with sigstore signature verification for one of the mirror registries in use. +// sourceByMirror maps each mirror scope to its upstream source registry (from IDMS/ICSP). +// 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, sourceByMirror map[string]string) (*SigstorePolicyInfo, error) { + if len(mirrorScopes) == 0 { + return nil, nil + } + + version, err := getServedClusterImagePolicyVersion(ctx, helper) + if err != nil { + return nil, err + } + if version == "" { + return nil, nil + } + + policyList, err := listClusterImagePolicies(ctx, helper, version) + if err != nil { + return nil, err + } + if policyList == nil { + return nil, nil + } + + var matches []string + var match *SigstorePolicyInfo + + for _, policy := range policyList.Items { + if policy.GetName() == "openshift" { + continue + } + + policyType, found, err := unstructured.NestedString(policy.Object, "spec", "policy", "rootOfTrust", "policyType") + if err != nil { + return nil, fmt.Errorf("failed to parse ClusterImagePolicy %s policyType: %w", policy.GetName(), err) + } + if !found || policyType != publicKeyRootOfTrustPolicyType { + continue + } + + keyData, found, err := unstructured.NestedString(policy.Object, "spec", "policy", "rootOfTrust", "publicKey", "keyData") + if err != nil { + return nil, fmt.Errorf("failed to parse ClusterImagePolicy %s keyData: %w", policy.GetName(), err) + } + if !found || len(keyData) == 0 { + continue + } + + scopes, found, err := unstructured.NestedStringSlice(policy.Object, "spec", "scopes") + if err != nil { + return nil, fmt.Errorf("failed to parse ClusterImagePolicy %s scopes: %w", policy.GetName(), err) + } + if !found || len(scopes) == 0 { + continue + } + + signedPrefix := "" + matchPolicy, found, err := unstructured.NestedString(policy.Object, "spec", "policy", "signedIdentity", "matchPolicy") + if err != nil { + return nil, fmt.Errorf("failed to parse ClusterImagePolicy %s matchPolicy: %w", policy.GetName(), err) + } + if found && matchPolicy == remapIdentityMatchPolicy { + signedPrefix, _, err = unstructured.NestedString( + policy.Object, + "spec", "policy", "signedIdentity", "remapIdentity", "signedPrefix", + ) + if err != nil { + return nil, fmt.Errorf("failed to parse ClusterImagePolicy %s signedPrefix: %w", policy.GetName(), err) + } + } + + matchedMirrorScopes := map[string]struct{}{} + for _, scope := range scopes { + policyScope := normalizeImageScope(scope) + if policyScope == "" { + continue + } + + for _, mirrorScope := range mirrorScopes { + if clusterImagePolicyScopeMatchesMirror(policyScope, mirrorScope) { + matchedMirrorScopes[normalizeImageScope(mirrorScope)] = struct{}{} + } + } + } + if len(matchedMirrorScopes) == 0 { + continue + } + + sortedMirrors := sortedSetKeys(matchedMirrorScopes) + + mappings := make([]RegistryMapping, 0, len(sortedMirrors)) + for _, m := range sortedMirrors { + mappings = append(mappings, RegistryMapping{ + Mirror: m, + Source: sourceByMirror[m], + }) + } + + matches = append(matches, fmt.Sprintf("%s (%s)", policy.GetName(), strings.Join(sortedMirrors, ", "))) + match = &SigstorePolicyInfo{ + RegistryMappings: mappings, + CosignKeyData: 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..a1bd93c493 100644 --- a/internal/dataplane/util/image_registry_test.go +++ b/internal/dataplane/util/image_registry_test.go @@ -20,6 +20,7 @@ import ( "context" "encoding/base64" "errors" + "fmt" "testing" . "github.com/onsi/gomega" //revive:disable:dot-imports @@ -30,8 +31,11 @@ import ( "github.com/openstack-k8s-operators/lib-common/modules/common/helper" corev1 "github.com/openstack-k8s-operators/openstack-operator/api/core/v1beta1" k8s_corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" @@ -44,6 +48,7 @@ import ( func setupTestHelper(includeOpenShiftCRDs bool, objects ...client.Object) *helper.Helper { s := runtime.NewScheme() _ = scheme.AddToScheme(s) + _ = apiextensionsv1.AddToScheme(s) _ = corev1.AddToScheme(s) _ = k8s_corev1.AddToScheme(s) @@ -237,6 +242,135 @@ 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, sourceByMirror, err := GetMirrorRegistryScopes(ctx, h) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(scopes).To(Equal([]string{"mirror.example.com:5000/rhosp-dev-preview"})) + g.Expect(sourceByMirror).To(Equal(map[string]string{ + "mirror.example.com:5000/rhosp-dev-preview": "registry.redhat.io/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, sourceByMirror, err := GetMirrorRegistryScopes(ctx, h) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(scopes).To(Equal([]string{"mirror.example.com:5000/openstack-k8s-operators"})) + g.Expect(sourceByMirror).To(Equal(map[string]string{ + "mirror.example.com:5000/openstack-k8s-operators": "quay.io/openstack-k8s-operators", + })) +} + +func TestGetMirrorRegistryScopes_MultipleIDMS(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + idms1 := &ocpidms.ImageDigestMirrorSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "idms-one", + }, + Spec: ocpidms.ImageDigestMirrorSetSpec{ + ImageDigestMirrors: []ocpidms.ImageDigestMirrors{ + { + Source: "registry.redhat.io/rhoso", + Mirrors: []ocpidms.ImageMirror{"mirror.example.com:5000/rhoso"}, + }, + }, + }, + } + idms2 := &ocpidms.ImageDigestMirrorSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "idms-two", + }, + Spec: ocpidms.ImageDigestMirrorSetSpec{ + ImageDigestMirrors: []ocpidms.ImageDigestMirrors{ + { + Source: "registry.redhat.io/rhoso-operators", + Mirrors: []ocpidms.ImageMirror{"mirror.example.com:5000/rhoso-operators"}, + }, + }, + }, + } + + h := setupTestHelper(true, idms1, idms2) + + scopes, sourceByMirror, err := GetMirrorRegistryScopes(ctx, h) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(scopes).To(Equal([]string{ + "mirror.example.com:5000/rhoso", + "mirror.example.com:5000/rhoso-operators", + })) + g.Expect(sourceByMirror).To(Equal(map[string]string{ + "mirror.example.com:5000/rhoso": "registry.redhat.io/rhoso", + "mirror.example.com:5000/rhoso-operators": "registry.redhat.io/rhoso-operators", + })) +} + +func TestGetMirrorRegistryScopes_CRDsNotInstalled(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + h := setupTestHelper(false) + + scopes, sourceByMirror, err := GetMirrorRegistryScopes(ctx, h) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(scopes).To(BeNil()) + g.Expect(sourceByMirror).To(BeNil()) +} + // Test GetMCRegistryConf scenarios func TestGetMCRegistryConf_Success(t *testing.T) { g := NewWithT(t) @@ -544,3 +678,268 @@ func TestGetMirrorRegistryCACerts_ConfigMapNotFound(t *testing.T) { g.Expect(err).ToNot(HaveOccurred()) g.Expect(caCerts).To(BeNil()) } + +func newSigstorePolicy( + version string, + name string, + scopes []string, + keyData string, + matchPolicy string, + signedPrefix string, +) *unstructured.Unstructured { + rawScopes := make([]interface{}, 0, len(scopes)) + for _, scope := range scopes { + rawScopes = append(rawScopes, scope) + } + + raw := map[string]interface{}{ + "apiVersion": fmt.Sprintf("config.openshift.io/%s", version), + "kind": "ClusterImagePolicy", + "metadata": map[string]interface{}{ + "name": name, + }, + "spec": map[string]interface{}{ + "scopes": rawScopes, + "policy": map[string]interface{}{ + "rootOfTrust": map[string]interface{}{ + "policyType": "PublicKey", + "publicKey": map[string]interface{}{ + "keyData": base64.StdEncoding.EncodeToString([]byte(keyData)), + }, + }, + "signedIdentity": map[string]interface{}{ + "matchPolicy": matchPolicy, + }, + }, + }, + } + + if matchPolicy == "RemapIdentity" { + rawPolicy := raw["spec"].(map[string]interface{})["policy"].(map[string]interface{}) + rawPolicy["signedIdentity"].(map[string]interface{})["remapIdentity"] = map[string]interface{}{ + "prefix": scopes[0], + "signedPrefix": signedPrefix, + } + } + + policy := &unstructured.Unstructured{Object: raw} + policy.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "config.openshift.io", + Version: version, + Kind: "ClusterImagePolicy", + }) + + return policy +} + +func newClusterImagePolicyCRD(servedVersions ...string) *apiextensionsv1.CustomResourceDefinition { + versions := make([]apiextensionsv1.CustomResourceDefinitionVersion, 0, len(servedVersions)) + for i, version := range servedVersions { + versions = append(versions, apiextensionsv1.CustomResourceDefinitionVersion{ + Name: version, + Served: true, + Storage: i == 0, + }) + } + + return &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "clusterimagepolicies.config.openshift.io"}, + Spec: apiextensionsv1.CustomResourceDefinitionSpec{ + Group: "config.openshift.io", + Names: apiextensionsv1.CustomResourceDefinitionNames{ + Kind: "ClusterImagePolicy", + Plural: "clusterimagepolicies", + }, + Scope: apiextensionsv1.ClusterScoped, + Versions: versions, + }, + } +} + +func TestGetSigstoreImagePolicy_WithRemapIdentity(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + policy := newSigstorePolicy( + "v1", + "test-policy", + []string{"local-registry.example.com:5000"}, + "test-public-key", + "RemapIdentity", + "registry.example.com/vendor", + ) + + h := setupTestHelper(true, newClusterImagePolicyCRD("v1"), policy) + + sourceByMirror := map[string]string{ + "local-registry.example.com:5000": "registry.example.com/vendor", + } + result, err := GetSigstoreImagePolicy(ctx, h, []string{"local-registry.example.com:5000"}, sourceByMirror) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.RegistryMappings).To(Equal([]RegistryMapping{ + {Mirror: "local-registry.example.com:5000", Source: "registry.example.com/vendor"}, + })) + 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( + "v1alpha1", + "test-policy", + []string{"local-registry.example.com:5000"}, + "test-public-key", + "MatchRepoDigestOrExact", + "", + ) + + h := setupTestHelper(true, newClusterImagePolicyCRD("v1alpha1"), policy) + + result, err := GetSigstoreImagePolicy(ctx, h, []string{"local-registry.example.com:5000"}, nil) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.RegistryMappings).To(Equal([]RegistryMapping{ + {Mirror: "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_ReturnsAllMatchingMirrorScopes(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + policy := newSigstorePolicy( + "v1alpha1", + "test-policy", + []string{"mirror.example.com:5000"}, + "test-public-key", + "MatchRepoDigestOrExact", + "", + ) + + h := setupTestHelper(true, newClusterImagePolicyCRD("v1alpha1"), policy) + + sourceByMirror := map[string]string{ + "mirror.example.com:5000/rhoso": "registry.redhat.io/rhoso", + "mirror.example.com:5000/rhoso-operators": "registry.redhat.io/rhoso-operators", + } + result, err := GetSigstoreImagePolicy(ctx, h, []string{ + "mirror.example.com:5000/rhoso", + "mirror.example.com:5000/rhoso-operators", + }, sourceByMirror) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.RegistryMappings).To(Equal([]RegistryMapping{ + {Mirror: "mirror.example.com:5000/rhoso", Source: "registry.redhat.io/rhoso"}, + {Mirror: "mirror.example.com:5000/rhoso-operators", Source: "registry.redhat.io/rhoso-operators"}, + })) + g.Expect(result.CosignKeyData).To(Equal(base64.StdEncoding.EncodeToString([]byte("test-public-key")))) +} + +func TestGetSigstoreImagePolicy_IgnoresNonMatchingPolicies(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + policy := newSigstorePolicy( + "v1alpha1", + "other-policy", + []string{"other-registry.example.com:5000"}, + "test-public-key", + "MatchRepoDigestOrExact", + "", + ) + + h := setupTestHelper(true, newClusterImagePolicyCRD("v1alpha1"), policy) + + result, err := GetSigstoreImagePolicy(ctx, h, []string{"mirror.example.com:5000/openstack-k8s-operators"}, nil) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(BeNil()) +} + +func TestGetSigstoreImagePolicy_ReturnsErrorForAmbiguousPolicies(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + policy1 := newSigstorePolicy( + "v1alpha1", + "policy-one", + []string{"mirror.example.com:5000/openstack-k8s-operators"}, + "key-one", + "MatchRepoDigestOrExact", + "", + ) + policy2 := newSigstorePolicy( + "v1alpha1", + "policy-two", + []string{"mirror.example.com:5000/openstack-k8s-operators"}, + "key-two", + "MatchRepoDigestOrExact", + "", + ) + + h := setupTestHelper(true, newClusterImagePolicyCRD("v1alpha1"), policy1, policy2) + + result, err := GetSigstoreImagePolicy(ctx, h, []string{"mirror.example.com:5000/openstack-k8s-operators"}, nil) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("expected exactly one ClusterImagePolicy matching mirror registries")) + g.Expect(result).To(BeNil()) +} + +func TestGetSigstoreImagePolicy_CRDNotInstalled(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + h := setupTestHelper(false) + + result, err := GetSigstoreImagePolicy(ctx, h, []string{"mirror.example.com:5000/openstack-k8s-operators"}, nil) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(BeNil()) +} + +func TestGetSigstoreImagePolicy_WildcardScopeMatch(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + policy := newSigstorePolicy( + "v1", + "wildcard-policy", + []string{"*.example.com"}, + "test-public-key", + "MatchRepoDigestOrExact", + "", + ) + + h := setupTestHelper(true, newClusterImagePolicyCRD("v1"), policy) + + result, err := GetSigstoreImagePolicy(ctx, h, []string{"mirror.example.com:5000/rhoso"}, nil) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.RegistryMappings).To(Equal([]RegistryMapping{ + {Mirror: "mirror.example.com:5000/rhoso"}, + })) +} + +func TestGetSigstoreImagePolicy_SkipsOpenshiftPolicy(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + openshiftPolicy := newSigstorePolicy( + "v1", + "openshift", + []string{"mirror.example.com:5000"}, + "openshift-key", + "MatchRepoDigestOrExact", + "", + ) + + h := setupTestHelper(true, newClusterImagePolicyCRD("v1"), openshiftPolicy) + + result, err := GetSigstoreImagePolicy(ctx, h, []string{"mirror.example.com:5000/rhoso"}, nil) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(BeNil()) +}