Skip to content

Commit 8d34581

Browse files
committed
fix(ignition): gate cloud config on hash to prevent serving stale content
The ignition server reads the cloud config ConfigMap (azure-cloud-config, openstack-cloud-config) live during payload generation. If the ConfigMap is updated by the CPO after the nodepool controller creates the token but before the ignition server reads it, stale content could be served. Store a hash of the cloud config ConfigMap in the token secret (computed by the nodepool controller), and verify it matches the live ConfigMap in the ignition server before writing cloud.conf. On mismatch, return an error to trigger retry, matching the existing pattern for pullSecretHash and additionalTrustBundleHash. Signed-off-by: Todd Wolff <twolff@redhat.com>
1 parent dda6055 commit 8d34581

10 files changed

Lines changed: 317 additions & 27 deletions

File tree

hypershift-operator/controllers/nodepool/config.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212

1313
hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1"
14+
cpomanifests "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/manifests"
1415
"github.com/openshift/hypershift/support/api"
1516
"github.com/openshift/hypershift/support/backwardcompat"
1617
"github.com/openshift/hypershift/support/capabilities"
@@ -24,6 +25,7 @@ import (
2425
"github.com/openshift/api/operator/v1alpha1"
2526

2627
corev1 "k8s.io/api/core/v1"
28+
apierrors "k8s.io/apimachinery/pkg/api/errors"
2729
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2830
"k8s.io/apimachinery/pkg/runtime"
2931
serializer "k8s.io/apimachinery/pkg/runtime/serializer/json"
@@ -354,3 +356,26 @@ func globalConfigString(hcluster *hyperv1.HostedCluster) (string, error) {
354356
// Some fields in the ClusterConfiguration have changes that are not backwards compatible with older versions of the CPO.
355357
return backwardcompat.GetBackwardCompatibleConfigString(rawConfig), nil
356358
}
359+
360+
// GetCloudConfigHash returns a hash of the platform-specific cloud config ConfigMap content.
361+
// For platforms without a cloud config ConfigMap (e.g. AWS), it returns an empty string.
362+
func (cg *ConfigGenerator) GetCloudConfigHash(ctx context.Context) (string, error) {
363+
var cm *corev1.ConfigMap
364+
switch cg.hostedCluster.Spec.Platform.Type {
365+
case hyperv1.AzurePlatform:
366+
cm = cpomanifests.AzureProviderConfig(cg.controlplaneNamespace)
367+
case hyperv1.OpenStackPlatform:
368+
cm = cpomanifests.OpenStackProviderConfig(cg.controlplaneNamespace)
369+
default:
370+
return "", nil
371+
}
372+
373+
if err := cg.Get(ctx, client.ObjectKeyFromObject(cm), cm); err != nil {
374+
if apierrors.IsNotFound(err) {
375+
return "", nil
376+
}
377+
return "", fmt.Errorf("failed to get cloud config ConfigMap %s/%s: %w", cm.Namespace, cm.Name, err)
378+
}
379+
380+
return supportutil.HashConfigMapData(cm.Data), nil
381+
}

hypershift-operator/controllers/nodepool/config_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1"
1414
"github.com/openshift/hypershift/api/util/ipnet"
15+
cpomanifests "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/manifests"
1516
api "github.com/openshift/hypershift/support/api"
1617
"github.com/openshift/hypershift/support/releaseinfo"
1718
supportutil "github.com/openshift/hypershift/support/util"
@@ -1812,3 +1813,102 @@ func TestGlobalConfigString(t *testing.T) {
18121813
})
18131814
}
18141815
}
1816+
1817+
func TestCloudConfigHash(t *testing.T) {
1818+
controlPlaneNamespace := "test-cp"
1819+
azureCloudConfig := cpomanifests.AzureProviderConfig(controlPlaneNamespace)
1820+
azureCloudConfig.Data = map[string]string{
1821+
"cloud.conf": `{"tenantId":"t1","subscriptionId":"s1"}`,
1822+
}
1823+
1824+
azureCloudConfigDifferent := cpomanifests.AzureProviderConfig(controlPlaneNamespace)
1825+
azureCloudConfigDifferent.Data = map[string]string{
1826+
"cloud.conf": `{"tenantId":"t1","subscriptionId":"s1","userAssignedIdentityID":"new-id"}`,
1827+
}
1828+
1829+
testCases := []struct {
1830+
name string
1831+
platform hyperv1.PlatformType
1832+
objects []crclient.Object
1833+
expectEmpty bool
1834+
}{
1835+
{
1836+
name: "When platform is Azure and cloud config exists it should return a hash",
1837+
platform: hyperv1.AzurePlatform,
1838+
objects: []crclient.Object{azureCloudConfig.DeepCopy()},
1839+
},
1840+
{
1841+
name: "When platform is Azure and cloud config is missing it should return empty",
1842+
platform: hyperv1.AzurePlatform,
1843+
objects: []crclient.Object{},
1844+
expectEmpty: true,
1845+
},
1846+
{
1847+
name: "When platform is AWS it should return empty",
1848+
platform: hyperv1.AWSPlatform,
1849+
objects: []crclient.Object{},
1850+
expectEmpty: true,
1851+
},
1852+
{
1853+
name: "When cloud config content changes it should produce a different hash",
1854+
platform: hyperv1.AzurePlatform,
1855+
objects: []crclient.Object{azureCloudConfigDifferent.DeepCopy()},
1856+
},
1857+
}
1858+
1859+
for _, tc := range testCases {
1860+
t.Run(tc.name, func(t *testing.T) {
1861+
g := NewWithT(t)
1862+
fakeClient := fake.NewClientBuilder().WithScheme(api.Scheme).WithObjects(tc.objects...).Build()
1863+
1864+
cg := &ConfigGenerator{
1865+
Client: fakeClient,
1866+
hostedCluster: &hyperv1.HostedCluster{
1867+
Spec: hyperv1.HostedClusterSpec{
1868+
Platform: hyperv1.PlatformSpec{
1869+
Type: tc.platform,
1870+
},
1871+
},
1872+
},
1873+
controlplaneNamespace: controlPlaneNamespace,
1874+
rolloutConfig: &rolloutConfig{},
1875+
}
1876+
1877+
hash, err := cg.GetCloudConfigHash(t.Context())
1878+
g.Expect(err).ToNot(HaveOccurred())
1879+
1880+
if tc.expectEmpty {
1881+
g.Expect(hash).To(BeEmpty())
1882+
} else {
1883+
g.Expect(hash).ToNot(BeEmpty())
1884+
}
1885+
})
1886+
}
1887+
1888+
t.Run("When content differs the hashes should differ", func(t *testing.T) {
1889+
g := NewWithT(t)
1890+
1891+
getHash := func(obj crclient.Object) string {
1892+
fakeClient := fake.NewClientBuilder().WithScheme(api.Scheme).WithObjects(obj).Build()
1893+
cg := &ConfigGenerator{
1894+
Client: fakeClient,
1895+
hostedCluster: &hyperv1.HostedCluster{
1896+
Spec: hyperv1.HostedClusterSpec{
1897+
Platform: hyperv1.PlatformSpec{
1898+
Type: hyperv1.AzurePlatform,
1899+
},
1900+
},
1901+
},
1902+
controlplaneNamespace: controlPlaneNamespace,
1903+
rolloutConfig: &rolloutConfig{},
1904+
}
1905+
hash, err := cg.GetCloudConfigHash(t.Context())
1906+
g.Expect(err).ToNot(HaveOccurred())
1907+
return hash
1908+
}
1909+
1910+
hash1 := getHash(azureCloudConfig.DeepCopy())
1911+
hash2 := getHash(azureCloudConfigDifferent.DeepCopy())
1912+
g.Expect(hash1).ToNot(Equal(hash2))
1913+
})
1914+
}

