From 539195c8f04166cd707523a4e3521f83eab15b9f Mon Sep 17 00:00:00 2001 From: Todd Wolff Date: Wed, 22 Apr 2026 20:00:44 -0400 Subject: [PATCH 1/5] feat(api): add AzureImageRegistryCredentials with UserAssignedManagedIdentity Add AzureImageRegistryCredentials struct on AzureNodePoolPlatform to configure kubelet credential provider for ACR authentication using a user-assigned managed identity on worker node VMs. - UserAssignedManagedIdentity struct with resourceID (required) - AzureManagedIdentityResourceID named type with CEL ARM resource ID validation, MinLength=1, MaxLength=512 - ImageRegistryCredentials is optional, value type with omitzero, triggers node rollout when changed - No Registries field: controller hardcodes wildcard patterns covering all standard ACR endpoints (*.azurecr.io/cn/de/us) Co-Authored-By: Claude Opus 4.6 (1M context) --- api/hypershift/v1beta1/azure.go | 46 +++++++++++++++++++ .../v1beta1/zz_generated.deepcopy.go | 32 +++++++++++++ .../AAA_ungated.yaml | 34 ++++++++++++++ .../GCPPlatform.yaml | 34 ++++++++++++++ .../OpenStack.yaml | 34 ++++++++++++++ 5 files changed, 180 insertions(+) diff --git a/api/hypershift/v1beta1/azure.go b/api/hypershift/v1beta1/azure.go index c99523ceedfc..8467fec88b01 100644 --- a/api/hypershift/v1beta1/azure.go +++ b/api/hypershift/v1beta1/azure.go @@ -109,6 +109,52 @@ type AzureNodePoolPlatform struct { // If not specified, then Boot diagnostics will be disabled. // +optional Diagnostics *Diagnostics `json:"diagnostics,omitempty"` + + // imageRegistryCredentials specifies configuration for enabling kubelet's image credential + // provider to authenticate to Azure Container Registry (ACR) using a managed identity. + // When configured, worker nodes will use the acr-credential-provider plugin to obtain + // short-lived tokens for ACR image pulls instead of relying on static pull secrets. + // Changing this field will trigger a node rollout. + // When not configured, no additional image credential provider is set up and worker nodes + // use the default pull secret for image authentication. + // +optional + ImageRegistryCredentials AzureImageRegistryCredentials `json:"imageRegistryCredentials,omitzero"` +} + +// AzureManagedIdentityResourceID is a full Azure resource ID for a user-assigned managed identity. +// The expected format is: +// +// /subscriptions/{subscriptionID}/resourceGroups/{resourceGroup}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} +// +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=512 +// +kubebuilder:validation:XValidation:rule="self.matches('^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\\\.ManagedIdentity/userAssignedIdentities/[^/]+$')",message="must be a valid ARM resource ID for a user-assigned managed identity (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name})" +type AzureManagedIdentityResourceID string + +// UserAssignedManagedIdentity specifies a user-assigned managed identity for Azure resource +// authentication. The resourceID is required for VMSS identity attachment and is also used +// for the kubelet credential provider configuration. +type UserAssignedManagedIdentity struct { + // resourceID is the ARM resource ID of the user-assigned managed identity. + // + // Format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} + // + // +required + ResourceID AzureManagedIdentityResourceID `json:"resourceID,omitempty"` +} + +// AzureImageRegistryCredentials configures the kubelet credential provider for ACR +// authentication using a user-assigned managed identity on worker node VMs. +// The credential provider is configured with wildcard patterns covering all standard Azure +// Container Registry endpoints (*.azurecr.io, *.azurecr.cn, *.azurecr.de, *.azurecr.us). +// The identity must have the AcrPull role granted on the target ACR registry(ies). +type AzureImageRegistryCredentials struct { + // managedIdentity specifies the user-assigned managed identity that will be assigned to + // worker node VMs/VMSS. The credential provider plugin running on each node will use this + // identity to authenticate to ACR via the Azure Instance Metadata Service (IMDS). + // + // +required + ManagedIdentity UserAssignedManagedIdentity `json:"managedIdentity,omitzero"` } // AzureVMImage represents the different types of boot image sources that can be provided for an Azure VM. diff --git a/api/hypershift/v1beta1/zz_generated.deepcopy.go b/api/hypershift/v1beta1/zz_generated.deepcopy.go index 558be0746f8f..8af1ff492953 100644 --- a/api/hypershift/v1beta1/zz_generated.deepcopy.go +++ b/api/hypershift/v1beta1/zz_generated.deepcopy.go @@ -621,6 +621,22 @@ func (in *AzureAuthenticationConfiguration) DeepCopy() *AzureAuthenticationConfi return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AzureImageRegistryCredentials) DeepCopyInto(out *AzureImageRegistryCredentials) { + *out = *in + out.ManagedIdentity = in.ManagedIdentity +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AzureImageRegistryCredentials. +func (in *AzureImageRegistryCredentials) DeepCopy() *AzureImageRegistryCredentials { + if in == nil { + return nil + } + out := new(AzureImageRegistryCredentials) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AzureKMSKey) DeepCopyInto(out *AzureKMSKey) { *out = *in @@ -703,6 +719,7 @@ func (in *AzureNodePoolPlatform) DeepCopyInto(out *AzureNodePoolPlatform) { *out = new(Diagnostics) (*in).DeepCopyInto(*out) } + out.ImageRegistryCredentials = in.ImageRegistryCredentials } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AzureNodePoolPlatform. @@ -4598,6 +4615,21 @@ func (in *UnmanagedEtcdSpec) DeepCopy() *UnmanagedEtcdSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserAssignedManagedIdentity) DeepCopyInto(out *UserAssignedManagedIdentity) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserAssignedManagedIdentity. +func (in *UserAssignedManagedIdentity) DeepCopy() *UserAssignedManagedIdentity { + if in == nil { + return nil + } + out := new(UserAssignedManagedIdentity) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UserManagedDiagnostics) DeepCopyInto(out *UserManagedDiagnostics) { *out = *in diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/nodepools.hypershift.openshift.io/AAA_ungated.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/nodepools.hypershift.openshift.io/AAA_ungated.yaml index 9463ef0840c2..d8dd39e97c29 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/nodepools.hypershift.openshift.io/AAA_ungated.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/nodepools.hypershift.openshift.io/AAA_ungated.yaml @@ -915,6 +915,40 @@ spec: AzureMarketplace rule: 'has(self.type) && self.type == ''AzureMarketplace'' ? true : !has(self.azureMarketplace)' + imageRegistryCredentials: + description: |- + imageRegistryCredentials specifies configuration for enabling kubelet's image credential + provider to authenticate to Azure Container Registry (ACR) using a managed identity. + When configured, worker nodes will use the acr-credential-provider plugin to obtain + short-lived tokens for ACR image pulls instead of relying on static pull secrets. + Changing this field will trigger a node rollout. + When not configured, no additional image credential provider is set up and worker nodes + use the default pull secret for image authentication. + properties: + managedIdentity: + description: |- + managedIdentity specifies the user-assigned managed identity that will be assigned to + worker node VMs/VMSS. The credential provider plugin running on each node will use this + identity to authenticate to ACR via the Azure Instance Metadata Service (IMDS). + properties: + resourceID: + description: |- + resourceID is the ARM resource ID of the user-assigned managed identity. + + Format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} + maxLength: 512 + minLength: 1 + type: string + x-kubernetes-validations: + - message: must be a valid ARM resource ID for a user-assigned + managed identity (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name}) + rule: self.matches('^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\.ManagedIdentity/userAssignedIdentities/[^/]+$') + required: + - resourceID + type: object + required: + - managedIdentity + type: object osDisk: description: |- osDisk provides configuration for the OS disk for the nodepool. diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/nodepools.hypershift.openshift.io/GCPPlatform.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/nodepools.hypershift.openshift.io/GCPPlatform.yaml index ea37b91c13af..f4e6a7e842e5 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/nodepools.hypershift.openshift.io/GCPPlatform.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/nodepools.hypershift.openshift.io/GCPPlatform.yaml @@ -915,6 +915,40 @@ spec: AzureMarketplace rule: 'has(self.type) && self.type == ''AzureMarketplace'' ? true : !has(self.azureMarketplace)' + imageRegistryCredentials: + description: |- + imageRegistryCredentials specifies configuration for enabling kubelet's image credential + provider to authenticate to Azure Container Registry (ACR) using a managed identity. + When configured, worker nodes will use the acr-credential-provider plugin to obtain + short-lived tokens for ACR image pulls instead of relying on static pull secrets. + Changing this field will trigger a node rollout. + When not configured, no additional image credential provider is set up and worker nodes + use the default pull secret for image authentication. + properties: + managedIdentity: + description: |- + managedIdentity specifies the user-assigned managed identity that will be assigned to + worker node VMs/VMSS. The credential provider plugin running on each node will use this + identity to authenticate to ACR via the Azure Instance Metadata Service (IMDS). + properties: + resourceID: + description: |- + resourceID is the ARM resource ID of the user-assigned managed identity. + + Format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} + maxLength: 512 + minLength: 1 + type: string + x-kubernetes-validations: + - message: must be a valid ARM resource ID for a user-assigned + managed identity (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name}) + rule: self.matches('^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\.ManagedIdentity/userAssignedIdentities/[^/]+$') + required: + - resourceID + type: object + required: + - managedIdentity + type: object osDisk: description: |- osDisk provides configuration for the OS disk for the nodepool. diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/nodepools.hypershift.openshift.io/OpenStack.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/nodepools.hypershift.openshift.io/OpenStack.yaml index baed37846b3e..7eb08d16105f 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/nodepools.hypershift.openshift.io/OpenStack.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/nodepools.hypershift.openshift.io/OpenStack.yaml @@ -915,6 +915,40 @@ spec: AzureMarketplace rule: 'has(self.type) && self.type == ''AzureMarketplace'' ? true : !has(self.azureMarketplace)' + imageRegistryCredentials: + description: |- + imageRegistryCredentials specifies configuration for enabling kubelet's image credential + provider to authenticate to Azure Container Registry (ACR) using a managed identity. + When configured, worker nodes will use the acr-credential-provider plugin to obtain + short-lived tokens for ACR image pulls instead of relying on static pull secrets. + Changing this field will trigger a node rollout. + When not configured, no additional image credential provider is set up and worker nodes + use the default pull secret for image authentication. + properties: + managedIdentity: + description: |- + managedIdentity specifies the user-assigned managed identity that will be assigned to + worker node VMs/VMSS. The credential provider plugin running on each node will use this + identity to authenticate to ACR via the Azure Instance Metadata Service (IMDS). + properties: + resourceID: + description: |- + resourceID is the ARM resource ID of the user-assigned managed identity. + + Format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} + maxLength: 512 + minLength: 1 + type: string + x-kubernetes-validations: + - message: must be a valid ARM resource ID for a user-assigned + managed identity (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name}) + rule: self.matches('^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\.ManagedIdentity/userAssignedIdentities/[^/]+$') + required: + - resourceID + type: object + required: + - managedIdentity + type: object osDisk: description: |- osDisk provides configuration for the OS disk for the nodepool. From 2dd918b50d4bd451ec93df4baacbcaea4875b9ab Mon Sep 17 00:00:00 2001 From: Todd Wolff Date: Wed, 22 Apr 2026 20:00:50 -0400 Subject: [PATCH 2/5] chore(api): regenerate CRDs, clients, deepcopy, and vendor Co-Authored-By: Claude Opus 4.6 (1M context) --- .../v1beta1/azureimageregistrycredentials.go | 38 +++++++++++++++ .../v1beta1/azurenodepoolplatform.go | 23 +++++++--- .../v1beta1/userassignedmanagedidentity.go | 42 +++++++++++++++++ client/applyconfiguration/utils.go | 4 ++ .../nodepools-CustomNoUpgrade.crd.yaml | 34 ++++++++++++++ .../nodepools-Default.crd.yaml | 34 ++++++++++++++ .../nodepools-TechPreviewNoUpgrade.crd.yaml | 34 ++++++++++++++ .../api/hypershift/v1beta1/azure.go | 46 +++++++++++++++++++ .../v1beta1/zz_generated.deepcopy.go | 32 +++++++++++++ 9 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 client/applyconfiguration/hypershift/v1beta1/azureimageregistrycredentials.go create mode 100644 client/applyconfiguration/hypershift/v1beta1/userassignedmanagedidentity.go diff --git a/client/applyconfiguration/hypershift/v1beta1/azureimageregistrycredentials.go b/client/applyconfiguration/hypershift/v1beta1/azureimageregistrycredentials.go new file mode 100644 index 000000000000..a6fb8ea3c91c --- /dev/null +++ b/client/applyconfiguration/hypershift/v1beta1/azureimageregistrycredentials.go @@ -0,0 +1,38 @@ +/* + + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +// AzureImageRegistryCredentialsApplyConfiguration represents a declarative configuration of the AzureImageRegistryCredentials type for use +// with apply. +type AzureImageRegistryCredentialsApplyConfiguration struct { + ManagedIdentity *UserAssignedManagedIdentityApplyConfiguration `json:"managedIdentity,omitempty"` +} + +// AzureImageRegistryCredentialsApplyConfiguration constructs a declarative configuration of the AzureImageRegistryCredentials type for use with +// apply. +func AzureImageRegistryCredentials() *AzureImageRegistryCredentialsApplyConfiguration { + return &AzureImageRegistryCredentialsApplyConfiguration{} +} + +// WithManagedIdentity sets the ManagedIdentity field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ManagedIdentity field is set to the value of the last call. +func (b *AzureImageRegistryCredentialsApplyConfiguration) WithManagedIdentity(value *UserAssignedManagedIdentityApplyConfiguration) *AzureImageRegistryCredentialsApplyConfiguration { + b.ManagedIdentity = value + return b +} diff --git a/client/applyconfiguration/hypershift/v1beta1/azurenodepoolplatform.go b/client/applyconfiguration/hypershift/v1beta1/azurenodepoolplatform.go index 77e5c0f2e9f1..ec9aba838f7a 100644 --- a/client/applyconfiguration/hypershift/v1beta1/azurenodepoolplatform.go +++ b/client/applyconfiguration/hypershift/v1beta1/azurenodepoolplatform.go @@ -20,13 +20,14 @@ package v1beta1 // AzureNodePoolPlatformApplyConfiguration represents a declarative configuration of the AzureNodePoolPlatform type for use // with apply. type AzureNodePoolPlatformApplyConfiguration struct { - VMSize *string `json:"vmSize,omitempty"` - Image *AzureVMImageApplyConfiguration `json:"image,omitempty"` - OSDisk *AzureNodePoolOSDiskApplyConfiguration `json:"osDisk,omitempty"` - AvailabilityZone *string `json:"availabilityZone,omitempty"` - EncryptionAtHost *string `json:"encryptionAtHost,omitempty"` - SubnetID *string `json:"subnetID,omitempty"` - Diagnostics *DiagnosticsApplyConfiguration `json:"diagnostics,omitempty"` + VMSize *string `json:"vmSize,omitempty"` + Image *AzureVMImageApplyConfiguration `json:"image,omitempty"` + OSDisk *AzureNodePoolOSDiskApplyConfiguration `json:"osDisk,omitempty"` + AvailabilityZone *string `json:"availabilityZone,omitempty"` + EncryptionAtHost *string `json:"encryptionAtHost,omitempty"` + SubnetID *string `json:"subnetID,omitempty"` + Diagnostics *DiagnosticsApplyConfiguration `json:"diagnostics,omitempty"` + ImageRegistryCredentials *AzureImageRegistryCredentialsApplyConfiguration `json:"imageRegistryCredentials,omitempty"` } // AzureNodePoolPlatformApplyConfiguration constructs a declarative configuration of the AzureNodePoolPlatform type for use with @@ -90,3 +91,11 @@ func (b *AzureNodePoolPlatformApplyConfiguration) WithDiagnostics(value *Diagnos b.Diagnostics = value return b } + +// WithImageRegistryCredentials sets the ImageRegistryCredentials field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ImageRegistryCredentials field is set to the value of the last call. +func (b *AzureNodePoolPlatformApplyConfiguration) WithImageRegistryCredentials(value *AzureImageRegistryCredentialsApplyConfiguration) *AzureNodePoolPlatformApplyConfiguration { + b.ImageRegistryCredentials = value + return b +} diff --git a/client/applyconfiguration/hypershift/v1beta1/userassignedmanagedidentity.go b/client/applyconfiguration/hypershift/v1beta1/userassignedmanagedidentity.go new file mode 100644 index 000000000000..cc7cd0c7438c --- /dev/null +++ b/client/applyconfiguration/hypershift/v1beta1/userassignedmanagedidentity.go @@ -0,0 +1,42 @@ +/* + + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +import ( + hypershiftv1beta1 "github.com/openshift/hypershift/api/hypershift/v1beta1" +) + +// UserAssignedManagedIdentityApplyConfiguration represents a declarative configuration of the UserAssignedManagedIdentity type for use +// with apply. +type UserAssignedManagedIdentityApplyConfiguration struct { + ResourceID *hypershiftv1beta1.AzureManagedIdentityResourceID `json:"resourceID,omitempty"` +} + +// UserAssignedManagedIdentityApplyConfiguration constructs a declarative configuration of the UserAssignedManagedIdentity type for use with +// apply. +func UserAssignedManagedIdentity() *UserAssignedManagedIdentityApplyConfiguration { + return &UserAssignedManagedIdentityApplyConfiguration{} +} + +// WithResourceID sets the ResourceID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ResourceID field is set to the value of the last call. +func (b *UserAssignedManagedIdentityApplyConfiguration) WithResourceID(value hypershiftv1beta1.AzureManagedIdentityResourceID) *UserAssignedManagedIdentityApplyConfiguration { + b.ResourceID = &value + return b +} diff --git a/client/applyconfiguration/utils.go b/client/applyconfiguration/utils.go index b89bca7ed49c..a762e24ef8d3 100644 --- a/client/applyconfiguration/utils.go +++ b/client/applyconfiguration/utils.go @@ -109,6 +109,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &hypershiftv1beta1.AWSSharedVPCRolesRefApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("AzureAuthenticationConfiguration"): return &hypershiftv1beta1.AzureAuthenticationConfigurationApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("AzureImageRegistryCredentials"): + return &hypershiftv1beta1.AzureImageRegistryCredentialsApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("AzureKMSKey"): return &hypershiftv1beta1.AzureKMSKeyApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("AzureKMSSpec"): @@ -405,6 +407,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &hypershiftv1beta1.TaintApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("UnmanagedEtcdSpec"): return &hypershiftv1beta1.UnmanagedEtcdSpecApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("UserAssignedManagedIdentity"): + return &hypershiftv1beta1.UserAssignedManagedIdentityApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("UserManagedDiagnostics"): return &hypershiftv1beta1.UserManagedDiagnosticsApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("Volume"): diff --git a/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/nodepools-CustomNoUpgrade.crd.yaml b/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/nodepools-CustomNoUpgrade.crd.yaml index 055967ac4557..27e5bb10bb4f 100644 --- a/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/nodepools-CustomNoUpgrade.crd.yaml +++ b/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/nodepools-CustomNoUpgrade.crd.yaml @@ -918,6 +918,40 @@ spec: AzureMarketplace rule: 'has(self.type) && self.type == ''AzureMarketplace'' ? true : !has(self.azureMarketplace)' + imageRegistryCredentials: + description: |- + imageRegistryCredentials specifies configuration for enabling kubelet's image credential + provider to authenticate to Azure Container Registry (ACR) using a managed identity. + When configured, worker nodes will use the acr-credential-provider plugin to obtain + short-lived tokens for ACR image pulls instead of relying on static pull secrets. + Changing this field will trigger a node rollout. + When not configured, no additional image credential provider is set up and worker nodes + use the default pull secret for image authentication. + properties: + managedIdentity: + description: |- + managedIdentity specifies the user-assigned managed identity that will be assigned to + worker node VMs/VMSS. The credential provider plugin running on each node will use this + identity to authenticate to ACR via the Azure Instance Metadata Service (IMDS). + properties: + resourceID: + description: |- + resourceID is the ARM resource ID of the user-assigned managed identity. + + Format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} + maxLength: 512 + minLength: 1 + type: string + x-kubernetes-validations: + - message: must be a valid ARM resource ID for a user-assigned + managed identity (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name}) + rule: self.matches('^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\.ManagedIdentity/userAssignedIdentities/[^/]+$') + required: + - resourceID + type: object + required: + - managedIdentity + type: object osDisk: description: |- osDisk provides configuration for the OS disk for the nodepool. diff --git a/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/nodepools-Default.crd.yaml b/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/nodepools-Default.crd.yaml index d317df1b41ae..8e71c97c97d4 100644 --- a/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/nodepools-Default.crd.yaml +++ b/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/nodepools-Default.crd.yaml @@ -918,6 +918,40 @@ spec: AzureMarketplace rule: 'has(self.type) && self.type == ''AzureMarketplace'' ? true : !has(self.azureMarketplace)' + imageRegistryCredentials: + description: |- + imageRegistryCredentials specifies configuration for enabling kubelet's image credential + provider to authenticate to Azure Container Registry (ACR) using a managed identity. + When configured, worker nodes will use the acr-credential-provider plugin to obtain + short-lived tokens for ACR image pulls instead of relying on static pull secrets. + Changing this field will trigger a node rollout. + When not configured, no additional image credential provider is set up and worker nodes + use the default pull secret for image authentication. + properties: + managedIdentity: + description: |- + managedIdentity specifies the user-assigned managed identity that will be assigned to + worker node VMs/VMSS. The credential provider plugin running on each node will use this + identity to authenticate to ACR via the Azure Instance Metadata Service (IMDS). + properties: + resourceID: + description: |- + resourceID is the ARM resource ID of the user-assigned managed identity. + + Format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} + maxLength: 512 + minLength: 1 + type: string + x-kubernetes-validations: + - message: must be a valid ARM resource ID for a user-assigned + managed identity (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name}) + rule: self.matches('^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\.ManagedIdentity/userAssignedIdentities/[^/]+$') + required: + - resourceID + type: object + required: + - managedIdentity + type: object osDisk: description: |- osDisk provides configuration for the OS disk for the nodepool. diff --git a/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/nodepools-TechPreviewNoUpgrade.crd.yaml b/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/nodepools-TechPreviewNoUpgrade.crd.yaml index f86509004219..a47f0b4787ab 100644 --- a/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/nodepools-TechPreviewNoUpgrade.crd.yaml +++ b/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/nodepools-TechPreviewNoUpgrade.crd.yaml @@ -918,6 +918,40 @@ spec: AzureMarketplace rule: 'has(self.type) && self.type == ''AzureMarketplace'' ? true : !has(self.azureMarketplace)' + imageRegistryCredentials: + description: |- + imageRegistryCredentials specifies configuration for enabling kubelet's image credential + provider to authenticate to Azure Container Registry (ACR) using a managed identity. + When configured, worker nodes will use the acr-credential-provider plugin to obtain + short-lived tokens for ACR image pulls instead of relying on static pull secrets. + Changing this field will trigger a node rollout. + When not configured, no additional image credential provider is set up and worker nodes + use the default pull secret for image authentication. + properties: + managedIdentity: + description: |- + managedIdentity specifies the user-assigned managed identity that will be assigned to + worker node VMs/VMSS. The credential provider plugin running on each node will use this + identity to authenticate to ACR via the Azure Instance Metadata Service (IMDS). + properties: + resourceID: + description: |- + resourceID is the ARM resource ID of the user-assigned managed identity. + + Format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} + maxLength: 512 + minLength: 1 + type: string + x-kubernetes-validations: + - message: must be a valid ARM resource ID for a user-assigned + managed identity (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name}) + rule: self.matches('^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\.ManagedIdentity/userAssignedIdentities/[^/]+$') + required: + - resourceID + type: object + required: + - managedIdentity + type: object osDisk: description: |- osDisk provides configuration for the OS disk for the nodepool. diff --git a/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/azure.go b/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/azure.go index c99523ceedfc..8467fec88b01 100644 --- a/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/azure.go +++ b/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/azure.go @@ -109,6 +109,52 @@ type AzureNodePoolPlatform struct { // If not specified, then Boot diagnostics will be disabled. // +optional Diagnostics *Diagnostics `json:"diagnostics,omitempty"` + + // imageRegistryCredentials specifies configuration for enabling kubelet's image credential + // provider to authenticate to Azure Container Registry (ACR) using a managed identity. + // When configured, worker nodes will use the acr-credential-provider plugin to obtain + // short-lived tokens for ACR image pulls instead of relying on static pull secrets. + // Changing this field will trigger a node rollout. + // When not configured, no additional image credential provider is set up and worker nodes + // use the default pull secret for image authentication. + // +optional + ImageRegistryCredentials AzureImageRegistryCredentials `json:"imageRegistryCredentials,omitzero"` +} + +// AzureManagedIdentityResourceID is a full Azure resource ID for a user-assigned managed identity. +// The expected format is: +// +// /subscriptions/{subscriptionID}/resourceGroups/{resourceGroup}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} +// +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=512 +// +kubebuilder:validation:XValidation:rule="self.matches('^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\\\.ManagedIdentity/userAssignedIdentities/[^/]+$')",message="must be a valid ARM resource ID for a user-assigned managed identity (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name})" +type AzureManagedIdentityResourceID string + +// UserAssignedManagedIdentity specifies a user-assigned managed identity for Azure resource +// authentication. The resourceID is required for VMSS identity attachment and is also used +// for the kubelet credential provider configuration. +type UserAssignedManagedIdentity struct { + // resourceID is the ARM resource ID of the user-assigned managed identity. + // + // Format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} + // + // +required + ResourceID AzureManagedIdentityResourceID `json:"resourceID,omitempty"` +} + +// AzureImageRegistryCredentials configures the kubelet credential provider for ACR +// authentication using a user-assigned managed identity on worker node VMs. +// The credential provider is configured with wildcard patterns covering all standard Azure +// Container Registry endpoints (*.azurecr.io, *.azurecr.cn, *.azurecr.de, *.azurecr.us). +// The identity must have the AcrPull role granted on the target ACR registry(ies). +type AzureImageRegistryCredentials struct { + // managedIdentity specifies the user-assigned managed identity that will be assigned to + // worker node VMs/VMSS. The credential provider plugin running on each node will use this + // identity to authenticate to ACR via the Azure Instance Metadata Service (IMDS). + // + // +required + ManagedIdentity UserAssignedManagedIdentity `json:"managedIdentity,omitzero"` } // AzureVMImage represents the different types of boot image sources that can be provided for an Azure VM. diff --git a/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/zz_generated.deepcopy.go b/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/zz_generated.deepcopy.go index 558be0746f8f..8af1ff492953 100644 --- a/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/zz_generated.deepcopy.go +++ b/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/zz_generated.deepcopy.go @@ -621,6 +621,22 @@ func (in *AzureAuthenticationConfiguration) DeepCopy() *AzureAuthenticationConfi return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AzureImageRegistryCredentials) DeepCopyInto(out *AzureImageRegistryCredentials) { + *out = *in + out.ManagedIdentity = in.ManagedIdentity +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AzureImageRegistryCredentials. +func (in *AzureImageRegistryCredentials) DeepCopy() *AzureImageRegistryCredentials { + if in == nil { + return nil + } + out := new(AzureImageRegistryCredentials) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AzureKMSKey) DeepCopyInto(out *AzureKMSKey) { *out = *in @@ -703,6 +719,7 @@ func (in *AzureNodePoolPlatform) DeepCopyInto(out *AzureNodePoolPlatform) { *out = new(Diagnostics) (*in).DeepCopyInto(*out) } + out.ImageRegistryCredentials = in.ImageRegistryCredentials } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AzureNodePoolPlatform. @@ -4598,6 +4615,21 @@ func (in *UnmanagedEtcdSpec) DeepCopy() *UnmanagedEtcdSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserAssignedManagedIdentity) DeepCopyInto(out *UserAssignedManagedIdentity) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserAssignedManagedIdentity. +func (in *UserAssignedManagedIdentity) DeepCopy() *UserAssignedManagedIdentity { + if in == nil { + return nil + } + out := new(UserAssignedManagedIdentity) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UserManagedDiagnostics) DeepCopyInto(out *UserManagedDiagnostics) { *out = *in From b06db3876b95bb6cada6b3bb09304fa9f5b91164 Mon Sep 17 00:00:00 2001 From: Todd Wolff Date: Wed, 22 Apr 2026 20:01:02 -0400 Subject: [PATCH 3/5] feat(hypershift-operator): wire ACR managed identity into nodepool controller Add ACR credential provider MachineConfig generation and CAPI managed identity attachment for Azure NodePools with imageRegistryCredentials. When imageRegistryCredentials.managedIdentity is set on a NodePool: - AzureMachineTemplate gets Identity: UserAssigned with the MI - MachineConfig overrides MCO credential provider config to point at a separate acr-azure.json with useManagedIdentityExtension: true - Kubelet uses acr-credential-provider binary for short-lived IMDS tokens, no static pull secrets needed Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controllers/nodepool/azure.go | 11 + .../controllers/nodepool/azure_acr.go | 208 +++++++++ .../controllers/nodepool/azure_acr_test.go | 414 ++++++++++++++++++ .../controllers/nodepool/azure_test.go | 82 ++++ .../controllers/nodepool/config.go | 43 ++ 5 files changed, 758 insertions(+) create mode 100644 hypershift-operator/controllers/nodepool/azure_acr.go create mode 100644 hypershift-operator/controllers/nodepool/azure_acr_test.go diff --git a/hypershift-operator/controllers/nodepool/azure.go b/hypershift-operator/controllers/nodepool/azure.go index 404b993d7ebb..3724788eadcb 100644 --- a/hypershift-operator/controllers/nodepool/azure.go +++ b/hypershift-operator/controllers/nodepool/azure.go @@ -14,6 +14,7 @@ import ( "k8s.io/utils/ptr" capiazure "sigs.k8s.io/cluster-api-provider-azure/api/v1beta1" + capzutil "sigs.k8s.io/cluster-api-provider-azure/util/azure" "github.com/blang/semver" ) @@ -246,6 +247,16 @@ func azureMachineTemplateSpec(nodePool *hyperv1.NodePool) (*capiazure.AzureMachi azureMachineTemplate.Template.Spec.SSHPublicKey = dummySSHKey + // NOTE: This replaces any existing identity on the AzureMachineTemplate. + // If additional user-assigned identities are needed in the future, + // refactor to append to the existing list instead of replacing. + if nodePool.Spec.Platform.Azure.ImageRegistryCredentials.ManagedIdentity.ResourceID != "" { + azureMachineTemplate.Template.Spec.Identity = capiazure.VMIdentityUserAssigned + azureMachineTemplate.Template.Spec.UserAssignedIdentities = []capiazure.UserAssignedIdentity{{ + ProviderID: capzutil.ProviderIDPrefix + string(nodePool.Spec.Platform.Azure.ImageRegistryCredentials.ManagedIdentity.ResourceID), + }} + } + return azureMachineTemplate, nil } diff --git a/hypershift-operator/controllers/nodepool/azure_acr.go b/hypershift-operator/controllers/nodepool/azure_acr.go new file mode 100644 index 000000000000..67a5449448b3 --- /dev/null +++ b/hypershift-operator/controllers/nodepool/azure_acr.go @@ -0,0 +1,208 @@ +package nodepool + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + + igntypes "github.com/coreos/ignition/v2/config/v3_2/types" +) + +const ( + // acrCredentialProviderConfigPath is the path where MCO already renders the + // credential provider config for Azure. Our MachineConfig overrides this file + // to point at acr-azure.json with user-assigned managed identity settings, + // instead of the default cloud.conf. + acrCredentialProviderConfigPath = "/etc/kubernetes/credential-providers/acr-credential-provider.yaml" + acrAzureJSONPath = "/etc/kubernetes/acr-azure.json" + acrCredentialProviderBinName = "acr-credential-provider" + acrMachineConfigName = "50-acr-credential-provider" +) + +// acrDefaultMatchImages lists the wildcard patterns for Azure Container Registry +// endpoints across all Azure clouds. These patterns are used as the matchImages +// list in the kubelet CredentialProviderConfig. +var acrDefaultMatchImages = []string{ + "*.azurecr.io", + "*.azurecr.cn", + "*.azurecr.de", + "*.azurecr.us", +} + +// acrAzureConfig represents the Azure authentication configuration consumed by the +// acr-credential-provider binary to obtain tokens via the Azure Instance Metadata Service (IMDS). +type acrAzureConfig struct { + Cloud string `json:"cloud"` + TenantID string `json:"tenantId"` + SubscriptionID string `json:"subscriptionId"` + UseManagedIdentityExtension bool `json:"useManagedIdentityExtension"` + UserAssignedIdentityID string `json:"userAssignedIdentityID"` +} + +// generateACRCredentialProviderMachineConfig generates a MachineConfig that configures +// the kubelet credential provider for ACR authentication using a managed identity. +// It overrides the MCO-rendered credential provider config at +// /etc/kubernetes/credential-providers/acr-credential-provider.yaml to point at a +// separate acr-azure.json containing the user-assigned managed identity settings. +// The kubelet flags (--image-credential-provider-config and --image-credential-provider-bin-dir) +// are already set by MCO and do not need to be configured here. +func generateACRCredentialProviderMachineConfig( + credentials *hyperv1.AzureImageRegistryCredentials, + cloud string, + tenantID string, + subscriptionID string, +) (*mcfgv1.MachineConfig, error) { + if credentials == nil { + return nil, fmt.Errorf("credentials must not be nil") + } + if string(credentials.ManagedIdentity.ResourceID) == "" { + return nil, fmt.Errorf("credentials.ManagedIdentity.ResourceID must not be empty") + } + if tenantID == "" { + return nil, fmt.Errorf("tenantID must not be empty") + } + if subscriptionID == "" { + return nil, fmt.Errorf("subscriptionID must not be empty") + } + if cloud == "" { + return nil, fmt.Errorf("cloud must not be empty") + } + + credentialProviderConfig := generateCredentialProviderConfig() + + identityID := string(credentials.ManagedIdentity.ResourceID) + + azureJSON, err := generateACRAzureJSON(identityID, cloud, tenantID, subscriptionID) + if err != nil { + return nil, fmt.Errorf("failed to generate azure.json: %w", err) + } + + ignConfig, err := buildACRIgnitionConfig(credentialProviderConfig, azureJSON) + if err != nil { + return nil, fmt.Errorf("failed to build ignition config: %w", err) + } + + mc := &mcfgv1.MachineConfig{ + TypeMeta: metav1.TypeMeta{ + APIVersion: mcfgv1.SchemeGroupVersion.String(), + Kind: "MachineConfig", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: acrMachineConfigName, + Labels: map[string]string{ + "machineconfiguration.openshift.io/role": "worker", + }, + }, + Spec: mcfgv1.MachineConfigSpec{ + Config: runtime.RawExtension{ + Raw: ignConfig, + }, + }, + } + + return mc, nil +} + +// generateCredentialProviderConfig produces the YAML content for the kubelet +// CredentialProviderConfig that configures the acr-credential-provider plugin. +// The matchImages list uses default wildcard patterns covering all standard +// Azure Container Registry endpoints. +func generateCredentialProviderConfig() []byte { + var matchImages []string + for _, pattern := range acrDefaultMatchImages { + matchImages = append(matchImages, fmt.Sprintf(" - %q", pattern)) + } + + config := fmt.Sprintf(`apiVersion: kubelet.config.k8s.io/v1 +kind: CredentialProviderConfig +providers: +- name: %s + apiVersion: credentialprovider.kubelet.k8s.io/v1 + defaultCacheDuration: 10m + matchImages: +%s + args: + - %s +`, acrCredentialProviderBinName, strings.Join(matchImages, "\n"), acrAzureJSONPath) + + return []byte(config) +} + +// generateACRAzureJSON produces the JSON content for the Azure authentication config +// consumed by the acr-credential-provider binary. +func generateACRAzureJSON(managedIdentity, cloud, tenantID, subscriptionID string) ([]byte, error) { + cfg := acrAzureConfig{ + Cloud: cloud, + TenantID: tenantID, + SubscriptionID: subscriptionID, + UseManagedIdentityExtension: true, + UserAssignedIdentityID: managedIdentity, + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal azure config: %w", err) + } + return data, nil +} + +// buildACRIgnitionConfig assembles the ignition v3.2.0 Config containing the +// credential provider config file and azure.json file. +func buildACRIgnitionConfig(credentialProviderConfig, azureJSON []byte) ([]byte, error) { + credProviderFileMode := 0644 + azureJSONFileMode := 0600 + + ignConfig := igntypes.Config{ + Ignition: igntypes.Ignition{ + Version: "3.2.0", + }, + Storage: igntypes.Storage{ + Files: []igntypes.File{ + { + Node: igntypes.Node{ + Path: acrCredentialProviderConfigPath, + Overwrite: ptr.To(true), + }, + FileEmbedded1: igntypes.FileEmbedded1{ + Mode: &credProviderFileMode, + Contents: igntypes.Resource{ + Source: ptr.To(dataURI(credentialProviderConfig)), + }, + }, + }, + { + Node: igntypes.Node{ + Path: acrAzureJSONPath, + Overwrite: ptr.To(true), + }, + FileEmbedded1: igntypes.FileEmbedded1{ + Mode: &azureJSONFileMode, + Contents: igntypes.Resource{ + Source: ptr.To(dataURI(azureJSON)), + }, + }, + }, + }, + }, + } + + data, err := json.Marshal(ignConfig) + if err != nil { + return nil, fmt.Errorf("failed to marshal ignition config: %w", err) + } + return data, nil +} + +// dataURI encodes raw bytes as a base64 data URI suitable for ignition file contents. +func dataURI(data []byte) string { + return fmt.Sprintf("data:text/plain;charset=utf-8;base64,%s", base64.StdEncoding.EncodeToString(data)) +} diff --git a/hypershift-operator/controllers/nodepool/azure_acr_test.go b/hypershift-operator/controllers/nodepool/azure_acr_test.go new file mode 100644 index 000000000000..6d3872bf98ff --- /dev/null +++ b/hypershift-operator/controllers/nodepool/azure_acr_test.go @@ -0,0 +1,414 @@ +package nodepool + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + + corev1 "k8s.io/api/core/v1" + + igntypes "github.com/coreos/ignition/v2/config/v3_2/types" +) + +func TestGenerateACRCredentialProviderMachineConfig(t *testing.T) { + testCases := []struct { + name string + credentials *hyperv1.AzureImageRegistryCredentials + cloud string + tenantID string + subscriptionID string + expectErr bool + expectErrMsg string + validate func(g Gomega, ignCfg *igntypes.Config) + }{ + { + name: "When credentials are nil it should return an error", + credentials: nil, + cloud: "AzurePublicCloud", + tenantID: "test-tenant", + subscriptionID: "test-sub", + expectErr: true, + expectErrMsg: "credentials must not be nil", + }, + { + name: "When ManagedIdentity ResourceID is empty it should return an error", + credentials: &hyperv1.AzureImageRegistryCredentials{ + ManagedIdentity: hyperv1.UserAssignedManagedIdentity{}, + }, + cloud: "AzurePublicCloud", + tenantID: "test-tenant", + subscriptionID: "test-sub", + expectErr: true, + expectErrMsg: "credentials.ManagedIdentity.ResourceID must not be empty", + }, + { + name: "When tenantID is empty it should return an error", + credentials: &hyperv1.AzureImageRegistryCredentials{ + ManagedIdentity: hyperv1.UserAssignedManagedIdentity{ + ResourceID: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id", + }, + }, + cloud: "AzurePublicCloud", + tenantID: "", + subscriptionID: "test-sub", + expectErr: true, + expectErrMsg: "tenantID must not be empty", + }, + { + name: "When subscriptionID is empty it should return an error", + credentials: &hyperv1.AzureImageRegistryCredentials{ + ManagedIdentity: hyperv1.UserAssignedManagedIdentity{ + ResourceID: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id", + }, + }, + cloud: "AzurePublicCloud", + tenantID: "test-tenant", + subscriptionID: "", + expectErr: true, + expectErrMsg: "subscriptionID must not be empty", + }, + { + name: "When cloud is empty it should return an error", + credentials: &hyperv1.AzureImageRegistryCredentials{ + ManagedIdentity: hyperv1.UserAssignedManagedIdentity{ + ResourceID: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id", + }, + }, + cloud: "", + tenantID: "test-tenant", + subscriptionID: "test-sub", + expectErr: true, + expectErrMsg: "cloud must not be empty", + }, + { + name: "When valid credentials are set it should generate credential provider config with default wildcards", + credentials: &hyperv1.AzureImageRegistryCredentials{ + ManagedIdentity: hyperv1.UserAssignedManagedIdentity{ + ResourceID: "/subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.ManagedIdentity/userAssignedIdentities/acr-pull-identity", + }, + }, + cloud: "AzurePublicCloud", + tenantID: "tenant-abc", + subscriptionID: "sub-123", + expectErr: false, + validate: func(g Gomega, ignCfg *igntypes.Config) { + g.Expect(ignCfg.Storage.Files).To(HaveLen(2)) + + credProviderFile := ignCfg.Storage.Files[0] + g.Expect(credProviderFile.Path).To(Equal("/etc/kubernetes/credential-providers/acr-credential-provider.yaml")) + content := decodeIgnitionFileContent(g, credProviderFile) + + g.Expect(content).To(ContainSubstring(`"*.azurecr.io"`)) + g.Expect(content).To(ContainSubstring(`"*.azurecr.cn"`)) + g.Expect(content).To(ContainSubstring(`"*.azurecr.de"`)) + g.Expect(content).To(ContainSubstring(`"*.azurecr.us"`)) + g.Expect(content).To(ContainSubstring("name: acr-credential-provider")) + g.Expect(content).To(ContainSubstring("apiVersion: credentialprovider.kubelet.k8s.io/v1")) + g.Expect(content).To(ContainSubstring("defaultCacheDuration: 10m")) + }, + }, + { + name: "When generating MachineConfig it should include both config files without systemd drop-in", + credentials: &hyperv1.AzureImageRegistryCredentials{ + ManagedIdentity: hyperv1.UserAssignedManagedIdentity{ + ResourceID: "/subscriptions/sub-789/resourceGroups/rg-dev/providers/Microsoft.ManagedIdentity/userAssignedIdentities/dev-identity", + }, + }, + cloud: "AzurePublicCloud", + tenantID: "tenant-dev", + subscriptionID: "sub-789", + expectErr: false, + validate: func(g Gomega, ignCfg *igntypes.Config) { + g.Expect(ignCfg.Storage.Files).To(HaveLen(2)) + g.Expect(ignCfg.Storage.Files[0].Path).To(Equal("/etc/kubernetes/credential-providers/acr-credential-provider.yaml")) + g.Expect(ignCfg.Storage.Files[1].Path).To(Equal("/etc/kubernetes/acr-azure.json")) + + g.Expect(*ignCfg.Storage.Files[0].Mode).To(Equal(0644)) + g.Expect(*ignCfg.Storage.Files[1].Mode).To(Equal(0600)) + g.Expect(*ignCfg.Storage.Files[0].Overwrite).To(BeTrue()) + g.Expect(*ignCfg.Storage.Files[1].Overwrite).To(BeTrue()) + g.Expect(ignCfg.Systemd.Units).To(BeEmpty()) + }, + }, + { + name: "When only resourceID is set it should use resourceID in azure.json", + credentials: &hyperv1.AzureImageRegistryCredentials{ + ManagedIdentity: hyperv1.UserAssignedManagedIdentity{ + ResourceID: "/subscriptions/sub-aaa/resourceGroups/rg-aaa/providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-identity", + }, + }, + cloud: "AzurePublicCloud", + tenantID: "my-tenant-id-12345", + subscriptionID: "my-subscription-id-67890", + expectErr: false, + validate: func(g Gomega, ignCfg *igntypes.Config) { + azureJSONFile := ignCfg.Storage.Files[1] + content := decodeIgnitionFileContent(g, azureJSONFile) + + var azureCfg acrAzureConfig + err := json.Unmarshal([]byte(content), &azureCfg) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(azureCfg.Cloud).To(Equal("AzurePublicCloud")) + g.Expect(azureCfg.TenantID).To(Equal("my-tenant-id-12345")) + g.Expect(azureCfg.SubscriptionID).To(Equal("my-subscription-id-67890")) + g.Expect(azureCfg.UseManagedIdentityExtension).To(BeTrue()) + g.Expect(azureCfg.UserAssignedIdentityID).To(Equal( + "/subscriptions/sub-aaa/resourceGroups/rg-aaa/providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-identity", + )) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + mc, err := generateACRCredentialProviderMachineConfig(tc.credentials, tc.cloud, tc.tenantID, tc.subscriptionID) + if tc.expectErr { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tc.expectErrMsg)) + return + } + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(mc).ToNot(BeNil()) + g.Expect(mc.Name).To(Equal("50-acr-credential-provider")) + g.Expect(mc.Labels).To(HaveKeyWithValue("machineconfiguration.openshift.io/role", "worker")) + g.Expect(mc.Kind).To(Equal("MachineConfig")) + + ignCfg := &igntypes.Config{} + err = json.Unmarshal(mc.Spec.Config.Raw, ignCfg) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(ignCfg.Ignition.Version).To(Equal("3.2.0")) + + if tc.validate != nil { + tc.validate(g, ignCfg) + } + }) + } +} + +func TestGenerateCredentialProviderConfig(t *testing.T) { + t.Run("When generating credential provider config it should include all default wildcard patterns", func(t *testing.T) { + g := NewWithT(t) + + content := string(generateCredentialProviderConfig()) + g.Expect(content).To(ContainSubstring("apiVersion: kubelet.config.k8s.io/v1")) + g.Expect(content).To(ContainSubstring("kind: CredentialProviderConfig")) + g.Expect(content).To(ContainSubstring(`"*.azurecr.io"`)) + g.Expect(content).To(ContainSubstring(`"*.azurecr.cn"`)) + g.Expect(content).To(ContainSubstring(`"*.azurecr.de"`)) + g.Expect(content).To(ContainSubstring(`"*.azurecr.us"`)) + g.Expect(content).To(ContainSubstring("/etc/kubernetes/acr-azure.json")) + }) +} + +func TestGenerateACRAzureJSON(t *testing.T) { + testCases := []struct { + name string + managedIdentity string + cloud string + tenantID string + subscriptionID string + validate func(g Gomega, content string) + }{ + { + name: "When generating azure.json it should produce valid JSON with all fields", + managedIdentity: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-id", + cloud: "AzurePublicCloud", + tenantID: "test-tenant", + subscriptionID: "test-subscription", + validate: func(g Gomega, content string) { + var cfg acrAzureConfig + err := json.Unmarshal([]byte(content), &cfg) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cfg.Cloud).To(Equal("AzurePublicCloud")) + g.Expect(cfg.TenantID).To(Equal("test-tenant")) + g.Expect(cfg.SubscriptionID).To(Equal("test-subscription")) + g.Expect(cfg.UseManagedIdentityExtension).To(BeTrue()) + g.Expect(cfg.UserAssignedIdentityID).To(Equal( + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-id", + )) + }, + }, + { + name: "When cloud is AzureUSGovernmentCloud it should use that cloud value", + managedIdentity: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/gov-id", + cloud: "AzureUSGovernmentCloud", + tenantID: "gov-tenant", + subscriptionID: "gov-subscription", + validate: func(g Gomega, content string) { + var cfg acrAzureConfig + err := json.Unmarshal([]byte(content), &cfg) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cfg.Cloud).To(Equal("AzureUSGovernmentCloud")) + g.Expect(cfg.UserAssignedIdentityID).To(Equal( + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/gov-id", + )) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + result, err := generateACRAzureJSON(tc.managedIdentity, tc.cloud, tc.tenantID, tc.subscriptionID) + g.Expect(err).ToNot(HaveOccurred()) + if tc.validate != nil { + tc.validate(g, string(result)) + } + }) + } +} + +// decodeIgnitionFileContent extracts and decodes the base64-encoded content +// from an ignition File's data URI source field. +func decodeIgnitionFileContent(g Gomega, file igntypes.File) string { + g.Expect(file.Contents.Source).ToNot(BeNil()) + source := *file.Contents.Source + + prefix := "data:text/plain;charset=utf-8;base64," + g.Expect(source).To(HavePrefix(prefix)) + encoded := strings.TrimPrefix(source, prefix) + + decoded, err := base64.StdEncoding.DecodeString(encoded) + g.Expect(err).ToNot(HaveOccurred()) + + return string(decoded) +} + +func TestGetACRCredentialProviderConfig(t *testing.T) { + testCases := []struct { + name string + nodePool *hyperv1.NodePool + hc *hyperv1.HostedCluster + expectNil bool + expectErr bool + expectErrMsg string + validate func(g Gomega, cms []corev1.ConfigMap) + }{ + { + name: "When platform is not Azure it should return nil", + nodePool: &hyperv1.NodePool{ + Spec: hyperv1.NodePoolSpec{ + Platform: hyperv1.NodePoolPlatform{ + Type: hyperv1.AWSPlatform, + }, + }, + }, + hc: &hyperv1.HostedCluster{}, + expectNil: true, + }, + { + name: "When Azure platform has empty ImageRegistryCredentials it should return nil", + nodePool: &hyperv1.NodePool{ + Spec: hyperv1.NodePoolSpec{ + Platform: hyperv1.NodePoolPlatform{ + Type: hyperv1.AzurePlatform, + Azure: &hyperv1.AzureNodePoolPlatform{}, + }, + }, + }, + hc: &hyperv1.HostedCluster{}, + expectNil: true, + }, + { + name: "When Azure platform spec is nil on HostedCluster it should return an error", + nodePool: &hyperv1.NodePool{ + Spec: hyperv1.NodePoolSpec{ + Platform: hyperv1.NodePoolPlatform{ + Type: hyperv1.AzurePlatform, + Azure: &hyperv1.AzureNodePoolPlatform{ + ImageRegistryCredentials: hyperv1.AzureImageRegistryCredentials{ + ManagedIdentity: hyperv1.UserAssignedManagedIdentity{ + ResourceID: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id", + }, + }, + }, + }, + }, + }, + hc: &hyperv1.HostedCluster{ + Spec: hyperv1.HostedClusterSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AzurePlatform, + Azure: nil, + }, + }, + }, + expectErr: true, + expectErrMsg: "hostedCluster platform Azure spec must be set", + }, + { + name: "When valid credentials are set it should return a ConfigMap with MachineConfig YAML", + nodePool: &hyperv1.NodePool{ + Spec: hyperv1.NodePoolSpec{ + Platform: hyperv1.NodePoolPlatform{ + Type: hyperv1.AzurePlatform, + Azure: &hyperv1.AzureNodePoolPlatform{ + ImageRegistryCredentials: hyperv1.AzureImageRegistryCredentials{ + ManagedIdentity: hyperv1.UserAssignedManagedIdentity{ + ResourceID: "/subscriptions/sub-123/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/acr-pull", + }, + }, + }, + }, + }, + }, + hc: &hyperv1.HostedCluster{ + Spec: hyperv1.HostedClusterSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AzurePlatform, + Azure: &hyperv1.AzurePlatformSpec{ + Cloud: "AzurePublicCloud", + TenantID: "test-tenant", + SubscriptionID: "test-sub", + }, + }, + }, + }, + validate: func(g Gomega, cms []corev1.ConfigMap) { + g.Expect(cms).To(HaveLen(1)) + mcYAML, ok := cms[0].Data[TokenSecretConfigKey] + g.Expect(ok).To(BeTrue(), "ConfigMap should have TokenSecretConfigKey") + g.Expect(mcYAML).To(ContainSubstring("50-acr-credential-provider")) + g.Expect(mcYAML).To(ContainSubstring("MachineConfig")) + g.Expect(mcYAML).To(ContainSubstring("machineconfiguration.openshift.io/role")) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + cg := &ConfigGenerator{ + hostedCluster: tc.hc, + nodePool: tc.nodePool, + } + + cms, err := cg.getACRCredentialProviderConfig() + if tc.expectErr { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tc.expectErrMsg)) + return + } + g.Expect(err).ToNot(HaveOccurred()) + + if tc.expectNil { + g.Expect(cms).To(BeNil()) + return + } + + if tc.validate != nil { + tc.validate(g, cms) + } + }) + } +} diff --git a/hypershift-operator/controllers/nodepool/azure_test.go b/hypershift-operator/controllers/nodepool/azure_test.go index 7b5e996ab8fc..68373e34ef1b 100644 --- a/hypershift-operator/controllers/nodepool/azure_test.go +++ b/hypershift-operator/controllers/nodepool/azure_test.go @@ -450,6 +450,88 @@ func TestAzureMachineTemplateSpec(t *testing.T) { }, expectedErr: false, }, + { + name: "When ImageRegistryCredentials is set it should assign user identity to VMSS", + nodePool: &hyperv1.NodePool{ + Spec: hyperv1.NodePoolSpec{ + Platform: hyperv1.NodePoolPlatform{ + Type: hyperv1.AzurePlatform, + Azure: &hyperv1.AzureNodePoolPlatform{ + Image: hyperv1.AzureVMImage{ + Type: hyperv1.ImageID, + ImageID: ptr.To("testImageID"), + }, + SubnetID: "/subscriptions/testSubscriptionID/resourceGroups/testResourceGroupName/providers/Microsoft.Network/virtualNetworks/testVnetName/subnets/testSubnetName", + VMSize: "Standard_D2_v2", + OSDisk: hyperv1.AzureNodePoolOSDisk{ + SizeGiB: 30, + DiskStorageAccountType: "Standard_LRS", + }, + ImageRegistryCredentials: hyperv1.AzureImageRegistryCredentials{ + ManagedIdentity: hyperv1.UserAssignedManagedIdentity{ + ResourceID: "/subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.ManagedIdentity/userAssignedIdentities/acr-pull", + }, + }, + }, + }, + }, + }, + expectedAzureMachineTemplateSpec: &capiazure.AzureMachineTemplateSpec{ + Template: capiazure.AzureMachineTemplateResource{ + ObjectMeta: clusterv1.ObjectMeta{Labels: nil, Annotations: nil}, + Spec: capiazure.AzureMachineSpec{ + ProviderID: nil, + VMSize: "Standard_D2_v2", + FailureDomain: nil, + Image: &capiazure.Image{ + ID: ptr.To("testImageID"), + SharedGallery: nil, + Marketplace: nil, + ComputeGallery: nil, + }, + Identity: capiazure.VMIdentityUserAssigned, + UserAssignedIdentities: []capiazure.UserAssignedIdentity{{ + ProviderID: "azure:///subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.ManagedIdentity/userAssignedIdentities/acr-pull", + }}, + SystemAssignedIdentityRole: nil, + RoleAssignmentName: "", + OSDisk: capiazure.OSDisk{ + OSType: "", + DiskSizeGB: ptr.To[int32](30), + ManagedDisk: &capiazure.ManagedDiskParameters{ + StorageAccountType: "Standard_LRS", + DiskEncryptionSet: nil, + SecurityProfile: nil, + }, + DiffDiskSettings: nil, + CachingType: "", + }, + DataDisks: nil, + SSHPublicKey: dummySSHKey, + AdditionalTags: nil, + AdditionalCapabilities: nil, + AllocatePublicIP: false, + EnableIPForwarding: false, + AcceleratedNetworking: nil, + Diagnostics: nil, + SpotVMOptions: nil, + SecurityProfile: nil, + SubnetName: "", + DNSServers: nil, + VMExtensions: nil, + NetworkInterfaces: []capiazure.NetworkInterface{ + { + SubnetName: "testSubnetName", + PrivateIPConfigs: 0, + AcceleratedNetworking: nil, + }, + }, + CapacityReservationGroupID: nil, + }, + }, + }, + expectedErr: false, + }, { name: "error case since ImageID and AzureMarketplace are not provided", nodePool: &hyperv1.NodePool{ diff --git a/hypershift-operator/controllers/nodepool/config.go b/hypershift-operator/controllers/nodepool/config.go index b86aab5b713d..943648cc51b6 100644 --- a/hypershift-operator/controllers/nodepool/config.go +++ b/hypershift-operator/controllers/nodepool/config.go @@ -158,6 +158,12 @@ func (cg *ConfigGenerator) generateMCORawConfig(ctx context.Context, caps *hyper configs = append(configs, nodeTuningGeneratedConfigs...) } + acrConfigs, err := cg.getACRCredentialProviderConfig() + if err != nil { + return "", err + } + configs = append(configs, acrConfigs...) + return cg.parse(configs) } @@ -223,6 +229,43 @@ func (e *MissingCoreConfigError) Error() string { return fmt.Sprintf("expected %d core ignition configs, found %d", e.Expected, e.Got) } +// getACRCredentialProviderConfig generates a ConfigMap-wrapped MachineConfig for ACR +// credential provider configuration when the nodepool is Azure and has ImageRegistryCredentials set. +func (cg *ConfigGenerator) getACRCredentialProviderConfig() ([]corev1.ConfigMap, error) { + if cg.nodePool.Spec.Platform.Type != hyperv1.AzurePlatform { + return nil, nil + } + if cg.nodePool.Spec.Platform.Azure == nil || cg.nodePool.Spec.Platform.Azure.ImageRegistryCredentials.ManagedIdentity.ResourceID == "" { + return nil, nil + } + if cg.hostedCluster.Spec.Platform.Azure == nil { + return nil, fmt.Errorf("hostedCluster platform Azure spec must be set for Azure node pools with imageRegistryCredentials") + } + + azureSpec := cg.hostedCluster.Spec.Platform.Azure + mc, err := generateACRCredentialProviderMachineConfig( + &cg.nodePool.Spec.Platform.Azure.ImageRegistryCredentials, + azureSpec.Cloud, + azureSpec.TenantID, + azureSpec.SubscriptionID, + ) + if err != nil { + return nil, fmt.Errorf("failed to generate ACR credential provider config: %w", err) + } + + mcYAML, err := api.CompatibleYAMLEncode(mc, api.YamlSerializer) + if err != nil { + return nil, fmt.Errorf("failed to encode ACR MachineConfig: %w", err) + } + + cm := corev1.ConfigMap{ + Data: map[string]string{ + TokenSecretConfigKey: string(mcYAML), + }, + } + return []corev1.ConfigMap{cm}, nil +} + // parse loops over a slice of configMaps and returns a string with the concatenated content if they are MCO consumable APIs. func (cg *ConfigGenerator) parse(configs []corev1.ConfigMap) (string, error) { var errors []error From 0b0c5e5f4414dd7dc9451f89dd994c4b44702016 Mon Sep 17 00:00:00 2001 From: Todd Wolff Date: Wed, 22 Apr 2026 20:01:12 -0400 Subject: [PATCH 4/5] test(e2e): add envtest coverage for ACR managed identity CEL validation Add YAML-driven envtest cases for imageRegistryCredentials validation: - Valid managedIdentity with camelCase resourceGroups (pass) - Lowercase resourcegroups rejected (fail, consistent with other Azure resource ID validations in this file) - Invalid format string (fail) - Wrong provider type (fail) - Missing identity name (fail) - Extra path segments (fail) - imageRegistryCredentials not set (pass) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../stable.nodepools.azure.testsuite.yaml | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/cmd/install/assets/crds/hypershift-operator/tests/nodepools.hypershift.openshift.io/stable.nodepools.azure.testsuite.yaml b/cmd/install/assets/crds/hypershift-operator/tests/nodepools.hypershift.openshift.io/stable.nodepools.azure.testsuite.yaml index 0258c76de04c..d21adef545a3 100644 --- a/cmd/install/assets/crds/hypershift-operator/tests/nodepools.hypershift.openshift.io/stable.nodepools.azure.testsuite.yaml +++ b/cmd/install/assets/crds/hypershift-operator/tests/nodepools.hypershift.openshift.io/stable.nodepools.azure.testsuite.yaml @@ -191,3 +191,196 @@ tests: subnetID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet" type: Azure expectedError: "publisher, offer, sku and version must either be all set, or all omitted" + + # --- ACR image registry credentials validation --- + - name: when imageRegistryCredentials is not set it should pass + initial: | + apiVersion: hypershift.openshift.io/v1beta1 + kind: NodePool + spec: + arch: amd64 + clusterName: some-cluster + management: + autoRepair: false + upgradeType: Replace + release: + image: quay.io/openshift-release-dev/ocp-release:4.17.0-rc.0-x86_64 + replicas: 0 + platform: + azure: + vmSize: Standard_D4s_v5 + image: + type: ImageID + imageID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Compute/images/test-image" + osDisk: + diskStorageAccountType: Premium_LRS + subnetID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet" + type: Azure + + - name: when imageRegistryCredentials has a valid managedIdentity it should pass + initial: | + apiVersion: hypershift.openshift.io/v1beta1 + kind: NodePool + spec: + arch: amd64 + clusterName: some-cluster + management: + autoRepair: false + upgradeType: Replace + release: + image: quay.io/openshift-release-dev/ocp-release:4.17.0-rc.0-x86_64 + replicas: 0 + platform: + azure: + vmSize: Standard_D4s_v5 + image: + type: ImageID + imageID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Compute/images/test-image" + osDisk: + diskStorageAccountType: Premium_LRS + subnetID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet" + imageRegistryCredentials: + managedIdentity: + resourceID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/acr-pull" + type: Azure + + - name: when imageRegistryCredentials has lowercase resourcegroups it should fail + initial: | + apiVersion: hypershift.openshift.io/v1beta1 + kind: NodePool + spec: + arch: amd64 + clusterName: some-cluster + management: + autoRepair: false + upgradeType: Replace + release: + image: quay.io/openshift-release-dev/ocp-release:4.17.0-rc.0-x86_64 + replicas: 0 + platform: + azure: + vmSize: Standard_D4s_v5 + image: + type: ImageID + imageID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Compute/images/test-image" + osDisk: + diskStorageAccountType: Premium_LRS + subnetID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet" + imageRegistryCredentials: + managedIdentity: + resourceID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourcegroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/acr-pull" + type: Azure + expectedError: "must be a valid ARM resource ID for a user-assigned managed identity" + + - name: when imageRegistryCredentials has an invalid managedIdentity format it should fail + initial: | + apiVersion: hypershift.openshift.io/v1beta1 + kind: NodePool + spec: + arch: amd64 + clusterName: some-cluster + management: + autoRepair: false + upgradeType: Replace + release: + image: quay.io/openshift-release-dev/ocp-release:4.17.0-rc.0-x86_64 + replicas: 0 + platform: + azure: + vmSize: Standard_D4s_v5 + image: + type: ImageID + imageID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Compute/images/test-image" + osDisk: + diskStorageAccountType: Premium_LRS + subnetID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet" + imageRegistryCredentials: + managedIdentity: + resourceID: "not-a-valid-arm-resource-id" + type: Azure + expectedError: "must be a valid ARM resource ID for a user-assigned managed identity" + + - name: when imageRegistryCredentials has a wrong provider type it should fail + initial: | + apiVersion: hypershift.openshift.io/v1beta1 + kind: NodePool + spec: + arch: amd64 + clusterName: some-cluster + management: + autoRepair: false + upgradeType: Replace + release: + image: quay.io/openshift-release-dev/ocp-release:4.17.0-rc.0-x86_64 + replicas: 0 + platform: + azure: + vmSize: Standard_D4s_v5 + image: + type: ImageID + imageID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Compute/images/test-image" + osDisk: + diskStorageAccountType: Premium_LRS + subnetID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet" + imageRegistryCredentials: + managedIdentity: + resourceID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/my-vm" + type: Azure + expectedError: "must be a valid ARM resource ID for a user-assigned managed identity" + + - name: when imageRegistryCredentials has a resourceID missing the identity name it should fail + initial: | + apiVersion: hypershift.openshift.io/v1beta1 + kind: NodePool + spec: + arch: amd64 + clusterName: some-cluster + management: + autoRepair: false + upgradeType: Replace + release: + image: quay.io/openshift-release-dev/ocp-release:4.17.0-rc.0-x86_64 + replicas: 0 + platform: + azure: + vmSize: Standard_D4s_v5 + image: + type: ImageID + imageID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Compute/images/test-image" + osDisk: + diskStorageAccountType: Premium_LRS + subnetID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet" + imageRegistryCredentials: + managedIdentity: + resourceID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/" + type: Azure + expectedError: "must be a valid ARM resource ID for a user-assigned managed identity" + + - name: when imageRegistryCredentials has a resourceID with extra path segments it should fail + initial: | + apiVersion: hypershift.openshift.io/v1beta1 + kind: NodePool + spec: + arch: amd64 + clusterName: some-cluster + management: + autoRepair: false + upgradeType: Replace + release: + image: quay.io/openshift-release-dev/ocp-release:4.17.0-rc.0-x86_64 + replicas: 0 + platform: + azure: + vmSize: Standard_D4s_v5 + image: + type: ImageID + imageID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Compute/images/test-image" + osDisk: + diskStorageAccountType: Premium_LRS + subnetID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/test-subnet" + imageRegistryCredentials: + managedIdentity: + resourceID: "/subscriptions/12345678-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-identity/extra" + type: Azure + expectedError: "must be a valid ARM resource ID for a user-assigned managed identity" + From b4ae9c1717481247aa71e1cffec54ec2c8870c2f Mon Sep 17 00:00:00 2001 From: Todd Wolff Date: Wed, 22 Apr 2026 20:01:19 -0400 Subject: [PATCH 5/5] docs: regenerate API reference for AzureImageRegistryCredentials Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/content/reference/aggregated-docs.md | 102 ++++++++++++++++++++++ docs/content/reference/api.md | 102 ++++++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/docs/content/reference/aggregated-docs.md b/docs/content/reference/aggregated-docs.md index 859d54d39b4c..b99001ebb842 100644 --- a/docs/content/reference/aggregated-docs.md +++ b/docs/content/reference/aggregated-docs.md @@ -34262,6 +34262,43 @@ applications and dev/test.

+###AzureImageRegistryCredentials { #hypershift.openshift.io/v1beta1.AzureImageRegistryCredentials } +

+(Appears on: +AzureNodePoolPlatform) +

+

+

AzureImageRegistryCredentials configures the kubelet credential provider for ACR +authentication using a user-assigned managed identity on worker node VMs. +The credential provider is configured with wildcard patterns covering all standard Azure +Container Registry endpoints (*.azurecr.io, *.azurecr.cn, *.azurecr.de, *.azurecr.us). +The identity must have the AcrPull role granted on the target ACR registry(ies).

+

+ + + + + + + + + + + + + +
FieldDescription
+managedIdentity,omitzero
+ + +UserAssignedManagedIdentity + + +
+

managedIdentity specifies the user-assigned managed identity that will be assigned to +worker node VMs/VMSS. The credential provider plugin running on each node will use this +identity to authenticate to ACR via the Azure Instance Metadata Service (IMDS).

+
###AzureKMSKey { #hypershift.openshift.io/v1beta1.AzureKMSKey }

(Appears on: @@ -34414,6 +34451,17 @@ and traffic must be routed through the private router (Swift).

+###AzureManagedIdentityResourceID { #hypershift.openshift.io/v1beta1.AzureManagedIdentityResourceID } +

+(Appears on: +UserAssignedManagedIdentity) +

+

+

AzureManagedIdentityResourceID is a full Azure resource ID for a user-assigned managed identity. +The expected format is:

+
/subscriptions/{subscriptionID}/resourceGroups/{resourceGroup}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName}
+
+

###AzureMarketplaceImage { #hypershift.openshift.io/v1beta1.AzureMarketplaceImage }

(Appears on: @@ -34735,6 +34783,26 @@ Diagnostics If not specified, then Boot diagnostics will be disabled.

+ + +imageRegistryCredentials,omitzero
+ + +AzureImageRegistryCredentials + + + + +(Optional) +

imageRegistryCredentials specifies configuration for enabling kubelet’s image credential +provider to authenticate to Azure Container Registry (ACR) using a managed identity. +When configured, worker nodes will use the acr-credential-provider plugin to obtain +short-lived tokens for ACR image pulls instead of relying on static pull secrets. +Changing this field will trigger a node rollout. +When not configured, no additional image credential provider is set up and worker nodes +use the default pull secret for image authentication.

+ + ###AzurePlatformSpec { #hypershift.openshift.io/v1beta1.AzurePlatformSpec } @@ -47313,6 +47381,40 @@ capacity.

+###UserAssignedManagedIdentity { #hypershift.openshift.io/v1beta1.UserAssignedManagedIdentity } +

+(Appears on: +AzureImageRegistryCredentials) +

+

+

UserAssignedManagedIdentity specifies a user-assigned managed identity for Azure resource +authentication. The resourceID is required for VMSS identity attachment and is also used +for the kubelet credential provider configuration.

+

+ + + + + + + + + + + + + +
FieldDescription
+resourceID
+ + +AzureManagedIdentityResourceID + + +
+

resourceID is the ARM resource ID of the user-assigned managed identity.

+

Format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName}

+
###UserManagedDiagnostics { #hypershift.openshift.io/v1beta1.UserManagedDiagnostics }

(Appears on: diff --git a/docs/content/reference/api.md b/docs/content/reference/api.md index 1d6c5b273a43..d6bbdc81b877 100644 --- a/docs/content/reference/api.md +++ b/docs/content/reference/api.md @@ -3304,6 +3304,43 @@ applications and dev/test.

+###AzureImageRegistryCredentials { #hypershift.openshift.io/v1beta1.AzureImageRegistryCredentials } +

+(Appears on: +AzureNodePoolPlatform) +

+

+

AzureImageRegistryCredentials configures the kubelet credential provider for ACR +authentication using a user-assigned managed identity on worker node VMs. +The credential provider is configured with wildcard patterns covering all standard Azure +Container Registry endpoints (*.azurecr.io, *.azurecr.cn, *.azurecr.de, *.azurecr.us). +The identity must have the AcrPull role granted on the target ACR registry(ies).

+

+ + + + + + + + + + + + + +
FieldDescription
+managedIdentity,omitzero
+ + +UserAssignedManagedIdentity + + +
+

managedIdentity specifies the user-assigned managed identity that will be assigned to +worker node VMs/VMSS. The credential provider plugin running on each node will use this +identity to authenticate to ACR via the Azure Instance Metadata Service (IMDS).

+
###AzureKMSKey { #hypershift.openshift.io/v1beta1.AzureKMSKey }

(Appears on: @@ -3456,6 +3493,17 @@ and traffic must be routed through the private router (Swift).

+###AzureManagedIdentityResourceID { #hypershift.openshift.io/v1beta1.AzureManagedIdentityResourceID } +

+(Appears on: +UserAssignedManagedIdentity) +

+

+

AzureManagedIdentityResourceID is a full Azure resource ID for a user-assigned managed identity. +The expected format is:

+
/subscriptions/{subscriptionID}/resourceGroups/{resourceGroup}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName}
+
+

###AzureMarketplaceImage { #hypershift.openshift.io/v1beta1.AzureMarketplaceImage }

(Appears on: @@ -3777,6 +3825,26 @@ Diagnostics If not specified, then Boot diagnostics will be disabled.

+ + +imageRegistryCredentials,omitzero
+ + +AzureImageRegistryCredentials + + + + +(Optional) +

imageRegistryCredentials specifies configuration for enabling kubelet’s image credential +provider to authenticate to Azure Container Registry (ACR) using a managed identity. +When configured, worker nodes will use the acr-credential-provider plugin to obtain +short-lived tokens for ACR image pulls instead of relying on static pull secrets. +Changing this field will trigger a node rollout. +When not configured, no additional image credential provider is set up and worker nodes +use the default pull secret for image authentication.

+ + ###AzurePlatformSpec { #hypershift.openshift.io/v1beta1.AzurePlatformSpec } @@ -16355,6 +16423,40 @@ capacity.

+###UserAssignedManagedIdentity { #hypershift.openshift.io/v1beta1.UserAssignedManagedIdentity } +

+(Appears on: +AzureImageRegistryCredentials) +

+

+

UserAssignedManagedIdentity specifies a user-assigned managed identity for Azure resource +authentication. The resourceID is required for VMSS identity attachment and is also used +for the kubelet credential provider configuration.

+

+ + + + + + + + + + + + + +
FieldDescription
+resourceID
+ + +AzureManagedIdentityResourceID + + +
+

resourceID is the ARM resource ID of the user-assigned managed identity.

+

Format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName}

+
###UserManagedDiagnostics { #hypershift.openshift.io/v1beta1.UserManagedDiagnostics }

(Appears on: