diff --git a/api/v1alpha1/public/clusterspec_types.go b/api/v1alpha1/public/clusterspec_types.go index 9d405e9d..ed79ac23 100644 --- a/api/v1alpha1/public/clusterspec_types.go +++ b/api/v1alpha1/public/clusterspec_types.go @@ -38,4 +38,7 @@ type ClusterSpec struct { // passthrough types with per-field visibility and write-mode markers. // +kubebuilder:validation:Required HostedCluster HostedClusterSpecPassthrough `json:"hostedCluster"` + // ControlPlaneUpgradePolicy is the control plane upgrade policy defined by the user. + // +optional + ControlPlaneUpgradePolicy *ControlPlaneUpgradePolicySpec `json:"controlPlaneUpgradePolicy,omitempty"` } diff --git a/api/v1alpha1/public/clusterstatus_types.go b/api/v1alpha1/public/clusterstatus_types.go index 4480a8c6..4f467364 100644 --- a/api/v1alpha1/public/clusterstatus_types.go +++ b/api/v1alpha1/public/clusterstatus_types.go @@ -10,7 +10,7 @@ import ( // ClusterStatus defines the observed state of a Cluster. type ClusterStatus struct { // Conditions represent the latest observations of the cluster's state. - // Known condition types: Synced, Available, Degraded. + // Known condition types: Synced, Available, Degraded, ControlPlaneUpgradeState. // +listType=map // +listMapKey=type // +optional @@ -30,4 +30,7 @@ type ClusterStatus struct { // ObservedGeneration is the most recent generation observed by the controller. // +optional ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // ControlPlaneUpgradePolicy summarizes the control plane upgrade policy status. + // +optional + ControlPlaneUpgradePolicy *ControlPlaneUpgradePolicyStatus `json:"controlPlaneUpgradePolicy,omitempty"` } diff --git a/api/v1alpha1/public/constants.go b/api/v1alpha1/public/constants.go index 9d6045ae..07776e8e 100644 --- a/api/v1alpha1/public/constants.go +++ b/api/v1alpha1/public/constants.go @@ -13,6 +13,15 @@ const ( ClusterPhaseDeleting ClusterPhase = "Deleting" ) +// ControlPlaneUpgradeType identifies the source of a control plane upgrade request. +// +kubebuilder:validation:Enum=UserInitiated;ServiceInitiated +type ControlPlaneUpgradeType string + +const ( + UserInitiated ControlPlaneUpgradeType = "UserInitiated" + ServiceInitiated ControlPlaneUpgradeType = "ServiceInitiated" +) + // ManifestPhase represents the lifecycle phase of a Manifest. // +kubebuilder:validation:Enum=Syncing;Applied;Deleting type ManifestPhase string @@ -42,3 +51,21 @@ const ( PlacementPhasePending PlacementPhase = "Pending" PlacementPhaseBound PlacementPhase = "Bound" ) + +// ScheduleUpgradeType indicates the type of schedule for the control plane upgrade. +// +kubebuilder:validation:Enum=Manual;Automatic +type ScheduleUpgradeType string + +const ( + ManualSchedule ScheduleUpgradeType = "Manual" + AutomaticSchedule ScheduleUpgradeType = "Automatic" +) + +// UpgradeScopeType indicates if an automatic upgrade support only patch upgrades or patch and minor upgrades. +// +kubebuilder:validation:Enum=PatchOnly;PatchAndMinor +type UpgradeScopeType string + +const ( + PatchOnly UpgradeScopeType = "PatchOnly" + PatchAndMinor UpgradeScopeType = "PatchAndMinor" +) diff --git a/api/v1alpha1/public/controlplaneupgradepolicyspec_types.go b/api/v1alpha1/public/controlplaneupgradepolicyspec_types.go new file mode 100644 index 00000000..69a8a35b --- /dev/null +++ b/api/v1alpha1/public/controlplaneupgradepolicyspec_types.go @@ -0,0 +1,49 @@ +// Code generated by conversion-gen. DO NOT EDIT. + +package public + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ControlPlaneUpgradePolicySpec defines the desired control plane upgrade policy of a Cluster. +// +kubebuilder:validation:XValidation:rule="self.scheduleType != 'Manual' || (has(self.version) && has(self.nextRun))",message="version and nextRun are required when scheduleType is Manual" +// +kubebuilder:validation:XValidation:rule="self.scheduleType != 'Manual' || !has(self.schedule)",message="schedule must not be set when scheduleType is Manual" +// +kubebuilder:validation:XValidation:rule="self.scheduleType != 'Automatic' || has(self.schedule)",message="schedule is required when scheduleType is Automatic" +// +kubebuilder:validation:XValidation:rule="self.scheduleType != 'Automatic' || (!has(self.version) && !has(self.nextRun))",message="version and nextRun must not be set when scheduleType is Automatic" +// +kubebuilder:validation:XValidation:rule="self.scheduleType != 'Manual' || !has(self.upgradeScope)",message="upgradeScope must not be set when scheduleType is Manual" +// +kubebuilder:validation:XValidation:rule="self.scheduleType != 'Automatic' || has(self.upgradeScope)",message="upgradeScope is required when scheduleType is Automatic" +type ControlPlaneUpgradePolicySpec struct { + // UpdateType indicates if it is a control plane upgrade policy defined by the user or + // triggered by Red Hat for addressing critical CVEs. + // +kubebuilder:validation:Required + UpdateType ControlPlaneUpgradeType `json:"updateType"` + // ScheduleType indicates if the control plane upgrade policy is "manual" and it's executed only one time or + // whether it is "automatic" where an expression will calculate recurrent upgrades. + // +kubebuilder:validation:Required + ScheduleType ScheduleUpgradeType `json:"scheduleType"` + // Schedule defines a cron expression that calculates the next automatic upgrade scheduling. + // The cron expression must follow the standard 5-field format: + // ┌───────────── minute (0 - 59) + // │ ┌───────────── hour (0 - 23) + // │ │ ┌───────────── day of month (1 - 31) + // │ │ │ ┌───────────── month (1 - 12) + // │ │ │ │ ┌───────────── day of week (0 - 6) (Sunday to Saturday) + // │ │ │ │ │ + // * * * * * + // +kubebuilder:validation:MaxLength=256 + // +kubebuilder:validation:Pattern=`^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])-([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])(,([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9]))*) (\*|([0-9]|1[0-9]|2[0-3])|([0-9]|1[0-9]|2[0-3])-([0-9]|1[0-9]|2[0-3])|\*/([0-9]|1[0-9]|2[0-3])|([0-9]|1[0-9]|2[0-3])(,([0-9]|1[0-9]|2[0-3]))*) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|([1-9]|1[0-9]|2[0-9]|3[0-1])-([1-9]|1[0-9]|2[0-9]|3[0-1])|\*/([1-9]|1[0-9]|2[0-9]|3[0-1])|([1-9]|1[0-9]|2[0-9]|3[0-1])(,([1-9]|1[0-9]|2[0-9]|3[0-1]))*) (\*|([1-9]|1[0-2])|([1-9]|1[0-2])-([1-9]|1[0-2])|\*/([1-9]|1[0-2])|([1-9]|1[0-2])(,([1-9]|1[0-2]))*) (\*|[0-6]|[0-6]-[0-6]|\*/[0-6]|[0-6](,[0-6])*)$` + // +optional + Schedule *string `json:"schedule,omitempty"` + // Version is the desired upgrade version on "manual" upgrade policies. + // +kubebuilder:validation:MaxLength=64 + // +optional + Version *string `json:"version,omitempty"` + // NextRun is the time the upgrade should run for "manual" upgrade policies + // +optional + NextRun *metav1.Time `json:"nextRun,omitempty"` + // UpgradeScope indicates if minor version upgrades are allowed for automatic upgrades. + // Manual upgrades always allow it. + // +optional + UpgradeScope UpgradeScopeType `json:"upgradeScope,omitempty"` +} diff --git a/api/v1alpha1/public/controlplaneupgradepolicystatus_types.go b/api/v1alpha1/public/controlplaneupgradepolicystatus_types.go new file mode 100644 index 00000000..dd0a3ff1 --- /dev/null +++ b/api/v1alpha1/public/controlplaneupgradepolicystatus_types.go @@ -0,0 +1,15 @@ +// Code generated by conversion-gen. DO NOT EDIT. + +package public + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ControlPlaneUpgradePolicyStatus is the REST representation of ControlPlaneUpgradePolicyStatus (visible fields only) +type ControlPlaneUpgradePolicyStatus struct { + // NextRun is the time when the control plane will upgrade. + // When the ScheduleType is "manual" it will match with the NextRun defined by the user. + // When the ScheduleType is "automatic" it will be calculated from the Schedule cron expression. + NextRun *metav1.Time `json:"nextRun,omitempty"` +} diff --git a/api/v1alpha1/public/openapi.yaml b/api/v1alpha1/public/openapi.yaml index 9882d118..9e7440df 100644 --- a/api/v1alpha1/public/openapi.yaml +++ b/api/v1alpha1/public/openapi.yaml @@ -2262,6 +2262,72 @@ components: metadata.Name is the human-readable cluster name; metadata.Namespace is the cluster UUID. The owning AWS account is stored as the label hyperfleet.io/account-id. properties: + controlPlaneUpgradePolicy: + description: ControlPlaneUpgradePolicy is the control plane upgrade policy defined by the user. + properties: + nextRun: + description: NextRun is the time the upgrade should run for "manual" upgrade policies + format: date-time + type: string + schedule: + description: |- + Schedule defines a cron expression that calculates the next automatic upgrade scheduling. + The cron expression must follow the standard 5-field format: + ┌───────────── minute (0 - 59) + │ ┌───────────── hour (0 - 23) + │ │ ┌───────────── day of month (1 - 31) + │ │ │ ┌───────────── month (1 - 12) + │ │ │ │ ┌───────────── day of week (0 - 6) (Sunday to Saturday) + │ │ │ │ │ + * * * * * + maxLength: 256 + pattern: ^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])-([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])(,([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9]))*) (\*|([0-9]|1[0-9]|2[0-3])|([0-9]|1[0-9]|2[0-3])-([0-9]|1[0-9]|2[0-3])|\*/([0-9]|1[0-9]|2[0-3])|([0-9]|1[0-9]|2[0-3])(,([0-9]|1[0-9]|2[0-3]))*) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|([1-9]|1[0-9]|2[0-9]|3[0-1])-([1-9]|1[0-9]|2[0-9]|3[0-1])|\*/([1-9]|1[0-9]|2[0-9]|3[0-1])|([1-9]|1[0-9]|2[0-9]|3[0-1])(,([1-9]|1[0-9]|2[0-9]|3[0-1]))*) (\*|([1-9]|1[0-2])|([1-9]|1[0-2])-([1-9]|1[0-2])|\*/([1-9]|1[0-2])|([1-9]|1[0-2])(,([1-9]|1[0-2]))*) (\*|[0-6]|[0-6]-[0-6]|\*/[0-6]|[0-6](,[0-6])*)$ + type: string + scheduleType: + description: |- + ScheduleType indicates if the control plane upgrade policy is "manual" and it's executed only one time or + whether it is "automatic" where an expression will calculate recurrent upgrades. + enum: + - Manual + - Automatic + type: string + updateType: + description: |- + UpdateType indicates if it is a control plane upgrade policy defined by the user or + triggered by Red Hat for addressing critical CVEs. + enum: + - UserInitiated + - ServiceInitiated + type: string + upgradeScope: + description: |- + UpgradeScope indicates if minor version upgrades are allowed for automatic upgrades. + Manual upgrades always allow it. + enum: + - PatchOnly + - PatchAndMinor + type: string + version: + description: Version is the desired upgrade version on "manual" upgrade policies. + maxLength: 64 + type: string + required: + - scheduleType + - updateType + type: object + x-kubernetes-validations: + - message: version and nextRun are required when scheduleType is Manual + rule: self.scheduleType != 'Manual' || (has(self.version) && has(self.nextRun)) + - message: schedule must not be set when scheduleType is Manual + rule: self.scheduleType != 'Manual' || !has(self.schedule) + - message: schedule is required when scheduleType is Automatic + rule: self.scheduleType != 'Automatic' || has(self.schedule) + - message: version and nextRun must not be set when scheduleType is Automatic + rule: self.scheduleType != 'Automatic' || (!has(self.version) && !has(self.nextRun)) + - message: upgradeScope must not be set when scheduleType is Manual + rule: self.scheduleType != 'Manual' || !has(self.upgradeScope) + - message: upgradeScope is required when scheduleType is Automatic + rule: self.scheduleType != 'Automatic' || has(self.upgradeScope) deleteProtection: description: DeleteProtection prevents accidental deletion when enabled. type: boolean diff --git a/api/v1alpha1/public/zz_generated.deepcopy.go b/api/v1alpha1/public/zz_generated.deepcopy.go index 8c05d34b..5e1925da 100644 --- a/api/v1alpha1/public/zz_generated.deepcopy.go +++ b/api/v1alpha1/public/zz_generated.deepcopy.go @@ -121,6 +121,11 @@ func (in *ClusterSpec) DeepCopyInto(out *ClusterSpec) { } } in.HostedCluster.DeepCopyInto(&out.HostedCluster) + if in.ControlPlaneUpgradePolicy != nil { + in, out := &in.ControlPlaneUpgradePolicy, &out.ControlPlaneUpgradePolicy + *out = new(ControlPlaneUpgradePolicySpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterSpec. @@ -149,6 +154,11 @@ func (in *ClusterStatus) DeepCopyInto(out *ClusterStatus) { *out = new(PlacementReference) **out = **in } + if in.ControlPlaneUpgradePolicy != nil { + in, out := &in.ControlPlaneUpgradePolicy, &out.ControlPlaneUpgradePolicy + *out = new(ControlPlaneUpgradePolicyStatus) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterStatus. @@ -161,6 +171,54 @@ func (in *ClusterStatus) DeepCopy() *ClusterStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControlPlaneUpgradePolicySpec) DeepCopyInto(out *ControlPlaneUpgradePolicySpec) { + *out = *in + if in.Schedule != nil { + in, out := &in.Schedule, &out.Schedule + *out = new(string) + **out = **in + } + if in.Version != nil { + in, out := &in.Version, &out.Version + *out = new(string) + **out = **in + } + if in.NextRun != nil { + in, out := &in.NextRun, &out.NextRun + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControlPlaneUpgradePolicySpec. +func (in *ControlPlaneUpgradePolicySpec) DeepCopy() *ControlPlaneUpgradePolicySpec { + if in == nil { + return nil + } + out := new(ControlPlaneUpgradePolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControlPlaneUpgradePolicyStatus) DeepCopyInto(out *ControlPlaneUpgradePolicyStatus) { + *out = *in + if in.NextRun != nil { + in, out := &in.NextRun, &out.NextRun + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControlPlaneUpgradePolicyStatus. +func (in *ControlPlaneUpgradePolicyStatus) DeepCopy() *ControlPlaneUpgradePolicyStatus { + if in == nil { + return nil + } + out := new(ControlPlaneUpgradePolicyStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FileSpec) DeepCopyInto(out *FileSpec) { *out = *in diff --git a/hack/api-codegen/pkg/conversion/generator.go b/hack/api-codegen/pkg/conversion/generator.go index 6a44c9b3..eabe7a6f 100644 --- a/hack/api-codegen/pkg/conversion/generator.go +++ b/hack/api-codegen/pkg/conversion/generator.go @@ -48,6 +48,7 @@ type typeInfo struct { Doc *ast.CommentGroup Fields []*fieldInfo Markers []string + Embeds []string } type namedTypeInfo struct { @@ -172,6 +173,7 @@ func (g *Generator) parseTypes() error { for _, field := range structType.Fields.List { if len(field.Names) == 0 { + ti.Embeds = append(ti.Embeds, g.exprToString(field.Type)) continue } for _, name := range field.Names { @@ -594,12 +596,10 @@ func (g *Generator) generateRESTTypes() error { // Discover resource types dynamically: types with both Spec and Status subtypes resourceTypes := g.discoverResourceTypes() - // Identify root resource types (have both Spec and Status, not a sub-type) + // Identify root resource types (CRDs with TypeMeta/ObjectMeta wrapper) rootTypes := make(map[string]bool) for typeName := range g.typeInfos { - _, hasSpec := g.typeInfos[typeName+"Spec"] - _, hasStatus := g.typeInfos[typeName+"Status"] - if hasSpec && hasStatus && !strings.Contains(typeName, "Passthrough") { + if g.isCRDResource(typeName) { rootTypes[typeName] = true } } @@ -752,20 +752,62 @@ func (g *Generator) generateRESTConstants(namedTypes []*namedTypeInfo) error { return g.writeRESTFile("constants.go", buf.String()) } -// discoverResourceTypes finds types that form CRD resources (have matching -// Spec and Status subtypes) plus any types referenced by their visible fields. +// isCRDResource returns true if typeName is a top-level CRD resource. It +// requires the wrapper struct to embed metav1.TypeMeta and metav1.ObjectMeta, +// have sibling FooSpec/FooStatus types, AND declare Spec and Status fields +// whose GoType values match those sibling types. Types that only have +// Spec+Status sub-types but no wrapper (e.g. ControlPlaneUpgradePolicy) are +// nested sub-types, not top-level CRDs. +func (g *Generator) isCRDResource(typeName string) bool { + ti, exists := g.typeInfos[typeName] + if !exists { + return false + } + specType := typeName + "Spec" + statusType := typeName + "Status" + if _, ok := g.typeInfos[specType]; !ok { + return false + } + if _, ok := g.typeInfos[statusType]; !ok { + return false + } + hasTypeMeta := false + hasObjectMeta := false + for _, embed := range ti.Embeds { + if embed == "metav1.TypeMeta" { + hasTypeMeta = true + } + if embed == "metav1.ObjectMeta" { + hasObjectMeta = true + } + } + if !hasTypeMeta || !hasObjectMeta { + return false + } + hasSpecField := false + hasStatusField := false + for _, fi := range ti.Fields { + if fi.GoName == "Spec" && fi.GoType == specType { + hasSpecField = true + } + if fi.GoName == "Status" && fi.GoType == statusType { + hasStatusField = true + } + } + return hasSpecField && hasStatusField +} + +// discoverResourceTypes finds types that form CRD resources (have a wrapper +// struct with TypeMeta/ObjectMeta embeds and matching Spec and Status subtypes) +// plus any types referenced by their visible fields. func (g *Generator) discoverResourceTypes() []string { typeSet := make(map[string]bool) for typeName := range g.typeInfos { - specName := typeName + "Spec" - statusName := typeName + "Status" - _, hasSpec := g.typeInfos[specName] - _, hasStatus := g.typeInfos[statusName] - if hasSpec && hasStatus { + if g.isCRDResource(typeName) { typeSet[typeName] = true - typeSet[specName] = true - typeSet[statusName] = true + typeSet[typeName+"Spec"] = true + typeSet[typeName+"Status"] = true } } @@ -1201,20 +1243,15 @@ func (g *Generator) generateConversionFunctions() error { return nil } -// discoverResources returns top-level resource names (types with Spec+Status). +// discoverResources returns top-level CRD resource names — types that have a +// wrapper struct with TypeMeta/ObjectMeta embeds and matching Spec+Status. func (g *Generator) discoverResources() []string { var resources []string seen := make(map[string]bool) for typeName := range g.typeInfos { - if strings.HasSuffix(typeName, "Spec") { - resource := strings.TrimSuffix(typeName, "Spec") - if _, hasStatus := g.typeInfos[resource+"Status"]; hasStatus && !seen[resource] { - // Exclude passthrough types — they are sub-types, not top-level resources - if !strings.Contains(resource, "Passthrough") { - seen[resource] = true - resources = append(resources, resource) - } - } + if !seen[typeName] && g.isCRDResource(typeName) { + seen[typeName] = true + resources = append(resources, typeName) } } sort.Strings(resources) diff --git a/hyperfleet-operator/config/crd/bases/hyperfleet.io_clusters.yaml b/hyperfleet-operator/config/crd/bases/hyperfleet.io_clusters.yaml index d7b99d6b..c8caa6c2 100644 --- a/hyperfleet-operator/config/crd/bases/hyperfleet.io_clusters.yaml +++ b/hyperfleet-operator/config/crd/bases/hyperfleet.io_clusters.yaml @@ -109,8 +109,8 @@ spec: UpdateType indicates if it is a control plane upgrade policy defined by the user or triggered by Red Hat for addressing critical CVEs. enum: - - ControlPlane - - ControlPlaneCVE + - UserInitiated + - ServiceInitiated type: string upgradeScope: description: |-