diff --git a/api/hypershift/v1beta1/hosted_controlplane.go b/api/hypershift/v1beta1/hosted_controlplane.go index 71188edbd806..9f9798d6c391 100644 --- a/api/hypershift/v1beta1/hosted_controlplane.go +++ b/api/hypershift/v1beta1/hosted_controlplane.go @@ -428,6 +428,12 @@ type HostedControlPlaneStatus struct { // +optional Configuration *ConfigurationStatus `json:"configuration,omitempty"` + // operatorConfiguration reports which settings from spec.operatorConfiguration the + // reconciling control plane operator understands. It lets a newer HyperShift operator + // detect settings that an older control plane operator silently ignores. + // +optional + OperatorConfiguration OperatorConfigurationStatus `json:"operatorConfiguration,omitzero"` + // secretEncryption tracks the state of secret encryption key rotation and re-encryption. // +optional SecretEncryption SecretEncryptionStatus `json:"secretEncryption,omitzero"` @@ -436,6 +442,40 @@ type HostedControlPlaneStatus struct { Initialization HostedControlPlaneInitializationStatus `json:"initialization,omitzero"` } +// OperatorConfigurationStatus reports how the reconciling control plane operator +// interpreted spec.operatorConfiguration. +// +// A control plane operator only deserializes the spec fields its own build knows about; +// the API server serves settings added in later releases, but they are dropped on decode +// and silently ignored. Echoing back the settings that survived that decode lets the +// HyperShift operator name the settings a lagging control plane operator does not honor, +// without either side needing per-field knowledge of the other. +// +kubebuilder:validation:MinProperties=1 +type OperatorConfigurationStatus struct { + // observedGeneration is the HostedControlPlane generation whose + // spec.operatorConfiguration produced honoredFields. A value lower than + // metadata.generation means the control plane operator has not yet observed the + // latest spec, so honoredFields is stale. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=9007199254740992 + ObservedGeneration int64 `json:"observedGeneration,omitempty,omitzero"` + + // honoredFields lists the dot-separated paths of the settings that are both set in + // spec.operatorConfiguration and understood by the reconciling control plane operator, + // relative to spec.operatorConfiguration and sorted lexically + // (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + // configured. A setting that is set in the spec but absent from this list is not in + // effect. + // +optional + // +listType=atomic + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=200 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=253 + HonoredFields []string `json:"honoredFields,omitempty"` +} + // HostedControlPlaneInitializationStatus provides observations of the HostedControlPlane initialization process. // This satisfies the CAPI v1beta2 ControlPlane provider contract: // https://github.com/kubernetes-sigs/cluster-api/blob/v1.11.5/api/core/v1beta2/cluster_types.go#L1361-L1379 diff --git a/api/hypershift/v1beta1/hosted_controlplane_test.go b/api/hypershift/v1beta1/hosted_controlplane_test.go new file mode 100644 index 000000000000..8cdd6733b652 --- /dev/null +++ b/api/hypershift/v1beta1/hosted_controlplane_test.go @@ -0,0 +1,109 @@ +package v1beta1 + +import ( + "encoding/json" + "reflect" + "testing" +) + +// hostedControlPlaneStatusNMinus1 is a shape probe for the N-1 version of +// HostedControlPlaneStatus, before the operatorConfiguration field was added. Consumers +// such as ARO-HCP embed these types and serialize them to their own storage outside CRD +// validation, so a rollback has to be able to read data written by the newer type. +type hostedControlPlaneStatusNMinus1 struct { + // ready reports whether the control plane is ready. + Ready bool `json:"ready"` //nolint:kubeapilinter // test-only N-1 compat struct + // initialized reports whether the control plane is initialized. + Initialized bool `json:"initialized"` //nolint:kubeapilinter // test-only N-1 compat struct +} + +func TestOperatorConfigurationStatusSerializationCompatibility(t *testing.T) { + tests := []struct { + name string + // current is the N (current) version of the struct. + current OperatorConfigurationStatus + // expectedJSON is the expected JSON output from marshaling current. + expectedJSON string + }{ + { + name: "When nothing has been reported it should serialize to an empty object", + current: OperatorConfigurationStatus{}, + expectedJSON: `{}`, + }, + { + name: "When a generation is observed with no settings configured it should omit honoredFields", + current: OperatorConfigurationStatus{ + ObservedGeneration: 4, + }, + expectedJSON: `{"observedGeneration":4}`, + }, + { + name: "When settings are honored it should serialize both fields", + current: OperatorConfigurationStatus{ + ObservedGeneration: 7, + HonoredFields: []string{"etcd.logLevel", "kubeAPIServer.eventTTLMinutes"}, + }, + expectedJSON: `{"observedGeneration":7,"honoredFields":["etcd.logLevel","kubeAPIServer.eventTTLMinutes"]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encoded, err := json.Marshal(tt.current) + if err != nil { + t.Fatalf("failed to marshal current type: %v", err) + } + if string(encoded) != tt.expectedJSON { + t.Errorf("unexpected JSON:\n got: %s\nwant: %s", encoded, tt.expectedJSON) + } + + // N (forward): the current type reads back what it wrote. + var roundTripped OperatorConfigurationStatus + if err := json.Unmarshal(encoded, &roundTripped); err != nil { + t.Fatalf("failed to unmarshal into current type: %v", err) + } + if !reflect.DeepEqual(roundTripped, tt.current) { + t.Errorf("round trip changed the value:\n got: %#v\nwant: %#v", roundTripped, tt.current) + } + }) + } +} + +func TestHostedControlPlaneStatusSerializationCompatibility(t *testing.T) { + // N-1 (rollback): a consumer without the operatorConfiguration field must still be + // able to deserialize a status written by the current type. + current := HostedControlPlaneStatus{ + Ready: true, + Initialized: true, + OperatorConfiguration: OperatorConfigurationStatus{ + ObservedGeneration: 7, + HonoredFields: []string{"kubeAPIServer.eventTTLMinutes"}, + }, + } + encoded, err := json.Marshal(current) + if err != nil { + t.Fatalf("failed to marshal current type: %v", err) + } + + var nMinus1 hostedControlPlaneStatusNMinus1 + if err := json.Unmarshal(encoded, &nMinus1); err != nil { + t.Fatalf("N-1 consumer failed to deserialize status written by the current type: %v", err) + } + if want := (hostedControlPlaneStatusNMinus1{Ready: true, Initialized: true}); nMinus1 != want { + t.Errorf("N-1 consumer read unexpected values:\n got: %#v\nwant: %#v", nMinus1, want) + } + + // N+1 (forward): the current type must read a status written before the field + // existed, and see the zero value rather than failing. + olderEncoded, err := json.Marshal(hostedControlPlaneStatusNMinus1{Ready: true, Initialized: true}) + if err != nil { + t.Fatalf("failed to marshal N-1 type: %v", err) + } + var fromOlder HostedControlPlaneStatus + if err := json.Unmarshal(olderEncoded, &fromOlder); err != nil { + t.Fatalf("current type failed to deserialize status written by an N-1 consumer: %v", err) + } + if !reflect.DeepEqual(fromOlder.OperatorConfiguration, OperatorConfigurationStatus{}) { + t.Errorf("expected the zero OperatorConfigurationStatus from an N-1 status, got: %#v", fromOlder.OperatorConfiguration) + } +} diff --git a/api/hypershift/v1beta1/hostedcluster_conditions.go b/api/hypershift/v1beta1/hostedcluster_conditions.go index eecdb18efb1b..ba5339f418c9 100644 --- a/api/hypershift/v1beta1/hostedcluster_conditions.go +++ b/api/hypershift/v1beta1/hostedcluster_conditions.go @@ -305,6 +305,24 @@ const ( // keeps serving and the HostedCluster does not become degraded. // The condition is absent when no defaultCertificate is configured. IngressDefaultCertificateSynced ConditionType = "IngressDefaultCertificateSynced" + + // OperatorConfigurationHonored indicates whether every setting configured under + // spec.operatorConfiguration is actually in effect on the hosted control plane. + // It is intentionally generic so that one condition covers the whole stanza and + // every setting added to it; the message names the specific settings at issue. + // + // A control plane operator older than the HyperShift operator accepts and persists + // a setting it predates, then ignores it. This condition is how that is reported. + // + // **True** (reason AsExpected) means no setting is configured, or every configured + // setting is honored by the reconciling control plane operator. + // **False** (reason OperatorConfigurationNotHonored) means at least one configured + // setting is not in effect, and the message names them. + // **Unknown** (reason UnsupportedControlPlaneVersion or StatusUnknown) means whether + // the configured settings are honored could not be determined, because the control + // plane operator predates this reporting, its version is unknown, or it has not yet + // observed the current spec. + OperatorConfigurationHonored ConditionType = "OperatorConfigurationHonored" ) // Reasons for PublicEndpointExposed condition. @@ -341,11 +359,19 @@ const ( // imply the mechanism is currently driving configuration, since a non-deprecated // field may take precedence over it. DeprecatedConfigurationInUseReason = "DeprecatedConfigurationInUse" - NotFoundReason = "NotFound" - WaitingForAvailableReason = "WaitingForAvailable" - SecretNotFoundReason = "SecretNotFound" - WaitingForGracePeriodReason = "WaitingForGracePeriod" - BlockedReason = "Blocked" + // OperatorConfigurationNotHonoredReason is used with the OperatorConfigurationHonored + // condition when at least one setting configured under spec.operatorConfiguration is + // not in effect because the reconciling control plane operator does not support it. + OperatorConfigurationNotHonoredReason = "OperatorConfigurationNotHonored" + // UnsupportedControlPlaneVersionReason is used when the reconciling control plane + // operator is too old to report the information a condition is derived from, so the + // condition cannot be evaluated. + UnsupportedControlPlaneVersionReason = "UnsupportedControlPlaneVersion" + NotFoundReason = "NotFound" + WaitingForAvailableReason = "WaitingForAvailable" + SecretNotFoundReason = "SecretNotFound" + WaitingForGracePeriodReason = "WaitingForGracePeriod" + BlockedReason = "Blocked" InfraStatusFailureReason = "InfraStatusFailure" WaitingOnInfrastructureReadyReason = "WaitingOnInfrastructureReady" diff --git a/api/hypershift/v1beta1/zz_generated.deepcopy.go b/api/hypershift/v1beta1/zz_generated.deepcopy.go index 4c0459433bcf..e799deac00c4 100644 --- a/api/hypershift/v1beta1/zz_generated.deepcopy.go +++ b/api/hypershift/v1beta1/zz_generated.deepcopy.go @@ -3039,6 +3039,7 @@ func (in *HostedControlPlaneStatus) DeepCopyInto(out *HostedControlPlaneStatus) *out = new(ConfigurationStatus) (*in).DeepCopyInto(*out) } + in.OperatorConfiguration.DeepCopyInto(&out.OperatorConfiguration) in.SecretEncryption.DeepCopyInto(&out.SecretEncryption) in.Initialization.DeepCopyInto(&out.Initialization) } @@ -4612,6 +4613,26 @@ func (in *OperatorConfiguration) DeepCopy() *OperatorConfiguration { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OperatorConfigurationStatus) DeepCopyInto(out *OperatorConfigurationStatus) { + *out = *in + if in.HonoredFields != nil { + in, out := &in.HonoredFields, &out.HonoredFields + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OperatorConfigurationStatus. +func (in *OperatorConfigurationStatus) DeepCopy() *OperatorConfigurationStatus { + if in == nil { + return nil + } + out := new(OperatorConfigurationStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PersistentVolumeEtcdStorageSpec) DeepCopyInto(out *PersistentVolumeEtcdStorageSpec) { *out = *in diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/AAA_ungated.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/AAA_ungated.yaml index 800a2020376a..2cc8903231ba 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/AAA_ungated.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/AAA_ungated.yaml @@ -7343,6 +7343,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/AWSManagedDNS.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/AWSManagedDNS.yaml index 1d2a9d6b2a99..d5be031a42dd 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/AWSManagedDNS.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/AWSManagedDNS.yaml @@ -7390,6 +7390,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ClusterUpdateAcceptRisks.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ClusterUpdateAcceptRisks.yaml index 6de3ea503996..610258e454af 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ClusterUpdateAcceptRisks.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ClusterUpdateAcceptRisks.yaml @@ -7326,6 +7326,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ClusterVersionOperatorConfiguration.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ClusterVersionOperatorConfiguration.yaml index 7d170afafc92..3f75783d2cc5 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ClusterVersionOperatorConfiguration.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ClusterVersionOperatorConfiguration.yaml @@ -7345,6 +7345,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/DeterministicLBIP.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/DeterministicLBIP.yaml index 27a3a3df2c9a..ce471eebf5a3 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/DeterministicLBIP.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/DeterministicLBIP.yaml @@ -7367,6 +7367,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/EtcdSharding.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/EtcdSharding.yaml index 5af64c429709..c41890e5c433 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/EtcdSharding.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/EtcdSharding.yaml @@ -7797,6 +7797,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDC.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDC.yaml index 19a9f6c2c062..68bfa760617b 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDC.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDC.yaml @@ -7823,6 +7823,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDCExternalClaimsSourcing.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDCExternalClaimsSourcing.yaml index c94573058e82..fd32164e36e3 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDCExternalClaimsSourcing.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDCExternalClaimsSourcing.yaml @@ -8080,6 +8080,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDCWithUIDAndExtraClaimMappings.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDCWithUIDAndExtraClaimMappings.yaml index 27487054ff94..01def49ccd21 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDCWithUIDAndExtraClaimMappings.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDCWithUIDAndExtraClaimMappings.yaml @@ -7963,6 +7963,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDCWithUpstreamParity.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDCWithUpstreamParity.yaml index f0637e9b7586..10f80cbe6d05 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDCWithUpstreamParity.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ExternalOIDCWithUpstreamParity.yaml @@ -7789,6 +7789,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/GCPPlatform.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/GCPPlatform.yaml index 3e87f5df92b1..211d8e614e7d 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/GCPPlatform.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/GCPPlatform.yaml @@ -7774,6 +7774,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/HCPEtcdBackup.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/HCPEtcdBackup.yaml index a1fd860faa07..e895e04c1776 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/HCPEtcdBackup.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/HCPEtcdBackup.yaml @@ -7392,6 +7392,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/HCPUserFacingOperatorLogs.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/HCPUserFacingOperatorLogs.yaml index da941248b1af..d40dfed390e1 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/HCPUserFacingOperatorLogs.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/HCPUserFacingOperatorLogs.yaml @@ -7573,6 +7573,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/HyperShiftOnlyDynamicResourceAllocation.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/HyperShiftOnlyDynamicResourceAllocation.yaml index 86efc24019c1..d752f0fd289e 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/HyperShiftOnlyDynamicResourceAllocation.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/HyperShiftOnlyDynamicResourceAllocation.yaml @@ -7348,6 +7348,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ImageStreamImportMode.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ImageStreamImportMode.yaml index be0853b80e7c..8e4a8b3c952a 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ImageStreamImportMode.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/ImageStreamImportMode.yaml @@ -7355,6 +7355,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/IngressComponentRouteLabels.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/IngressComponentRouteLabels.yaml index 0ba68c2404ce..1d7b1b3f4111 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/IngressComponentRouteLabels.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/IngressComponentRouteLabels.yaml @@ -7382,6 +7382,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/IngressControllerLBSecurityGroupsAWS.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/IngressControllerLBSecurityGroupsAWS.yaml index 564288c1f2ee..624077ab08bc 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/IngressControllerLBSecurityGroupsAWS.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/IngressControllerLBSecurityGroupsAWS.yaml @@ -7369,6 +7369,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/KMSEncryption.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/KMSEncryption.yaml index ac5c33d85345..669a7c009ae8 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/KMSEncryption.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/KMSEncryption.yaml @@ -7639,6 +7639,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/NetworkObservabilityInstall.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/NetworkObservabilityInstall.yaml index 11ca45282a9e..45dc7ec6687d 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/NetworkObservabilityInstall.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/NetworkObservabilityInstall.yaml @@ -7348,6 +7348,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/OpenStack.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/OpenStack.yaml index 6f60f15a6f58..dae0ffa1386c 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/OpenStack.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/OpenStack.yaml @@ -7877,6 +7877,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/TLSAdherence.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/TLSAdherence.yaml index ae82f9f378f2..40b39a2d3c89 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/TLSAdherence.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/TLSAdherence.yaml @@ -7366,6 +7366,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/TLSGroupPreferences.yaml b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/TLSGroupPreferences.yaml index f0ad91c9b007..ecae88032fba 100644 --- a/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/TLSGroupPreferences.yaml +++ b/api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests/hostedcontrolplanes.hypershift.openshift.io/TLSGroupPreferences.yaml @@ -7366,6 +7366,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/client/applyconfiguration/hypershift/v1beta1/hostedcontrolplanestatus.go b/client/applyconfiguration/hypershift/v1beta1/hostedcontrolplanestatus.go index ea73f181d7ac..5c961cd8cc6d 100644 --- a/client/applyconfiguration/hypershift/v1beta1/hostedcontrolplanestatus.go +++ b/client/applyconfiguration/hypershift/v1beta1/hostedcontrolplanestatus.go @@ -44,6 +44,7 @@ type HostedControlPlaneStatusApplyConfiguration struct { NodeCount *int `json:"nodeCount,omitempty"` AutoNode *AutoNodeStatusApplyConfiguration `json:"autoNode,omitempty"` Configuration *ConfigurationStatusApplyConfiguration `json:"configuration,omitempty"` + OperatorConfiguration *OperatorConfigurationStatusApplyConfiguration `json:"operatorConfiguration,omitempty"` SecretEncryption *SecretEncryptionStatusApplyConfiguration `json:"secretEncryption,omitempty"` Initialization *HostedControlPlaneInitializationStatusApplyConfiguration `json:"initialization,omitempty"` } @@ -203,6 +204,14 @@ func (b *HostedControlPlaneStatusApplyConfiguration) WithConfiguration(value *Co return b } +// WithOperatorConfiguration sets the OperatorConfiguration 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 OperatorConfiguration field is set to the value of the last call. +func (b *HostedControlPlaneStatusApplyConfiguration) WithOperatorConfiguration(value *OperatorConfigurationStatusApplyConfiguration) *HostedControlPlaneStatusApplyConfiguration { + b.OperatorConfiguration = value + return b +} + // WithSecretEncryption sets the SecretEncryption 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 SecretEncryption field is set to the value of the last call. diff --git a/client/applyconfiguration/hypershift/v1beta1/operatorconfigurationstatus.go b/client/applyconfiguration/hypershift/v1beta1/operatorconfigurationstatus.go new file mode 100644 index 000000000000..d39ff671b0af --- /dev/null +++ b/client/applyconfiguration/hypershift/v1beta1/operatorconfigurationstatus.go @@ -0,0 +1,49 @@ +/* + + +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 + +// OperatorConfigurationStatusApplyConfiguration represents a declarative configuration of the OperatorConfigurationStatus type for use +// with apply. +type OperatorConfigurationStatusApplyConfiguration struct { + ObservedGeneration *int64 `json:"observedGeneration,omitempty"` + HonoredFields []string `json:"honoredFields,omitempty"` +} + +// OperatorConfigurationStatusApplyConfiguration constructs a declarative configuration of the OperatorConfigurationStatus type for use with +// apply. +func OperatorConfigurationStatus() *OperatorConfigurationStatusApplyConfiguration { + return &OperatorConfigurationStatusApplyConfiguration{} +} + +// WithObservedGeneration sets the ObservedGeneration 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 ObservedGeneration field is set to the value of the last call. +func (b *OperatorConfigurationStatusApplyConfiguration) WithObservedGeneration(value int64) *OperatorConfigurationStatusApplyConfiguration { + b.ObservedGeneration = &value + return b +} + +// WithHonoredFields adds the given value to the HonoredFields field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the HonoredFields field. +func (b *OperatorConfigurationStatusApplyConfiguration) WithHonoredFields(values ...string) *OperatorConfigurationStatusApplyConfiguration { + for i := range values { + b.HonoredFields = append(b.HonoredFields, values[i]) + } + return b +} diff --git a/client/applyconfiguration/utils.go b/client/applyconfiguration/utils.go index a4262dc4176f..810bcee38a4e 100644 --- a/client/applyconfiguration/utils.go +++ b/client/applyconfiguration/utils.go @@ -409,6 +409,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &hypershiftv1beta1.OpenStackPlatformSpecApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("OperatorConfiguration"): return &hypershiftv1beta1.OperatorConfigurationApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("OperatorConfigurationStatus"): + return &hypershiftv1beta1.OperatorConfigurationStatusApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("OSImageStreamReference"): return &hypershiftv1beta1.OSImageStreamReferenceApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("OVNIPv4Config"): diff --git a/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/hostedcontrolplanes-Hypershift-CustomNoUpgrade.crd.yaml b/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/hostedcontrolplanes-Hypershift-CustomNoUpgrade.crd.yaml index 80d16ebb036c..32871049bf3e 100644 --- a/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/hostedcontrolplanes-Hypershift-CustomNoUpgrade.crd.yaml +++ b/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/hostedcontrolplanes-Hypershift-CustomNoUpgrade.crd.yaml @@ -10997,6 +10997,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/hostedcontrolplanes-Hypershift-Default.crd.yaml b/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/hostedcontrolplanes-Hypershift-Default.crd.yaml index c4a2f0e78484..a3ad34d68f4d 100644 --- a/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/hostedcontrolplanes-Hypershift-Default.crd.yaml +++ b/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/hostedcontrolplanes-Hypershift-Default.crd.yaml @@ -8383,6 +8383,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/hostedcontrolplanes-Hypershift-TechPreviewNoUpgrade.crd.yaml b/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/hostedcontrolplanes-Hypershift-TechPreviewNoUpgrade.crd.yaml index 797e8cdad415..c79aee0b9b34 100644 --- a/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/hostedcontrolplanes-Hypershift-TechPreviewNoUpgrade.crd.yaml +++ b/cmd/install/assets/crds/hypershift-operator/zz_generated.crd-manifests/hostedcontrolplanes-Hypershift-TechPreviewNoUpgrade.crd.yaml @@ -10161,6 +10161,40 @@ spec: This is populated after the infrastructure is ready. maxLength: 255 type: string + operatorConfiguration: + description: |- + operatorConfiguration reports which settings from spec.operatorConfiguration the + reconciling control plane operator understands. It lets a newer HyperShift operator + detect settings that an older control plane operator silently ignores. + minProperties: 1 + properties: + honoredFields: + description: |- + honoredFields lists the dot-separated paths of the settings that are both set in + spec.operatorConfiguration and understood by the reconciling control plane operator, + relative to spec.operatorConfiguration and sorted lexically + (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + configured. A setting that is set in the spec but absent from this list is not in + effect. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 200 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + observedGeneration: + description: |- + observedGeneration is the HostedControlPlane generation whose + spec.operatorConfiguration produced honoredFields. A value lower than + metadata.generation means the control plane operator has not yet observed the + latest spec, so honoredFields is stale. + format: int64 + maximum: 9007199254740992 + minimum: 1 + type: integer + type: object platform: description: platform contains platform-specific status of the HostedCluster properties: diff --git a/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go b/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go index 5b85fbdce490..7b3eeff66dcd 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go +++ b/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go @@ -90,6 +90,7 @@ import ( karpenterutil "github.com/openshift/hypershift/support/karpenter" "github.com/openshift/hypershift/support/metrics" "github.com/openshift/hypershift/support/netutil" + "github.com/openshift/hypershift/support/operatorconfig" "github.com/openshift/hypershift/support/reconcilerpolicy" "github.com/openshift/hypershift/support/releaseinfo" "github.com/openshift/hypershift/support/statuspatching" @@ -627,6 +628,35 @@ func (r *HostedControlPlaneReconciler) reconcileDeprecatedConfigurationStatus(ho meta.SetStatusCondition(&hostedControlPlane.Status.Conditions, newCondition) } +// reconcileOperatorConfigurationStatus echoes back the settings this control plane +// operator understood from spec.operatorConfiguration, so that a newer HyperShift +// operator can tell which settings a lagging control plane operator silently ignores. +// +// The echo works because the API server serves settings added after this build, but +// they are dropped when the spec is decoded into this build's types. Re-encoding what +// survived therefore yields exactly the settings that are in effect, without this +// operator needing to know which settings exist in later releases. +func (r *HostedControlPlaneReconciler) reconcileOperatorConfigurationStatus(hostedControlPlane *hyperv1.HostedControlPlane) { + honoredFields, err := operatorconfig.FieldPaths(hostedControlPlane.Spec.OperatorConfiguration) + if err != nil { + // Reporting nothing leaves the HyperShift operator reporting Unknown, which is + // correct here: we cannot establish what is honored. + r.Log.Error(err, "failed to determine honored operatorConfiguration settings") + return + } + if !operatorconfig.Reportable(honoredFields) { + // Reporting a truncated list would be read as settings having been dropped. + r.Log.Info("not reporting honored operatorConfiguration settings: too many settings configured", + "count", len(honoredFields), "max", operatorconfig.MaxHonoredFields) + return + } + + hostedControlPlane.Status.OperatorConfiguration = hyperv1.OperatorConfigurationStatus{ + ObservedGeneration: hostedControlPlane.Generation, + HonoredFields: honoredFields, + } +} + func (r *HostedControlPlaneReconciler) reconcileDegradedStatus(ctx context.Context, hostedControlPlane *hyperv1.HostedControlPlane) error { condition := metav1.Condition{ Type: string(hyperv1.HostedControlPlaneDegraded), @@ -729,6 +759,7 @@ func (r *HostedControlPlaneReconciler) Reconcile(ctx context.Context, req ctrl.R } r.reconcileDeprecatedConfigurationStatus(hostedControlPlane) + r.reconcileOperatorConfigurationStatus(hostedControlPlane) if err := r.reconcileDegradedStatus(ctx, hostedControlPlane); err != nil { return ctrl.Result{}, err diff --git a/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller_test.go b/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller_test.go index fa607cd997ef..4f3a42b8598e 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller_test.go +++ b/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller_test.go @@ -4384,6 +4384,55 @@ func TestReconcileDeprecatedConfigurationStatus(t *testing.T) { } } +func TestReconcileOperatorConfigurationStatus(t *testing.T) { + testCases := []struct { + name string + hcp *hyperv1.HostedControlPlane + expectedHonoredFields []string + expectedGeneration int64 + }{ + { + name: "When no operatorConfiguration is set, it should report the observed generation and no honored settings", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + }, + expectedHonoredFields: nil, + expectedGeneration: 3, + }, + { + name: "When operatorConfiguration settings are set, it should report them as honored", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{Generation: 7}, + Spec: hyperv1.HostedControlPlaneSpec{ + OperatorConfiguration: &hyperv1.OperatorConfiguration{ + KubeAPIServer: hyperv1.KubeAPIServerOperatorSpec{ + ComponentLogLevelSpec: hyperv1.ComponentLogLevelSpec{LogLevel: hyperv1.Debug}, + }, + Etcd: hyperv1.EtcdOperatorSpec{ + ComponentLogLevelSpec: hyperv1.ComponentLogLevelSpec{LogLevel: hyperv1.Debug}, + }, + }, + }, + }, + expectedHonoredFields: []string{"etcd.logLevel", "kubeAPIServer.logLevel"}, + expectedGeneration: 7, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + r := &HostedControlPlaneReconciler{Log: ctrl.LoggerFrom(t.Context())} + r.reconcileOperatorConfigurationStatus(tc.hcp) + + g.Expect(tc.hcp.Status.OperatorConfiguration.ObservedGeneration).To(Equal(tc.expectedGeneration)) + g.Expect(tc.hcp.Status.OperatorConfiguration.HonoredFields).To(Equal(tc.expectedHonoredFields)) + }) + } + +} + func TestReconcileDegradedStatus(t *testing.T) { testNamespace := "test-namespace" diff --git a/docs/content/reference/aggregated-docs.md b/docs/content/reference/aggregated-docs.md index 26646c2c6767..b180a92b6075 100644 --- a/docs/content/reference/aggregated-docs.md +++ b/docs/content/reference/aggregated-docs.md @@ -49695,6 +49695,22 @@ A failure here often means a software bug or a non-stable cluster.
KubeVirtNodesLiveMigratable indicates if all nodes (VirtualMachines) of the kubevirt hosted cluster can be live migrated without experiencing a node restart
"OperatorConfigurationHonored"
OperatorConfigurationHonored indicates whether every setting configured under +spec.operatorConfiguration is actually in effect on the hosted control plane. +It is intentionally generic so that one condition covers the whole stanza and +every setting added to it; the message names the specific settings at issue.
+A control plane operator older than the HyperShift operator accepts and persists +a setting it predates, then ignores it. This condition is how that is reported.
+True (reason AsExpected) means no setting is configured, or every configured +setting is honored by the reconciling control plane operator. +False (reason OperatorConfigurationNotHonored) means at least one configured +setting is not in effect, and the message names them. +Unknown (reason UnsupportedControlPlaneVersion or StatusUnknown) means whether +the configured settings are honored could not be determined, because the control +plane operator predates this reporting, its version is unknown, or it has not yet +observed the current spec.
+"PlatformCredentialsFound"
PlatformCredentialsFound indicates that credentials required for the desired platform are valid. @@ -54506,6 +54522,22 @@ ConfigurationStatus
operatorConfiguration,omitzero
+
+
+OperatorConfigurationStatus
+
+
+operatorConfiguration reports which settings from spec.operatorConfiguration the +reconciling control plane operator understands. It lets a newer HyperShift operator +detect settings that an older control plane operator silently ignores.
+secretEncryption,omitzero
@@ -59536,6 +59568,62 @@ Settings are grouped by platform.
+(Appears on: +HostedControlPlaneStatus) +
++
OperatorConfigurationStatus reports how the reconciling control plane operator +interpreted spec.operatorConfiguration.
+A control plane operator only deserializes the spec fields its own build knows about; +the API server serves settings added in later releases, but they are dropped on decode +and silently ignored. Echoing back the settings that survived that decode lets the +HyperShift operator name the settings a lagging control plane operator does not honor, +without either side needing per-field knowledge of the other.
+ +| Field | +Description | +
|---|---|
+observedGeneration,omitempty,omitzero
+
+int64
+
+ |
+
+(Optional)
+ observedGeneration is the HostedControlPlane generation whose +spec.operatorConfiguration produced honoredFields. A value lower than +metadata.generation means the control plane operator has not yet observed the +latest spec, so honoredFields is stale. + |
+
+honoredFields
+
+[]string
+
+ |
+
+(Optional)
+ honoredFields lists the dot-separated paths of the settings that are both set in +spec.operatorConfiguration and understood by the reconciling control plane operator, +relative to spec.operatorConfiguration and sorted lexically +(for example “kubeAPIServer.eventTTLMinutes”). It is empty when no settings are +configured. A setting that is set in the spec but absent from this list is not in +effect. + |
+
(Appears on: @@ -71438,6 +71526,54 @@ The NodePool controller automatically validates version compatibility and report - If a NodePool becomes incompatible, upgrade it to a compatible version as soon as operationally feasible - When creating new NodePools, the CLI will enforce compatibility checks to prevent misconfiguration +### operatorConfiguration Settings and HO/CPO Skew + +The HO and the CPO are released on separate cadences, so a management cluster can run an +HO that is newer than the CPO reconciling a given HostedControlPlane. In that window the +API server accepts and persists a `HostedCluster.spec.operatorConfiguration` setting that +the CPO predates, mirrors it onto the HostedControlPlane, and the CPO drops it when it +decodes the spec. The setting is not in effect, but the cluster stays `Available` and +nothing is `Degraded`. + +The `OperatorConfigurationHonored` condition on the HostedCluster reports this. It is +generic: one condition covers the whole `operatorConfiguration` stanza and every setting +added to it, and the message names the settings at issue. + +| Status | Reason | Meaning | +|---|---|---| +| `True` | `AsExpected` | Nothing is configured, or every configured setting is in effect. | +| `False` | `OperatorConfigurationNotHonored` | At least one configured setting is not in effect. The message names them. | +| `Unknown` | `UnsupportedControlPlaneVersion` | The CPO is too old to report which settings it honors, so this cannot be established. | +| `Unknown` | `StatusUnknown` | The CPO version is not reported yet, or the CPO has not yet observed the current spec. | + +```shell +oc get hostedcluster -n clusters example \ + -o jsonpath='{.status.conditions[?(@.type=="OperatorConfigurationHonored")]}' +``` + +**How it works.** The CPO publishes, on +`HostedControlPlane.status.operatorConfiguration.honoredFields`, the paths of the settings +that survived decoding into its own build's types. That list is therefore exactly the set +of settings it understands. The HO compares it against the settings configured in the +spec and names the difference. Neither side needs per-field knowledge of the other, so +settings added in future releases are covered automatically. + +**Behavior during an upgrade.** The condition does not flap through a routine control +plane upgrade: an upgrade bumps the HostedControlPlane generation without changing any +setting, and the condition only reacts when a configured setting is actually missing from +what the CPO reports. + +**Resolution.** Upgrade the control plane to a release whose CPO supports the setting. The +value is preserved throughout and takes effect on upgrade; the condition clears on its +own. No reconfiguration is needed. + +!!! note + + A CPO released before this reporting mechanism existed cannot report anything, so + the condition is `Unknown` for those versions rather than naming settings. Settings + that shipped alongside the mechanism carry a recorded minimum CPO version and are + still named in that window. + --- diff --git a/docs/content/reference/api.md b/docs/content/reference/api.md index 8e9e9f34be69..bf2a1bb40bbe 100644 --- a/docs/content/reference/api.md +++ b/docs/content/reference/api.md @@ -7028,6 +7028,22 @@ A failure here often means a software bug or a non-stable cluster.
KubeVirtNodesLiveMigratable indicates if all nodes (VirtualMachines) of the kubevirt hosted cluster can be live migrated without experiencing a node restart
"OperatorConfigurationHonored"
OperatorConfigurationHonored indicates whether every setting configured under +spec.operatorConfiguration is actually in effect on the hosted control plane. +It is intentionally generic so that one condition covers the whole stanza and +every setting added to it; the message names the specific settings at issue.
+A control plane operator older than the HyperShift operator accepts and persists +a setting it predates, then ignores it. This condition is how that is reported.
+True (reason AsExpected) means no setting is configured, or every configured +setting is honored by the reconciling control plane operator. +False (reason OperatorConfigurationNotHonored) means at least one configured +setting is not in effect, and the message names them. +Unknown (reason UnsupportedControlPlaneVersion or StatusUnknown) means whether +the configured settings are honored could not be determined, because the control +plane operator predates this reporting, its version is unknown, or it has not yet +observed the current spec.
+"PlatformCredentialsFound"
PlatformCredentialsFound indicates that credentials required for the desired platform are valid. @@ -11839,6 +11855,22 @@ ConfigurationStatus
operatorConfiguration,omitzero
+
+
+OperatorConfigurationStatus
+
+
+operatorConfiguration reports which settings from spec.operatorConfiguration the +reconciling control plane operator understands. It lets a newer HyperShift operator +detect settings that an older control plane operator silently ignores.
+secretEncryption,omitzero
@@ -16869,6 +16901,62 @@ Settings are grouped by platform.
+(Appears on: +HostedControlPlaneStatus) +
++
OperatorConfigurationStatus reports how the reconciling control plane operator +interpreted spec.operatorConfiguration.
+A control plane operator only deserializes the spec fields its own build knows about; +the API server serves settings added in later releases, but they are dropped on decode +and silently ignored. Echoing back the settings that survived that decode lets the +HyperShift operator name the settings a lagging control plane operator does not honor, +without either side needing per-field knowledge of the other.
+ +| Field | +Description | +
|---|---|
+observedGeneration,omitempty,omitzero
+
+int64
+
+ |
+
+(Optional)
+ observedGeneration is the HostedControlPlane generation whose +spec.operatorConfiguration produced honoredFields. A value lower than +metadata.generation means the control plane operator has not yet observed the +latest spec, so honoredFields is stale. + |
+
+honoredFields
+
+[]string
+
+ |
+
+(Optional)
+ honoredFields lists the dot-separated paths of the settings that are both set in +spec.operatorConfiguration and understood by the reconciling control plane operator, +relative to spec.operatorConfiguration and sorted lexically +(for example “kubeAPIServer.eventTTLMinutes”). It is empty when no settings are +configured. A setting that is set in the spec but absent from this list is not in +effect. + |
+
(Appears on: diff --git a/docs/content/reference/versioning-support.md b/docs/content/reference/versioning-support.md index 2d4c56d4d735..1225d2e42283 100644 --- a/docs/content/reference/versioning-support.md +++ b/docs/content/reference/versioning-support.md @@ -186,3 +186,51 @@ The NodePool controller automatically validates version compatibility and report - When upgrading HostedClusters, verify that all NodePools remain within the supported version skew window - If a NodePool becomes incompatible, upgrade it to a compatible version as soon as operationally feasible - When creating new NodePools, the CLI will enforce compatibility checks to prevent misconfiguration + +### operatorConfiguration Settings and HO/CPO Skew + +The HO and the CPO are released on separate cadences, so a management cluster can run an +HO that is newer than the CPO reconciling a given HostedControlPlane. In that window the +API server accepts and persists a `HostedCluster.spec.operatorConfiguration` setting that +the CPO predates, mirrors it onto the HostedControlPlane, and the CPO drops it when it +decodes the spec. The setting is not in effect, but the cluster stays `Available` and +nothing is `Degraded`. + +The `OperatorConfigurationHonored` condition on the HostedCluster reports this. It is +generic: one condition covers the whole `operatorConfiguration` stanza and every setting +added to it, and the message names the settings at issue. + +| Status | Reason | Meaning | +|---|---|---| +| `True` | `AsExpected` | Nothing is configured, or every configured setting is in effect. | +| `False` | `OperatorConfigurationNotHonored` | At least one configured setting is not in effect. The message names them. | +| `Unknown` | `UnsupportedControlPlaneVersion` | The CPO is too old to report which settings it honors, so this cannot be established. | +| `Unknown` | `StatusUnknown` | The CPO version is not reported yet, or the CPO has not yet observed the current spec. | + +```shell +oc get hostedcluster -n clusters example \ + -o jsonpath='{.status.conditions[?(@.type=="OperatorConfigurationHonored")]}' +``` + +**How it works.** The CPO publishes, on +`HostedControlPlane.status.operatorConfiguration.honoredFields`, the paths of the settings +that survived decoding into its own build's types. That list is therefore exactly the set +of settings it understands. The HO compares it against the settings configured in the +spec and names the difference. Neither side needs per-field knowledge of the other, so +settings added in future releases are covered automatically. + +**Behavior during an upgrade.** The condition does not flap through a routine control +plane upgrade: an upgrade bumps the HostedControlPlane generation without changing any +setting, and the condition only reacts when a configured setting is actually missing from +what the CPO reports. + +**Resolution.** Upgrade the control plane to a release whose CPO supports the setting. The +value is preserved throughout and takes effect on upgrade; the condition clears on its +own. No reconfiguration is needed. + +!!! note + + A CPO released before this reporting mechanism existed cannot report anything, so + the condition is `Unknown` for those versions rather than naming settings. Settings + that shipped alongside the mechanism carry a recorded minimum CPO version and are + still named in that window. diff --git a/hypershift-operator/controllers/hostedcluster/hostedcluster_controller.go b/hypershift-operator/controllers/hostedcluster/hostedcluster_controller.go index 0f01306a49f5..52c212edc306 100644 --- a/hypershift-operator/controllers/hostedcluster/hostedcluster_controller.go +++ b/hypershift-operator/controllers/hostedcluster/hostedcluster_controller.go @@ -69,6 +69,7 @@ import ( "github.com/openshift/hypershift/support/metrics" "github.com/openshift/hypershift/support/netutil" "github.com/openshift/hypershift/support/oidc" + "github.com/openshift/hypershift/support/operatorconfig" "github.com/openshift/hypershift/support/podspec" "github.com/openshift/hypershift/support/reconcilerpolicy" "github.com/openshift/hypershift/support/releaseinfo" @@ -923,6 +924,9 @@ func (r *HostedClusterReconciler) reconcile(ctx context.Context, req ctrl.Reques // Aggregate deprecated-configuration warnings from the HostedCluster and the HCP. r.reconcileDeprecatedConfigurationStatus(hcluster, hcp) + // Report operatorConfiguration settings the reconciling CPO does not honor. + r.reconcileOperatorConfigurationHonoredStatus(hcluster, hcp) + // Copy the platform status from the hostedcontrolplane if hcp != nil { hcluster.Status.Platform = hcp.Status.Platform @@ -1728,6 +1732,148 @@ func (r *HostedClusterReconciler) reconcileDeprecatedConfigurationStatus(hcluste meta.SetStatusCondition(&hcluster.Status.Conditions, condition) } +// maxNamedUnhonoredFields bounds how many settings a condition message names, so that +// a large configuration cannot produce an unreadable message or approach the API +// server's limit on condition message length. +const maxNamedUnhonoredFields = 10 + +// reconcileOperatorConfigurationHonoredStatus reports, on the generic +// OperatorConfigurationHonored condition, whether every setting configured under +// spec.operatorConfiguration is actually in effect. +// +// A control plane operator older than the HyperShift operator drops settings it +// predates when it decodes the HostedControlPlane spec, and ignores them without +// surfacing anything. Two sources close that gap, in order of precision: +// +// - The settings the control plane operator echoed back on +// status.operatorConfiguration.honoredFields. This covers every setting generically, +// including ones added after this code was written, but only a control plane +// operator new enough to report at all can be read this way. +// - For settings that predate that reporting, a recorded minimum version per setting. +// +// Neither source can see a setting the HyperShift operator itself predates: a setting +// the installed CRD accepts but this build does not know about is dropped here too, and +// is not reported. +func (r *HostedClusterReconciler) reconcileOperatorConfigurationHonoredStatus(hcluster *hyperv1.HostedCluster, hcp *hyperv1.HostedControlPlane) { + condition := metav1.Condition{ + Type: string(hyperv1.OperatorConfigurationHonored), + ObservedGeneration: hcluster.Generation, + } + // version is only known once the HostedControlPlane has been read; notHonored is + // never called before then. + var version string + honored := func(message string) { + condition.Status = metav1.ConditionTrue + condition.Reason = hyperv1.AsExpectedReason + condition.Message = message + } + undetermined := func(reason, message string) { + condition.Status = metav1.ConditionUnknown + condition.Reason = reason + condition.Message = message + } + // notHonored is only reachable once version is known to be a usable version, so the + // message never names an empty one. + notHonored := func(unhonored []string) { + condition.Status = metav1.ConditionFalse + condition.Reason = hyperv1.OperatorConfigurationNotHonoredReason + condition.Message = fmt.Sprintf( + "%s %s not in effect: control plane operator version %s does not support %s. %s will take effect once the control plane is upgraded to a version that does.", + describeSettings(unhonored), pluralize(unhonored, "is", "are"), version, + pluralize(unhonored, "it", "them"), pluralize(unhonored, "It", "They")) + } + defer func() { meta.SetStatusCondition(&hcluster.Status.Conditions, condition) }() + + if hcp == nil { + // Stay quiet for the common case of a cluster that configures nothing and whose + // HCP has not been created yet. + if hcluster.Spec.OperatorConfiguration == nil { + honored("No operatorConfiguration settings are configured") + } else { + undetermined(hyperv1.StatusUnknownReason, "The hosted control plane is not found") + } + return + } + + // Evaluate against the HostedControlPlane spec rather than the HostedCluster spec: + // it is the input the control plane operator actually reconciles, so the two sides + // of the comparison describe the same generation of the user's intent. A + // HostedCluster edit not yet mirrored down is picked up on the next reconcile. + configured, err := operatorconfig.FieldPaths(hcp.Spec.OperatorConfiguration) + if err != nil { + undetermined(hyperv1.StatusUnknownReason, fmt.Sprintf("Failed to evaluate the configured operatorConfiguration settings: %v", err)) + return + } + if len(configured) == 0 { + honored("No operatorConfiguration settings are configured") + return + } + + version = hcp.Status.ControlPlaneVersion.Desired.Version + + // Settings that predate honoredFields reporting, and so can only be judged from the + // control plane operator's version. Checked first because this is conclusive even + // when the control plane operator reports nothing at all. + if unhonored := operatorconfig.UnhonoredBefore(configured, version); len(unhonored) > 0 { + notHonored(unhonored) + return + } + + switch supported, known := operatorconfig.SupportsHonoredFieldsReporting(version); { + case !known: + undetermined(hyperv1.StatusUnknownReason, "The control plane operator version is not reported yet, so whether the configured operatorConfiguration settings are in effect is unknown") + return + case !supported: + undetermined(hyperv1.UnsupportedControlPlaneVersionReason, fmt.Sprintf( + "Control plane operator version %s does not report which operatorConfiguration settings it honors, so whether the configured settings are in effect is unknown", version)) + return + } + + reported := hcp.Status.OperatorConfiguration + if reported.ObservedGeneration == 0 { + undetermined(hyperv1.StatusUnknownReason, "The control plane operator has not reported which operatorConfiguration settings it honors yet") + return + } + + unhonored := operatorconfig.Unhonored(configured, reported.HonoredFields) + if len(unhonored) == 0 { + // Nothing to warn about, whether or not the report is current. Not gating this + // on the report being current is what keeps the condition from flapping through + // an ordinary control plane upgrade, which bumps the generation without + // changing any setting. + honored("All operatorConfiguration settings are honored") + return + } + if reported.ObservedGeneration != hcp.Generation { + // A discrepancy against a report for an earlier generation is expected right + // after a setting changes; the control plane operator has simply not caught up. + undetermined(hyperv1.StatusUnknownReason, fmt.Sprintf( + "The control plane operator has not observed generation %d of the hosted control plane yet, so it is unknown whether %s %s in effect", + hcp.Generation, describeSettings(unhonored), pluralize(unhonored, "is", "are"))) + return + } + notHonored(unhonored) +} + +// describeSettings renders setting paths for a condition message, naming at most +// maxNamedUnhonoredFields of them. +func describeSettings(paths []string) string { + named := paths + suffix := "" + if len(named) > maxNamedUnhonoredFields { + suffix = fmt.Sprintf(" and %d more", len(named)-maxNamedUnhonoredFields) + named = named[:maxNamedUnhonoredFields] + } + return fmt.Sprintf("operatorConfiguration %s %s%s", pluralize(paths, "setting", "settings"), strings.Join(named, ", "), suffix) +} + +func pluralize[T any](items []T, singular, plural string) string { + if len(items) == 1 { + return singular + } + return plural +} + // reconcileCoreHCPChain reconciles the core HCP chain: reconcile the HCP object, // CAPI Infra CR, AWS subnets, and CAPI Cluster. These operations are sequential // because each depends on the previous. Returns the (potentially reassigned) HCP diff --git a/hypershift-operator/controllers/hostedcluster/hostedcluster_controller_test.go b/hypershift-operator/controllers/hostedcluster/hostedcluster_controller_test.go index 3d39fbc5cd75..ffb06b036bd1 100644 --- a/hypershift-operator/controllers/hostedcluster/hostedcluster_controller_test.go +++ b/hypershift-operator/controllers/hostedcluster/hostedcluster_controller_test.go @@ -42,6 +42,7 @@ import ( "github.com/openshift/hypershift/support/imageregistry" "github.com/openshift/hypershift/support/k8sutil" "github.com/openshift/hypershift/support/metrics" + "github.com/openshift/hypershift/support/operatorconfig" "github.com/openshift/hypershift/support/releaseinfo" "github.com/openshift/hypershift/support/releaseinfo/registryclient" "github.com/openshift/hypershift/support/releaseinfo/testutils" @@ -9618,6 +9619,196 @@ func TestReconcileDeprecatedConfigurationStatus(t *testing.T) { } } +func TestReconcileOperatorConfigurationHonoredStatus(t *testing.T) { + // logLevel is used as the configured setting throughout: it is a real + // operatorConfiguration field with no recorded version floor, so what the condition + // says about it comes purely from the generic honoredFields comparison. + logLevelConfig := &hyperv1.OperatorConfiguration{ + KubeAPIServer: hyperv1.KubeAPIServerOperatorSpec{ + ComponentLogLevelSpec: hyperv1.ComponentLogLevelSpec{LogLevel: hyperv1.Debug}, + }, + } + // eventTTLMinutes is the one setting with a recorded floor, covering control plane + // operators too old to report honoredFields at all. + eventTTLPath := "kubeAPIServer.eventTTLMinutes" + + // hcpWith builds a HostedControlPlane at the given generation whose control plane + // operator reports version and the given honored settings. + hcpWith := func(generation int64, config *hyperv1.OperatorConfiguration, version string, observedGeneration int64, honored []string) *hyperv1.HostedControlPlane { + return &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{Generation: generation}, + Spec: hyperv1.HostedControlPlaneSpec{OperatorConfiguration: config}, + Status: hyperv1.HostedControlPlaneStatus{ + ControlPlaneVersion: hyperv1.ControlPlaneVersionStatus{ + Desired: configv1.Release{Version: version}, + }, + OperatorConfiguration: hyperv1.OperatorConfigurationStatus{ + ObservedGeneration: observedGeneration, + HonoredFields: honored, + }, + }, + } + } + + testCases := []struct { + name string + hclusterConfig *hyperv1.OperatorConfiguration + hcp *hyperv1.HostedControlPlane + expectedStatus metav1.ConditionStatus + expectedReason string + expectedMessageSubstrs []string + }{ + { + name: "When nothing is configured and the HCP is not found yet, it should set the condition to True", + hclusterConfig: nil, + hcp: nil, + expectedStatus: metav1.ConditionTrue, + expectedReason: hyperv1.AsExpectedReason, + }, + { + name: "When settings are configured and the HCP is not found yet, it should set the condition to Unknown", + hclusterConfig: logLevelConfig, + hcp: nil, + expectedStatus: metav1.ConditionUnknown, + expectedReason: hyperv1.StatusUnknownReason, + }, + { + name: "When nothing is configured, it should set the condition to True without consulting the control plane operator", + hclusterConfig: nil, + hcp: hcpWith(1, nil, "", 0, nil), + expectedStatus: metav1.ConditionTrue, + expectedReason: hyperv1.AsExpectedReason, + }, + { + name: "When every configured setting is honored, it should set the condition to True", + hclusterConfig: logLevelConfig, + hcp: hcpWith(4, logLevelConfig, "5.1.0", 4, []string{"kubeAPIServer.logLevel"}), + expectedStatus: metav1.ConditionTrue, + expectedReason: hyperv1.AsExpectedReason, + }, + { + name: "When a configured setting is absent from the honored settings, it should set the condition to False and name it", + hclusterConfig: logLevelConfig, + hcp: hcpWith(4, logLevelConfig, "5.1.0", 4, nil), + expectedStatus: metav1.ConditionFalse, + expectedReason: hyperv1.OperatorConfigurationNotHonoredReason, + expectedMessageSubstrs: []string{"kubeAPIServer.logLevel", "5.1.0", "not in effect"}, + }, + { + name: "When the control plane operator has not observed the latest spec and a setting looks unhonored, it should set the condition to Unknown rather than raise a false alarm", + hclusterConfig: logLevelConfig, + hcp: hcpWith(5, logLevelConfig, "5.1.0", 4, nil), + expectedStatus: metav1.ConditionUnknown, + expectedReason: hyperv1.StatusUnknownReason, + expectedMessageSubstrs: []string{"has not observed generation 5"}, + }, + { + name: "When the control plane operator has not observed the latest spec but every setting is honored, it should stay True so an upgrade does not flap the condition", + hclusterConfig: logLevelConfig, + hcp: hcpWith(5, logLevelConfig, "5.1.0", 4, []string{"kubeAPIServer.logLevel"}), + expectedStatus: metav1.ConditionTrue, + expectedReason: hyperv1.AsExpectedReason, + }, + { + name: "When the control plane operator predates honoredFields reporting, it should set the condition to Unknown", + hclusterConfig: logLevelConfig, + hcp: hcpWith(4, logLevelConfig, "5.0.3", 0, nil), + expectedStatus: metav1.ConditionUnknown, + expectedReason: hyperv1.UnsupportedControlPlaneVersionReason, + expectedMessageSubstrs: []string{"5.0.3", "does not report"}, + }, + { + name: "When the control plane operator version is not reported yet, it should set the condition to Unknown", + hclusterConfig: logLevelConfig, + hcp: hcpWith(4, logLevelConfig, "", 0, nil), + expectedStatus: metav1.ConditionUnknown, + expectedReason: hyperv1.StatusUnknownReason, + }, + { + name: "When the control plane operator supports reporting but has not reported yet, it should set the condition to Unknown", + hclusterConfig: logLevelConfig, + hcp: hcpWith(4, logLevelConfig, "5.1.0", 0, nil), + expectedStatus: metav1.ConditionUnknown, + expectedReason: hyperv1.StatusUnknownReason, + }, + { + name: "When several settings are configured and all are honored, it should set the condition to True", + hclusterConfig: logLevelConfig, + hcp: hcpWith(4, &hyperv1.OperatorConfiguration{ + KubeAPIServer: hyperv1.KubeAPIServerOperatorSpec{ + ComponentLogLevelSpec: hyperv1.ComponentLogLevelSpec{LogLevel: hyperv1.Debug}, + }, + Etcd: hyperv1.EtcdOperatorSpec{ + ComponentLogLevelSpec: hyperv1.ComponentLogLevelSpec{LogLevel: hyperv1.Debug}, + }, + }, "5.1.0", 4, []string{"etcd.logLevel", "kubeAPIServer.logLevel"}), + expectedStatus: metav1.ConditionTrue, + expectedReason: hyperv1.AsExpectedReason, + }, + { + name: "When only some configured settings are honored, it should name just the unhonored ones", + hclusterConfig: logLevelConfig, + hcp: hcpWith(4, &hyperv1.OperatorConfiguration{ + KubeAPIServer: hyperv1.KubeAPIServerOperatorSpec{ + ComponentLogLevelSpec: hyperv1.ComponentLogLevelSpec{LogLevel: hyperv1.Debug}, + }, + Etcd: hyperv1.EtcdOperatorSpec{ + ComponentLogLevelSpec: hyperv1.ComponentLogLevelSpec{LogLevel: hyperv1.Debug}, + }, + }, "5.1.0", 4, []string{"etcd.logLevel"}), + expectedStatus: metav1.ConditionFalse, + expectedReason: hyperv1.OperatorConfigurationNotHonoredReason, + expectedMessageSubstrs: []string{"kubeAPIServer.logLevel"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + hcluster := &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{Generation: 9}, + Spec: hyperv1.HostedClusterSpec{OperatorConfiguration: tc.hclusterConfig}, + } + r := &HostedClusterReconciler{} + r.reconcileOperatorConfigurationHonoredStatus(hcluster, tc.hcp) + + cond := meta.FindStatusCondition(hcluster.Status.Conditions, string(hyperv1.OperatorConfigurationHonored)) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(tc.expectedStatus)) + g.Expect(cond.Reason).To(Equal(tc.expectedReason)) + g.Expect(cond.ObservedGeneration).To(Equal(hcluster.Generation)) + for _, substr := range tc.expectedMessageSubstrs { + g.Expect(cond.Message).To(ContainSubstring(substr)) + } + if tc.expectedStatus == metav1.ConditionFalse { + g.Expect(cond.Message).ToNot(ContainSubstring("etcd.logLevel"), + "honored settings should not be named as unhonored") + } + }) + } + + // The version floor is the only thing that can name a setting against a control + // plane operator too old to report honoredFields, which is exactly the skew window + // that motivated this condition. It is exercised through the honoredFields list + // being absent, as it would be from such an operator. + t.Run("When a setting with a recorded version floor runs on a control plane operator below that floor, it should set the condition to False and name it", func(t *testing.T) { + g := NewWithT(t) + + // Stand in for the eventTTLMinutes field, which is gated and may not be + // compiled into every build: drive the comparison from the path directly. + configured, err := operatorconfig.FieldPaths(&hyperv1.OperatorConfiguration{ + KubeAPIServer: hyperv1.KubeAPIServerOperatorSpec{ + ComponentLogLevelSpec: hyperv1.ComponentLogLevelSpec{LogLevel: hyperv1.Debug}, + }, + }) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(operatorconfig.UnhonoredBefore(append(configured, eventTTLPath), "5.0.3")).To( + Equal([]string{eventTTLPath}), + "a pre-reporting control plane operator should still have the floored setting named") + }) +} + func TestReconcilePullSecretSync(t *testing.T) { t.Parallel() diff --git a/hypershift-operator/controllers/hostedcluster/internal/platform/gcp/gcp.go b/hypershift-operator/controllers/hostedcluster/internal/platform/gcp/gcp.go index 93a01faf0092..d3c3b86e9716 100644 --- a/hypershift-operator/controllers/hostedcluster/internal/platform/gcp/gcp.go +++ b/hypershift-operator/controllers/hostedcluster/internal/platform/gcp/gcp.go @@ -517,7 +517,7 @@ func ComputeGCPCredentialConditions(hc *hyperv1.HostedCluster, hcp *hyperv1.Host ObservedGeneration: hc.Generation, } if known { - fresh.Reason = "UnsupportedControlPlaneVersion" + fresh.Reason = hyperv1.UnsupportedControlPlaneVersionReason fresh.Message = "Runtime GCP credential validation requires control plane version 5.1 or later" } } else if hcpCond == nil || hcpCond.Status == metav1.ConditionUnknown { diff --git a/support/conditions/conditions.go b/support/conditions/conditions.go index 76ffe3eb565f..b88e13d0b45e 100644 --- a/support/conditions/conditions.go +++ b/support/conditions/conditions.go @@ -3,6 +3,7 @@ package conditions import ( hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" "github.com/openshift/hypershift/support/netutil" + "github.com/openshift/hypershift/support/operatorconfig" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" @@ -109,6 +110,16 @@ func ExpectedHCConditions(hostedCluster *hyperv1.HostedCluster) map[hyperv1.Cond conditions[hyperv1.UnmanagedEtcdAvailable] = metav1.ConditionTrue } + // A control plane operator that predates honoredFields reporting cannot say which + // operatorConfiguration settings it honors, so the condition is legitimately Unknown + // there. With nothing configured there is nothing to establish and it is True. + supportsHonoredFields, honoredFieldsKnown := operatorconfig.SupportsHonoredFieldsReporting(hostedCluster.Status.ControlPlaneVersion.Desired.Version) + if hostedCluster.Spec.OperatorConfiguration == nil || (honoredFieldsKnown && supportsHonoredFields) { + conditions[hyperv1.OperatorConfigurationHonored] = metav1.ConditionTrue + } else { + conditions[hyperv1.OperatorConfigurationHonored] = metav1.ConditionUnknown + } + kasExternalHostname := netutil.ServiceExternalDNSHostnameByHC(hostedCluster, hyperv1.APIServer) if kasExternalHostname == "" { // ExternalDNS is not configured diff --git a/support/operatorconfig/operatorconfig.go b/support/operatorconfig/operatorconfig.go new file mode 100644 index 000000000000..18c81d801c83 --- /dev/null +++ b/support/operatorconfig/operatorconfig.go @@ -0,0 +1,182 @@ +// Package operatorconfig derives, and compares, the set of settings configured under +// HostedCluster.spec.operatorConfiguration. +// +// The control plane operator only deserializes the settings its own build knows about. +// During a HyperShift-operator / control-plane-operator version skew window the API +// server accepts and persists a setting added in a later release, mirrors it onto the +// HostedControlPlane, and the older control plane operator drops it on decode and +// silently ignores it. +// +// The helpers here turn that decode behavior into a signal. Both operators run +// FieldPaths over the same HostedControlPlane spec bytes, each with its own compiled-in +// types, so the control plane operator's result is exactly the subset of settings it +// understands. The difference between the two results names the settings that are not +// in effect, with neither side needing to know anything about the individual fields. +package operatorconfig + +import ( + "encoding/json" + "fmt" + "slices" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + + "github.com/blang/semver" +) + +// MaxHonoredFields bounds how many paths are reported on +// HostedControlPlane.status.operatorConfiguration.honoredFields. It must match the +// MaxItems marker on that field. A configuration that exceeds it is not reported at +// all, rather than reported truncated, because a truncated list is indistinguishable +// from settings being dropped and would be read as a false skew report. +const MaxHonoredFields = 200 + +// honoredFieldsReportingMinVersion is the first control plane operator version that +// populates HostedControlPlane.status.operatorConfiguration.honoredFields. Older +// versions report nothing, so what they honor can only be established from +// preReportingFieldFloors. +var honoredFieldsReportingMinVersion = semver.MustParse("5.1.0") + +// preReportingFieldFloors maps a setting to the first control plane operator version +// that honors it, for settings that shipped before honoredFields reporting existed and +// so cannot be covered by it. +// +// This map is closed. Settings introduced from honoredFieldsReportingMinVersion onwards +// are reported generically and must not be added here; every entry can be deleted once +// honoredFieldsReportingMinVersion drops out of the supported skew window. +var preReportingFieldFloors = map[string]semver.Version{ + // Added by CNTRLPLANE-4417 in the same release that introduced honoredFields + // reporting, so no control plane operator that honors it fails to report it. + "kubeAPIServer.eventTTLMinutes": honoredFieldsReportingMinVersion, +} + +// FieldPaths returns the dot-separated paths of every setting set in cfg, relative to +// operatorConfiguration itself and sorted lexically, for example +// "kubeAPIServer.eventTTLMinutes". It returns nil when nothing is set. +// +// Paths are derived from the JSON encoding of cfg rather than by reflection, so the +// omitempty and omitzero tags on the API types decide what counts as "set", exactly as +// they do on the wire. Elements of a list of objects are indexed, for example +// "foo[0].bar"; a list of scalars is reported as the single path of the list itself. +func FieldPaths(cfg *hyperv1.OperatorConfiguration) ([]string, error) { + if cfg == nil { + return nil, nil + } + encoded, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("failed to marshal operator configuration: %w", err) + } + var decoded any + if err := json.Unmarshal(encoded, &decoded); err != nil { + return nil, fmt.Errorf("failed to unmarshal operator configuration: %w", err) + } + + var paths []string + collectFieldPaths(decoded, "", &paths) + slices.Sort(paths) + return slices.Compact(paths), nil +} + +func collectFieldPaths(value any, prefix string, paths *[]string) { + switch typed := value.(type) { + case map[string]any: + if len(typed) == 0 { + break + } + for key, child := range typed { + childPrefix := key + if prefix != "" { + childPrefix = prefix + "." + key + } + collectFieldPaths(child, childPrefix, paths) + } + return + case []any: + // Only descend into lists of objects: a list of scalars has no nested settings + // to drop, and indexing it would add noise without adding signal. + containsObject := false + for _, element := range typed { + if _, isObject := element.(map[string]any); isObject { + containsObject = true + break + } + } + if !containsObject { + break + } + for i, element := range typed { + collectFieldPaths(element, fmt.Sprintf("%s[%d]", prefix, i), paths) + } + return + } + + // A scalar, an empty object, or a list with no nested settings: the path itself is + // the setting. The empty prefix means cfg encoded to something with no fields at + // all, which is not a setting. + if prefix != "" { + *paths = append(*paths, prefix) + } +} + +// Reportable reports whether paths fit within the bound on +// HostedControlPlane.status.operatorConfiguration.honoredFields. The current API cannot +// express a configuration this large; the check exists so that growing the API can never +// turn into a status write the API server rejects, taking the rest of the status with it. +func Reportable(paths []string) bool { + return len(paths) <= MaxHonoredFields +} + +// Unhonored returns the paths in configured that are absent from honored, preserving +// the order of configured. Paths in honored that are not configured are ignored: an +// older control plane operator serializing a setting the current build no longer emits +// says nothing about whether what the user asked for is in effect. +func Unhonored(configured, honored []string) []string { + var unhonored []string + for _, path := range configured { + if !slices.Contains(honored, path) { + unhonored = append(unhonored, path) + } + } + return unhonored +} + +// SupportsHonoredFieldsReporting reports whether a control plane operator at the given +// version populates HostedControlPlane.status.operatorConfiguration.honoredFields. +// known is false for an empty or malformed version, which must not be read as support. +// Patch, prerelease, and build metadata do not affect support. +func SupportsHonoredFieldsReporting(version string) (supported bool, known bool) { + parsed, err := semver.Parse(version) + if err != nil { + return false, false + } + return meetsFloor(parsed, honoredFieldsReportingMinVersion), true +} + +// UnhonoredBefore returns the configured paths that a control plane operator at the +// given version is known not to honor, for settings that predate honoredFields +// reporting. It returns nothing for an empty or malformed version, and nothing for a +// setting with no recorded floor, since absence of a floor is not evidence of support. +func UnhonoredBefore(configured []string, version string) []string { + parsed, err := semver.Parse(version) + if err != nil { + return nil + } + var unhonored []string + for _, path := range configured { + floor, hasFloor := preReportingFieldFloors[path] + if hasFloor && !meetsFloor(parsed, floor) { + unhonored = append(unhonored, path) + } + } + return unhonored +} + +// meetsFloor compares major and minor only, so that a prerelease or a z-stream of a +// supporting minor counts as supporting. semver orders 5.1.0-rc.1 before 5.1.0, but a +// 5.1 nightly carries the 5.1 behavior. +func meetsFloor(version, floor semver.Version) bool { + if version.Major != floor.Major { + return version.Major > floor.Major + } + return version.Minor >= floor.Minor +} diff --git a/support/operatorconfig/operatorconfig_test.go b/support/operatorconfig/operatorconfig_test.go new file mode 100644 index 000000000000..e65697897b10 --- /dev/null +++ b/support/operatorconfig/operatorconfig_test.go @@ -0,0 +1,331 @@ +package operatorconfig + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + + operatorv1 "github.com/openshift/api/operator/v1" +) + +func TestFieldPaths(t *testing.T) { + testCases := []struct { + name string + config *hyperv1.OperatorConfiguration + expected []string + }{ + { + name: "When the configuration is nil, it should report no settings", + config: nil, + expected: nil, + }, + { + name: "When the configuration is empty, it should report no settings", + config: &hyperv1.OperatorConfiguration{}, + expected: nil, + }, + { + name: "When a scalar setting is nested two levels deep, it should report its dotted path", + config: &hyperv1.OperatorConfiguration{ + KubeAPIServer: hyperv1.KubeAPIServerOperatorSpec{ + ComponentLogLevelSpec: hyperv1.ComponentLogLevelSpec{LogLevel: hyperv1.Debug}, + }, + }, + expected: []string{"kubeAPIServer.logLevel"}, + }, + { + name: "When several settings are set across operators, it should report every leaf sorted", + config: &hyperv1.OperatorConfiguration{ + KubeAPIServer: hyperv1.KubeAPIServerOperatorSpec{ + ComponentLogLevelSpec: hyperv1.ComponentLogLevelSpec{LogLevel: hyperv1.Trace}, + }, + Etcd: hyperv1.EtcdOperatorSpec{ + ComponentLogLevelSpec: hyperv1.ComponentLogLevelSpec{LogLevel: hyperv1.Debug}, + }, + ClusterNetworkOperator: &hyperv1.ClusterNetworkOperatorSpec{ + OVNKubernetesConfig: &hyperv1.OVNKubernetesConfig{ + IPv4: &hyperv1.OVNIPv4Config{ + InternalJoinSubnet: "100.66.0.0/16", + InternalTransitSwitchSubnet: "100.69.0.0/16", + }, + }, + }, + }, + expected: []string{ + "clusterNetworkOperator.ovnKubernetesConfig.ipv4.internalJoinSubnet", + "clusterNetworkOperator.ovnKubernetesConfig.ipv4.internalTransitSwitchSubnet", + "etcd.logLevel", + "kubeAPIServer.logLevel", + }, + }, + { + name: "When a setting is set to its zero value, it should not be reported as set", + config: &hyperv1.OperatorConfiguration{ + ClusterNetworkOperator: &hyperv1.ClusterNetworkOperatorSpec{ + OVNKubernetesConfig: &hyperv1.OVNKubernetesConfig{ + IPv4: &hyperv1.OVNIPv4Config{InternalJoinSubnet: "100.66.0.0/16"}, + }, + }, + }, + expected: []string{"clusterNetworkOperator.ovnKubernetesConfig.ipv4.internalJoinSubnet"}, + }, + { + name: "When a setting is an embedded struct from another API group, it should report its leaves", + config: &hyperv1.OperatorConfiguration{ + IngressOperator: &hyperv1.IngressOperatorSpec{ + EndpointPublishingStrategy: &operatorv1.EndpointPublishingStrategy{ + Type: operatorv1.LoadBalancerServiceStrategyType, + LoadBalancer: &operatorv1.LoadBalancerStrategy{ + Scope: operatorv1.InternalLoadBalancer, + }, + }, + }, + }, + expected: []string{ + "ingressOperator.endpointPublishingStrategy.loadBalancer.scope", + "ingressOperator.endpointPublishingStrategy.type", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + paths, err := FieldPaths(tc.config) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(paths).To(Equal(tc.expected)) + }) + } +} + +// TestFieldPathsIsStableAcrossBuilds asserts the property the whole mechanism rests on: +// a build that does not know a setting drops it on decode, so re-encoding what survived +// yields exactly the settings that build understands. An older control plane operator is +// simulated by decoding a spec that carries an unknown setting into a struct without it. +func TestFieldPathsIsStableAcrossBuilds(t *testing.T) { + g := NewWithT(t) + + // What a newer HyperShift operator mirrors onto the HostedControlPlane. + newer := &hyperv1.OperatorConfiguration{ + KubeAPIServer: hyperv1.KubeAPIServerOperatorSpec{ + ComponentLogLevelSpec: hyperv1.ComponentLogLevelSpec{LogLevel: hyperv1.Debug}, + }, + } + configured, err := FieldPaths(newer) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(configured).To(Equal([]string{"kubeAPIServer.logLevel"})) + + // The same spec as seen by a build whose types also know the setting. + honored, err := FieldPaths(newer.DeepCopy()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(Unhonored(configured, honored)).To(BeEmpty(), "a build that knows every setting should honor every setting") + + // The same spec as seen by a build that drops the setting. + g.Expect(Unhonored(configured, nil)).To(Equal([]string{"kubeAPIServer.logLevel"})) +} + +func TestUnhonored(t *testing.T) { + testCases := []struct { + name string + configured []string + honored []string + expected []string + }{ + { + name: "When every configured setting is honored, it should report none", + configured: []string{"etcd.logLevel", "kubeAPIServer.logLevel"}, + honored: []string{"etcd.logLevel", "kubeAPIServer.logLevel"}, + expected: nil, + }, + { + name: "When a configured setting is missing from the honored list, it should report it", + configured: []string{"etcd.logLevel", "kubeAPIServer.eventTTLMinutes"}, + honored: []string{"etcd.logLevel"}, + expected: []string{"kubeAPIServer.eventTTLMinutes"}, + }, + { + name: "When nothing is honored, it should report every configured setting", + configured: []string{"etcd.logLevel", "kubeAPIServer.eventTTLMinutes"}, + honored: nil, + expected: []string{"etcd.logLevel", "kubeAPIServer.eventTTLMinutes"}, + }, + { + name: "When the honored list has settings that are not configured, it should ignore them", + configured: []string{"etcd.logLevel"}, + honored: []string{"etcd.logLevel", "kubeScheduler.logLevel"}, + expected: nil, + }, + { + name: "When nothing is configured, it should report none", + configured: nil, + honored: []string{"etcd.logLevel"}, + expected: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + g.Expect(Unhonored(tc.configured, tc.honored)).To(Equal(tc.expected)) + }) + } +} + +func TestReportable(t *testing.T) { + testCases := []struct { + name string + count int + expected bool + }{ + { + name: "When no settings are configured, it should be reportable", + count: 0, + expected: true, + }, + { + name: "When exactly the maximum number of settings are configured, it should be reportable", + count: MaxHonoredFields, + expected: true, + }, + { + name: "When one more than the maximum number of settings are configured, it should not be reportable", + count: MaxHonoredFields + 1, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + g.Expect(Reportable(make([]string, tc.count))).To(Equal(tc.expected)) + }) + } +} + +func TestSupportsHonoredFieldsReporting(t *testing.T) { + testCases := []struct { + name string + version string + expectedSupported bool + expectedKnown bool + }{ + { + name: "When the version is empty, it should report unknown", + version: "", + expectedSupported: false, + expectedKnown: false, + }, + { + name: "When the version is not semver, it should report unknown", + version: "not-a-version", + expectedSupported: false, + expectedKnown: false, + }, + { + name: "When the version predates reporting, it should report unsupported", + version: "5.0.3", + expectedSupported: false, + expectedKnown: true, + }, + { + name: "When the version is from a much older major, it should report unsupported", + version: "4.19.12", + expectedSupported: false, + expectedKnown: true, + }, + { + name: "When the version is the first supporting version, it should report supported", + version: "5.1.0", + expectedSupported: true, + expectedKnown: true, + }, + { + name: "When the version is a prerelease of the first supporting version, it should report supported", + version: "5.1.0-0.nightly-2026-10-20-123456", + expectedSupported: true, + expectedKnown: true, + }, + { + name: "When the version is a later z-stream, it should report supported", + version: "5.1.7", + expectedSupported: true, + expectedKnown: true, + }, + { + name: "When the version is a later major, it should report supported", + version: "6.0.0", + expectedSupported: true, + expectedKnown: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + supported, known := SupportsHonoredFieldsReporting(tc.version) + g.Expect(supported).To(Equal(tc.expectedSupported)) + g.Expect(known).To(Equal(tc.expectedKnown)) + }) + } +} + +func TestUnhonoredBefore(t *testing.T) { + testCases := []struct { + name string + configured []string + version string + expected []string + }{ + { + name: "When a setting with a recorded floor runs on an older control plane, it should report it", + configured: []string{"kubeAPIServer.eventTTLMinutes"}, + version: "5.0.3", + expected: []string{"kubeAPIServer.eventTTLMinutes"}, + }, + { + name: "When a setting with a recorded floor runs on a supporting control plane, it should report none", + configured: []string{"kubeAPIServer.eventTTLMinutes"}, + version: "5.1.0", + expected: nil, + }, + { + name: "When a setting has no recorded floor, it should report none", + configured: []string{"etcd.logLevel"}, + version: "4.19.12", + expected: nil, + }, + { + name: "When the version is unparsable, it should report none", + configured: []string{"kubeAPIServer.eventTTLMinutes"}, + version: "", + expected: nil, + }, + { + name: "When only some configured settings are below their floor, it should report just those", + configured: []string{"etcd.logLevel", "kubeAPIServer.eventTTLMinutes"}, + version: "5.0.3", + expected: []string{"kubeAPIServer.eventTTLMinutes"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + g.Expect(UnhonoredBefore(tc.configured, tc.version)).To(Equal(tc.expected)) + }) + } +} + +// TestPreReportingFieldFloorsAreClosed guards the invariant that the per-setting floors +// are a closed, shrinking set. An entry is only meaningful for a setting that shipped no +// later than honoredFields reporting itself; a setting introduced after that is reported +// generically by every control plane operator that honors it. +func TestPreReportingFieldFloorsAreClosed(t *testing.T) { + g := NewWithT(t) + for path, floor := range preReportingFieldFloors { + g.Expect(meetsFloor(honoredFieldsReportingMinVersion, floor)).To(BeTrue(), + "setting %q was introduced after honoredFields reporting, so it is reported generically and must not have a floor entry", path) + } +} diff --git a/test/e2e/v2/tests/hosted_cluster_operator_configuration_honored_test.go b/test/e2e/v2/tests/hosted_cluster_operator_configuration_honored_test.go new file mode 100644 index 000000000000..e94474ac6309 --- /dev/null +++ b/test/e2e/v2/tests/hosted_cluster_operator_configuration_honored_test.go @@ -0,0 +1,212 @@ +//go:build e2ev2 + +/* +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. +*/ + +package tests + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/support/operatorconfig" + "github.com/openshift/hypershift/test/e2e/v2/internal" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + crclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// logLevelPath is the operatorConfiguration setting this test configures. It is a real, +// day-2 mutable setting, which is what makes the end-to-end assertion meaningful: the +// path has to travel from the HostedCluster spec, through the HostedControlPlane, into +// what the control plane operator reports it honors. +const logLevelPath = "kubeAPIServer.logLevel" + +func RegisterOperatorConfigurationHonoredTests(getTestCtx internal.TestContextGetter) { + OperatorConfigurationHonoredTest(getTestCtx) +} + +// hostedControlPlane fetches the HostedControlPlane backing the hosted cluster. +func hostedControlPlane(g Gomega, tc *internal.TestContext) *hyperv1.HostedControlPlane { + GinkgoHelper() + hcpList := &hyperv1.HostedControlPlaneList{} + g.Expect(tc.MgmtClient.List(tc.Context, hcpList, crclient.InNamespace(tc.ControlPlaneNamespace))).To(Succeed()) + g.Expect(hcpList.Items).To(HaveLen(1), "exactly one HostedControlPlane should exist in %s", tc.ControlPlaneNamespace) + return &hcpList.Items[0] +} + +// operatorConfigurationHonoredCondition returns the HostedCluster's +// OperatorConfigurationHonored condition, which the HyperShift operator always sets. +func operatorConfigurationHonoredCondition(g Gomega, tc *internal.TestContext, hc *hyperv1.HostedCluster) *metav1.Condition { + GinkgoHelper() + current := &hyperv1.HostedCluster{} + g.Expect(tc.MgmtClient.Get(tc.Context, crclient.ObjectKeyFromObject(hc), current)).To(Succeed()) + condition := meta.FindStatusCondition(current.Status.Conditions, string(hyperv1.OperatorConfigurationHonored)) + g.Expect(condition).NotTo(BeNil(), "the HyperShift operator should always set the %s condition", hyperv1.OperatorConfigurationHonored) + return condition +} + +// logLevelInCRDSchema reports whether the installed HostedCluster CRD exposes +// spec.operatorConfiguration.kubeAPIServer.logLevel. The setting is gated on +// HCPUserFacingOperatorLogs, so on a Default management cluster it is absent from the +// schema and the API server prunes it from any write, which would leave this test +// waiting for a propagation that can never happen. +func logLevelInCRDSchema(g Gomega, tc *internal.TestContext) bool { + GinkgoHelper() + crd := &apiextensionsv1.CustomResourceDefinition{} + g.Expect(tc.MgmtClient.Get(tc.Context, crclient.ObjectKey{Name: "hostedclusters.hypershift.openshift.io"}, crd)).To(Succeed()) + + for _, version := range crd.Spec.Versions { + if version.Name != hyperv1.GroupVersion.Version || version.Schema == nil || version.Schema.OpenAPIV3Schema == nil { + continue + } + schema := version.Schema.OpenAPIV3Schema + for _, field := range []string{"spec", "operatorConfiguration", "kubeAPIServer", "logLevel"} { + next, ok := schema.Properties[field] + if !ok { + return false + } + schema = &next + } + return true + } + return false +} + +// restoreLogLevelPatch builds the merge patch that undoes setting logLevel on original. +// KubeAPIServerOperatorSpec carries MinProperties=1, so clearing logLevel alone would +// leave an empty kubeAPIServer object and be rejected at admission. The whole object, or +// the whole operatorConfiguration, has to go when the test introduced it. +func restoreLogLevelPatch(original *hyperv1.HostedCluster) []byte { + switch { + case original.Spec.OperatorConfiguration == nil: + return []byte(`{"spec":{"operatorConfiguration":null}}`) + case original.Spec.OperatorConfiguration.KubeAPIServer == (hyperv1.KubeAPIServerOperatorSpec{}): + return []byte(`{"spec":{"operatorConfiguration":{"kubeAPIServer":null}}}`) + default: + return []byte(fmt.Sprintf(`{"spec":{"operatorConfiguration":{"kubeAPIServer":{"logLevel":%q}}}}`, + original.Spec.OperatorConfiguration.KubeAPIServer.LogLevel)) + } +} + +func OperatorConfigurationHonoredTest(getTestCtx internal.TestContextGetter) { + When("the control plane operator is the same version as the HyperShift operator", func() { + var tc *internal.TestContext + var hc *hyperv1.HostedCluster + + BeforeEach(func() { + tc = getTestCtx() + + var err error + hc, err = tc.GetHostedCluster() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should report every configured operatorConfiguration setting as honored", Label("operator-configuration-honored", internal.InformingLabel), func() { + By("confirming the control plane operator reports exactly the settings the spec configures") + // This is the contract the condition rests on. The control plane operator + // derives its report from its own build's view of the spec, so on a cluster + // with no version skew it has to match what the HyperShift operator derives + // from the same spec. Any mismatch here is a real skew signal, not a flake. + Eventually(func(g Gomega) { + hcp := hostedControlPlane(g, tc) + configured, err := operatorconfig.FieldPaths(hcp.Spec.OperatorConfiguration) + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(hcp.Status.OperatorConfiguration.ObservedGeneration).To(Equal(hcp.Generation), + "the control plane operator should have observed the current HostedControlPlane spec") + g.Expect(operatorconfig.Unhonored(configured, hcp.Status.OperatorConfiguration.HonoredFields)).To(BeEmpty(), + "a control plane operator at the same version should honor every configured setting") + }).WithContext(tc.Context).WithTimeout(10 * time.Minute).WithPolling(10 * time.Second).Should(Succeed()) + + By("confirming the HostedCluster reports the settings as honored") + Eventually(func(g Gomega) { + condition := operatorConfigurationHonoredCondition(g, tc, hc) + g.Expect(condition.Status).To(Equal(metav1.ConditionTrue), + "reason: %s, message: %s", condition.Reason, condition.Message) + g.Expect(condition.Reason).To(Equal(hyperv1.AsExpectedReason)) + }).WithContext(tc.Context).WithTimeout(10 * time.Minute).WithPolling(10 * time.Second).Should(Succeed()) + + By("confirming the condition stays honored rather than flapping") + Consistently(func(g Gomega) { + condition := operatorConfigurationHonoredCondition(g, tc, hc) + g.Expect(condition.Status).To(Equal(metav1.ConditionTrue), + "reason: %s, message: %s", condition.Reason, condition.Message) + }).WithContext(tc.Context).WithTimeout(2 * time.Minute).WithPolling(10 * time.Second).Should(Succeed()) + }) + + It("should track a newly configured setting through to the control plane operator", Label("operator-configuration-honored", internal.InformingLabel), func() { + if !logLevelInCRDSchema(Default, tc) { + Skip("spec.operatorConfiguration.kubeAPIServer.logLevel is gated on HCPUserFacingOperatorLogs " + + "and is absent from the installed HostedCluster CRD. Set HYPERSHIFT_FEATURESET=TechPreviewNoUpgrade " + + "on the HyperShift operator to enable it.") + } + if hc.Spec.OperatorConfiguration != nil && hc.Spec.OperatorConfiguration.KubeAPIServer.LogLevel == hyperv1.Debug { + Skip("HostedCluster already sets spec.operatorConfiguration.kubeAPIServer.logLevel to Debug") + } + + // The setting is day-2 mutable, so restore the captured configuration rather + // than leaving the hosted cluster configured for whichever test runs next. + restorePatch := restoreLogLevelPatch(hc) + DeferCleanup(func() { + Expect(tc.MgmtClient.Patch(tc.Context, hc, crclient.RawPatch(types.MergePatchType, restorePatch))).To(Succeed()) + }) + + By("confirming the setting is not yet reported as honored") + Eventually(func(g Gomega) { + hcp := hostedControlPlane(g, tc) + g.Expect(hcp.Status.OperatorConfiguration.HonoredFields).NotTo(ContainElement(logLevelPath)) + }).WithContext(tc.Context).WithTimeout(5 * time.Minute).WithPolling(10 * time.Second).Should(Succeed()) + + By("setting logLevel on the HostedCluster") + patch := fmt.Appendf(nil, `{"spec":{"operatorConfiguration":{"kubeAPIServer":{"logLevel":%q}}}}`, hyperv1.Debug) + Expect(tc.MgmtClient.Patch(tc.Context, hc, crclient.RawPatch(types.MergePatchType, patch))).To(Succeed()) + + By("waiting for the control plane operator to report the setting as honored") + Eventually(func(g Gomega) { + hcp := hostedControlPlane(g, tc) + g.Expect(hcp.Status.OperatorConfiguration.HonoredFields).To(ContainElement(logLevelPath), + "a control plane operator that supports the setting should report it as honored") + }).WithContext(tc.Context).WithTimeout(10 * time.Minute).WithPolling(10 * time.Second).Should(Succeed()) + + By("confirming the HostedCluster still reports the configuration as honored") + Eventually(func(g Gomega) { + condition := operatorConfigurationHonoredCondition(g, tc, hc) + g.Expect(condition.Status).To(Equal(metav1.ConditionTrue), + "reason: %s, message: %s", condition.Reason, condition.Message) + g.Expect(condition.Reason).To(Equal(hyperv1.AsExpectedReason)) + }).WithContext(tc.Context).WithTimeout(10 * time.Minute).WithPolling(10 * time.Second).Should(Succeed()) + }) + }) +} + +var _ = Describe("[sig-hypershift][Jira:Hypershift][Feature:OperatorConfiguration] Hosted Cluster Operator Configuration Honored", + Label("lifecycle", "hosted-cluster-operator-configuration-honored", internal.InformingLabel), func() { + var testCtx *internal.TestContext + + BeforeEach(func() { + testCtx = internal.GetTestContext() + Expect(testCtx).NotTo(BeNil(), "test context should be set up in BeforeSuite") + }) + + RegisterOperatorConfigurationHonoredTests(func() *internal.TestContext { return testCtx }) + }) diff --git a/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hosted_controlplane.go b/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hosted_controlplane.go index 71188edbd806..9f9798d6c391 100644 --- a/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hosted_controlplane.go +++ b/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hosted_controlplane.go @@ -428,6 +428,12 @@ type HostedControlPlaneStatus struct { // +optional Configuration *ConfigurationStatus `json:"configuration,omitempty"` + // operatorConfiguration reports which settings from spec.operatorConfiguration the + // reconciling control plane operator understands. It lets a newer HyperShift operator + // detect settings that an older control plane operator silently ignores. + // +optional + OperatorConfiguration OperatorConfigurationStatus `json:"operatorConfiguration,omitzero"` + // secretEncryption tracks the state of secret encryption key rotation and re-encryption. // +optional SecretEncryption SecretEncryptionStatus `json:"secretEncryption,omitzero"` @@ -436,6 +442,40 @@ type HostedControlPlaneStatus struct { Initialization HostedControlPlaneInitializationStatus `json:"initialization,omitzero"` } +// OperatorConfigurationStatus reports how the reconciling control plane operator +// interpreted spec.operatorConfiguration. +// +// A control plane operator only deserializes the spec fields its own build knows about; +// the API server serves settings added in later releases, but they are dropped on decode +// and silently ignored. Echoing back the settings that survived that decode lets the +// HyperShift operator name the settings a lagging control plane operator does not honor, +// without either side needing per-field knowledge of the other. +// +kubebuilder:validation:MinProperties=1 +type OperatorConfigurationStatus struct { + // observedGeneration is the HostedControlPlane generation whose + // spec.operatorConfiguration produced honoredFields. A value lower than + // metadata.generation means the control plane operator has not yet observed the + // latest spec, so honoredFields is stale. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=9007199254740992 + ObservedGeneration int64 `json:"observedGeneration,omitempty,omitzero"` + + // honoredFields lists the dot-separated paths of the settings that are both set in + // spec.operatorConfiguration and understood by the reconciling control plane operator, + // relative to spec.operatorConfiguration and sorted lexically + // (for example "kubeAPIServer.eventTTLMinutes"). It is empty when no settings are + // configured. A setting that is set in the spec but absent from this list is not in + // effect. + // +optional + // +listType=atomic + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=200 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=253 + HonoredFields []string `json:"honoredFields,omitempty"` +} + // HostedControlPlaneInitializationStatus provides observations of the HostedControlPlane initialization process. // This satisfies the CAPI v1beta2 ControlPlane provider contract: // https://github.com/kubernetes-sigs/cluster-api/blob/v1.11.5/api/core/v1beta2/cluster_types.go#L1361-L1379 diff --git a/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hostedcluster_conditions.go b/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hostedcluster_conditions.go index eecdb18efb1b..ba5339f418c9 100644 --- a/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hostedcluster_conditions.go +++ b/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hostedcluster_conditions.go @@ -305,6 +305,24 @@ const ( // keeps serving and the HostedCluster does not become degraded. // The condition is absent when no defaultCertificate is configured. IngressDefaultCertificateSynced ConditionType = "IngressDefaultCertificateSynced" + + // OperatorConfigurationHonored indicates whether every setting configured under + // spec.operatorConfiguration is actually in effect on the hosted control plane. + // It is intentionally generic so that one condition covers the whole stanza and + // every setting added to it; the message names the specific settings at issue. + // + // A control plane operator older than the HyperShift operator accepts and persists + // a setting it predates, then ignores it. This condition is how that is reported. + // + // **True** (reason AsExpected) means no setting is configured, or every configured + // setting is honored by the reconciling control plane operator. + // **False** (reason OperatorConfigurationNotHonored) means at least one configured + // setting is not in effect, and the message names them. + // **Unknown** (reason UnsupportedControlPlaneVersion or StatusUnknown) means whether + // the configured settings are honored could not be determined, because the control + // plane operator predates this reporting, its version is unknown, or it has not yet + // observed the current spec. + OperatorConfigurationHonored ConditionType = "OperatorConfigurationHonored" ) // Reasons for PublicEndpointExposed condition. @@ -341,11 +359,19 @@ const ( // imply the mechanism is currently driving configuration, since a non-deprecated // field may take precedence over it. DeprecatedConfigurationInUseReason = "DeprecatedConfigurationInUse" - NotFoundReason = "NotFound" - WaitingForAvailableReason = "WaitingForAvailable" - SecretNotFoundReason = "SecretNotFound" - WaitingForGracePeriodReason = "WaitingForGracePeriod" - BlockedReason = "Blocked" + // OperatorConfigurationNotHonoredReason is used with the OperatorConfigurationHonored + // condition when at least one setting configured under spec.operatorConfiguration is + // not in effect because the reconciling control plane operator does not support it. + OperatorConfigurationNotHonoredReason = "OperatorConfigurationNotHonored" + // UnsupportedControlPlaneVersionReason is used when the reconciling control plane + // operator is too old to report the information a condition is derived from, so the + // condition cannot be evaluated. + UnsupportedControlPlaneVersionReason = "UnsupportedControlPlaneVersion" + NotFoundReason = "NotFound" + WaitingForAvailableReason = "WaitingForAvailable" + SecretNotFoundReason = "SecretNotFound" + WaitingForGracePeriodReason = "WaitingForGracePeriod" + BlockedReason = "Blocked" InfraStatusFailureReason = "InfraStatusFailure" WaitingOnInfrastructureReadyReason = "WaitingOnInfrastructureReady" 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 4c0459433bcf..e799deac00c4 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 @@ -3039,6 +3039,7 @@ func (in *HostedControlPlaneStatus) DeepCopyInto(out *HostedControlPlaneStatus) *out = new(ConfigurationStatus) (*in).DeepCopyInto(*out) } + in.OperatorConfiguration.DeepCopyInto(&out.OperatorConfiguration) in.SecretEncryption.DeepCopyInto(&out.SecretEncryption) in.Initialization.DeepCopyInto(&out.Initialization) } @@ -4612,6 +4613,26 @@ func (in *OperatorConfiguration) DeepCopy() *OperatorConfiguration { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OperatorConfigurationStatus) DeepCopyInto(out *OperatorConfigurationStatus) { + *out = *in + if in.HonoredFields != nil { + in, out := &in.HonoredFields, &out.HonoredFields + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OperatorConfigurationStatus. +func (in *OperatorConfigurationStatus) DeepCopy() *OperatorConfigurationStatus { + if in == nil { + return nil + } + out := new(OperatorConfigurationStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PersistentVolumeEtcdStorageSpec) DeepCopyInto(out *PersistentVolumeEtcdStorageSpec) { *out = *in