hypershift-operator/controllers/nodepool/token.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const (
4141
TokenSecretPullSecretHashKey = "pull-secret-hash"
4242
TokenSecretHCConfigurationHashKey = "hc-configuration-hash"
4343
TokenSecretAdditionalTrustBundleKey = "additional-trust-bundle-hash"
44+
TokenSecretCloudConfigHashKey = "cloud-config-hash"
4445
TokenSecretConfigKey = "config"
4546
TokenSecretAnnotation = "hypershift.openshift.io/ignition-config"
4647
TokenSecretIgnitionReachedAnnotation = "hypershift.openshift.io/ignition-reached"
@@ -61,6 +62,7 @@ type Token struct {
6162
pullSecretHash []byte
6263
additionalTrustBundleHash []byte
6364
globalConfigHash []byte
65+
cloudConfigHash []byte
6466
userData *userData
6567
}
6668

@@ -113,13 +115,19 @@ func NewToken(ctx context.Context, configGenerator *ConfigGenerator, cpoCapabili
113115
return nil, fmt.Errorf("failed to hash HostedCluster configuration: %w", err)
114116
}
115117

118+
cloudConfigHash, err := configGenerator.GetCloudConfigHash(ctx)
119+
if err != nil {
120+
return nil, err
121+
}
122+
116123
token := &Token{
117124
CreateOrUpdateProvider: upsert.New(false),
118125
ConfigGenerator: configGenerator,
119126
cpoCapabilities: cpoCapabilities,
120127
pullSecretHash: []byte(supportutil.HashSimple(pullSecretBytes)),
121128
additionalTrustBundleHash: []byte(supportutil.HashSimple(additionalTrustBundle)),
122129
globalConfigHash: []byte(hcConfigurationHash),
130+
cloudConfigHash: []byte(cloudConfigHash),
123131
}
124132

125133
// User data input.
@@ -354,11 +362,13 @@ func (t *Token) reconcileTokenSecret(tokenSecret *corev1.Secret) error {
354362
tokenSecret.Data[TokenSecretPullSecretHashKey] = t.pullSecretHash
355363
tokenSecret.Data[TokenSecretAdditionalTrustBundleKey] = t.additionalTrustBundleHash
356364
tokenSecret.Data[TokenSecretHCConfigurationHashKey] = t.globalConfigHash
365+
tokenSecret.Data[TokenSecretCloudConfigHashKey] = t.cloudConfigHash
357366
}
358367
// TODO (alberto): Only apply this on creation and change the hash generation to only use triggering upgrade fields.
359368
// We let this change to happen inplace now as the tokenSecret and the mcs config use the whole spec.Config for the comparing hash.
360369
// Otherwise if something which does not trigger a new token generation from spec.Config changes, like .IDP, both hashes would mismatch forever.
361370
tokenSecret.Data[TokenSecretHCConfigurationHashKey] = t.globalConfigHash
371+
tokenSecret.Data[TokenSecretCloudConfigHashKey] = t.cloudConfigHash
362372

363373
return nil
364374
}

