feat: reject cluster version updates lacking a reachable update channel - #6445
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds an admission-time guardrail to prevent properties.version.id updates when the target version’s update channel is not reachable for the cluster, using HostedCluster-derived channel availability mirrored into ServiceProviderCluster by the backend (preserving the frontend’s isolation from management-cluster APIs).
Changes:
- Mirror
HostedCluster.Status.Version.Desired.ChannelsintoServiceProviderCluster.Status.DesiredVersionChannelsvia the backendControlPlaneActiveVersionscontroller, and include it in change detection to avoid no-op Cosmos writes. - Add an admission check (
admitClusterVersionChannel) that rejectsversion.idupdates when the expected channel is not present inDesiredVersionChannels, plus unit tests for the new behavior. - Document/enforce the architectural boundary that frontend/admission must not access management-cluster state directly (must be mirrored through
ServiceProviderCluster).
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/api/zz_generated.deepcopy.go | Deep-copy support for the new DesiredVersionChannels slice field. |
| internal/api/types_serviceprovider_cluster.go | Adds ServiceProviderClusterStatus.DesiredVersionChannels with semantics and writer annotation. |
| internal/admission/CLAUDE.md | Documents the “mirror through ServiceProviderCluster” architectural rule for admission. |
| internal/admission/admit_cluster.go | Implements admitClusterVersionChannel and wires it into version update validation. |
| internal/admission/admit_cluster_test.go | Adds unit tests covering the new version-channel admission behavior. |
| frontend/pkg/frontend/cluster.go | Adds an architectural comment near newClusterAdmissionContext reinforcing the isolation boundary. |
| backend/pkg/controllers/cluster/version/control_plane_active_version_controller.go | Mirrors HostedCluster desired channels into SPC status and includes them in update detection/logging. |
Files not reviewed (1)
- internal/api/zz_generated.deepcopy.go: Generated file
| { | ||
| name: "version.id update with matching channel passes", | ||
| op: operation.Operation{Type: operation.Update}, | ||
| oldVersion: &api.VersionProfile{ID: "4.18", ChannelGroup: "stable"}, | ||
| newVersion: &api.VersionProfile{ID: "4.19", ChannelGroup: "stable"}, | ||
| spc: spcWithChannels("stable-4.18", "candidate-4.19", "stable-4.19"), | ||
| expectErrors: []utils.ExpectedError{}, | ||
| }, | ||
| { | ||
| name: "version.id update with no matching channel is rejected", | ||
| op: operation.Operation{Type: operation.Update}, | ||
| oldVersion: &api.VersionProfile{ID: "4.18", ChannelGroup: "stable"}, | ||
| newVersion: &api.VersionProfile{ID: "4.19", ChannelGroup: "stable"}, | ||
| spc: spcWithChannels("stable-4.20", "candidate-4.20"), | ||
| expectErrors: []utils.ExpectedError{ | ||
| {FieldPath: "properties.version.id", Message: `no upgrade path to update channel "stable-4.19"`}, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
Yup, definitely add. And add a test the nightly build versions you see on a page like https://amd64.ocp.releases.ci.openshift.org/#5.0.0-0.nightly
| expectedChannel := fmt.Sprintf("%s-%s", newObj.ChannelGroup, newObj.ID) | ||
| availableChannels := admissionContext.ServiceProviderCluster.Status.DesiredVersionChannels | ||
| if slices.Contains(availableChannels, expectedChannel) { |
There was a problem hiding this comment.
Comment is correct. Channels are only major.minor. Add a test to be sure this handles it properly.
| // HostedCluster) so that DB-free cluster admission can reject a version.id | ||
| // change whose target channel ("<channelGroup>-<id>") is not reachable, | ||
| // without the frontend ever needing access to the management cluster. | ||
| // Written by: ControlPlaneActiveVersions | ||
| DesiredVersionChannels []string `json:"desiredVersionChannels,omitempty"` |
19e2ad3 to
afb8d12
Compare
|
/retest-required AI-generated. Review for accuracy. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- internal/api/zz_generated.deepcopy.go: Generated file
Suppressed comments (3)
internal/admission/admit_cluster.go:565
- The PR description says this check returns InternalError when ServiceProviderCluster is nil and rejects when the channel list is empty, but the implementation here is explicitly fail-open in both cases (and the tests reflect that). Please align the PR description with the actual behavior (or adjust code/tests if the stricter behavior is intended).
// No channel data yet: fail open (see the doc comment above). A genuinely
// missing ServiceProviderCluster prefetch is still surfaced as an
// InternalError by the version-skew check in admitClusterVersionProfile, so
// we do not duplicate that here.
if admissionContext.ServiceProviderCluster == nil {
return nil
}
availableChannels := admissionContext.ServiceProviderCluster.Status.DesiredVersionChannels
if len(availableChannels) == 0 {
return nil
}
internal/api/types_serviceprovider_cluster.go:167
- This adds a new Cosmos-persisted ServiceProviderCluster status field. Per the repo’s Cosmos data flow documentation, docs/cosmos-data-flow.md should be updated to reflect that ControlPlaneActiveVersions now also writes Status.DesiredVersionChannels (in addition to Status.ControlPlaneVersion.ActiveVersions).
// DesiredVersionChannels mirrors the observed HostedCluster's
// status.version.desired.channels (see hypershift ClusterVersionStatus ->
// configv1.Release.Channels). Each entry is an OpenShift update channel name
// such as "stable-4.19" or "candidate-4.20".
//
// A channel only appears in status.version.desired.channels when the
// cluster's current desired release has a valid upgrade edge to a release
// served by that channel. In other words, this is the set of channels the
// cluster can currently move along; a channel being absent means there is no
// supported upgrade path to that channel's release line yet.
//
// This list is mirrored here by the backend (which alone can observe the
// HostedCluster) so that DB-free cluster admission can reject a version.id
// change whose target channel ("<channelGroup>-<id>") is not reachable,
// without the frontend ever needing access to the management cluster.
// Written by: ControlPlaneActiveVersions
DesiredVersionChannels []string `json:"desiredVersionChannels,omitempty"`
internal/admission/admit_cluster.go:506
- This comment says the version change is rejected when the requested channel has no reachable upgrade edge, but the implementation intentionally fails open when DesiredVersionChannels is empty/unavailable. Consider rewording to reflect that rejection only happens once a non-empty channel list has been mirrored by the backend.
This issue also appears on line 555 of the same file.
// Reject the version change if the requested update channel has no reachable
// upgrade edge for this cluster (see admitClusterVersionChannel).
errs = append(errs, admitClusterVersionChannel(ctx, admissionContext, op, fldPath, newObj, oldObj)...)
David Eads (deads2k)
left a comment
There was a problem hiding this comment.
forgot to update the Cosmos Data Flow Documentation
| // change until the mirror is populated, which is incorrect. This check therefore | ||
| // only rejects when the backend has published a non-empty channel list that | ||
| // genuinely lacks the requested channel. | ||
| func admitClusterVersionChannel(_ context.Context, admissionContext *ClusterAdmissionContext, op operation.Operation, fldPath *field.Path, newObj, oldObj *api.VersionProfile) field.ErrorList { |
There was a problem hiding this comment.
rename to adminClusterVersionID
| // records) so admission can validate version skew without hitting the DB itself. | ||
| // On CREATE pass a nil clusterResourceID — no prior state exists to prefetch. | ||
| // | ||
| // Architectural rule: the frontend must NEVER reach the management cluster |
There was a problem hiding this comment.
Add this to the top level CLAUDE.md in a reasonable spot as a rule for the entire frontend.
| expectedChannel := fmt.Sprintf("%s-%s", newObj.ChannelGroup, newObj.ID) | ||
| availableChannels := admissionContext.ServiceProviderCluster.Status.DesiredVersionChannels | ||
| if slices.Contains(availableChannels, expectedChannel) { |
There was a problem hiding this comment.
Comment is correct. Channels are only major.minor. Add a test to be sure this handles it properly.
| { | ||
| name: "version.id update with matching channel passes", | ||
| op: operation.Operation{Type: operation.Update}, | ||
| oldVersion: &api.VersionProfile{ID: "4.18", ChannelGroup: "stable"}, | ||
| newVersion: &api.VersionProfile{ID: "4.19", ChannelGroup: "stable"}, | ||
| spc: spcWithChannels("stable-4.18", "candidate-4.19", "stable-4.19"), | ||
| expectErrors: []utils.ExpectedError{}, | ||
| }, | ||
| { | ||
| name: "version.id update with no matching channel is rejected", | ||
| op: operation.Operation{Type: operation.Update}, | ||
| oldVersion: &api.VersionProfile{ID: "4.18", ChannelGroup: "stable"}, | ||
| newVersion: &api.VersionProfile{ID: "4.19", ChannelGroup: "stable"}, | ||
| spc: spcWithChannels("stable-4.20", "candidate-4.20"), | ||
| expectErrors: []utils.ExpectedError{ | ||
| {FieldPath: "properties.version.id", Message: `no upgrade path to update channel "stable-4.19"`}, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
Yup, definitely add. And add a test the nightly build versions you see on a page like https://amd64.ocp.releases.ci.openshift.org/#5.0.0-0.nightly
afb8d12 to
c390bf0
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- internal/api/coreapi/zz_generated.deepcopy.go: Generated file
Suppressed comments (2)
internal/admission/CLAUDE.md:101
internal/admission/CLAUDE.mdreferencesadmitClusterVersionChannel, but the implementation added in this PR is namedadmitClusterVersionID. This mismatch makes the guidance harder to follow and will confuse future readers/searches.
Concrete example in this package: `admitClusterVersionChannel` validates a
version.id change against `ServiceProviderCluster.Status.DesiredVersionChannels`.
internal/admission/admit_cluster.go:587
- The PR description says the new channel validation returns an InternalError when
ServiceProviderClusteris nil and rejects an emptyDesiredVersionChannelslist, but the code here explicitly fails open for both cases (ServiceProviderCluster == nilandlen(DesiredVersionChannels) == 0both skip validation). If fail-open is intended, the PR description (and the test-case list in it) should be updated to match; if not intended, the logic here needs to be tightened to enforce the described behavior.
// No channel data yet: fail open (see the doc comment above). A genuinely
// missing ServiceProviderCluster prefetch is still surfaced as an
// InternalError by the version-skew check in admitClusterVersionProfile, so
// we do not duplicate that here.
if admissionContext.ServiceProviderCluster == nil {
return nil
}
availableChannels := admissionContext.ServiceProviderCluster.Status.DesiredVersionChannels
if len(availableChannels) == 0 {
return nil
}
|
/retest-required AI-generated. Review for accuracy. |
| // whether a requested version.id has a reachable update channel. Doing the | ||
| // mirroring here keeps the frontend (and therefore admission) free of any | ||
| // management-cluster access; see internal/admission/CLAUDE.md. |
There was a problem hiding this comment.
This seems like an architectural rule that should live in a different file other than CLAUDE.md
There was a problem hiding this comment.
Ah okay, it was instructed here #6445 (comment) , ignore the comment.
| `ServiceProviderCluster` in `newClusterAdmissionContext` like any other | ||
| server-side state, and admission reads it from the context struct. | ||
|
|
||
| Concrete example in this package: `admitClusterVersionChannel` validates a |
There was a problem hiding this comment.
| Concrete example in this package: `admitClusterVersionChannel` validates a | |
| Concrete example in this package: `admitClusterVersionID` validates a |
There was a problem hiding this comment.
Good catch — this reference was missed during the rename. Fixing now along with updating the PR description to accurately reflect the fail-open behavior.
AI-generated. Review for accuracy.
|
Chai-bot (@redhat-chai-bot) for (ServiceProviderCluster == nil and len(DesiredVersionChannels) == 0 both skip validation) and we don't fail as stated in the PR's description: |
|
Only two minor comments, otherwise the code lgtm |
|
Manyanda Chitimbo (@machi1990) You're right — the PR description is stale from the original implementation before the fail-open fix. Updating both the CLAUDE.md function name reference and the PR description now. AI-generated. Review for accuracy. |
…ades
Add a validating admission check that fires when a cluster's version.id is
updated. Once the backend has published the cluster's observed update
channels, the check requires the target update channel
("<channelGroup>-<major>.<minor>") to be present in that list. A channel only
appears in HostedCluster.status.version.desired.channels when the cluster's
current version has a valid upgrade edge to a release served by that channel,
so a missing channel means there is no supported upgrade path to the requested
version line and the update is rejected.
The requested version's release line is derived with versionMajorMinor, so
patch ("4.20.8"), nightly ("5.0.0-0.nightly-...") and pre-release
("4.21.0-rc.1") IDs all resolve to their "<major>.<minor>" channel rather than
being compared by raw ID.
The check fails open until the channel data is synced: DesiredVersionChannels
is populated asynchronously by the backend and is empty on freshly created
clusters and until the first sync completes. When the list is unavailable
(no ServiceProviderCluster prefetched, or an empty list) the check is skipped
rather than blocking the update, so it never rejects a version change purely
because the mirror has not been populated yet. It only rejects against a
non-empty published channel list that genuinely lacks the requested channel.
The observed HostedCluster lives on the management cluster, which the frontend
must never access. Instead the backend ControlPlaneActiveVersions controller
mirrors HostedCluster.status.version.desired.channels onto
ServiceProviderCluster.Status.DesiredVersionChannels, which the frontend
prefetches into the admission context. This "mirror through
ServiceProviderCluster" rule is documented in internal/admission/CLAUDE.md,
the root CLAUDE.md, and reiterated near newClusterAdmissionContext.
- api: add DesiredVersionChannels to ServiceProviderClusterStatus (+ deepcopy)
- backend: sync desired channels in control_plane_active_version_controller
- admission: add admitClusterVersionID (major.minor channel lookup) wired into
admitClusterVersionProfile
- docs: document the new read/write in cosmos-data-flow.md and the frontend
isolation boundary in the root CLAUDE.md
- tests: table-driven coverage incl. micro/nightly/pre-release resolution
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
c390bf0 to
09b0a3a
Compare
Manyanda Chitimbo (machi1990)
left a comment
There was a problem hiding this comment.
/lgtm
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: machi1990, redhat-chai-bot The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- internal/api/coreapi/zz_generated.deepcopy.go: Generated file
Suppressed comments (2)
internal/api/coreapi/types_serviceprovider_cluster.go:165
- The comment describes the target channel as "-", but the admission check (and OpenShift channel naming) uses the release line "-." (e.g. stable-4.20). Keeping this precise avoids confusion for future readers and keeps the doc consistent with admitClusterVersionID.
// This list is mirrored here by the backend (which alone can observe the
// HostedCluster) so that DB-free cluster admission can reject a version.id
// change whose target channel ("<channelGroup>-<id>") is not reachable,
// without the frontend ever needing access to the management cluster.
backend/pkg/controllers/cluster/version/control_plane_active_version_controller.go:114
- This controller now mirrors HostedCluster desired-version channels onto ServiceProviderCluster.Status.DesiredVersionChannels, but the existing unit tests in control_plane_active_version_controller_test.go only assert ActiveVersions updates. Adding a test case that seeds HostedCluster.Status.Version.Desired.Channels (and verifies the SPC Status field is updated, and that no Replace occurs when channels are unchanged) would prevent regressions in the admission data path.
// Mirror the observed HostedCluster's status.version.desired.channels onto
// the ServiceProviderCluster. Cluster admission consumes this list to decide
// whether a requested version.id has a reachable update channel. Doing the
// mirroring here keeps the frontend (and therefore admission) free of any
// management-cluster access; see internal/admission/CLAUDE.md.
newDesiredChannels := getHostedClusterDesiredVersionChannels(hostedCluster)
Summary
Adds a validating admission check that prevents
properties.version.idupdates when the target version's update channel is not reachable for the cluster.When a cluster's
version.idis updated, the admission check builds the expected channel name (<channelGroup>-<major>.<minor>— e.g.stable-4.20for version4.20.8) and verifies it exists in theServiceProviderCluster.Status.DesiredVersionChannelslist. If the channel is absent from a non-empty list, the update is rejected withfield.Invalid.Channels only appear in the HostedCluster's
status.version.desired.channelswhen the cluster's current desired release has a valid upgrade edge to a release in that channel — so a missing channel means there is no supported upgrade path.Architecture
The HostedCluster channel data is mirrored into
ServiceProviderClusterby the backend'sControlPlaneActiveVersionscontroller. The admission check reads fromServiceProviderCluster(which is prefetched intoClusterAdmissionContextby the frontend), preserving the architectural boundary: the frontend must never access kube-applier, ReadDesireLister, or management cluster APIs directly.Behavior
ServiceProviderCluster == nilandlen(DesiredVersionChannels) == 0skip validation (return no error). SinceDesiredVersionChannelsis populated asynchronously by the backend, an empty list means the data hasn't been mirrored yet — not that no channels exist. A genuinely missingServiceProviderClusterprefetch is still surfaced asInternalErrorby the existing version-skew check.<channelGroup>-<major>.<minor>is not in it, returnsfield.Invalid.ChannelGroupis empty: For backwards compatibility with clusters that predate channel groups.Changes
ServiceProviderClusterStatus.DesiredVersionChannels— new[]stringfield mirroring HostedCluster channelsControlPlaneActiveVersionscontroller — syncs channels from HostedCluster into ServiceProviderCluster, included in change detectionadmitClusterVersionID— new admission function withversionMajorMinor()helper for semver parsingdocs/cosmos-data-flow.md— updated with the new fieldCLAUDE.md(root +internal/admission/) — architectural rules documented