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()) +}