diff --git a/apis/placement/v1beta1/stageupdate_types.go b/apis/placement/v1beta1/stageupdate_types.go index db72de9d5..1f03fb625 100644 --- a/apis/placement/v1beta1/stageupdate_types.go +++ b/apis/placement/v1beta1/stageupdate_types.go @@ -92,6 +92,7 @@ type UpdateRunObjList interface { // +kubebuilder:printcolumn:JSONPath=`.spec.resourceSnapshotIndex`,name="Resource-Snapshot-Index",type=string // +kubebuilder:printcolumn:JSONPath=`.status.policySnapshotIndexUsed`,name="Policy-Snapshot-Index",type=string // +kubebuilder:printcolumn:JSONPath=`.status.conditions[?(@.type=="Initialized")].status`,name="Initialized",type=string +// +kubebuilder:printcolumn:JSONPath=`.status.conditions[?(@.type=="Progressing")].status`,name="Progressing",type=string // +kubebuilder:printcolumn:JSONPath=`.status.conditions[?(@.type=="Succeeded")].status`,name="Succeeded",type=string // +kubebuilder:printcolumn:JSONPath=`.metadata.creationTimestamp`,name="Age",type=date // +kubebuilder:printcolumn:JSONPath=`.spec.stagedRolloutStrategyName`,name="Strategy",priority=1,type=string @@ -107,9 +108,8 @@ type ClusterStagedUpdateRun struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - // The desired state of ClusterStagedUpdateRun. The spec is immutable. + // The desired state of ClusterStagedUpdateRun. // +kubebuilder:validation:Required - // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="The spec field is immutable" Spec UpdateRunSpec `json:"spec"` // The observed status of ClusterStagedUpdateRun. @@ -147,19 +147,47 @@ func (c *ClusterStagedUpdateRun) SetUpdateRunStatus(status UpdateRunStatus) { c.Status = status } +// State represents the desired state of an update run. +// +enum +type State string + +const ( + // StateNotStarted describes user intent to initialize but not execute the update run. + // This is the default state when an update run is created. + StateNotStarted State = "NotStarted" + + // StateStarted describes user intent to execute (or resume execution if paused). + // Users can subsequently set the state to Stopped or Abandoned. + StateStarted State = "Started" + + // StateStopped describes user intent to pause the update run. + // Users can subsequently set the state to Started or Abandoned. + StateStopped State = "Stopped" + + // StateAbandoned describes user intent to abandon the update run. + // This is a terminal state; once set, it cannot be changed. + StateAbandoned State = "Abandoned" +) + // UpdateRunSpec defines the desired rollout strategy and the snapshot indices of the resources to be updated. // It specifies a stage-by-stage update process across selected clusters for the given ResourcePlacement object. +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.state) || oldSelf.state != 'NotStarted' || self.state != 'Stopped'",message="invalid state transition: cannot transition from NotStarted to Stopped" +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.state) || oldSelf.state != 'Started' || self.state != 'NotStarted'",message="invalid state transition: cannot transition from Started to NotStarted" +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.state) || oldSelf.state != 'Stopped' || self.state != 'NotStarted'",message="invalid state transition: cannot transition from Stopped to NotStarted" +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.state) || oldSelf.state != 'Abandoned' || self.state == 'Abandoned'",message="invalid state transition: Abandoned is a terminal state and cannot transition to any other state" type UpdateRunSpec struct { // PlacementName is the name of placement that this update run is applied to. // There can be multiple active update runs for each placement, but // it's up to the DevOps team to ensure they don't conflict with each other. // +kubebuilder:validation:Required // +kubebuilder:validation:MaxLength=255 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="placementName is immutable" PlacementName string `json:"placementName"` // The resource snapshot index of the selected resources to be updated across clusters. // The index represents a group of resource snapshots that includes all the resources a ResourcePlacement selected. // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="resourceSnapshotIndex is immutable" ResourceSnapshotIndex string `json:"resourceSnapshotIndex"` // The name of the update strategy that specifies the stages and the sequence @@ -167,7 +195,18 @@ type UpdateRunSpec struct { // are computed according to the referenced strategy when the update run starts // and recorded in the status field. // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="stagedRolloutStrategyName is immutable" StagedUpdateStrategyName string `json:"stagedRolloutStrategyName"` + + // State indicates the desired state of the update run. + // NotStarted: The update run is initialized but execution has not started (default). + // Started: The update run should execute or resume execution. + // Stopped: The update run should pause execution. + // Abandoned: The update run should be abandoned and terminated. + // +kubebuilder:validation:Optional + // +kubebuilder:default=NotStarted + // +kubebuilder:validation:Enum=NotStarted;Started;Stopped;Abandoned + State State `json:"state,omitempty"` } // UpdateStrategySpecGetterSetter offers the functionality to work with UpdateStrategySpec. @@ -387,7 +426,7 @@ const ( // StagedUpdateRunConditionProgressing indicates whether the staged update run is making progress. // Its condition status can be one of the following: // - "True": The staged update run is making progress. - // - "False": The staged update run is waiting/paused. + // - "False": The staged update run is waiting/paused/abandoned. // - "Unknown" means it is unknown. StagedUpdateRunConditionProgressing StagedUpdateRunConditionType = "Progressing" @@ -746,6 +785,7 @@ func (c *ClusterApprovalRequestList) GetApprovalRequestObjs() []ApprovalRequestO // +kubebuilder:printcolumn:JSONPath=`.spec.resourceSnapshotIndex`,name="Resource-Snapshot-Index",type=string // +kubebuilder:printcolumn:JSONPath=`.status.policySnapshotIndexUsed`,name="Policy-Snapshot-Index",type=string // +kubebuilder:printcolumn:JSONPath=`.status.conditions[?(@.type=="Initialized")].status`,name="Initialized",type=string +// +kubebuilder:printcolumn:JSONPath=`.status.conditions[?(@.type=="Progressing")].status`,name="Progressing",type=string // +kubebuilder:printcolumn:JSONPath=`.status.conditions[?(@.type=="Succeeded")].status`,name="Succeeded",type=string // +kubebuilder:printcolumn:JSONPath=`.metadata.creationTimestamp`,name="Age",type=date // +kubebuilder:printcolumn:JSONPath=`.spec.stagedRolloutStrategyName`,name="Strategy",priority=1,type=string @@ -761,9 +801,8 @@ type StagedUpdateRun struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - // The desired state of StagedUpdateRun. The spec is immutable. + // The desired state of StagedUpdateRun. // +kubebuilder:validation:Required - // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="The spec field is immutable" Spec UpdateRunSpec `json:"spec"` // The observed status of StagedUpdateRun. diff --git a/config/crd/bases/placement.kubernetes-fleet.io_clusterstagedupdateruns.yaml b/config/crd/bases/placement.kubernetes-fleet.io_clusterstagedupdateruns.yaml index 3102c7f53..bf75d613d 100644 --- a/config/crd/bases/placement.kubernetes-fleet.io_clusterstagedupdateruns.yaml +++ b/config/crd/bases/placement.kubernetes-fleet.io_clusterstagedupdateruns.yaml @@ -1112,6 +1112,9 @@ spec: - jsonPath: .status.conditions[?(@.type=="Initialized")].status name: Initialized type: string + - jsonPath: .status.conditions[?(@.type=="Progressing")].status + name: Progressing + type: string - jsonPath: .status.conditions[?(@.type=="Succeeded")].status name: Succeeded type: string @@ -1151,8 +1154,7 @@ spec: metadata: type: object spec: - description: The desired state of ClusterStagedUpdateRun. The spec is - immutable. + description: The desired state of ClusterStagedUpdateRun. properties: placementName: description: |- @@ -1161,11 +1163,17 @@ spec: it's up to the DevOps team to ensure they don't conflict with each other. maxLength: 255 type: string + x-kubernetes-validations: + - message: placementName is immutable + rule: self == oldSelf resourceSnapshotIndex: description: |- The resource snapshot index of the selected resources to be updated across clusters. The index represents a group of resource snapshots that includes all the resources a ResourcePlacement selected. type: string + x-kubernetes-validations: + - message: resourceSnapshotIndex is immutable + rule: self == oldSelf stagedRolloutStrategyName: description: |- The name of the update strategy that specifies the stages and the sequence @@ -1173,14 +1181,45 @@ spec: are computed according to the referenced strategy when the update run starts and recorded in the status field. type: string + x-kubernetes-validations: + - message: stagedRolloutStrategyName is immutable + rule: self == oldSelf + state: + default: NotStarted + description: |- + State indicates the desired state of the update run. + NotStarted: The update run is initialized but execution has not started (default). + Started: The update run should execute or resume execution. + Stopped: The update run should pause execution. + Abandoned: The update run should be abandoned and terminated. + enum: + - NotStarted + - Started + - Stopped + - Abandoned + type: string required: - placementName - resourceSnapshotIndex - stagedRolloutStrategyName type: object x-kubernetes-validations: - - message: The spec field is immutable - rule: self == oldSelf + - message: 'invalid state transition: cannot transition from NotStarted + to Stopped' + rule: '!has(oldSelf.state) || oldSelf.state != ''NotStarted'' || self.state + != ''Stopped''' + - message: 'invalid state transition: cannot transition from Started to + NotStarted' + rule: '!has(oldSelf.state) || oldSelf.state != ''Started'' || self.state + != ''NotStarted''' + - message: 'invalid state transition: cannot transition from Stopped to + NotStarted' + rule: '!has(oldSelf.state) || oldSelf.state != ''Stopped'' || self.state + != ''NotStarted''' + - message: 'invalid state transition: Abandoned is a terminal state and + cannot transition to any other state' + rule: '!has(oldSelf.state) || oldSelf.state != ''Abandoned'' || self.state + == ''Abandoned''' status: description: The observed status of ClusterStagedUpdateRun. properties: @@ -1998,7 +2037,7 @@ spec: description: |- MaxConcurrency specifies the maximum number of clusters that can be updated concurrently within this stage. Value can be an absolute number (ex: 5) or a percentage of the total clusters in the stage (ex: 50%). - Absolute number is calculated from percentage by rounding up. + Fractional results are rounded down. A minimum of 1 update is enforced. If not specified, all clusters in the stage are updated sequentially (effectively maxConcurrency = 1). Defaults to 1. pattern: ^((100|[0-9]{1,2})%|[0-9]+)$ diff --git a/config/crd/bases/placement.kubernetes-fleet.io_clusterstagedupdatestrategies.yaml b/config/crd/bases/placement.kubernetes-fleet.io_clusterstagedupdatestrategies.yaml index 8791e4ce9..4d088a0ce 100644 --- a/config/crd/bases/placement.kubernetes-fleet.io_clusterstagedupdatestrategies.yaml +++ b/config/crd/bases/placement.kubernetes-fleet.io_clusterstagedupdatestrategies.yaml @@ -312,7 +312,7 @@ spec: description: |- MaxConcurrency specifies the maximum number of clusters that can be updated concurrently within this stage. Value can be an absolute number (ex: 5) or a percentage of the total clusters in the stage (ex: 50%). - Absolute number is calculated from percentage by rounding up. + Fractional results are rounded down. A minimum of 1 update is enforced. If not specified, all clusters in the stage are updated sequentially (effectively maxConcurrency = 1). Defaults to 1. pattern: ^((100|[0-9]{1,2})%|[0-9]+)$ diff --git a/config/crd/bases/placement.kubernetes-fleet.io_stagedupdateruns.yaml b/config/crd/bases/placement.kubernetes-fleet.io_stagedupdateruns.yaml index ec319411f..10fe5f738 100644 --- a/config/crd/bases/placement.kubernetes-fleet.io_stagedupdateruns.yaml +++ b/config/crd/bases/placement.kubernetes-fleet.io_stagedupdateruns.yaml @@ -32,6 +32,9 @@ spec: - jsonPath: .status.conditions[?(@.type=="Initialized")].status name: Initialized type: string + - jsonPath: .status.conditions[?(@.type=="Progressing")].status + name: Progressing + type: string - jsonPath: .status.conditions[?(@.type=="Succeeded")].status name: Succeeded type: string @@ -71,7 +74,7 @@ spec: metadata: type: object spec: - description: The desired state of StagedUpdateRun. The spec is immutable. + description: The desired state of StagedUpdateRun. properties: placementName: description: |- @@ -80,11 +83,17 @@ spec: it's up to the DevOps team to ensure they don't conflict with each other. maxLength: 255 type: string + x-kubernetes-validations: + - message: placementName is immutable + rule: self == oldSelf resourceSnapshotIndex: description: |- The resource snapshot index of the selected resources to be updated across clusters. The index represents a group of resource snapshots that includes all the resources a ResourcePlacement selected. type: string + x-kubernetes-validations: + - message: resourceSnapshotIndex is immutable + rule: self == oldSelf stagedRolloutStrategyName: description: |- The name of the update strategy that specifies the stages and the sequence @@ -92,14 +101,45 @@ spec: are computed according to the referenced strategy when the update run starts and recorded in the status field. type: string + x-kubernetes-validations: + - message: stagedRolloutStrategyName is immutable + rule: self == oldSelf + state: + default: NotStarted + description: |- + State indicates the desired state of the update run. + NotStarted: The update run is initialized but execution has not started (default). + Started: The update run should execute or resume execution. + Stopped: The update run should pause execution. + Abandoned: The update run should be abandoned and terminated. + enum: + - NotStarted + - Started + - Stopped + - Abandoned + type: string required: - placementName - resourceSnapshotIndex - stagedRolloutStrategyName type: object x-kubernetes-validations: - - message: The spec field is immutable - rule: self == oldSelf + - message: 'invalid state transition: cannot transition from NotStarted + to Stopped' + rule: '!has(oldSelf.state) || oldSelf.state != ''NotStarted'' || self.state + != ''Stopped''' + - message: 'invalid state transition: cannot transition from Started to + NotStarted' + rule: '!has(oldSelf.state) || oldSelf.state != ''Started'' || self.state + != ''NotStarted''' + - message: 'invalid state transition: cannot transition from Stopped to + NotStarted' + rule: '!has(oldSelf.state) || oldSelf.state != ''Stopped'' || self.state + != ''NotStarted''' + - message: 'invalid state transition: Abandoned is a terminal state and + cannot transition to any other state' + rule: '!has(oldSelf.state) || oldSelf.state != ''Abandoned'' || self.state + == ''Abandoned''' status: description: The observed status of StagedUpdateRun. properties: @@ -917,7 +957,7 @@ spec: description: |- MaxConcurrency specifies the maximum number of clusters that can be updated concurrently within this stage. Value can be an absolute number (ex: 5) or a percentage of the total clusters in the stage (ex: 50%). - Absolute number is calculated from percentage by rounding up. + Fractional results are rounded down. A minimum of 1 update is enforced. If not specified, all clusters in the stage are updated sequentially (effectively maxConcurrency = 1). Defaults to 1. pattern: ^((100|[0-9]{1,2})%|[0-9]+)$ diff --git a/config/crd/bases/placement.kubernetes-fleet.io_stagedupdatestrategies.yaml b/config/crd/bases/placement.kubernetes-fleet.io_stagedupdatestrategies.yaml index fe0dfc567..898f92a88 100644 --- a/config/crd/bases/placement.kubernetes-fleet.io_stagedupdatestrategies.yaml +++ b/config/crd/bases/placement.kubernetes-fleet.io_stagedupdatestrategies.yaml @@ -174,7 +174,7 @@ spec: description: |- MaxConcurrency specifies the maximum number of clusters that can be updated concurrently within this stage. Value can be an absolute number (ex: 5) or a percentage of the total clusters in the stage (ex: 50%). - Absolute number is calculated from percentage by rounding up. + Fractional results are rounded down. A minimum of 1 update is enforced. If not specified, all clusters in the stage are updated sequentially (effectively maxConcurrency = 1). Defaults to 1. pattern: ^((100|[0-9]{1,2})%|[0-9]+)$ diff --git a/test/apis/placement/v1beta1/api_validation_integration_test.go b/test/apis/placement/v1beta1/api_validation_integration_test.go index d0260d0ff..d8eeb7d0c 100644 --- a/test/apis/placement/v1beta1/api_validation_integration_test.go +++ b/test/apis/placement/v1beta1/api_validation_integration_test.go @@ -1141,13 +1141,15 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("metadata.name max length is 127")) }) - It("Should deny update of ClusterStagedUpdateRun spec", func() { + It("Should deny update of ClusterStagedUpdateRun placementName field", func() { updateRun := placementv1beta1.ClusterStagedUpdateRun{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf(validupdateRunNameTemplate, GinkgoParallelProcess()), }, Spec: placementv1beta1.UpdateRunSpec{ - PlacementName: "test-placement", + PlacementName: "test-placement", + ResourceSnapshotIndex: "1", + StagedUpdateStrategyName: "test-strategy", }, } Expect(hubClient.Create(ctx, &updateRun)).Should(Succeed()) @@ -1156,7 +1158,68 @@ var _ = Describe("Test placement v1beta1 API validation", func() { err := hubClient.Update(ctx, &updateRun) var statusErr *k8sErrors.StatusError Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update updateRun call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("The spec field is immutable")) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("placementName is immutable")) + Expect(hubClient.Delete(ctx, &updateRun)).Should(Succeed()) + }) + + It("Should deny update of ClusterStagedUpdateRun resourceSnapshotIndex field", func() { + updateRun := placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf(validupdateRunNameTemplate, GinkgoParallelProcess()), + }, + Spec: placementv1beta1.UpdateRunSpec{ + PlacementName: "test-placement", + ResourceSnapshotIndex: "1", + StagedUpdateStrategyName: "test-strategy", + }, + } + Expect(hubClient.Create(ctx, &updateRun)).Should(Succeed()) + + updateRun.Spec.ResourceSnapshotIndex = "2" + err := hubClient.Update(ctx, &updateRun) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update updateRun call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("resourceSnapshotIndex is immutable")) + Expect(hubClient.Delete(ctx, &updateRun)).Should(Succeed()) + }) + + It("Should deny update of ClusterStagedUpdateRun stagedRolloutStrategyName field", func() { + updateRun := placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf(validupdateRunNameTemplate, GinkgoParallelProcess()), + }, + Spec: placementv1beta1.UpdateRunSpec{ + PlacementName: "test-placement", + ResourceSnapshotIndex: "1", + StagedUpdateStrategyName: "test-strategy", + }, + } + Expect(hubClient.Create(ctx, &updateRun)).Should(Succeed()) + + updateRun.Spec.StagedUpdateStrategyName = "test-strategy-2" + err := hubClient.Update(ctx, &updateRun) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update updateRun call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("stagedRolloutStrategyName is immutable")) + Expect(hubClient.Delete(ctx, &updateRun)).Should(Succeed()) + }) + + It("Should allow update of ClusterStagedUpdateRun state field", func() { + updateRun := placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf(validupdateRunNameTemplate, GinkgoParallelProcess()), + }, + Spec: placementv1beta1.UpdateRunSpec{ + PlacementName: "test-placement", + ResourceSnapshotIndex: "1", + StagedUpdateStrategyName: "test-strategy", + State: placementv1beta1.StateNotStarted, + }, + } + Expect(hubClient.Create(ctx, &updateRun)).Should(Succeed()) + + updateRun.Spec.State = placementv1beta1.StateStarted + Expect(hubClient.Update(ctx, &updateRun)).Should(Succeed()) Expect(hubClient.Delete(ctx, &updateRun)).Should(Succeed()) }) }) @@ -1544,6 +1607,274 @@ var _ = Describe("Test placement v1beta1 API validation", func() { }) }) + Context("Test ClusterStagedUpdateRun State API validation - valid NotStarted state transitions", func() { + var updateRun *placementv1beta1.ClusterStagedUpdateRun + updateRunName := fmt.Sprintf(validupdateRunNameTemplate, GinkgoParallelProcess()) + + BeforeEach(func() { + updateRun = &placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: updateRunName, + }, + Spec: placementv1beta1.UpdateRunSpec{ + State: placementv1beta1.StateNotStarted, + }, + } + Expect(hubClient.Create(ctx, updateRun)).Should(Succeed()) + }) + + AfterEach(func() { + Expect(hubClient.Delete(ctx, updateRun)).Should(Succeed()) + }) + + It("should allow creation of ClusterStagedUpdateRun when state in unspecified", func() { + updateRunWithDefaultState := &placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unspecfied-state-update-run-" + fmt.Sprintf("%d", GinkgoParallelProcess()), + }, + Spec: placementv1beta1.UpdateRunSpec{ + // State not specified - should default to NotStarted + }, + } + Expect(hubClient.Create(ctx, updateRunWithDefaultState)).Should(Succeed()) + Expect(updateRunWithDefaultState.Spec.State).To(Equal(placementv1beta1.StateNotStarted)) + Expect(hubClient.Delete(ctx, updateRunWithDefaultState)).Should(Succeed()) + }) + + It("should allow creation of ClusterStagedUpdateRun with empty state (defaults to NotStarted)", func() { + updateRun := &placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "empty-state-update-run-" + fmt.Sprintf("%d", GinkgoParallelProcess()), + }, + Spec: placementv1beta1.UpdateRunSpec{ + State: "", + }, + } + Expect(hubClient.Create(ctx, updateRun)).Should(Succeed()) + Expect(updateRun.Spec.State).To(Equal(placementv1beta1.StateNotStarted)) + Expect(hubClient.Delete(ctx, updateRun)).Should(Succeed()) + }) + + It("should allow transition from NotStarted to Started", func() { + updateRun.Spec.State = placementv1beta1.StateStarted + Expect(hubClient.Update(ctx, updateRun)).Should(Succeed()) + }) + + It("should allow transition from NotStarted to Abandoned", func() { + updateRun.Spec.State = placementv1beta1.StateAbandoned + Expect(hubClient.Update(ctx, updateRun)).Should(Succeed()) + }) + }) + + Context("Test ClusterStagedUpdateRun State API validation - valid Started state transitions", func() { + var updateRun *placementv1beta1.ClusterStagedUpdateRun + updateRunName := fmt.Sprintf(validupdateRunNameTemplate, GinkgoParallelProcess()) + + BeforeEach(func() { + updateRun = &placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: updateRunName, + }, + Spec: placementv1beta1.UpdateRunSpec{ + State: placementv1beta1.StateStarted, + }, + } + Expect(hubClient.Create(ctx, updateRun)).Should(Succeed()) + }) + + AfterEach(func() { + Expect(hubClient.Delete(ctx, updateRun)).Should(Succeed()) + }) + + It("should allow transition from Started to Stopped", func() { + updateRun.Spec.State = placementv1beta1.StateStopped + Expect(hubClient.Update(ctx, updateRun)).Should(Succeed()) + }) + + It("should allow transition from Started to Abandoned", func() { + updateRun.Spec.State = placementv1beta1.StateAbandoned + Expect(hubClient.Update(ctx, updateRun)).Should(Succeed()) + }) + }) + + Context("Test ClusterStagedUpdateRun State API validation - valid Stopped state transitions", func() { + var updateRun *placementv1beta1.ClusterStagedUpdateRun + updateRunName := fmt.Sprintf(validupdateRunNameTemplate, GinkgoParallelProcess()) + + BeforeEach(func() { + updateRun = &placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: updateRunName, + }, + Spec: placementv1beta1.UpdateRunSpec{ + State: placementv1beta1.StateStarted, + }, + } + Expect(hubClient.Create(ctx, updateRun)).Should(Succeed()) + // Transition to Stopped state first + updateRun.Spec.State = placementv1beta1.StateStopped + Expect(hubClient.Update(ctx, updateRun)).Should(Succeed()) + }) + + AfterEach(func() { + Expect(hubClient.Delete(ctx, updateRun)).Should(Succeed()) + }) + + It("should allow transition from Stopped to Started", func() { + updateRun.Spec.State = placementv1beta1.StateStarted + Expect(hubClient.Update(ctx, updateRun)).Should(Succeed()) + }) + + It("should allow transition from Stopped to Abandoned", func() { + updateRun.Spec.State = placementv1beta1.StateAbandoned + Expect(hubClient.Update(ctx, updateRun)).Should(Succeed()) + }) + }) + + Context("Test ClusterStagedUpdateRun State API validation - invalid state transitions", func() { + var updateRun *placementv1beta1.ClusterStagedUpdateRun + updateRunName := fmt.Sprintf(validupdateRunNameTemplate, GinkgoParallelProcess()) + + AfterEach(func() { + if updateRun != nil { + Expect(hubClient.Delete(ctx, updateRun)).Should(Succeed()) + } + }) + + It("should deny transition from NotStarted to Stopped", func() { + updateRun = &placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: updateRunName, + }, + Spec: placementv1beta1.UpdateRunSpec{ + State: placementv1beta1.StateNotStarted, + }, + } + Expect(hubClient.Create(ctx, updateRun)).Should(Succeed()) + + updateRun.Spec.State = placementv1beta1.StateStopped + err := hubClient.Update(ctx, updateRun) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update ClusterStagedUpdateRun call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("invalid state transition: cannot transition from NotStarted to Stopped")) + }) + + It("should deny transition from Started to NotStarted", func() { + updateRun = &placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: updateRunName, + }, + Spec: placementv1beta1.UpdateRunSpec{ + State: placementv1beta1.StateStarted, + }, + } + Expect(hubClient.Create(ctx, updateRun)).Should(Succeed()) + + updateRun.Spec.State = placementv1beta1.StateNotStarted + err := hubClient.Update(ctx, updateRun) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update ClusterStagedUpdateRun call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("invalid state transition: cannot transition from Started to NotStarted")) + }) + + It("should deny transition from Stopped to NotStarted", func() { + updateRun = &placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: updateRunName, + }, + Spec: placementv1beta1.UpdateRunSpec{ + State: placementv1beta1.StateStarted, + }, + } + Expect(hubClient.Create(ctx, updateRun)).Should(Succeed()) + + // Transition to Stopped first + updateRun.Spec.State = placementv1beta1.StateStopped + Expect(hubClient.Update(ctx, updateRun)).Should(Succeed()) + + // Try to transition back to NotStarted + updateRun.Spec.State = placementv1beta1.StateNotStarted + err := hubClient.Update(ctx, updateRun) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update ClusterStagedUpdateRun call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("invalid state transition: cannot transition from Stopped to NotStarted")) + }) + + It("should deny transition from Abandoned to NotStarted", func() { + updateRun = &placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: updateRunName, + }, + Spec: placementv1beta1.UpdateRunSpec{ + State: placementv1beta1.StateAbandoned, + }, + } + Expect(hubClient.Create(ctx, updateRun)).Should(Succeed()) + + updateRun.Spec.State = placementv1beta1.StateNotStarted + err := hubClient.Update(ctx, updateRun) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update ClusterStagedUpdateRun call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("invalid state transition: Abandoned is a terminal state and cannot transition to any other state")) + }) + + It("should deny transition from Abandoned to Started", func() { + updateRun = &placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: updateRunName, + }, + Spec: placementv1beta1.UpdateRunSpec{ + State: placementv1beta1.StateAbandoned, + }, + } + Expect(hubClient.Create(ctx, updateRun)).Should(Succeed()) + + updateRun.Spec.State = placementv1beta1.StateStarted + err := hubClient.Update(ctx, updateRun) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update ClusterStagedUpdateRun call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("invalid state transition: Abandoned is a terminal state and cannot transition to any other state")) + }) + + It("should deny transition from Abandoned to Stopped", func() { + updateRun = &placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: updateRunName, + }, + Spec: placementv1beta1.UpdateRunSpec{ + State: placementv1beta1.StateAbandoned, + }, + } + Expect(hubClient.Create(ctx, updateRun)).Should(Succeed()) + + updateRun.Spec.State = placementv1beta1.StateStopped + err := hubClient.Update(ctx, updateRun) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update ClusterStagedUpdateRun call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("invalid state transition: Abandoned is a terminal state and cannot transition to any other state")) + }) + }) + + Context("Test ClusterStagedUpdateRun State API validation - invalid state values", func() { + It("should deny creation of ClusterStagedUpdateRun with invalid state value", func() { + updateRun := &placementv1beta1.ClusterStagedUpdateRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf(validupdateRunNameTemplate, GinkgoParallelProcess()), + }, + Spec: placementv1beta1.UpdateRunSpec{ + PlacementName: "test-placement", + ResourceSnapshotIndex: "1", + StagedUpdateStrategyName: "test-strategy", + State: "InvalidState", + }, + } + err := hubClient.Create(ctx, updateRun) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create ClusterStagedUpdateRun call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("supported values: \"NotStarted\", \"Started\", \"Stopped\", \"Abandoned\"")) + }) + }) + Context("Test ClusterResourceOverride API validation - valid cases", func() { It("should allow creation of ClusterResourceOverride without placement reference", func() { cro := createValidClusterResourceOverride(