Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bindata/rbac/rbac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ rules:
- apiGroups:
- config.openshift.io
resources:
- clusterimagepolicies
- imagedigestmirrorsets
- images
- networks
Expand Down
1 change: 1 addition & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ rules:
- apiGroups:
- config.openshift.io
resources:
- clusterimagepolicies
- imagedigestmirrorsets
- images
- networks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 21 additions & 10 deletions internal/dataplane/inventory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
287 changes: 287 additions & 0 deletions internal/dataplane/util/image_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strings"

ocpconfigv1 "github.com/openshift/api/config/v1"
mc "github.com/openshift/api/machineconfiguration/v1"
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"
)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading