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).
+
+
+
+
+| Field |
+Description |
+
+
+
+
+
+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.
+
+
+
+
+| Field |
+Description |
+
+
+
+
+
+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).
+
+
+
+
+| Field |
+Description |
+
+
+
+
+
+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.
+
+
+
+
+| Field |
+Description |
+
+
+
+
+
+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: