From 1141357a860aaa988158f9841199fcc1b718a52d Mon Sep 17 00:00:00 2001 From: Mulham Raee Date: Thu, 8 Jan 2026 17:29:13 +0100 Subject: [PATCH] feat(cpo): use HO image for CPO on 4.20+ clusters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For cluster versions 4.20 and above, use the HyperShift Operator image directly for the Control Plane Operator instead of extracting it from the OCP release payload. This enables: - Faster feature delivery for CPO (ships with HO releases) - Simplified hotfix process (single HO image bump fixes all clusters) - Consistent deployment model between managed and self-managed The change includes a safety check that verifies the control-plane-operator binary exists in the HO image before using it. This ensures backward compatibility with older HO images that don't include the CPO binary - they will continue to use the release payload CPO. Dockerfiles are updated to: - Build and include control-plane-operator and control-plane-pki-operator - Add symlinks for ignition-server, konnectivity-socks5-proxy, etc. - Add missing CPO feature discovery labels 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Containerfile.operator | 13 ++- Dockerfile | 13 ++- support/util/util.go | 46 ++++++++++- support/util/util_test.go | 167 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 233 insertions(+), 6 deletions(-) diff --git a/Containerfile.operator b/Containerfile.operator index bca1699c4cde..2d6dc44e2e8a 100644 --- a/Containerfile.operator +++ b/Containerfile.operator @@ -8,7 +8,9 @@ RUN make hypershift \ && make hypershift-no-cgo \ && make hypershift-operator \ && make product-cli \ - && make karpenter-operator + && make karpenter-operator \ + && make control-plane-operator \ + && make control-plane-pki-operator FROM registry.access.redhat.com/ubi9/ubi-minimal:9.6-1760515502 COPY --from=builder /hypershift/bin/hypershift \ @@ -16,8 +18,16 @@ COPY --from=builder /hypershift/bin/hypershift \ /hypershift/bin/hcp \ /hypershift/bin/hypershift-operator \ /hypershift/bin/karpenter-operator \ + /hypershift/bin/control-plane-operator \ + /hypershift/bin/control-plane-pki-operator \ /usr/bin/ +RUN cd /usr/bin && \ + ln -s control-plane-operator ignition-server && \ + ln -s control-plane-operator konnectivity-socks5-proxy && \ + ln -s control-plane-operator availability-prober && \ + ln -s control-plane-operator token-minter + ENTRYPOINT ["/usr/bin/hypershift"] LABEL name="multicluster-engine/hypershift-operator" @@ -41,4 +51,5 @@ LABEL io.openshift.hypershift.control-plane-operator-applies-management-kas-netw LABEL io.openshift.hypershift.restricted-psa=true LABEL io.openshift.hypershift.control-plane-pki-operator-signs-csrs=true LABEL io.openshift.hypershift.hosted-cluster-config-operator-reports-node-count=true +LABEL io.openshift.hypershift.control-plane-operator-supports-kas-custom-kubeconfig=true LABEL io.openshift.hypershift.control-plane-operator.v2-isdefault=true diff --git a/Dockerfile b/Dockerfile index bedac2475971..ee94c8aa0f3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,9 @@ RUN make hypershift \ && make hypershift-no-cgo \ && make hypershift-operator \ && make product-cli \ - && make karpenter-operator + && make karpenter-operator \ + && make control-plane-operator \ + && make control-plane-pki-operator FROM registry.access.redhat.com/ubi9:latest COPY --from=builder /hypershift/bin/hypershift \ @@ -16,8 +18,16 @@ COPY --from=builder /hypershift/bin/hypershift \ /hypershift/bin/hcp \ /hypershift/bin/hypershift-operator \ /hypershift/bin/karpenter-operator \ + /hypershift/bin/control-plane-operator \ + /hypershift/bin/control-plane-pki-operator \ /usr/bin/ +RUN cd /usr/bin && \ + ln -s control-plane-operator ignition-server && \ + ln -s control-plane-operator konnectivity-socks5-proxy && \ + ln -s control-plane-operator availability-prober && \ + ln -s control-plane-operator token-minter + ENTRYPOINT ["/usr/bin/hypershift"] LABEL io.openshift.hypershift.control-plane-operator-subcommands=true @@ -32,4 +42,5 @@ LABEL io.openshift.hypershift.control-plane-operator-applies-management-kas-netw LABEL io.openshift.hypershift.restricted-psa=true LABEL io.openshift.hypershift.control-plane-pki-operator-signs-csrs=true LABEL io.openshift.hypershift.hosted-cluster-config-operator-reports-node-count=true +LABEL io.openshift.hypershift.control-plane-operator-supports-kas-custom-kubeconfig=true LABEL io.openshift.hypershift.control-plane-operator.v2-isdefault=true \ No newline at end of file diff --git a/support/util/util.go b/support/util/util.go index aff63f1cb17d..5eea6cb35d94 100644 --- a/support/util/util.go +++ b/support/util/util.go @@ -17,6 +17,7 @@ import ( "regexp" "sort" "strings" + "sync" "time" hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" @@ -627,17 +628,43 @@ func GetPullSecretBytes(ctx context.Context, c client.Client, hc *hyperv1.Hosted return pullSecretBytes, nil } +// cpoBinaryPath is the expected location of the control-plane-operator binary +const cpoBinaryPath = "/usr/bin/control-plane-operator" + +var ( + cpoBinaryExistsOnce sync.Once + cpoBinaryExistsResult bool + // cpoBinaryExistsFunc is used for testing to override the binary existence check. + // When nil, the actual file system check is performed. + cpoBinaryExistsFunc func() bool +) + +// cpoBinaryExistsInHOImage checks if the control-plane-operator binary exists in +// the current container image. This is used to determine if the HO image can be +// used as the CPO image for 4.20+ clusters. The result is cached after the first check. +func cpoBinaryExistsInHOImage() bool { + if cpoBinaryExistsFunc != nil { + return cpoBinaryExistsFunc() + } + cpoBinaryExistsOnce.Do(func() { + _, err := os.Stat(cpoBinaryPath) + cpoBinaryExistsResult = err == nil + }) + return cpoBinaryExistsResult +} + // GetControlPlaneOperatorImage resolves the appropriate control plane operator // image based on the following order of precedence (from most to least // preferred): // // 1. The image specified by the ControlPlaneOperatorImageAnnotation on the // HostedCluster resource itself -// 2. The hypershift image specified in the release payload indicated by the +// 2. If CPO overrides are enabled, the override image for the platform and version +// 3. For release versions 4.20+, the hypershift-operator's own image (if it +// contains the control-plane-operator binary) +// 4. The hypershift image specified in the release payload indicated by the // HostedCluster's release field -// 3. The hypershift-operator's own image for release versions 4.9 and 4.10 -// 4. The registry.ci.openshift.org/hypershift/hypershift:4.8 image for release -// version 4.8 +// 5. The hypershift-operator's own image for release versions 4.9+ // // If no image can be found according to these rules, an error is returned. func GetControlPlaneOperatorImage(ctx context.Context, hc *hyperv1.HostedCluster, releaseProvider releaseinfo.Provider, hypershiftOperatorImage string, pullSecret []byte) (string, error) { @@ -659,6 +686,17 @@ func GetControlPlaneOperatorImage(ctx context.Context, hc *hyperv1.HostedCluster } } + // For 4.20+, use the hypershift-operator image directly if it contains the + // control-plane-operator binary. This enables faster feature delivery and + // simplified hotfix processes, as the CPO is delivered with the HO rather + // than being tied to the OCP release payload. + minVersionForHOImage := semver.Version{Major: 4, Minor: 20, Patch: 0} + // Compare only Major.Minor.Patch, ignoring pre-release info (e.g., 4.20.0-rc.1 should match >= 4.20.0) + versionWithoutPrerelease := semver.Version{Major: version.Major, Minor: version.Minor, Patch: version.Patch} + if versionWithoutPrerelease.GTE(minVersionForHOImage) && cpoBinaryExistsInHOImage() { + return hypershiftOperatorImage, nil + } + if hypershiftImage, exists := releaseInfo.ComponentImages()["hypershift"]; exists { return hypershiftImage, nil } diff --git a/support/util/util_test.go b/support/util/util_test.go index 6ad7c4c98a66..b2627b389006 100644 --- a/support/util/util_test.go +++ b/support/util/util_test.go @@ -9,6 +9,9 @@ import ( hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" "github.com/openshift/hypershift/support/api" + "github.com/openshift/hypershift/support/releaseinfo" + + imagev1 "github.com/openshift/api/image/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -1009,3 +1012,167 @@ func TestCountAvailableNodes(t *testing.T) { }) } } + +// testReleaseProvider is a simple fake release provider for testing GetControlPlaneOperatorImage +type testReleaseProvider struct { + version string + components map[string]string +} + +func (f *testReleaseProvider) Lookup(_ context.Context, _ string, _ []byte) (*releaseinfo.ReleaseImage, error) { + releaseImage := &releaseinfo.ReleaseImage{ + ImageStream: &imagev1.ImageStream{ + ObjectMeta: metav1.ObjectMeta{Name: f.version}, + Spec: imagev1.ImageStreamSpec{}, + }, + } + for name, image := range f.components { + releaseImage.ImageStream.Spec.Tags = append(releaseImage.ImageStream.Spec.Tags, imagev1.TagReference{ + Name: name, + From: &corev1.ObjectReference{Name: image}, + }) + } + return releaseImage, nil +} + +func TestGetControlPlaneOperatorImage(t *testing.T) { + const ( + hoImage = "quay.io/hypershift/hypershift-operator:latest" + payloadCPOImage = "quay.io/openshift-release-dev/ocp-v4.0-art-dev@sha256:abc123" + annotationCPOImage = "quay.io/custom/cpo:v1" + ) + + testCases := []struct { + name string + version string + hostedClusterAnnotation map[string]string + payloadHasHypershift bool + cpoBinaryExists bool + expectedImage string + }{ + { + name: "When annotation is set it should use annotation image", + version: "4.20.0", + hostedClusterAnnotation: map[string]string{ + hyperv1.ControlPlaneOperatorImageAnnotation: annotationCPOImage, + }, + payloadHasHypershift: true, + cpoBinaryExists: true, + expectedImage: annotationCPOImage, + }, + { + name: "When version is 4.20 and CPO binary exists it should use HO image", + version: "4.20.0", + payloadHasHypershift: true, + cpoBinaryExists: true, + expectedImage: hoImage, + }, + { + name: "When version is 4.21 and CPO binary exists it should use HO image", + version: "4.21.0", + payloadHasHypershift: true, + cpoBinaryExists: true, + expectedImage: hoImage, + }, + { + name: "When version is 4.22 and CPO binary exists it should use HO image", + version: "4.22.5", + payloadHasHypershift: true, + cpoBinaryExists: true, + expectedImage: hoImage, + }, + { + name: "When version is 4.20 but CPO binary does not exist it should use payload image", + version: "4.20.0", + payloadHasHypershift: true, + cpoBinaryExists: false, + expectedImage: payloadCPOImage, + }, + { + name: "When version is 4.19 with payload hypershift it should use payload image", + version: "4.19.0", + payloadHasHypershift: true, + cpoBinaryExists: true, + expectedImage: payloadCPOImage, + }, + { + name: "When version is 4.18 with payload hypershift it should use payload image", + version: "4.18.5", + payloadHasHypershift: true, + cpoBinaryExists: true, + expectedImage: payloadCPOImage, + }, + { + name: "When version is 4.14 with payload hypershift it should use payload image", + version: "4.14.0", + payloadHasHypershift: true, + cpoBinaryExists: true, + expectedImage: payloadCPOImage, + }, + { + name: "When version is 4.19 without payload hypershift it should fallback to HO image", + version: "4.19.0", + payloadHasHypershift: false, + cpoBinaryExists: true, + expectedImage: hoImage, + }, + { + name: "When version is 4.10 without payload hypershift it should fallback to HO image", + version: "4.10.0", + payloadHasHypershift: false, + cpoBinaryExists: true, + expectedImage: hoImage, + }, + { + name: "When version is 5.0 and CPO binary exists it should use HO image", + version: "5.0.0", + payloadHasHypershift: true, + cpoBinaryExists: true, + expectedImage: hoImage, + }, + { + name: "When version is 4.20.0-rc.1 and CPO binary exists it should use HO image", + version: "4.20.0-rc.1", + payloadHasHypershift: true, + cpoBinaryExists: true, + expectedImage: hoImage, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + // Set the CPO binary existence for this test case + cpoBinaryExistsFunc = func() bool { return tc.cpoBinaryExists } + defer func() { cpoBinaryExistsFunc = nil }() + + components := map[string]string{} + if tc.payloadHasHypershift { + components["hypershift"] = payloadCPOImage + } + + releaseProvider := &testReleaseProvider{ + version: tc.version, + components: components, + } + + hc := &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "test-ns", + Annotations: tc.hostedClusterAnnotation, + }, + Spec: hyperv1.HostedClusterSpec{ + Release: hyperv1.Release{ + Image: "quay.io/openshift-release-dev/ocp-release:4.20.0-x86_64", + }, + }, + } + + image, err := GetControlPlaneOperatorImage(context.Background(), hc, releaseProvider, hoImage, nil) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(image).To(Equal(tc.expectedImage)) + }) + } +}