diff --git a/api/v1alpha1/controllerconfiguration_types.go b/api/v1alpha1/controllerconfiguration_types.go index 25b55fba2..a17c30a72 100644 --- a/api/v1alpha1/controllerconfiguration_types.go +++ b/api/v1alpha1/controllerconfiguration_types.go @@ -59,6 +59,11 @@ type ControllerConfigurationSpec struct { // including WorkQueue settings that control reconciliation behavior. // +required TimedCommitStatus TimedCommitStatusConfiguration `json:"timedCommitStatus"` + + // ScheduledCommitStatus contains the configuration for the ScheduledCommitStatus controller, + // including WorkQueue settings that control reconciliation behavior. + // +required + ScheduledCommitStatus ScheduledCommitStatusConfiguration `json:"scheduledCommitStatus"` } // PromotionStrategyConfiguration defines the configuration for the PromotionStrategy controller. @@ -140,6 +145,17 @@ type TimedCommitStatusConfiguration struct { WorkQueue WorkQueue `json:"workQueue"` } +// ScheduledCommitStatusConfiguration defines the configuration for the ScheduledCommitStatus controller. +// +// This configuration controls how the ScheduledCommitStatus controller processes reconciliation +// requests, including requeue intervals, concurrency limits, and rate limiting behavior. +type ScheduledCommitStatusConfiguration struct { + // WorkQueue contains the work queue configuration for the ScheduledCommitStatus controller. + // This includes requeue duration, maximum concurrent reconciles, and rate limiter settings. + // +required + WorkQueue WorkQueue `json:"workQueue"` +} + // WorkQueue defines the work queue configuration for a controller. // // This configuration directly correlates to parameters used with Kubernetes client-go work queues. diff --git a/api/v1alpha1/scheduledcommitstatus_types.go b/api/v1alpha1/scheduledcommitstatus_types.go new file mode 100644 index 000000000..510b2edd1 --- /dev/null +++ b/api/v1alpha1/scheduledcommitstatus_types.go @@ -0,0 +1,160 @@ +/* +Copyright 2024. + +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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// ScheduledCommitStatusSpec defines the desired state of ScheduledCommitStatus +type ScheduledCommitStatusSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + // The following markers will use OpenAPI v3 schema to validate the value + // More info: https://book.kubebuilder.io/reference/markers/crd-validation.html + + // PromotionStrategyRef is a reference to the promotion strategy that this scheduled commit status applies to. + // +required + PromotionStrategyRef ObjectReference `json:"promotionStrategyRef"` + + // Environments is a list of environments with their deployment schedules. + // +required + Environments []ScheduledCommitStatusEnvironment `json:"environments"` +} + +// ScheduledCommitStatusEnvironment defines the branch/environment and its deployment schedule. +type ScheduledCommitStatusEnvironment struct { + // Branch is the name of the branch/environment you want to gate with the configured schedule. + // +required + Branch string `json:"branch"` + + // Schedule defines when deployments are allowed for this environment. + // +required + Schedule Schedule `json:"schedule"` +} + +// Schedule defines a deployment window using cron syntax. +type Schedule struct { + // Cron is a cron expression defining when the deployment window starts. + // For example, "0 9 * * 1-5" means 9 AM Monday through Friday. + // Uses standard cron syntax: minute hour day-of-month month day-of-week + // +required + // +kubebuilder:validation:MinLength=1 + Cron string `json:"cron"` + + // Window is the duration of the deployment window after the cron schedule triggers. + // The window should be in a format accepted by Go's time.ParseDuration function, e.g., "2h", "8h", "30m". + // +required + // +kubebuilder:validation:MinLength=1 + Window string `json:"window"` + + // Timezone is the IANA timezone name to use for the cron schedule. + // For example, "America/New_York", "Europe/London", "Asia/Tokyo". + // If not specified, defaults to UTC. + // +optional + // +kubebuilder:default="UTC" + Timezone string `json:"timezone,omitempty"` +} + +// ScheduledCommitStatusStatus defines the observed state of ScheduledCommitStatus. +type ScheduledCommitStatusStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file + + // Environments holds the status of each environment being tracked. + // +listType=map + // +listMapKey=branch + // +optional + Environments []ScheduledCommitStatusEnvironmentStatus `json:"environments,omitempty"` + + // Conditions represent the latest available observations of an object's state + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// ScheduledCommitStatusEnvironmentStatus defines the observed schedule status for a specific environment. +type ScheduledCommitStatusEnvironmentStatus struct { + // Branch is the name of the branch/environment. + // +required + Branch string `json:"branch"` + + // Sha is the proposed commit SHA being tracked for this environment. + // +required + Sha string `json:"sha"` + + // Phase represents the current phase of the scheduled gate. + // +kubebuilder:validation:Enum=pending;success + // +required + Phase string `json:"phase"` + + // CurrentlyInWindow indicates whether the current time is within a deployment window. + // +required + CurrentlyInWindow bool `json:"currentlyInWindow"` + + // NextWindowStart is when the next deployment window starts. + // +optional + NextWindowStart *metav1.Time `json:"nextWindowStart,omitempty"` + + // NextWindowEnd is when the next deployment window ends. + // +optional + NextWindowEnd *metav1.Time `json:"nextWindowEnd,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// ScheduledCommitStatus is the Schema for the scheduledcommitstatuses API +// +kubebuilder:printcolumn:name="Strategy",type=string,JSONPath=`.spec.promotionStrategyRef.name` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +type ScheduledCommitStatus struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + // spec defines the desired state of ScheduledCommitStatus + // +required + Spec ScheduledCommitStatusSpec `json:"spec"` + + // status defines the observed state of ScheduledCommitStatus + // +optional + Status ScheduledCommitStatusStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ScheduledCommitStatusList contains a list of ScheduledCommitStatus +type ScheduledCommitStatusList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ScheduledCommitStatus `json:"items"` +} + +// GetConditions returns the conditions of the ScheduledCommitStatus. +func (scs *ScheduledCommitStatus) GetConditions() *[]metav1.Condition { + return &scs.Status.Conditions +} + +func init() { + SchemeBuilder.Register(&ScheduledCommitStatus{}, &ScheduledCommitStatusList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8358a266d..970cb6a2c 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -698,6 +698,7 @@ func (in *ControllerConfigurationSpec) DeepCopyInto(out *ControllerConfiguration in.CommitStatus.DeepCopyInto(&out.CommitStatus) in.ArgoCDCommitStatus.DeepCopyInto(&out.ArgoCDCommitStatus) in.TimedCommitStatus.DeepCopyInto(&out.TimedCommitStatus) + in.ScheduledCommitStatus.DeepCopyInto(&out.ScheduledCommitStatus) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControllerConfigurationSpec. @@ -1562,6 +1563,185 @@ func (in *RevisionReference) DeepCopy() *RevisionReference { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Schedule) DeepCopyInto(out *Schedule) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Schedule. +func (in *Schedule) DeepCopy() *Schedule { + if in == nil { + return nil + } + out := new(Schedule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScheduledCommitStatus) DeepCopyInto(out *ScheduledCommitStatus) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScheduledCommitStatus. +func (in *ScheduledCommitStatus) DeepCopy() *ScheduledCommitStatus { + if in == nil { + return nil + } + out := new(ScheduledCommitStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScheduledCommitStatus) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScheduledCommitStatusConfiguration) DeepCopyInto(out *ScheduledCommitStatusConfiguration) { + *out = *in + in.WorkQueue.DeepCopyInto(&out.WorkQueue) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScheduledCommitStatusConfiguration. +func (in *ScheduledCommitStatusConfiguration) DeepCopy() *ScheduledCommitStatusConfiguration { + if in == nil { + return nil + } + out := new(ScheduledCommitStatusConfiguration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScheduledCommitStatusEnvironment) DeepCopyInto(out *ScheduledCommitStatusEnvironment) { + *out = *in + out.Schedule = in.Schedule +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScheduledCommitStatusEnvironment. +func (in *ScheduledCommitStatusEnvironment) DeepCopy() *ScheduledCommitStatusEnvironment { + if in == nil { + return nil + } + out := new(ScheduledCommitStatusEnvironment) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScheduledCommitStatusEnvironmentStatus) DeepCopyInto(out *ScheduledCommitStatusEnvironmentStatus) { + *out = *in + if in.NextWindowStart != nil { + in, out := &in.NextWindowStart, &out.NextWindowStart + *out = (*in).DeepCopy() + } + if in.NextWindowEnd != nil { + in, out := &in.NextWindowEnd, &out.NextWindowEnd + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScheduledCommitStatusEnvironmentStatus. +func (in *ScheduledCommitStatusEnvironmentStatus) DeepCopy() *ScheduledCommitStatusEnvironmentStatus { + if in == nil { + return nil + } + out := new(ScheduledCommitStatusEnvironmentStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScheduledCommitStatusList) DeepCopyInto(out *ScheduledCommitStatusList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ScheduledCommitStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScheduledCommitStatusList. +func (in *ScheduledCommitStatusList) DeepCopy() *ScheduledCommitStatusList { + if in == nil { + return nil + } + out := new(ScheduledCommitStatusList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScheduledCommitStatusList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScheduledCommitStatusSpec) DeepCopyInto(out *ScheduledCommitStatusSpec) { + *out = *in + out.PromotionStrategyRef = in.PromotionStrategyRef + if in.Environments != nil { + in, out := &in.Environments, &out.Environments + *out = make([]ScheduledCommitStatusEnvironment, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScheduledCommitStatusSpec. +func (in *ScheduledCommitStatusSpec) DeepCopy() *ScheduledCommitStatusSpec { + if in == nil { + return nil + } + out := new(ScheduledCommitStatusSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScheduledCommitStatusStatus) DeepCopyInto(out *ScheduledCommitStatusStatus) { + *out = *in + if in.Environments != nil { + in, out := &in.Environments, &out.Environments + *out = make([]ScheduledCommitStatusEnvironmentStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScheduledCommitStatusStatus. +func (in *ScheduledCommitStatusStatus) DeepCopy() *ScheduledCommitStatusStatus { + if in == nil { + return nil + } + out := new(ScheduledCommitStatusStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ScmProvider) DeepCopyInto(out *ScmProvider) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index c8696b247..3edaaa744 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -284,6 +284,14 @@ func runController( }).SetupWithManager(processSignalsCtx, localManager); err != nil { panic("unable to create TimedCommitStatus controller") } + if err := (&controller.ScheduledCommitStatusReconciler{ + Client: localManager.GetClient(), + Scheme: localManager.GetScheme(), + Recorder: localManager.GetEventRecorderFor("ScheduledCommitStatus"), + SettingsMgr: settingsMgr, + }).SetupWithManager(processSignalsCtx, localManager); err != nil { + panic("unable to create ScheduledCommitStatus controller") + } //+kubebuilder:scaffold:builder if err := localManager.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/config/controllerconfiguration.yaml b/config/config/controllerconfiguration.yaml index c4b6558c1..5f340db00 100644 --- a/config/config/controllerconfiguration.yaml +++ b/config/config/controllerconfiguration.yaml @@ -89,3 +89,16 @@ spec: fastDelay: "1s" slowDelay: "5m" maxFastAttempts: 3 + scheduledCommitStatus: + workQueue: + maxConcurrentReconciles: 10 + requeueDuration: "1h" + rateLimiter: + maxOf: + - bucket: + qps: 10 + bucket: 100 + - fastSlow: + fastDelay: "1s" + slowDelay: "5m" + maxFastAttempts: 3 diff --git a/config/crd/bases/promoter.argoproj.io_controllerconfigurations.yaml b/config/crd/bases/promoter.argoproj.io_controllerconfigurations.yaml index d430a13c0..5a685e841 100644 --- a/config/crd/bases/promoter.argoproj.io_controllerconfigurations.yaml +++ b/config/crd/bases/promoter.argoproj.io_controllerconfigurations.yaml @@ -1085,6 +1085,208 @@ spec: - template - workQueue type: object + scheduledCommitStatus: + description: |- + ScheduledCommitStatus contains the configuration for the ScheduledCommitStatus controller, + including WorkQueue settings that control reconciliation behavior. + properties: + workQueue: + description: |- + WorkQueue contains the work queue configuration for the ScheduledCommitStatus controller. + This includes requeue duration, maximum concurrent reconciles, and rate limiter settings. + properties: + maxConcurrentReconciles: + description: |- + MaxConcurrentReconciles defines the maximum number of concurrent reconcile operations + that can run for this controller. Higher values increase throughput but consume more + resources. Must be at least 1. + type: integer + rateLimiter: + description: |- + RateLimiter defines the rate limiting strategy for the controller's work queue. + Rate limiting controls how quickly failed reconciliations are retried and helps + prevent overwhelming external APIs or systems. + properties: + bucket: + description: |- + Bucket rate limiter uses a token bucket algorithm to control request rate. + Allows bursts while maintaining an average rate limit. + properties: + bucket: + description: |- + Bucket is the maximum number of tokens that can be accumulated in the bucket. + This defines the maximum burst size - how many operations can occur in rapid + succession before rate limiting takes effect. Must be non-negative. + type: integer + qps: + description: |- + Qps (queries per second) is the rate at which tokens are added to the bucket. + This defines the sustained rate limit for operations. Must be non-negative. + type: integer + required: + - bucket + - qps + type: object + exponentialFailure: + description: |- + ExponentialFailure rate limiter increases delay exponentially with each failure. + Standard approach for backing off when operations fail repeatedly. + properties: + baseDelay: + description: |- + BaseDelay is the initial delay after the first failure. Subsequent failures will exponentially + increase this delay (2x, 4x, 8x, etc.) until MaxDelay is reached. + Format follows Go's time.Duration syntax (e.g., "1s" for 1 second). + type: string + maxDelay: + description: |- + MaxDelay is the maximum delay between retry attempts. Once the exponential backoff reaches + this value, all subsequent retries will use this delay. + Format follows Go's time.Duration syntax (e.g., "1m" for 1 minute). + type: string + required: + - baseDelay + - maxDelay + type: object + fastSlow: + description: |- + FastSlow rate limiter provides fast retries initially, then switches to slow retries. + Useful for quickly retrying transient errors while backing off for persistent failures. + properties: + fastDelay: + description: |- + FastDelay is the delay used for the first MaxFastAttempts retry attempts. + Format follows Go's time.Duration syntax (e.g., "100ms" for 100 milliseconds). + type: string + maxFastAttempts: + description: |- + MaxFastAttempts is the number of retry attempts that use FastDelay before switching to SlowDelay. + Must be at least 1. + type: integer + slowDelay: + description: |- + SlowDelay is the delay used for retry attempts after MaxFastAttempts have been exhausted. + Format follows Go's time.Duration syntax (e.g., "10s" for 10 seconds). + type: string + required: + - fastDelay + - maxFastAttempts + - slowDelay + type: object + maxOf: + description: |- + MaxOf allows combining multiple rate limiters, where the maximum delay from all + limiters is used. This enables sophisticated rate limiting that respects multiple + constraints simultaneously (e.g., both per-item exponential backoff and global rate limits). + items: + description: |- + RateLimiterTypes defines the different algorithms available for rate limiting. + + Exactly one of the three rate limiter types must be specified: + - FastSlow: Quick retry for transient errors, then slower retry for persistent failures + - ExponentialFailure: Standard exponential backoff for repeated failures + - Bucket: Token bucket algorithm for controlling overall request rate + + See https://pkg.go.dev/k8s.io/client-go/util/workqueue for implementation details. + properties: + bucket: + description: |- + Bucket rate limiter uses a token bucket algorithm to control request rate. + Allows bursts while maintaining an average rate limit. + properties: + bucket: + description: |- + Bucket is the maximum number of tokens that can be accumulated in the bucket. + This defines the maximum burst size - how many operations can occur in rapid + succession before rate limiting takes effect. Must be non-negative. + type: integer + qps: + description: |- + Qps (queries per second) is the rate at which tokens are added to the bucket. + This defines the sustained rate limit for operations. Must be non-negative. + type: integer + required: + - bucket + - qps + type: object + exponentialFailure: + description: |- + ExponentialFailure rate limiter increases delay exponentially with each failure. + Standard approach for backing off when operations fail repeatedly. + properties: + baseDelay: + description: |- + BaseDelay is the initial delay after the first failure. Subsequent failures will exponentially + increase this delay (2x, 4x, 8x, etc.) until MaxDelay is reached. + Format follows Go's time.Duration syntax (e.g., "1s" for 1 second). + type: string + maxDelay: + description: |- + MaxDelay is the maximum delay between retry attempts. Once the exponential backoff reaches + this value, all subsequent retries will use this delay. + Format follows Go's time.Duration syntax (e.g., "1m" for 1 minute). + type: string + required: + - baseDelay + - maxDelay + type: object + fastSlow: + description: |- + FastSlow rate limiter provides fast retries initially, then switches to slow retries. + Useful for quickly retrying transient errors while backing off for persistent failures. + properties: + fastDelay: + description: |- + FastDelay is the delay used for the first MaxFastAttempts retry attempts. + Format follows Go's time.Duration syntax (e.g., "100ms" for 100 milliseconds). + type: string + maxFastAttempts: + description: |- + MaxFastAttempts is the number of retry attempts that use FastDelay before switching to SlowDelay. + Must be at least 1. + type: integer + slowDelay: + description: |- + SlowDelay is the delay used for retry attempts after MaxFastAttempts have been exhausted. + Format follows Go's time.Duration syntax (e.g., "10s" for 10 seconds). + type: string + required: + - fastDelay + - maxFastAttempts + - slowDelay + type: object + type: object + x-kubernetes-validations: + - message: at most one of the fields in [fastSlow exponentialFailure + bucket] may be set + rule: '[has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() + <= 1' + maxItems: 3 + type: array + type: object + x-kubernetes-validations: + - message: at most one of the fields in [fastSlow exponentialFailure + bucket maxOf] may be set + rule: '[has(self.fastSlow),has(self.exponentialFailure),has(self.bucket),has(self.maxOf)].filter(x,x==true).size() + <= 1' + - message: at most one of the fields in [fastSlow exponentialFailure + bucket] may be set + rule: '[has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() + <= 1' + requeueDuration: + description: |- + RequeueDuration specifies how frequently resources should be requeued for automatic reconciliation. + This creates a periodic reconciliation loop that ensures the desired state is maintained even + without external triggers. Format follows Go's time.Duration syntax (e.g., "5m" for 5 minutes). + type: string + required: + - maxConcurrentReconciles + - rateLimiter + - requeueDuration + type: object + required: + - workQueue + type: object timedCommitStatus: description: |- TimedCommitStatus contains the configuration for the TimedCommitStatus controller, @@ -1293,6 +1495,7 @@ spec: - commitStatus - promotionStrategy - pullRequest + - scheduledCommitStatus - timedCommitStatus type: object status: diff --git a/config/crd/bases/promoter.argoproj.io_scheduledcommitstatuses.yaml b/config/crd/bases/promoter.argoproj.io_scheduledcommitstatuses.yaml new file mode 100644 index 000000000..db49677be --- /dev/null +++ b/config/crd/bases/promoter.argoproj.io_scheduledcommitstatuses.yaml @@ -0,0 +1,224 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: scheduledcommitstatuses.promoter.argoproj.io +spec: + group: promoter.argoproj.io + names: + kind: ScheduledCommitStatus + listKind: ScheduledCommitStatusList + plural: scheduledcommitstatuses + singular: scheduledcommitstatus + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.promotionStrategyRef.name + name: Strategy + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: ScheduledCommitStatus is the Schema for the scheduledcommitstatuses + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of ScheduledCommitStatus + properties: + environments: + description: Environments is a list of environments with their deployment + schedules. + items: + description: ScheduledCommitStatusEnvironment defines the branch/environment + and its deployment schedule. + properties: + branch: + description: Branch is the name of the branch/environment you + want to gate with the configured schedule. + type: string + schedule: + description: Schedule defines when deployments are allowed for + this environment. + properties: + cron: + description: |- + Cron is a cron expression defining when the deployment window starts. + For example, "0 9 * * 1-5" means 9 AM Monday through Friday. + Uses standard cron syntax: minute hour day-of-month month day-of-week + minLength: 1 + type: string + timezone: + default: UTC + description: |- + Timezone is the IANA timezone name to use for the cron schedule. + For example, "America/New_York", "Europe/London", "Asia/Tokyo". + If not specified, defaults to UTC. + type: string + window: + description: |- + Window is the duration of the deployment window after the cron schedule triggers. + The window should be in a format accepted by Go's time.ParseDuration function, e.g., "2h", "8h", "30m". + minLength: 1 + type: string + required: + - cron + - window + type: object + required: + - branch + - schedule + type: object + type: array + promotionStrategyRef: + description: PromotionStrategyRef is a reference to the promotion + strategy that this scheduled commit status applies to. + properties: + name: + description: Name is the name of the object to refer to. + type: string + required: + - name + type: object + required: + - environments + - promotionStrategyRef + type: object + status: + description: status defines the observed state of ScheduledCommitStatus + properties: + conditions: + description: Conditions represent the latest available observations + of an object's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + environments: + description: Environments holds the status of each environment being + tracked. + items: + description: ScheduledCommitStatusEnvironmentStatus defines the + observed schedule status for a specific environment. + properties: + branch: + description: Branch is the name of the branch/environment. + type: string + currentlyInWindow: + description: CurrentlyInWindow indicates whether the current + time is within a deployment window. + type: boolean + nextWindowEnd: + description: NextWindowEnd is when the next deployment window + ends. + format: date-time + type: string + nextWindowStart: + description: NextWindowStart is when the next deployment window + starts. + format: date-time + type: string + phase: + description: Phase represents the current phase of the scheduled + gate. + enum: + - pending + - success + type: string + sha: + description: Sha is the proposed commit SHA being tracked for + this environment. + type: string + required: + - branch + - currentlyInWindow + - phase + - sha + type: object + type: array + x-kubernetes-list-map-keys: + - branch + x-kubernetes-list-type: map + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 5a700466f..ae62f6eed 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -41,6 +41,7 @@ rules: - promotionstrategies - pullrequests - revertcommits + - scheduledcommitstatuses - scmproviders - timedcommitstatuses verbs: @@ -63,6 +64,7 @@ rules: - promotionstrategies/finalizers - pullrequests/finalizers - revertcommits/finalizers + - scheduledcommitstatuses/finalizers - scmproviders/finalizers - timedcommitstatuses/finalizers verbs: @@ -79,6 +81,7 @@ rules: - promotionstrategies/status - pullrequests/status - revertcommits/status + - scheduledcommitstatuses/status - scmproviders/status - timedcommitstatuses/status verbs: diff --git a/config/samples/promoter_v1alpha1_scheduledcommitstatus.yaml b/config/samples/promoter_v1alpha1_scheduledcommitstatus.yaml new file mode 100644 index 000000000..5257a7341 --- /dev/null +++ b/config/samples/promoter_v1alpha1_scheduledcommitstatus.yaml @@ -0,0 +1,27 @@ +apiVersion: promoter.argoproj.io/v1alpha1 +kind: ScheduledCommitStatus +metadata: + name: webservice-tier-1 + namespace: default +spec: + promotionStrategyRef: + name: webservice-tier-1 + environments: + - branch: environment/development + schedule: + cron: "0 9 * * 1-5" # 9 AM Monday-Friday + window: "8h" # 9 AM - 5 PM deployment window + timezone: "America/New_York" + + - branch: environment/staging + schedule: + cron: "0 9 * * 1-5" # 9 AM Monday-Friday + window: "8h" # 9 AM - 5 PM deployment window + timezone: "America/New_York" + + - branch: environment/production + schedule: + cron: "0 14 * * 1-5" # 2 PM Monday-Friday + window: "2h" # 2 PM - 4 PM deployment window + timezone: "America/New_York" + diff --git a/dist/install.yaml b/dist/install.yaml index 274763814..d3e46922c 100644 --- a/dist/install.yaml +++ b/dist/install.yaml @@ -2688,6 +2688,208 @@ spec: - template - workQueue type: object + scheduledCommitStatus: + description: |- + ScheduledCommitStatus contains the configuration for the ScheduledCommitStatus controller, + including WorkQueue settings that control reconciliation behavior. + properties: + workQueue: + description: |- + WorkQueue contains the work queue configuration for the ScheduledCommitStatus controller. + This includes requeue duration, maximum concurrent reconciles, and rate limiter settings. + properties: + maxConcurrentReconciles: + description: |- + MaxConcurrentReconciles defines the maximum number of concurrent reconcile operations + that can run for this controller. Higher values increase throughput but consume more + resources. Must be at least 1. + type: integer + rateLimiter: + description: |- + RateLimiter defines the rate limiting strategy for the controller's work queue. + Rate limiting controls how quickly failed reconciliations are retried and helps + prevent overwhelming external APIs or systems. + properties: + bucket: + description: |- + Bucket rate limiter uses a token bucket algorithm to control request rate. + Allows bursts while maintaining an average rate limit. + properties: + bucket: + description: |- + Bucket is the maximum number of tokens that can be accumulated in the bucket. + This defines the maximum burst size - how many operations can occur in rapid + succession before rate limiting takes effect. Must be non-negative. + type: integer + qps: + description: |- + Qps (queries per second) is the rate at which tokens are added to the bucket. + This defines the sustained rate limit for operations. Must be non-negative. + type: integer + required: + - bucket + - qps + type: object + exponentialFailure: + description: |- + ExponentialFailure rate limiter increases delay exponentially with each failure. + Standard approach for backing off when operations fail repeatedly. + properties: + baseDelay: + description: |- + BaseDelay is the initial delay after the first failure. Subsequent failures will exponentially + increase this delay (2x, 4x, 8x, etc.) until MaxDelay is reached. + Format follows Go's time.Duration syntax (e.g., "1s" for 1 second). + type: string + maxDelay: + description: |- + MaxDelay is the maximum delay between retry attempts. Once the exponential backoff reaches + this value, all subsequent retries will use this delay. + Format follows Go's time.Duration syntax (e.g., "1m" for 1 minute). + type: string + required: + - baseDelay + - maxDelay + type: object + fastSlow: + description: |- + FastSlow rate limiter provides fast retries initially, then switches to slow retries. + Useful for quickly retrying transient errors while backing off for persistent failures. + properties: + fastDelay: + description: |- + FastDelay is the delay used for the first MaxFastAttempts retry attempts. + Format follows Go's time.Duration syntax (e.g., "100ms" for 100 milliseconds). + type: string + maxFastAttempts: + description: |- + MaxFastAttempts is the number of retry attempts that use FastDelay before switching to SlowDelay. + Must be at least 1. + type: integer + slowDelay: + description: |- + SlowDelay is the delay used for retry attempts after MaxFastAttempts have been exhausted. + Format follows Go's time.Duration syntax (e.g., "10s" for 10 seconds). + type: string + required: + - fastDelay + - maxFastAttempts + - slowDelay + type: object + maxOf: + description: |- + MaxOf allows combining multiple rate limiters, where the maximum delay from all + limiters is used. This enables sophisticated rate limiting that respects multiple + constraints simultaneously (e.g., both per-item exponential backoff and global rate limits). + items: + description: |- + RateLimiterTypes defines the different algorithms available for rate limiting. + + Exactly one of the three rate limiter types must be specified: + - FastSlow: Quick retry for transient errors, then slower retry for persistent failures + - ExponentialFailure: Standard exponential backoff for repeated failures + - Bucket: Token bucket algorithm for controlling overall request rate + + See https://pkg.go.dev/k8s.io/client-go/util/workqueue for implementation details. + properties: + bucket: + description: |- + Bucket rate limiter uses a token bucket algorithm to control request rate. + Allows bursts while maintaining an average rate limit. + properties: + bucket: + description: |- + Bucket is the maximum number of tokens that can be accumulated in the bucket. + This defines the maximum burst size - how many operations can occur in rapid + succession before rate limiting takes effect. Must be non-negative. + type: integer + qps: + description: |- + Qps (queries per second) is the rate at which tokens are added to the bucket. + This defines the sustained rate limit for operations. Must be non-negative. + type: integer + required: + - bucket + - qps + type: object + exponentialFailure: + description: |- + ExponentialFailure rate limiter increases delay exponentially with each failure. + Standard approach for backing off when operations fail repeatedly. + properties: + baseDelay: + description: |- + BaseDelay is the initial delay after the first failure. Subsequent failures will exponentially + increase this delay (2x, 4x, 8x, etc.) until MaxDelay is reached. + Format follows Go's time.Duration syntax (e.g., "1s" for 1 second). + type: string + maxDelay: + description: |- + MaxDelay is the maximum delay between retry attempts. Once the exponential backoff reaches + this value, all subsequent retries will use this delay. + Format follows Go's time.Duration syntax (e.g., "1m" for 1 minute). + type: string + required: + - baseDelay + - maxDelay + type: object + fastSlow: + description: |- + FastSlow rate limiter provides fast retries initially, then switches to slow retries. + Useful for quickly retrying transient errors while backing off for persistent failures. + properties: + fastDelay: + description: |- + FastDelay is the delay used for the first MaxFastAttempts retry attempts. + Format follows Go's time.Duration syntax (e.g., "100ms" for 100 milliseconds). + type: string + maxFastAttempts: + description: |- + MaxFastAttempts is the number of retry attempts that use FastDelay before switching to SlowDelay. + Must be at least 1. + type: integer + slowDelay: + description: |- + SlowDelay is the delay used for retry attempts after MaxFastAttempts have been exhausted. + Format follows Go's time.Duration syntax (e.g., "10s" for 10 seconds). + type: string + required: + - fastDelay + - maxFastAttempts + - slowDelay + type: object + type: object + x-kubernetes-validations: + - message: at most one of the fields in [fastSlow exponentialFailure + bucket] may be set + rule: '[has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() + <= 1' + maxItems: 3 + type: array + type: object + x-kubernetes-validations: + - message: at most one of the fields in [fastSlow exponentialFailure + bucket maxOf] may be set + rule: '[has(self.fastSlow),has(self.exponentialFailure),has(self.bucket),has(self.maxOf)].filter(x,x==true).size() + <= 1' + - message: at most one of the fields in [fastSlow exponentialFailure + bucket] may be set + rule: '[has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() + <= 1' + requeueDuration: + description: |- + RequeueDuration specifies how frequently resources should be requeued for automatic reconciliation. + This creates a periodic reconciliation loop that ensures the desired state is maintained even + without external triggers. Format follows Go's time.Duration syntax (e.g., "5m" for 5 minutes). + type: string + required: + - maxConcurrentReconciles + - rateLimiter + - requeueDuration + type: object + required: + - workQueue + type: object timedCommitStatus: description: |- TimedCommitStatus contains the configuration for the TimedCommitStatus controller, @@ -2896,6 +3098,7 @@ spec: - commitStatus - promotionStrategy - pullRequest + - scheduledCommitStatus - timedCommitStatus type: object status: @@ -5168,6 +5371,7 @@ rules: - promotionstrategies - pullrequests - revertcommits + - scheduledcommitstatuses - scmproviders - timedcommitstatuses verbs: @@ -5190,6 +5394,7 @@ rules: - promotionstrategies/finalizers - pullrequests/finalizers - revertcommits/finalizers + - scheduledcommitstatuses/finalizers - scmproviders/finalizers - timedcommitstatuses/finalizers verbs: @@ -5206,6 +5411,7 @@ rules: - promotionstrategies/status - pullrequests/status - revertcommits/status + - scheduledcommitstatuses/status - scmproviders/status - timedcommitstatuses/status verbs: @@ -5610,6 +5816,19 @@ spec: maxFastAttempts: 3 slowDelay: 5m requeueDuration: 5m + scheduledCommitStatus: + workQueue: + maxConcurrentReconciles: 10 + rateLimiter: + maxOf: + - bucket: + bucket: 100 + qps: 10 + - fastSlow: + fastDelay: 1s + maxFastAttempts: 3 + slowDelay: 5m + requeueDuration: 1h timedCommitStatus: workQueue: maxConcurrentReconciles: 10 diff --git a/docs/commit-status-controllers/scheduled.md b/docs/commit-status-controllers/scheduled.md new file mode 100644 index 000000000..a67c34d50 --- /dev/null +++ b/docs/commit-status-controllers/scheduled.md @@ -0,0 +1,265 @@ +# Scheduled Commit Status Controller + +The Scheduled Commit Status controller provides time window based gating for environment promotions. It ensures that changes can only be promoted during specific scheduled deployment windows. This is useful for implementing deployment policies like "business hours only" or "maintenance window" requirements. + +## Overview + +The ScheduledCommitStatus controller monitors proposed commits for specified environments and automatically creates CommitStatus resources that act as proposed commit status gates based on whether the current time falls within configured deployment windows. + +### How It Works + +For each environment configured in a ScheduledCommitStatus resource: + +1. The controller checks if the current time is within a configured deployment window +2. A deployment window is defined by a cron schedule (when it starts) and a duration (how long it lasts) +3. It creates/updates a CommitStatus for the **proposed** environment's hydrated SHA +4. The CommitStatus phase is set to: + - `success` - If the current time is within the deployment window (allows promotions) + - `pending` - If the current time is outside the deployment window (blocks promotions) + +## Example Configurations + +### Basic Business Hours Deployment + +In this example, we configure deployment windows for business hours only: + +```yaml +apiVersion: promoter.argoproj.io/v1alpha1 +kind: ScheduledCommitStatus +metadata: + name: webservice-tier-1 +spec: + promotionStrategyRef: + name: webservice-tier-1 + environments: + - branch: environment/development + schedule: + cron: "0 9 * * 1-5" # 9 AM Monday-Friday + window: "8h" # 9 AM - 5 PM deployment window + timezone: "America/New_York" + + - branch: environment/production + schedule: + cron: "0 14 * * 1-5" # 2 PM Monday-Friday + window: "2h" # 2 PM - 4 PM deployment window + timezone: "America/New_York" +``` + +This configuration: +- Allows deployments to `development` only between 9 AM - 5 PM EST on weekdays +- Allows deployments to `production` only between 2 PM - 4 PM EST on weekdays +- Blocks all deployments outside these windows + +### Integrating with PromotionStrategy + +To use scheduled gating, configure your PromotionStrategy to check for the `schedule` commit status key as a proposed commit status: + +```yaml +apiVersion: promoter.argoproj.io/v1alpha1 +kind: PromotionStrategy +metadata: + name: webservice-tier-1 +spec: + gitRepositoryRef: + name: webservice-tier-1 + proposedCommitStatuses: + - key: schedule + environments: + - branch: environment/development + - branch: environment/staging + - branch: environment/production +``` + +In this configuration: +- Changes can only be promoted when the current time is within the configured deployment window +- The scheduled gate applies globally as a proposed commit status, preventing promotions when outside deployment windows +- PRs will remain open but unmerged until the deployment window opens + +### Maintenance Window Deployments + +Configure deployments to only occur during maintenance windows: + +```yaml +apiVersion: promoter.argoproj.io/v1alpha1 +kind: ScheduledCommitStatus +metadata: + name: database-service +spec: + promotionStrategyRef: + name: database-service + environments: + - branch: environment/production + schedule: + cron: "0 2 * * 0" # 2 AM Sunday + window: "4h" # 2 AM - 6 AM maintenance window + timezone: "UTC" +``` + +This allows production deployments only during the Sunday morning maintenance window. + +### Multi-Region Deployment Windows + +Configure different deployment windows for different regions: + +```yaml +apiVersion: promoter.argoproj.io/v1alpha1 +kind: ScheduledCommitStatus +metadata: + name: global-service +spec: + promotionStrategyRef: + name: global-service + environments: + - branch: environment/us-production + schedule: + cron: "0 14 * * 1-5" # 2 PM EST weekdays + window: "2h" + timezone: "America/New_York" + + - branch: environment/eu-production + schedule: + cron: "0 14 * * 1-5" # 2 PM CET weekdays + window: "2h" + timezone: "Europe/Paris" + + - branch: environment/apac-production + schedule: + cron: "0 14 * * 1-5" # 2 PM JST weekdays + window: "2h" + timezone: "Asia/Tokyo" +``` + +This ensures each region has deployments during their local business hours. + +### Complete Example with Multiple Gates + +You can combine scheduled gating with other commit status checks: + +```yaml +apiVersion: promoter.argoproj.io/v1alpha1 +kind: PromotionStrategy +metadata: + name: webservice-tier-1 +spec: + gitRepositoryRef: + name: webservice-tier-1 + activeCommitStatuses: + - key: argocd-health + - key: timer + proposedCommitStatuses: + - key: schedule + - key: manual-approval + environments: + - branch: environment/development + - branch: environment/staging + - branch: environment/production +--- +apiVersion: promoter.argoproj.io/v1alpha1 +kind: ScheduledCommitStatus +metadata: + name: webservice-tier-1 +spec: + promotionStrategyRef: + name: webservice-tier-1 + environments: + - branch: environment/development + schedule: + cron: "0 6 * * 1-5" # 6 AM - 10 PM weekdays + window: "16h" + timezone: "America/New_York" + + - branch: environment/production + schedule: + cron: "0 10 * * 1-5" # 10 AM - 2 PM weekdays + window: "4h" + timezone: "America/New_York" +``` + +This configuration requires: +- Argo CD health checks to pass in all environments (active commit status) +- Minimum soak time requirements (timer active commit status) +- Deployments only during scheduled windows (schedule proposed commit status) +- Manual approval before any promotion (manual-approval proposed commit status) + +## Schedule Configuration + +### Cron Syntax + +The `cron` field uses standard cron syntax with 5 fields: + +``` +┌───────────── minute (0 - 59) +│ ┌───────────── hour (0 - 23) +│ │ ┌───────────── day of month (1 - 31) +│ │ │ ┌───────────── month (1 - 12) +│ │ │ │ ┌───────────── day of week (0 - 6) (Sunday to Saturday) +│ │ │ │ │ +│ │ │ │ │ +* * * * * +``` + +**Common Examples:** + +- `0 9 * * 1-5` - 9 AM Monday through Friday +- `0 14 * * *` - 2 PM every day +- `0 2 * * 0` - 2 AM every Sunday +- `30 8 * * 1,3,5` - 8:30 AM Monday, Wednesday, Friday +- `0 */4 * * *` - Every 4 hours +- `0 0 1 * *` - Midnight on the first day of each month + +### Window Duration + +The `window` field accepts standard Go duration strings: + +- `30m` - 30 minutes +- `1h` - 1 hour +- `2h30m` - 2 hours and 30 minutes +- `8h` - 8 hours +- `24h` - 24 hours + +### Timezone Configuration + +The `timezone` field accepts IANA timezone names: + +**Americas:** +- `America/New_York` - Eastern Time +- `America/Chicago` - Central Time +- `America/Denver` - Mountain Time +- `America/Los_Angeles` - Pacific Time +- `America/Sao_Paulo` - Brazil Time + +**Europe:** +- `Europe/London` - UK Time +- `Europe/Paris` - Central European Time +- `Europe/Berlin` - Central European Time + +**Asia/Pacific:** +- `Asia/Tokyo` - Japan Time +- `Asia/Shanghai` - China Time +- `Asia/Singapore` - Singapore Time +- `Australia/Sydney` - Australian Eastern Time + +**Default:** If not specified, defaults to `UTC`. + +### Status Fields + +The ScheduledCommitStatus resource maintains detailed status information: + +```yaml +status: + environments: + - branch: environment/production + sha: abc123def456 + phase: pending + currentlyInWindow: false + nextWindowStart: "2024-01-15T14:00:00Z" + nextWindowEnd: "2024-01-15T16:00:00Z" +``` + +Fields: +- `branch` - The environment branch being monitored +- `sha` - The proposed hydrated commit SHA for this environment +- `phase` - Current gate status (`pending` or `success`) +- `currentlyInWindow` - Whether the current time is within a deployment window +- `nextWindowStart` - When the next deployment window starts +- `nextWindowEnd` - When the next deployment window ends \ No newline at end of file diff --git a/go.mod b/go.mod index adf8902ec..1424f3219 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/onsi/gomega v1.38.2 github.com/prometheus/client_golang v1.23.2 github.com/relvacode/iso8601 v1.7.0 + github.com/robfig/cron/v3 v3.0.1 github.com/sosedoff/gitkit v0.4.0 github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index c4192156a..26f936a87 100644 --- a/go.sum +++ b/go.sum @@ -176,6 +176,8 @@ github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9M github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= github.com/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo= github.com/relvacode/iso8601 v1.7.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/controller/scheduledcommitstatus_controller.go b/internal/controller/scheduledcommitstatus_controller.go new file mode 100644 index 000000000..6596ebbf6 --- /dev/null +++ b/internal/controller/scheduledcommitstatus_controller.go @@ -0,0 +1,511 @@ +/* +Copyright 2024. + +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 controller + +import ( + "context" + "fmt" + "time" + + "github.com/argoproj-labs/gitops-promoter/internal/settings" + promoterConditions "github.com/argoproj-labs/gitops-promoter/internal/types/conditions" + "github.com/argoproj-labs/gitops-promoter/internal/utils" + "github.com/robfig/cron/v3" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + promoterv1alpha1 "github.com/argoproj-labs/gitops-promoter/api/v1alpha1" +) + +// ScheduledCommitStatusReconciler reconciles a ScheduledCommitStatus object +type ScheduledCommitStatusReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + SettingsMgr *settings.Manager +} + +// +kubebuilder:rbac:groups=promoter.argoproj.io,resources=scheduledcommitstatuses,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=promoter.argoproj.io,resources=scheduledcommitstatuses/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=promoter.argoproj.io,resources=scheduledcommitstatuses/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *ScheduledCommitStatusReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { + logger := log.FromContext(ctx) + logger.Info("Reconciling ScheduledCommitStatus") + startTime := time.Now() + + var scs promoterv1alpha1.ScheduledCommitStatus + defer utils.HandleReconciliationResult(ctx, startTime, &scs, r.Client, r.Recorder, &err) + + // 1. Fetch the ScheduledCommitStatus instance + err = r.Get(ctx, req.NamespacedName, &scs, &client.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + logger.Info("ScheduledCommitStatus not found") + return ctrl.Result{}, nil + } + logger.Error(err, "failed to get ScheduledCommitStatus") + return ctrl.Result{}, fmt.Errorf("failed to get ScheduledCommitStatus %q: %w", req.Name, err) + } + + // Remove any existing Ready condition. We want to start fresh. + meta.RemoveStatusCondition(scs.GetConditions(), string(promoterConditions.Ready)) + + // 2. Fetch the referenced PromotionStrategy + var ps promoterv1alpha1.PromotionStrategy + psKey := client.ObjectKey{ + Namespace: scs.Namespace, + Name: scs.Spec.PromotionStrategyRef.Name, + } + err = r.Get(ctx, psKey, &ps) + if err != nil { + if k8serrors.IsNotFound(err) { + logger.Error(err, "referenced PromotionStrategy not found", "promotionStrategy", scs.Spec.PromotionStrategyRef.Name) + return ctrl.Result{}, fmt.Errorf("referenced PromotionStrategy %q not found: %w", scs.Spec.PromotionStrategyRef.Name, err) + } + logger.Error(err, "failed to get PromotionStrategy") + return ctrl.Result{}, fmt.Errorf("failed to get PromotionStrategy %q: %w", scs.Spec.PromotionStrategyRef.Name, err) + } + + // 3. Process each environment defined in the ScheduledCommitStatus + commitStatuses, err := r.processEnvironments(ctx, &scs, &ps) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to process environments: %w", err) + } + + // 4. Cleanup orphaned CommitStatus resources + err = r.cleanupOrphanedCommitStatuses(ctx, &scs, commitStatuses) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to cleanup orphaned CommitStatuses: %w", err) + } + + // 5. Inherit conditions from CommitStatus objects + utils.InheritNotReadyConditionFromObjects(&scs, promoterConditions.CommitStatusesNotReady, commitStatuses...) + + // 6. Update status + err = r.Status().Update(ctx, &scs) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update ScheduledCommitStatus status: %w", err) + } + + // 7. Calculate smart requeue duration based on next window boundaries + requeueDuration := r.calculateRequeueDuration(ctx, &scs) + + return ctrl.Result{ + RequeueAfter: requeueDuration, + }, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ScheduledCommitStatusReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + // Use Direct methods to read configuration from the API server without cache during setup. + // The cache is not started during SetupWithManager, so we must use the non-cached API reader. + rateLimiter, err := settings.GetRateLimiterDirect[promoterv1alpha1.ScheduledCommitStatusConfiguration, ctrl.Request](ctx, r.SettingsMgr) + if err != nil { + return fmt.Errorf("failed to get ScheduledCommitStatus rate limiter: %w", err) + } + + maxConcurrentReconciles, err := settings.GetMaxConcurrentReconcilesDirect[promoterv1alpha1.ScheduledCommitStatusConfiguration](ctx, r.SettingsMgr) + if err != nil { + return fmt.Errorf("failed to get ScheduledCommitStatus max concurrent reconciles: %w", err) + } + + err = ctrl.NewControllerManagedBy(mgr). + For(&promoterv1alpha1.ScheduledCommitStatus{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Watches(&promoterv1alpha1.PromotionStrategy{}, r.enqueueScheduledCommitStatusForPromotionStrategy()). + WithOptions(controller.Options{MaxConcurrentReconciles: maxConcurrentReconciles, RateLimiter: rateLimiter}). + Complete(r) + if err != nil { + return fmt.Errorf("failed to create controller: %w", err) + } + return nil +} + +// processEnvironments processes each environment defined in the ScheduledCommitStatus spec, +// creating or updating CommitStatus resources based on schedule-based rules. +// The logic is: for each environment, check if the current time is within the configured deployment window. +// If so, report success for the proposed hydrated SHA. If not, report pending. +// This allows using the scheduled commit status as a proposed commit status gate to block promotions +// when the current time is outside the deployment window. +// Returns the CommitStatus objects created/updated. +func (r *ScheduledCommitStatusReconciler) processEnvironments(ctx context.Context, scs *promoterv1alpha1.ScheduledCommitStatus, ps *promoterv1alpha1.PromotionStrategy) ([]*promoterv1alpha1.CommitStatus, error) { + logger := log.FromContext(ctx) + + // Track all CommitStatus objects created/updated + commitStatuses := make([]*promoterv1alpha1.CommitStatus, 0, len(scs.Spec.Environments)) + + // Build a map of environments from PromotionStrategy for efficient lookup + envStatusMap := make(map[string]*promoterv1alpha1.EnvironmentStatus, len(ps.Status.Environments)) + for i := range ps.Status.Environments { + envStatusMap[ps.Status.Environments[i].Branch] = &ps.Status.Environments[i] + } + + // Initialize or clear the environments status + scs.Status.Environments = make([]promoterv1alpha1.ScheduledCommitStatusEnvironmentStatus, 0, len(scs.Spec.Environments)) + + for _, envConfig := range scs.Spec.Environments { + // Look up the environment in the map + envStatus, found := envStatusMap[envConfig.Branch] + if !found { + logger.Info("Environment not found in PromotionStrategy status", "branch", envConfig.Branch) + continue + } + + // Get the proposed hydrated SHA + proposedHydratedSha := envStatus.Proposed.Hydrated.Sha + + if proposedHydratedSha == "" { + logger.Info("No proposed hydrated commit in environment", "branch", envConfig.Branch) + continue + } + + // Parse the schedule configuration + windowInfo, err := r.calculateWindowInfo(ctx, envConfig.Schedule) + if err != nil { + logger.Error(err, "failed to calculate window info", "branch", envConfig.Branch) + // Continue with other environments even if one fails + continue + } + + // Determine commit status phase based on whether we're in the deployment window + phase, message := r.calculateCommitStatusPhase(windowInfo.InWindow, envConfig.Branch) + + // Update status for this environment + envScheduledStatus := promoterv1alpha1.ScheduledCommitStatusEnvironmentStatus{ + Branch: envConfig.Branch, + Sha: proposedHydratedSha, + Phase: string(phase), + CurrentlyInWindow: windowInfo.InWindow, + NextWindowStart: windowInfo.NextWindowStart, + NextWindowEnd: windowInfo.NextWindowEnd, + } + scs.Status.Environments = append(scs.Status.Environments, envScheduledStatus) + + // Create or update the CommitStatus for the proposed hydrated SHA + // This acts as a proposed commit status that gates promotions to this environment + cs, err := r.upsertCommitStatus(ctx, scs, ps, envConfig.Branch, proposedHydratedSha, phase, message) + if err != nil { + return nil, fmt.Errorf("failed to upsert CommitStatus for environment %q: %w", envConfig.Branch, err) + } + commitStatuses = append(commitStatuses, cs) + + logger.Info("Processed environment schedule gate", + "branch", envConfig.Branch, + "proposedSha", proposedHydratedSha, + "phase", phase, + "inWindow", windowInfo.InWindow, + "nextWindowStart", windowInfo.NextWindowStart, + "nextWindowEnd", windowInfo.NextWindowEnd) + } + + return commitStatuses, nil +} + +// WindowInfo contains information about the deployment window +type WindowInfo struct { + NextWindowStart *metav1.Time + NextWindowEnd *metav1.Time + InWindow bool +} + +// calculateWindowInfo determines if the current time is within a deployment window +// and calculates the next window start and end times. +func (r *ScheduledCommitStatusReconciler) calculateWindowInfo(ctx context.Context, schedule promoterv1alpha1.Schedule) (*WindowInfo, error) { + logger := log.FromContext(ctx) + + // Parse the window duration + windowDuration, err := time.ParseDuration(schedule.Window) + if err != nil { + return nil, fmt.Errorf("failed to parse window duration %q: %w", schedule.Window, err) + } + + // Load the timezone + timezone := schedule.Timezone + if timezone == "" { + timezone = "UTC" + } + loc, err := time.LoadLocation(timezone) + if err != nil { + return nil, fmt.Errorf("failed to load timezone %q: %w", timezone, err) + } + + // Parse the cron expression + parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + cronSchedule, err := parser.Parse(schedule.Cron) + if err != nil { + return nil, fmt.Errorf("failed to parse cron expression %q: %w", schedule.Cron, err) + } + + // Get current time in the configured timezone + now := time.Now().In(loc) + + // Find the most recent window start (could be in the past or now) + // We need to check if we're currently in a window + // Start by finding the next occurrence from a point in the past + checkTime := now.Add(-windowDuration) // Go back by window duration to catch current window + lastWindowStart := cronSchedule.Next(checkTime) + + // Check if we're currently in a window + var nextWindowStart, nextWindowEnd time.Time + inWindow := now.After(lastWindowStart) && now.Before(lastWindowStart.Add(windowDuration)) + + if now.After(lastWindowStart) && now.Before(lastWindowStart.Add(windowDuration)) { + // We're in the current window + nextWindowStart = lastWindowStart + nextWindowEnd = lastWindowStart.Add(windowDuration) + } else { + // We're not in a window, find the next one + nextWindowStart = cronSchedule.Next(now) + nextWindowEnd = nextWindowStart.Add(windowDuration) + } + + logger.V(4).Info("Calculated window info", + "now", now, + "inWindow", inWindow, + "nextWindowStart", nextWindowStart, + "nextWindowEnd", nextWindowEnd, + "timezone", timezone) + + return &WindowInfo{ + InWindow: inWindow, + NextWindowStart: &metav1.Time{Time: nextWindowStart}, + NextWindowEnd: &metav1.Time{Time: nextWindowEnd}, + }, nil +} + +// calculateCommitStatusPhase determines the commit status phase based on whether we're in a deployment window +func (r *ScheduledCommitStatusReconciler) calculateCommitStatusPhase(inWindow bool, envBranch string) (promoterv1alpha1.CommitStatusPhase, string) { + if inWindow { + // We're in the deployment window + return promoterv1alpha1.CommitPhaseSuccess, fmt.Sprintf("Deployment window is open for %s environment", envBranch) + } + + // We're outside the deployment window + return promoterv1alpha1.CommitPhasePending, fmt.Sprintf("Deployment window is closed for %s environment", envBranch) +} + +// upsertCommitStatus creates or updates a CommitStatus resource for a given environment. +// +//nolint:dupl // Similar to TimedCommitStatus but with different labels and naming +func (r *ScheduledCommitStatusReconciler) upsertCommitStatus(ctx context.Context, scs *promoterv1alpha1.ScheduledCommitStatus, ps *promoterv1alpha1.PromotionStrategy, branch, sha string, phase promoterv1alpha1.CommitStatusPhase, message string) (*promoterv1alpha1.CommitStatus, error) { + // Generate a consistent name for the CommitStatus + commitStatusName := utils.KubeSafeUniqueName(ctx, fmt.Sprintf("%s-%s-scheduled", scs.Name, branch)) + + commitStatus := promoterv1alpha1.CommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: commitStatusName, + Namespace: scs.Namespace, + }, + } + + // Create or update the CommitStatus + _, err := ctrl.CreateOrUpdate(ctx, r.Client, &commitStatus, func() error { + // Set owner reference to the ScheduledCommitStatus + if err := ctrl.SetControllerReference(scs, &commitStatus, r.Scheme); err != nil { + return fmt.Errorf("failed to set controller reference: %w", err) + } + + // Set labels for easy identification + if commitStatus.Labels == nil { + commitStatus.Labels = make(map[string]string) + } + commitStatus.Labels["promoter.argoproj.io/scheduled-commit-status"] = utils.KubeSafeLabel(scs.Name) + commitStatus.Labels[promoterv1alpha1.EnvironmentLabel] = utils.KubeSafeLabel(branch) + commitStatus.Labels[promoterv1alpha1.CommitStatusLabel] = "schedule" + + // Set the spec + commitStatus.Spec.RepositoryReference = ps.Spec.RepositoryReference + // Use the environment branch name to show which environment's schedule gate this is checking + commitStatus.Spec.Name = "schedule/" + branch + commitStatus.Spec.Description = message + commitStatus.Spec.Phase = phase + commitStatus.Spec.Sha = sha + + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to create or update CommitStatus: %w", err) + } + + return &commitStatus, nil +} + +// cleanupOrphanedCommitStatuses deletes CommitStatus resources that are owned by this ScheduledCommitStatus +// but are not in the current list of valid CommitStatuses (i.e., they correspond to removed or renamed environments). +func (r *ScheduledCommitStatusReconciler) cleanupOrphanedCommitStatuses(ctx context.Context, scs *promoterv1alpha1.ScheduledCommitStatus, validCommitStatuses []*promoterv1alpha1.CommitStatus) error { + logger := log.FromContext(ctx) + + // Create a set of valid CommitStatus names for quick lookup + validCommitStatusNames := make(map[string]bool) + for _, cs := range validCommitStatuses { + validCommitStatusNames[cs.Name] = true + } + + // List all CommitStatuses in the namespace with the ScheduledCommitStatus label + var csList promoterv1alpha1.CommitStatusList + err := r.List(ctx, &csList, client.InNamespace(scs.Namespace), client.MatchingLabels{ + "promoter.argoproj.io/scheduled-commit-status": utils.KubeSafeLabel(scs.Name), + }) + if err != nil { + return fmt.Errorf("failed to list CommitStatuses: %w", err) + } + + // Delete CommitStatuses that are not in the valid list + for i := range csList.Items { + cs := &csList.Items[i] + + // Skip if this CommitStatus is in the valid list + if validCommitStatusNames[cs.Name] { + continue + } + + // Verify this CommitStatus is owned by this ScheduledCommitStatus before deleting + if !metav1.IsControlledBy(cs, scs) { + logger.V(4).Info("Skipping CommitStatus not owned by this ScheduledCommitStatus", + "commitStatusName", cs.Name, + "scheduledCommitStatus", scs.Name) + continue + } + + // Delete the orphaned CommitStatus + logger.Info("Deleting orphaned CommitStatus", + "commitStatusName", cs.Name, + "scheduledCommitStatus", scs.Name, + "namespace", scs.Namespace) + + if err := r.Delete(ctx, cs); err != nil { + if k8serrors.IsNotFound(err) { + // Already deleted, which is fine + logger.V(4).Info("CommitStatus already deleted", "commitStatusName", cs.Name) + continue + } + return fmt.Errorf("failed to delete orphaned CommitStatus %q: %w", cs.Name, err) + } + + logger.Info("Successfully deleted orphaned CommitStatus", "commitStatusName", cs.Name) + } + + return nil +} + +// calculateRequeueDuration determines when to requeue based on the next window boundary. +// If currently in a window, requeue shortly after the window ends. +// If outside a window, requeue shortly before the next window starts. +// This provides timely status updates when windows open or close. +func (r *ScheduledCommitStatusReconciler) calculateRequeueDuration(ctx context.Context, scs *promoterv1alpha1.ScheduledCommitStatus) time.Duration { + logger := log.FromContext(ctx) + + var nextBoundary time.Time + hasValidBoundary := false + + // Find the earliest next boundary (either window start or end) across all environments + for _, envStatus := range scs.Status.Environments { + var boundaryTime time.Time + var hasBoundary bool + + if envStatus.CurrentlyInWindow && envStatus.NextWindowEnd != nil { + // We're in a window, requeue when it ends + boundaryTime = envStatus.NextWindowEnd.Time + hasBoundary = true + } else if !envStatus.CurrentlyInWindow && envStatus.NextWindowStart != nil { + // We're outside a window, requeue when the next one starts + boundaryTime = envStatus.NextWindowStart.Time + hasBoundary = true + } + + if hasBoundary && (!hasValidBoundary || boundaryTime.Before(nextBoundary)) { + nextBoundary = boundaryTime + hasValidBoundary = true + } + } + + if hasValidBoundary { + // Calculate duration until the boundary, add 1 minute buffer for safety + durationUntilBoundary := time.Until(nextBoundary) + if durationUntilBoundary < 0 { + // Boundary is in the past, requeue immediately + logger.V(4).Info("Next boundary is in the past, requeuing immediately") + return time.Minute + } + + // Add 1 minute buffer to ensure we're past the boundary + requeueDuration := durationUntilBoundary + time.Minute + + // Cap at a reasonable maximum (e.g., 24 hours) to ensure periodic reconciliation + maxDuration := 24 * time.Hour + if requeueDuration > maxDuration { + logger.V(4).Info("Capping requeue duration at 24 hours", "calculated", requeueDuration) + return maxDuration + } + + logger.V(4).Info("Requeuing at next window boundary", + "boundary", nextBoundary, + "duration", requeueDuration) + return requeueDuration + } + + // No valid boundary found, use default requeue duration + defaultDuration, err := settings.GetRequeueDuration[promoterv1alpha1.ScheduledCommitStatusConfiguration](ctx, r.SettingsMgr) + if err != nil { + logger.Error(err, "failed to get default requeue duration, using 1 hour") + return time.Hour + } + + return defaultDuration +} + +// enqueueScheduledCommitStatusForPromotionStrategy returns a handler that enqueues all ScheduledCommitStatus resources +// that reference a PromotionStrategy when that PromotionStrategy changes +func (r *ScheduledCommitStatusReconciler) enqueueScheduledCommitStatusForPromotionStrategy() handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request { + ps, ok := obj.(*promoterv1alpha1.PromotionStrategy) + if !ok { + return nil + } + + // List all ScheduledCommitStatus resources in the same namespace + var scsList promoterv1alpha1.ScheduledCommitStatusList + if err := r.List(ctx, &scsList, client.InNamespace(ps.Namespace)); err != nil { + log.FromContext(ctx).Error(err, "failed to list ScheduledCommitStatus resources") + return nil + } + + // Enqueue all ScheduledCommitStatus resources that reference this PromotionStrategy + var requests []ctrl.Request + for _, scs := range scsList.Items { + if scs.Spec.PromotionStrategyRef.Name == ps.Name { + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKeyFromObject(&scs), + }) + } + } + + return requests + }) +} diff --git a/internal/controller/scheduledcommitstatus_controller_test.go b/internal/controller/scheduledcommitstatus_controller_test.go new file mode 100644 index 000000000..f89a49dd3 --- /dev/null +++ b/internal/controller/scheduledcommitstatus_controller_test.go @@ -0,0 +1,1025 @@ +/* +Copyright 2024. + +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 controller + +import ( + "context" + "fmt" + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + + "github.com/argoproj-labs/gitops-promoter/internal/types/constants" + "github.com/argoproj-labs/gitops-promoter/internal/utils" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + promoterv1alpha1 "github.com/argoproj-labs/gitops-promoter/api/v1alpha1" +) + +var _ = Describe("ScheduledCommitStatus Controller", func() { + Context("When schedule window is active", func() { + ctx := context.Background() + + var name string + var promotionStrategy *promoterv1alpha1.PromotionStrategy + var scheduledCommitStatus *promoterv1alpha1.ScheduledCommitStatus + + BeforeEach(func() { + By("Creating the test resources") + var scmSecret *v1.Secret + var scmProvider *promoterv1alpha1.ScmProvider + var gitRepo *promoterv1alpha1.GitRepository + name, scmSecret, scmProvider, gitRepo, _, _, promotionStrategy = promotionStrategyResource(ctx, "scheduled-status-active", "default") + + // Configure ProposedCommitStatuses to check for schedule commit status + promotionStrategy.Spec.ProposedCommitStatuses = []promoterv1alpha1.CommitStatusSelector{ + {Key: "schedule"}, + } + + setupInitialTestGitRepoOnServer(ctx, name, name) + + Expect(k8sClient.Create(ctx, scmSecret)).To(Succeed()) + Expect(k8sClient.Create(ctx, scmProvider)).To(Succeed()) + Expect(k8sClient.Create(ctx, gitRepo)).To(Succeed()) + Expect(k8sClient.Create(ctx, promotionStrategy)).To(Succeed()) + + By("Waiting for PromotionStrategy to be reconciled with initial state") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, promotionStrategy) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(promotionStrategy.Status.Environments).To(HaveLen(3)) + g.Expect(promotionStrategy.Status.Environments[0].Active.Hydrated.Sha).ToNot(BeEmpty()) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Making a change to trigger proposed commits") + gitPath, err := os.MkdirTemp("", "*") + Expect(err).NotTo(HaveOccurred()) + defer func() { + _ = os.RemoveAll(gitPath) + }() + makeChangeAndHydrateRepo(gitPath, name, name, "test commit for scheduled", "") + + By("Waiting for proposed commits to appear") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, promotionStrategy) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(promotionStrategy.Status.Environments).To(HaveLen(3)) + for _, env := range promotionStrategy.Status.Environments { + g.Expect(env.Proposed.Hydrated.Sha).ToNot(BeEmpty()) + } + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Creating a ScheduledCommitStatus resource with active window") + now := time.Now().UTC() + // Create a window that includes the current time (started 1 hour ago, lasts 2 hours) + cronExpr := fmt.Sprintf("%d %d * * *", now.Add(-1*time.Hour).Minute(), now.Add(-1*time.Hour).Hour()) + + scheduledCommitStatus = &promoterv1alpha1.ScheduledCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: promoterv1alpha1.ScheduledCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Environments: []promoterv1alpha1.ScheduledCommitStatusEnvironment{ + { + Branch: testEnvironmentDevelopment, + Schedule: promoterv1alpha1.Schedule{ + Cron: cronExpr, + Window: "2h", + Timezone: "UTC", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, scheduledCommitStatus)).To(Succeed()) + }) + + AfterEach(func() { + By("Cleaning up resources") + _ = k8sClient.Delete(ctx, scheduledCommitStatus) + _ = k8sClient.Delete(ctx, promotionStrategy) + }) + + It("should report success when inside deployment window", func() { + By("Waiting for ScheduledCommitStatus to process the environment") + Eventually(func(g Gomega) { + var scs promoterv1alpha1.ScheduledCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, &scs) + g.Expect(err).NotTo(HaveOccurred()) + + // Should have status for dev environment + g.Expect(scs.Status.Environments).To(HaveLen(1)) + g.Expect(scs.Status.Environments[0].Branch).To(Equal(testEnvironmentDevelopment)) + g.Expect(scs.Status.Environments[0].Phase).To(Equal(string(promoterv1alpha1.CommitPhaseSuccess))) + + // Validate status fields are populated + g.Expect(scs.Status.Environments[0].Sha).ToNot(BeEmpty(), "Sha should be populated") + g.Expect(scs.Status.Environments[0].CurrentlyInWindow).To(BeTrue(), "Should be in window") + g.Expect(scs.Status.Environments[0].NextWindowStart).ToNot(BeNil(), "NextWindowStart should be set") + g.Expect(scs.Status.Environments[0].NextWindowEnd).ToNot(BeNil(), "NextWindowEnd should be set") + + // Verify CommitStatus was created for dev environment with success phase + commitStatusName := utils.KubeSafeUniqueName(ctx, name+"-"+testEnvironmentDevelopment+"-scheduled") + var cs promoterv1alpha1.CommitStatus + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cs.Spec.Phase).To(Equal(promoterv1alpha1.CommitPhaseSuccess)) + g.Expect(cs.Spec.Description).To(ContainSubstring("Deployment window is open")) + g.Expect(cs.Labels).To(HaveKey(promoterv1alpha1.CommitStatusLabel)) + g.Expect(cs.Labels[promoterv1alpha1.CommitStatusLabel]).To(Equal("schedule")) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Verifying phase remains success for 5 seconds (doesn't flip back to pending)") + Consistently(func(g Gomega) { + var scs promoterv1alpha1.ScheduledCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, &scs) + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(scs.Status.Environments).To(HaveLen(1)) + + // Critical: phase must stay success, not flip back to pending + g.Expect(scs.Status.Environments[0].Phase).To(Equal(string(promoterv1alpha1.CommitPhaseSuccess)), + "Phase must remain success while in window") + g.Expect(scs.Status.Environments[0].CurrentlyInWindow).To(BeTrue(), + "Should remain in window") + + // Verify CommitStatus phase remains success + commitStatusName := utils.KubeSafeUniqueName(ctx, name+"-"+testEnvironmentDevelopment+"-scheduled") + var cs promoterv1alpha1.CommitStatus + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cs.Spec.Phase).To(Equal(promoterv1alpha1.CommitPhaseSuccess), + "CommitStatus phase must remain success") + }, 5*time.Second, 1*time.Second).Should(Succeed()) + }) + }) + + Context("When schedule window is not active", func() { + ctx := context.Background() + + var name string + var promotionStrategy *promoterv1alpha1.PromotionStrategy + var scheduledCommitStatus *promoterv1alpha1.ScheduledCommitStatus + + BeforeEach(func() { + By("Creating the test resources") + var scmSecret *v1.Secret + var scmProvider *promoterv1alpha1.ScmProvider + var gitRepo *promoterv1alpha1.GitRepository + name, scmSecret, scmProvider, gitRepo, _, _, promotionStrategy = promotionStrategyResource(ctx, "scheduled-status-inactive", "default") + + // Configure ProposedCommitStatuses to check for schedule commit status + promotionStrategy.Spec.ProposedCommitStatuses = []promoterv1alpha1.CommitStatusSelector{ + {Key: "schedule"}, + } + + setupInitialTestGitRepoOnServer(ctx, name, name) + + Expect(k8sClient.Create(ctx, scmSecret)).To(Succeed()) + Expect(k8sClient.Create(ctx, scmProvider)).To(Succeed()) + Expect(k8sClient.Create(ctx, gitRepo)).To(Succeed()) + Expect(k8sClient.Create(ctx, promotionStrategy)).To(Succeed()) + + By("Waiting for PromotionStrategy to be reconciled with initial state") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, promotionStrategy) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(promotionStrategy.Status.Environments).To(HaveLen(3)) + g.Expect(promotionStrategy.Status.Environments[0].Active.Hydrated.Sha).ToNot(BeEmpty()) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Making a change to trigger proposed commits") + gitPath, err := os.MkdirTemp("", "*") + Expect(err).NotTo(HaveOccurred()) + defer func() { + _ = os.RemoveAll(gitPath) + }() + makeChangeAndHydrateRepo(gitPath, name, name, "test commit for scheduled", "") + + By("Waiting for proposed commits to appear") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, promotionStrategy) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(promotionStrategy.Status.Environments).To(HaveLen(3)) + for _, env := range promotionStrategy.Status.Environments { + g.Expect(env.Proposed.Hydrated.Sha).ToNot(BeEmpty()) + } + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Creating a ScheduledCommitStatus resource with future window") + now := time.Now().UTC() + // Create a window that starts 2 hours in the future + cronExpr := fmt.Sprintf("%d %d * * *", now.Add(2*time.Hour).Minute(), now.Add(2*time.Hour).Hour()) + + scheduledCommitStatus = &promoterv1alpha1.ScheduledCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: promoterv1alpha1.ScheduledCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Environments: []promoterv1alpha1.ScheduledCommitStatusEnvironment{ + { + Branch: testEnvironmentDevelopment, + Schedule: promoterv1alpha1.Schedule{ + Cron: cronExpr, + Window: "1h", + Timezone: "UTC", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, scheduledCommitStatus)).To(Succeed()) + }) + + AfterEach(func() { + By("Cleaning up resources") + _ = k8sClient.Delete(ctx, scheduledCommitStatus) + _ = k8sClient.Delete(ctx, promotionStrategy) + }) + + It("should report pending when outside deployment window", func() { + By("Waiting for ScheduledCommitStatus to process the environment") + Eventually(func(g Gomega) { + var scs promoterv1alpha1.ScheduledCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, &scs) + g.Expect(err).NotTo(HaveOccurred()) + + // Should have status for dev environment + g.Expect(scs.Status.Environments).To(HaveLen(1)) + g.Expect(scs.Status.Environments[0].Branch).To(Equal(testEnvironmentDevelopment)) + g.Expect(scs.Status.Environments[0].Phase).To(Equal(string(promoterv1alpha1.CommitPhasePending))) + + // Validate status fields + g.Expect(scs.Status.Environments[0].Sha).ToNot(BeEmpty(), "Sha should be populated") + g.Expect(scs.Status.Environments[0].CurrentlyInWindow).To(BeFalse(), "Should not be in window") + g.Expect(scs.Status.Environments[0].NextWindowStart).ToNot(BeNil(), "NextWindowStart should be set") + g.Expect(scs.Status.Environments[0].NextWindowEnd).ToNot(BeNil(), "NextWindowEnd should be set") + + // Verify CommitStatus was created for dev environment with pending phase + commitStatusName := utils.KubeSafeUniqueName(ctx, name+"-"+testEnvironmentDevelopment+"-scheduled") + var cs promoterv1alpha1.CommitStatus + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cs.Spec.Phase).To(Equal(promoterv1alpha1.CommitPhasePending)) + g.Expect(cs.Spec.Description).To(ContainSubstring("Deployment window is closed")) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Verifying phase stays pending for 10 seconds (doesn't incorrectly switch to success)") + Consistently(func(g Gomega) { + var scs promoterv1alpha1.ScheduledCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, &scs) + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(scs.Status.Environments).To(HaveLen(1)) + + // Critical: phase must stay pending because window is 2 hours in the future + g.Expect(scs.Status.Environments[0].Phase).To(Equal(string(promoterv1alpha1.CommitPhasePending)), + "Phase must remain pending while outside window") + g.Expect(scs.Status.Environments[0].CurrentlyInWindow).To(BeFalse(), + "Should remain outside window") + + // Verify CommitStatus phase remains pending + commitStatusName := utils.KubeSafeUniqueName(ctx, name+"-"+testEnvironmentDevelopment+"-scheduled") + var cs promoterv1alpha1.CommitStatus + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cs.Spec.Phase).To(Equal(promoterv1alpha1.CommitPhasePending), + "CommitStatus phase must remain pending") + }, 10*time.Second, 1*time.Second).Should(Succeed()) + }) + }) + + Context("When handling multiple environments with different schedules", func() { + ctx := context.Background() + + var name string + var promotionStrategy *promoterv1alpha1.PromotionStrategy + var scheduledCommitStatus *promoterv1alpha1.ScheduledCommitStatus + + BeforeEach(func() { + By("Creating the test resources") + var scmSecret *v1.Secret + var scmProvider *promoterv1alpha1.ScmProvider + var gitRepo *promoterv1alpha1.GitRepository + name, scmSecret, scmProvider, gitRepo, _, _, promotionStrategy = promotionStrategyResource(ctx, "scheduled-status-multi", "default") + + // Configure ProposedCommitStatuses to check for schedule commit status + promotionStrategy.Spec.ProposedCommitStatuses = []promoterv1alpha1.CommitStatusSelector{ + {Key: "schedule"}, + } + + setupInitialTestGitRepoOnServer(ctx, name, name) + + Expect(k8sClient.Create(ctx, scmSecret)).To(Succeed()) + Expect(k8sClient.Create(ctx, scmProvider)).To(Succeed()) + Expect(k8sClient.Create(ctx, gitRepo)).To(Succeed()) + Expect(k8sClient.Create(ctx, promotionStrategy)).To(Succeed()) + + By("Waiting for PromotionStrategy to be reconciled") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, promotionStrategy) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(promotionStrategy.Status.Environments).To(HaveLen(3)) + g.Expect(promotionStrategy.Status.Environments[0].Active.Hydrated.Sha).ToNot(BeEmpty()) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Making a change to trigger proposed commits") + gitPath, err := os.MkdirTemp("", "*") + Expect(err).NotTo(HaveOccurred()) + defer func() { + _ = os.RemoveAll(gitPath) + }() + makeChangeAndHydrateRepo(gitPath, name, name, "test commit for scheduled", "") + + By("Waiting for proposed commits to appear") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, promotionStrategy) + g.Expect(err).NotTo(HaveOccurred()) + for _, env := range promotionStrategy.Status.Environments { + g.Expect(env.Proposed.Hydrated.Sha).ToNot(BeEmpty()) + } + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Creating ScheduledCommitStatus with multiple environments") + now := time.Now().UTC() + // Dev: in window (started 1h ago, lasts 2h) + devCron := fmt.Sprintf("%d %d * * *", now.Add(-1*time.Hour).Minute(), now.Add(-1*time.Hour).Hour()) + // Staging: outside window (starts 2h in future) + stagingCron := fmt.Sprintf("%d %d * * *", now.Add(2*time.Hour).Minute(), now.Add(2*time.Hour).Hour()) + + scheduledCommitStatus = &promoterv1alpha1.ScheduledCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: promoterv1alpha1.ScheduledCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Environments: []promoterv1alpha1.ScheduledCommitStatusEnvironment{ + { + Branch: testEnvironmentDevelopment, + Schedule: promoterv1alpha1.Schedule{ + Cron: devCron, + Window: "2h", + Timezone: "UTC", + }, + }, + { + Branch: testEnvironmentStaging, + Schedule: promoterv1alpha1.Schedule{ + Cron: stagingCron, + Window: "1h", + Timezone: "UTC", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, scheduledCommitStatus)).To(Succeed()) + }) + + AfterEach(func() { + By("Cleaning up resources") + _ = k8sClient.Delete(ctx, scheduledCommitStatus) + _ = k8sClient.Delete(ctx, promotionStrategy) + }) + + It("should handle different schedules for each environment", func() { + By("Waiting for ScheduledCommitStatus to process both environments") + Eventually(func(g Gomega) { + var scs promoterv1alpha1.ScheduledCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, &scs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(scs.Status.Environments).To(HaveLen(2)) + + var devEnv, stagingEnv *promoterv1alpha1.ScheduledCommitStatusEnvironmentStatus + for i := range scs.Status.Environments { + if scs.Status.Environments[i].Branch == testEnvironmentDevelopment { + devEnv = &scs.Status.Environments[i] + } + if scs.Status.Environments[i].Branch == testEnvironmentStaging { + stagingEnv = &scs.Status.Environments[i] + } + } + + // Dev should be in window with success + g.Expect(devEnv).NotTo(BeNil()) + g.Expect(devEnv.CurrentlyInWindow).To(BeTrue()) + g.Expect(devEnv.Phase).To(Equal(string(promoterv1alpha1.CommitPhaseSuccess))) + + // Staging should be outside window with pending + g.Expect(stagingEnv).NotTo(BeNil()) + g.Expect(stagingEnv.CurrentlyInWindow).To(BeFalse()) + g.Expect(stagingEnv.Phase).To(Equal(string(promoterv1alpha1.CommitPhasePending))) + }, constants.EventuallyTimeout).Should(Succeed()) + }) + }) + + Context("When handling timezones", func() { + ctx := context.Background() + + var name string + var promotionStrategy *promoterv1alpha1.PromotionStrategy + var scheduledCommitStatus *promoterv1alpha1.ScheduledCommitStatus + + BeforeEach(func() { + By("Creating the test resources") + var scmSecret *v1.Secret + var scmProvider *promoterv1alpha1.ScmProvider + var gitRepo *promoterv1alpha1.GitRepository + name, scmSecret, scmProvider, gitRepo, _, _, promotionStrategy = promotionStrategyResource(ctx, "scheduled-status-tz", "default") + + // Configure ProposedCommitStatuses + promotionStrategy.Spec.ProposedCommitStatuses = []promoterv1alpha1.CommitStatusSelector{ + {Key: "schedule"}, + } + + setupInitialTestGitRepoOnServer(ctx, name, name) + + Expect(k8sClient.Create(ctx, scmSecret)).To(Succeed()) + Expect(k8sClient.Create(ctx, scmProvider)).To(Succeed()) + Expect(k8sClient.Create(ctx, gitRepo)).To(Succeed()) + Expect(k8sClient.Create(ctx, promotionStrategy)).To(Succeed()) + + By("Waiting for PromotionStrategy to be reconciled") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, promotionStrategy) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(promotionStrategy.Status.Environments).To(HaveLen(3)) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Making a change to trigger proposed commits") + gitPath, err := os.MkdirTemp("", "*") + Expect(err).NotTo(HaveOccurred()) + defer func() { + _ = os.RemoveAll(gitPath) + }() + makeChangeAndHydrateRepo(gitPath, name, name, "test commit for scheduled", "") + + By("Waiting for proposed commits to appear") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, promotionStrategy) + g.Expect(err).NotTo(HaveOccurred()) + for _, env := range promotionStrategy.Status.Environments { + g.Expect(env.Proposed.Hydrated.Sha).ToNot(BeEmpty()) + } + }, constants.EventuallyTimeout).Should(Succeed()) + }) + + AfterEach(func() { + By("Cleaning up resources") + _ = k8sClient.Delete(ctx, scheduledCommitStatus) + _ = k8sClient.Delete(ctx, promotionStrategy) + }) + + It("should handle America/New_York timezone correctly", func() { + By("Creating ScheduledCommitStatus with America/New_York timezone") + // Get current time in New York + nyLocation, err := time.LoadLocation("America/New_York") + Expect(err).NotTo(HaveOccurred()) + nowNY := time.Now().In(nyLocation) + + // Create a window that includes current NY time + cronExpr := fmt.Sprintf("%d %d * * *", nowNY.Add(-1*time.Hour).Minute(), nowNY.Add(-1*time.Hour).Hour()) + + scheduledCommitStatus = &promoterv1alpha1.ScheduledCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: promoterv1alpha1.ScheduledCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Environments: []promoterv1alpha1.ScheduledCommitStatusEnvironment{ + { + Branch: testEnvironmentDevelopment, + Schedule: promoterv1alpha1.Schedule{ + Cron: cronExpr, + Window: "2h", + Timezone: "America/New_York", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, scheduledCommitStatus)).To(Succeed()) + + By("Verifying the window is calculated correctly for the timezone") + Eventually(func(g Gomega) { + var scs promoterv1alpha1.ScheduledCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, &scs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(scs.Status.Environments).To(HaveLen(1)) + // Should be in window since we created a window containing current NY time + g.Expect(scs.Status.Environments[0].CurrentlyInWindow).To(BeTrue()) + g.Expect(scs.Status.Environments[0].Phase).To(Equal(string(promoterv1alpha1.CommitPhaseSuccess))) + }, constants.EventuallyTimeout).Should(Succeed()) + }) + }) + + Context("When schedule is updated to change window status", func() { + ctx := context.Background() + + var name string + var promotionStrategy *promoterv1alpha1.PromotionStrategy + var scheduledCommitStatus *promoterv1alpha1.ScheduledCommitStatus + + BeforeEach(func() { + By("Creating the test resources") + var scmSecret *v1.Secret + var scmProvider *promoterv1alpha1.ScmProvider + var gitRepo *promoterv1alpha1.GitRepository + name, scmSecret, scmProvider, gitRepo, _, _, promotionStrategy = promotionStrategyResource(ctx, "scheduled-status-transition", "default") + + // Configure ProposedCommitStatuses to check for schedule commit status + promotionStrategy.Spec.ProposedCommitStatuses = []promoterv1alpha1.CommitStatusSelector{ + {Key: "schedule"}, + } + + setupInitialTestGitRepoOnServer(ctx, name, name) + + Expect(k8sClient.Create(ctx, scmSecret)).To(Succeed()) + Expect(k8sClient.Create(ctx, scmProvider)).To(Succeed()) + Expect(k8sClient.Create(ctx, gitRepo)).To(Succeed()) + Expect(k8sClient.Create(ctx, promotionStrategy)).To(Succeed()) + + By("Waiting for PromotionStrategy to be reconciled with initial state") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, promotionStrategy) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(promotionStrategy.Status.Environments).To(HaveLen(3)) + g.Expect(promotionStrategy.Status.Environments[0].Active.Hydrated.Sha).ToNot(BeEmpty()) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Making a change to trigger proposed commits") + gitPath, err := os.MkdirTemp("", "*") + Expect(err).NotTo(HaveOccurred()) + defer func() { + _ = os.RemoveAll(gitPath) + }() + makeChangeAndHydrateRepo(gitPath, name, name, "test commit for schedule transition", "") + + By("Waiting for proposed commits to appear") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, promotionStrategy) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(promotionStrategy.Status.Environments).To(HaveLen(3)) + for _, env := range promotionStrategy.Status.Environments { + g.Expect(env.Proposed.Hydrated.Sha).ToNot(BeEmpty()) + } + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Creating a ScheduledCommitStatus with window far in the future (initially pending)") + now := time.Now().UTC() + // Create a window that starts 5 hours in the future + futureTime := now.Add(5 * time.Hour) + cronExpr := fmt.Sprintf("%d %d * * *", futureTime.Minute(), futureTime.Hour()) + + scheduledCommitStatus = &promoterv1alpha1.ScheduledCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: promoterv1alpha1.ScheduledCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Environments: []promoterv1alpha1.ScheduledCommitStatusEnvironment{ + { + Branch: testEnvironmentDevelopment, + Schedule: promoterv1alpha1.Schedule{ + Cron: cronExpr, + Window: "1h", + Timezone: "UTC", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, scheduledCommitStatus)).To(Succeed()) + + By("Waiting for ScheduledCommitStatus to initially report pending status") + Eventually(func(g Gomega) { + var scs promoterv1alpha1.ScheduledCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, &scs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(scs.Status.Environments).To(HaveLen(1)) + g.Expect(scs.Status.Environments[0].Phase).To(Equal(string(promoterv1alpha1.CommitPhasePending))) + g.Expect(scs.Status.Environments[0].CurrentlyInWindow).To(BeFalse()) + }, constants.EventuallyTimeout).Should(Succeed()) + }) + + AfterEach(func() { + By("Cleaning up resources") + _ = k8sClient.Delete(ctx, scheduledCommitStatus) + _ = k8sClient.Delete(ctx, promotionStrategy) + }) + + It("should transition from pending to success when schedule is updated to active window", func() { + By("Verifying initial pending state with CommitStatus") + commitStatusName := utils.KubeSafeUniqueName(ctx, name+"-"+testEnvironmentDevelopment+"-scheduled") + Eventually(func(g Gomega) { + var cs promoterv1alpha1.CommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cs.Spec.Phase).To(Equal(promoterv1alpha1.CommitPhasePending)) + g.Expect(cs.Spec.Description).To(ContainSubstring("Deployment window is closed")) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Updating ScheduledCommitStatus schedule to create an active window") + Eventually(func(g Gomega) { + var scs promoterv1alpha1.ScheduledCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, &scs) + g.Expect(err).NotTo(HaveOccurred()) + + // Update to a window that includes current time (started 1 hour ago, lasts 2 hours) + now := time.Now().UTC() + activeWindowCron := fmt.Sprintf("%d %d * * *", now.Add(-1*time.Hour).Minute(), now.Add(-1*time.Hour).Hour()) + scs.Spec.Environments[0].Schedule.Cron = activeWindowCron + scs.Spec.Environments[0].Schedule.Window = "2h" + + err = k8sClient.Update(ctx, &scs) + g.Expect(err).NotTo(HaveOccurred()) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Waiting for ScheduledCommitStatus to transition to success") + Eventually(func(g Gomega) { + var scs promoterv1alpha1.ScheduledCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, &scs) + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(scs.Status.Environments).To(HaveLen(1)) + g.Expect(scs.Status.Environments[0].Branch).To(Equal(testEnvironmentDevelopment)) + g.Expect(scs.Status.Environments[0].Phase).To(Equal(string(promoterv1alpha1.CommitPhaseSuccess)), + "Phase should transition to success when window becomes active") + g.Expect(scs.Status.Environments[0].CurrentlyInWindow).To(BeTrue(), + "Should now be in window") + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Verifying CommitStatus was updated to success") + Eventually(func(g Gomega) { + var cs promoterv1alpha1.CommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cs.Spec.Phase).To(Equal(promoterv1alpha1.CommitPhaseSuccess), + "CommitStatus should transition to success") + g.Expect(cs.Spec.Description).To(ContainSubstring("Deployment window is open")) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Verifying the transition is stable (doesn't flip back)") + Consistently(func(g Gomega) { + var scs promoterv1alpha1.ScheduledCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, &scs) + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(scs.Status.Environments).To(HaveLen(1)) + g.Expect(scs.Status.Environments[0].Phase).To(Equal(string(promoterv1alpha1.CommitPhaseSuccess)), + "Phase must remain success") + g.Expect(scs.Status.Environments[0].CurrentlyInWindow).To(BeTrue()) + + // Verify CommitStatus remains success + var cs promoterv1alpha1.CommitStatus + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cs.Spec.Phase).To(Equal(promoterv1alpha1.CommitPhaseSuccess)) + }, 5*time.Second, 1*time.Second).Should(Succeed()) + }) + }) + + Context("When environments are removed from spec", func() { + ctx := context.Background() + + var name string + var promotionStrategy *promoterv1alpha1.PromotionStrategy + var scheduledCommitStatus *promoterv1alpha1.ScheduledCommitStatus + + BeforeEach(func() { + By("Creating the test resources") + var scmSecret *v1.Secret + var scmProvider *promoterv1alpha1.ScmProvider + var gitRepo *promoterv1alpha1.GitRepository + name, scmSecret, scmProvider, gitRepo, _, _, promotionStrategy = promotionStrategyResource(ctx, "scheduled-cleanup", "default") + + // Configure ProposedCommitStatuses to check for schedule commit status + promotionStrategy.Spec.ProposedCommitStatuses = []promoterv1alpha1.CommitStatusSelector{ + {Key: "schedule"}, + } + + setupInitialTestGitRepoOnServer(ctx, name, name) + + Expect(k8sClient.Create(ctx, scmSecret)).To(Succeed()) + Expect(k8sClient.Create(ctx, scmProvider)).To(Succeed()) + Expect(k8sClient.Create(ctx, gitRepo)).To(Succeed()) + Expect(k8sClient.Create(ctx, promotionStrategy)).To(Succeed()) + + By("Waiting for PromotionStrategy to be reconciled") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, promotionStrategy) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(promotionStrategy.Status.Environments).To(HaveLen(3)) + g.Expect(promotionStrategy.Status.Environments[0].Active.Hydrated.Sha).ToNot(BeEmpty()) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Making a change to trigger proposed commits") + gitPath, err := os.MkdirTemp("", "*") + Expect(err).NotTo(HaveOccurred()) + defer func() { + _ = os.RemoveAll(gitPath) + }() + makeChangeAndHydrateRepo(gitPath, name, name, "test commit for cleanup", "") + + By("Waiting for proposed commits to appear") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, promotionStrategy) + g.Expect(err).NotTo(HaveOccurred()) + for _, env := range promotionStrategy.Status.Environments { + g.Expect(env.Proposed.Hydrated.Sha).ToNot(BeEmpty()) + } + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Creating ScheduledCommitStatus with two environments") + now := time.Now().UTC() + cronExpr := fmt.Sprintf("%d %d * * *", now.Add(-1*time.Hour).Minute(), now.Add(-1*time.Hour).Hour()) + + scheduledCommitStatus = &promoterv1alpha1.ScheduledCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: promoterv1alpha1.ScheduledCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Environments: []promoterv1alpha1.ScheduledCommitStatusEnvironment{ + { + Branch: testEnvironmentDevelopment, + Schedule: promoterv1alpha1.Schedule{ + Cron: cronExpr, + Window: "2h", + Timezone: "UTC", + }, + }, + { + Branch: testEnvironmentStaging, + Schedule: promoterv1alpha1.Schedule{ + Cron: cronExpr, + Window: "2h", + Timezone: "UTC", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, scheduledCommitStatus)).To(Succeed()) + + By("Waiting for both CommitStatuses to be created") + Eventually(func(g Gomega) { + var scs promoterv1alpha1.ScheduledCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, &scs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(scs.Status.Environments).To(HaveLen(2)) + }, constants.EventuallyTimeout).Should(Succeed()) + }) + + AfterEach(func() { + By("Cleaning up resources") + _ = k8sClient.Delete(ctx, scheduledCommitStatus) + _ = k8sClient.Delete(ctx, promotionStrategy) + }) + + It("should delete orphaned CommitStatus when environment is removed from spec", func() { + By("Verifying both CommitStatuses exist") + devCommitStatusName := utils.KubeSafeUniqueName(ctx, name+"-"+testEnvironmentDevelopment+"-scheduled") + stagingCommitStatusName := utils.KubeSafeUniqueName(ctx, name+"-"+testEnvironmentStaging+"-scheduled") + + Eventually(func(g Gomega) { + var devCS promoterv1alpha1.CommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: devCommitStatusName, + Namespace: "default", + }, &devCS) + g.Expect(err).NotTo(HaveOccurred()) + + var stagingCS promoterv1alpha1.CommitStatus + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: stagingCommitStatusName, + Namespace: "default", + }, &stagingCS) + g.Expect(err).NotTo(HaveOccurred()) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Removing staging environment from ScheduledCommitStatus spec") + Eventually(func(g Gomega) { + var scs promoterv1alpha1.ScheduledCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, &scs) + g.Expect(err).NotTo(HaveOccurred()) + + // Remove staging environment, keep only dev + scs.Spec.Environments = []promoterv1alpha1.ScheduledCommitStatusEnvironment{ + scs.Spec.Environments[0], // Keep dev + } + + err = k8sClient.Update(ctx, &scs) + g.Expect(err).NotTo(HaveOccurred()) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Verifying staging CommitStatus is deleted") + Eventually(func(g Gomega) { + var stagingCS promoterv1alpha1.CommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: stagingCommitStatusName, + Namespace: "default", + }, &stagingCS) + g.Expect(k8serrors.IsNotFound(err)).To(BeTrue(), + "Staging CommitStatus should be deleted") + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Verifying dev CommitStatus still exists") + Eventually(func(g Gomega) { + var devCS promoterv1alpha1.CommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: devCommitStatusName, + Namespace: "default", + }, &devCS) + g.Expect(err).NotTo(HaveOccurred(), + "Dev CommitStatus should still exist") + g.Expect(devCS.Spec.Phase).To(Equal(promoterv1alpha1.CommitPhaseSuccess)) + }, constants.EventuallyTimeout).Should(Succeed()) + }) + }) + + Context("When PromotionStrategy is not found", func() { + const resourceName = "scheduled-status-no-ps" + + ctx := context.Background() + + var scheduledCommitStatus *promoterv1alpha1.ScheduledCommitStatus + + BeforeEach(func() { + By("Creating only a ScheduledCommitStatus resource without PromotionStrategy") + scheduledCommitStatus = &promoterv1alpha1.ScheduledCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: promoterv1alpha1.ScheduledCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: "non-existent", + }, + Environments: []promoterv1alpha1.ScheduledCommitStatusEnvironment{ + { + Branch: "environment/dev", + Schedule: promoterv1alpha1.Schedule{ + Cron: "0 9 * * *", + Window: "1h", + Timezone: "UTC", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, scheduledCommitStatus)).To(Succeed()) + }) + + AfterEach(func() { + By("Cleaning up resources") + _ = k8sClient.Delete(ctx, scheduledCommitStatus) + }) + + It("should handle missing PromotionStrategy gracefully", func() { + By("Verifying the ScheduledCommitStatus exists but doesn't process environments") + Consistently(func(g Gomega) { + var scs promoterv1alpha1.ScheduledCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: resourceName, + Namespace: "default", + }, &scs) + g.Expect(err).NotTo(HaveOccurred()) + // Status should be empty since PromotionStrategy doesn't exist + g.Expect(scs.Status.Environments).To(BeEmpty()) + }, 2*time.Second, 500*time.Millisecond).Should(Succeed()) + }) + }) +}) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index a178bcd02..2ad96ad23 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -311,6 +311,28 @@ var _ = BeforeSuite(func() { }, }, }, + ScheduledCommitStatus: promoterv1alpha1.ScheduledCommitStatusConfiguration{ + WorkQueue: promoterv1alpha1.WorkQueue{ + RequeueDuration: metav1.Duration{Duration: time.Second * 1}, + MaxConcurrentReconciles: 10, + RateLimiter: promoterv1alpha1.RateLimiter{ + MaxOf: []promoterv1alpha1.RateLimiterTypes{ + { + Bucket: &promoterv1alpha1.Bucket{ + Qps: 10, + Bucket: 100, + }, + }, + { + ExponentialFailure: &promoterv1alpha1.ExponentialFailure{ + BaseDelay: metav1.Duration{Duration: time.Millisecond * 5}, + MaxDelay: metav1.Duration{Duration: time.Minute * 1}, + }, + }, + }, + }, + }, + }, }, } Expect(k8sClient.Create(ctx, controllerConfiguration)).To(Succeed()) @@ -335,6 +357,14 @@ var _ = BeforeSuite(func() { }).SetupWithManager(ctx, k8sManager) Expect(err).ToNot(HaveOccurred()) + err = (&ScheduledCommitStatusReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: k8sManager.GetEventRecorderFor("ScheduledCommitStatus"), + SettingsMgr: settingsMgr, + }).SetupWithManager(ctx, k8sManager) + Expect(err).ToNot(HaveOccurred()) + err = (&PromotionStrategyReconciler{ Client: k8sManager.GetClient(), Scheme: k8sManager.GetScheme(), diff --git a/internal/controller/timedcommitstatus_controller.go b/internal/controller/timedcommitstatus_controller.go index 9972bf5f1..12540a2ca 100644 --- a/internal/controller/timedcommitstatus_controller.go +++ b/internal/controller/timedcommitstatus_controller.go @@ -278,6 +278,9 @@ func (r *TimedCommitStatusReconciler) calculateCommitStatusPhase(requiredDuratio return promoterv1alpha1.CommitPhasePending, fmt.Sprintf("Waiting for time-based gate on %s environment", envBranch) } +// upsertCommitStatus creates or updates a CommitStatus resource for a given environment. +// +//nolint:dupl // Similar to ScheduledCommitStatus but with different labels and naming func (r *TimedCommitStatusReconciler) upsertCommitStatus(ctx context.Context, tcs *promoterv1alpha1.TimedCommitStatus, ps *promoterv1alpha1.PromotionStrategy, branch, sha string, phase promoterv1alpha1.CommitStatusPhase, message string, envBranch string) (*promoterv1alpha1.CommitStatus, error) { // Generate a consistent name for the CommitStatus commitStatusName := utils.KubeSafeUniqueName(ctx, fmt.Sprintf("%s-%s-timed", tcs.Name, branch)) diff --git a/internal/settings/manager.go b/internal/settings/manager.go index 3892636fa..992d67da4 100644 --- a/internal/settings/manager.go +++ b/internal/settings/manager.go @@ -34,7 +34,8 @@ type ControllerConfigurationTypes interface { promoterv1alpha1.PullRequestConfiguration | promoterv1alpha1.CommitStatusConfiguration | promoterv1alpha1.ArgoCDCommitStatusConfiguration | - promoterv1alpha1.TimedCommitStatusConfiguration + promoterv1alpha1.TimedCommitStatusConfiguration | + promoterv1alpha1.ScheduledCommitStatusConfiguration } // ControllerResultTypes is a constraint that defines the set of result types returned by controller @@ -277,6 +278,8 @@ func getWorkQueueForController[T ControllerConfigurationTypes](ctx context.Conte return config.Spec.ArgoCDCommitStatus.WorkQueue, nil case promoterv1alpha1.TimedCommitStatusConfiguration: return config.Spec.TimedCommitStatus.WorkQueue, nil + case promoterv1alpha1.ScheduledCommitStatusConfiguration: + return config.Spec.ScheduledCommitStatus.WorkQueue, nil default: return promoterv1alpha1.WorkQueue{}, fmt.Errorf("unsupported configuration type: %T", cfg) }