ignition-server/cmd/run_local_ignitionprovider.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ func (o *RunLocalIgnitionProviderOptions) Run(ctx context.Context) error {
111111
FeatureGateManifest: o.FeatureGateManifest,
112112
}
113113

114-
payload, err := p.GetPayload(ctx, o.Image, config.String(), "", "", "")
114+
payload, err := p.GetPayload(ctx, o.Image, config.String(), "", "", "", "")
115115
if err != nil {
116116
return err
117117
}

ignition-server/controllers/cache.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ type ExpiringCache struct {
3232
}
3333

3434
type CacheValue struct {
35-
Payload []byte
36-
SecretName string
35+
Payload []byte
36+
SecretName string
37+
CloudConfigHash string
3738
}
3839

3940
type entry struct {

ignition-server/controllers/local_ignitionprovider.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,10 @@ func (p *LocalIgnitionProvider) extractImageReferences(ctx context.Context, rele
300300
return releaseImage, nil
301301
}
302302

303-
// For Azure and OpenStack, extract the cloud provider config file as MCO input
304-
func (p *LocalIgnitionProvider) writeCloudProviderConfig(ctx context.Context, mcoDir string) error {
303+
// For Azure and OpenStack, extract the cloud provider config file as MCO input.
304+
// When cloudConfigHash is non-empty, the ConfigMap content is verified against the hash
305+
// before writing to ensure the ignition server does not serve stale cloud config.
306+
func (p *LocalIgnitionProvider) writeCloudProviderConfig(ctx context.Context, mcoDir, cloudConfigHash string) error {
305307
if p.CloudProvider != hyperv1.AzurePlatform && p.CloudProvider != hyperv1.OpenStackPlatform {
306308
return nil
307309
}
@@ -316,6 +318,9 @@ func (p *LocalIgnitionProvider) writeCloudProviderConfig(ctx context.Context, mc
316318
return fmt.Errorf("failed to get cloud provider configmap: %w", err)
317319
}
318320
}
321+
if cloudConfigHash != "" && util.HashConfigMapData(cloudConfigMap.Data) != cloudConfigHash {
322+
return fmt.Errorf("cloud config does not match hash, waiting for update")
323+
}
319324
cloudConfYaml, err := yaml.Marshal(cloudConfigMap)
320325
if err != nil {
321326
return fmt.Errorf("failed to marshal cloud config: %w", err)
@@ -607,7 +612,7 @@ func (p *LocalIgnitionProvider) runMCSAndFetchPayload(ctx context.Context, dirs
607612
return payload, err
608613
}
609614

610-
func (p *LocalIgnitionProvider) GetPayload(ctx context.Context, releaseImage, customConfig, pullSecretHash, additionalTrustBundleHash, hcConfigurationHash string) ([]byte, error) {
615+
func (p *LocalIgnitionProvider) GetPayload(ctx context.Context, releaseImage, customConfig, pullSecretHash, additionalTrustBundleHash, hcConfigurationHash, cloudConfigHash string) ([]byte, error) {
611616
p.lock.Lock()
612617
defer p.lock.Unlock()
613618

@@ -687,7 +692,7 @@ func (p *LocalIgnitionProvider) GetPayload(ctx context.Context, releaseImage, cu
687692
return nil, fmt.Errorf("failed to extract image-references from image: %w", err)
688693
}
689694

690-
if err := p.writeCloudProviderConfig(ctx, dirs.mcoDir); err != nil {
695+
if err := p.writeCloudProviderConfig(ctx, dirs.mcoDir, cloudConfigHash); err != nil {
691696
return nil, err
692697
}
693698

ignition-server/controllers/local_ignitionprovider_test.go

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1"
1818
"github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/imageprovider"
19+
"github.com/openshift/hypershift/support/util"
1920

2021
corev1 "k8s.io/api/core/v1"
2122
"k8s.io/apimachinery/pkg/api/meta"
@@ -1104,11 +1105,12 @@ func TestWriteCloudProviderConfig(t *testing.T) {
11041105
t.Parallel()
11051106

11061107
tests := []struct {
1107-
name string
1108-
cloudProvider hyperv1.PlatformType
1109-
objects []client.Object
1110-
expectFile bool
1111-
expectError string
1108+
name string
1109+
cloudProvider hyperv1.PlatformType
1110+
objects []client.Object
1111+
cloudConfigHash string
1112+
expectFile bool
1113+
expectError string
11121114
}{
11131115
{
11141116
name: "When cloud provider is AWS, it should return nil without writing any file",
@@ -1148,6 +1150,30 @@ func TestWriteCloudProviderConfig(t *testing.T) {
11481150
objects: []client.Object{},
11491151
expectError: "failed to get cloud provider configmap",
11501152
},
1153+
{
1154+
name: "When cloud provider is Azure and hash matches, it should write cloud config file",
1155+
cloudProvider: hyperv1.AzurePlatform,
1156+
objects: []client.Object{
1157+
&corev1.ConfigMap{
1158+
ObjectMeta: metav1.ObjectMeta{Name: "azure-cloud-config", Namespace: "test-ns"},
1159+
Data: map[string]string{"cloud.conf": "azure-config-data"},
1160+
},
1161+
},
1162+
cloudConfigHash: util.HashConfigMapData(map[string]string{"cloud.conf": "azure-config-data"}),
1163+
expectFile: true,
1164+
},
1165+
{
1166+
name: "When cloud provider is Azure and hash does not match, it should return an error",
1167+
cloudProvider: hyperv1.AzurePlatform,
1168+
objects: []client.Object{
1169+
&corev1.ConfigMap{
1170+
ObjectMeta: metav1.ObjectMeta{Name: "azure-cloud-config", Namespace: "test-ns"},
1171+
Data: map[string]string{"cloud.conf": "azure-config-data"},
1172+
},
1173+
},
1174+
cloudConfigHash: "wrong-hash",
1175+
expectError: "cloud config does not match hash, waiting for update",
1176+
},
11511177
}
11521178

11531179
for _, tt := range tests {
@@ -1164,7 +1190,7 @@ func TestWriteCloudProviderConfig(t *testing.T) {
11641190
CloudProvider: tt.cloudProvider,
11651191
}
11661192

1167-
err := provider.writeCloudProviderConfig(t.Context(), mcoDir)
1193+
err := provider.writeCloudProviderConfig(t.Context(), mcoDir, tt.cloudConfigHash)
11681194
if tt.expectError != "" {
11691195
g.Expect(err).To(HaveOccurred())
11701196
g.Expect(err.Error()).To(ContainSubstring(tt.expectError))

0 commit comments

Comments
 (0)