diff --git a/README.md b/README.md index f0b59fe..60dd0a2 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,11 @@ As a common example, if you sleep all of your staging/ephemeral deployments for helm install snorlax moonbeam/snorlax --create-namespace --namespace snorlax ``` -2. Create your `SleepSchedule` resource to define the schedule for the deployment +2. Create your `SleepSchedule` resource to define the schedule for the deployment. You can use either a daily window or cron schedule: + + **Using Daily Window:** ```yaml # filename: your-app-sleep-schedule.yaml - apiVersion: snorlax.moonbeam.nyc/v1beta1 kind: SleepSchedule metadata: @@ -47,13 +48,17 @@ As a common example, if you sleep all of your staging/ephemeral deployments for name: your-app spec: # Required fields - wakeTime: '8:00am' - sleepTime: '10:00pm' + dailyWindow: + wakeTime: '8:00am' + sleepTime: '10:00pm' deployments: - name: your-app-frontend - name: your-app-db - name: your-app-redis + # (optional, defaults to UTC) timezone for the sleep schedule using IANA format + timezone: 'America/New_York' + # (optional) the ingresses to update and point to the snorlax wake server, # which wakes your deployment when a request is received while it's # sleeping. @@ -65,9 +70,33 @@ As a common example, if you sleep all of your staging/ephemeral deployments for requires: - deployment: name: your-app-frontend + ``` + + **Using Cron Schedule:** + ```yaml + # filename: your-app-sleep-schedule.yaml + apiVersion: snorlax.moonbeam.nyc/v1beta1 + kind: SleepSchedule + metadata: + namespace: your-app-namespace + name: your-app + spec: + # Required fields + cronSchedule: + wakeSchedule: '0 8 * * 1-5' # Wake at 8am on weekdays + sleepSchedule: '0 22 * * *' # Sleep at 10pm daily + deployments: + - name: your-app-frontend + - name: your-app-db + - name: your-app-redis - # (optional, defaults to UTC) the timezone to use for the input times above + # Optional fields timezone: 'America/New_York' + ingresses: + - name: your-app-ingress + requires: + - deployment: + name: your-app-frontend ``` 3. Apply the `SleepSchedule` resource @@ -77,7 +106,8 @@ As a common example, if you sleep all of your staging/ephemeral deployments for ## Other features -- **Ingress controller awareness**: Snorlax determines which ingress controller you're running so it can create the correct ingress routes for sleep. +- **Flexible scheduling**: Support for both daily windows and cron expressions for more complex scheduling needs +- **Ingress controller awareness**: Snorlax determines which ingress controller you're running so it can create the correct ingress routes for sleep - **Stays awake until next sleep cycle**: If a request is received during the sleep time, the deployment will stay awake until the next sleep cycle - **Ignores ELB health checks**: Snorlax ignores health checks from ELBs so that they don't wake up the deployment @@ -114,7 +144,6 @@ make dev-run - Scale entire namespaces - Sleep when no requests are received for a certain period of time - Add support for custom wake & sleep actions (e.g. hit a webhook on wake) -- Add support for cron-style schedules (e.g. `0 8 * * *`) - Add a button to manually wake up the deployment (instead of auto-waking on request) - Custom image/gif for sleeping page - Always sleeping mode, reset at a certain time of day diff --git a/charts/snorlax/templates/sleepschedule-crd.yaml b/charts/snorlax/templates/sleepschedule-crd.yaml index 2fee715..1f9b123 100644 --- a/charts/snorlax/templates/sleepschedule-crd.yaml +++ b/charts/snorlax/templates/sleepschedule-crd.yaml @@ -76,19 +76,42 @@ spec: - name type: object type: array - sleepTime: - description: The time that the deployment will start sleeping - type: string + dailyWindow: + description: The daily times that the deployment will wake up and start sleeping + properties: + wakeTime: + description: The time that the deployment will wake up + type: string + sleepTime: + description: The time that the deployment will start sleeping + type: string + required: + - wakeTime + - sleepTime + type: object + cronSchedule: + description: The cron schedule that will be used to determine when the deployment will sleep/wake + properties: + wakeSchedule: + description: The cron schedule for when the deployment should wake up + type: string + sleepSchedule: + description: The cron schedule for when the deployment should go to sleep + type: string + required: + - wakeSchedule + - sleepSchedule + type: object timezone: description: The timezone that the input times are based in type: string - wakeTime: - description: The time that the deployment will wake up - type: string - required: - - sleepTime - - timezone - - wakeTime + oneOf: + - required: + - dailyWindow + - timezone + - required: + - cronSchedule + - timezone type: object status: description: SleepScheduleStatus defines the observed state of SleepSchedule diff --git a/dummy-app/k8s-manifests.yaml b/dummy-app/k8s-manifests.yaml index ab46af9..0f909d2 100644 --- a/dummy-app/k8s-manifests.yaml +++ b/dummy-app/k8s-manifests.yaml @@ -4,8 +4,9 @@ metadata: namespace: default name: dummy spec: - wakeTime: '10:00am' - sleepTime: '10:01am' + dailyWindow: + wakeTime: '10:00am' + sleepTime: '10:01am' timezone: 'America/New_York' deployments: - name: dummy-frontend diff --git a/operator/Makefile b/operator/Makefile index f606809..d155e25 100644 --- a/operator/Makefile +++ b/operator/Makefile @@ -113,7 +113,7 @@ vet: ## Run go vet against code. .PHONY: test test: manifests generate fmt vet envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -v -ginkgo.v -coverprofile cover.out # Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. .PHONY: test-e2e # Run the e2e tests against a Kind k8s instance that is spun up. diff --git a/operator/api/v1beta1/sleepschedule_types.go b/operator/api/v1beta1/sleepschedule_types.go index 8d8e7c7..befc810 100644 --- a/operator/api/v1beta1/sleepschedule_types.go +++ b/operator/api/v1beta1/sleepschedule_types.go @@ -23,10 +23,7 @@ import ( // 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. -type SleepScheduleSpec struct { - // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster - // Important: Run "make" to regenerate code after modifying this file - +type DailyWindow struct { // The time that the deployment will wake up // +kubebuilder:validation:Required WakeTime string `json:"wakeTime"` @@ -34,9 +31,32 @@ type SleepScheduleSpec struct { // The time that the deployment will start sleeping // +kubebuilder:validation:Required SleepTime string `json:"sleepTime"` +} - // The timezone that the input times are based in +type CronSchedule struct { + // The cron schedule for when the deployment should wake up + // +kubebuilder:validation:Required + WakeSchedule string `json:"wakeSchedule"` + + // The cron schedule for when the deployment should go to sleep // +kubebuilder:validation:Required + SleepSchedule string `json:"sleepSchedule"` +} + +// SleepScheduleSpec defines optional and required fields for SleepSchedule +// +kubebuilder:validation:XValidation:rule="has(self.dailyWindow) != has(self.cronSchedule)",message="exactly one of dailyWindow or cronSchedule must be specified" +type SleepScheduleSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + + // The time that the deployment will wake up and start sleeping + DailyWindow *DailyWindow `json:"dailyWindow,omitempty"` + + // The cron schedule that will be used to determine when the deployment will sleep/wake + CronSchedule *CronSchedule `json:"cronSchedule,omitempty"` + + // The timezone that the input times are based in + // +optional Timezone string `json:"timezone"` // The deployments that will be slept/woken. diff --git a/operator/api/v1beta1/zz_generated.deepcopy.go b/operator/api/v1beta1/zz_generated.deepcopy.go index 9e5a4b1..26a8cc5 100644 --- a/operator/api/v1beta1/zz_generated.deepcopy.go +++ b/operator/api/v1beta1/zz_generated.deepcopy.go @@ -24,6 +24,36 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CronSchedule) DeepCopyInto(out *CronSchedule) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CronSchedule. +func (in *CronSchedule) DeepCopy() *CronSchedule { + if in == nil { + return nil + } + out := new(CronSchedule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DailyWindow) DeepCopyInto(out *DailyWindow) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DailyWindow. +func (in *DailyWindow) DeepCopy() *DailyWindow { + if in == nil { + return nil + } + out := new(DailyWindow) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Deployment) DeepCopyInto(out *Deployment) { *out = *in @@ -137,6 +167,16 @@ func (in *SleepScheduleList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SleepScheduleSpec) DeepCopyInto(out *SleepScheduleSpec) { *out = *in + if in.DailyWindow != nil { + in, out := &in.DailyWindow, &out.DailyWindow + *out = new(DailyWindow) + **out = **in + } + if in.CronSchedule != nil { + in, out := &in.CronSchedule, &out.CronSchedule + *out = new(CronSchedule) + **out = **in + } if in.Deployments != nil { in, out := &in.Deployments, &out.Deployments *out = make([]Deployment, len(*in)) diff --git a/operator/cmd/main.go b/operator/cmd/main.go index 37a7de3..0501452 100644 --- a/operator/cmd/main.go +++ b/operator/cmd/main.go @@ -36,7 +36,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" snorlaxv1beta1 "moonbeam-nyc/snorlax/api/v1beta1" - "moonbeam-nyc/snorlax/internal/controller" + controller "moonbeam-nyc/snorlax/internal/controller" + util "moonbeam-nyc/snorlax/internal/util" //+kubebuilder:scaffold:imports ) @@ -124,10 +125,11 @@ func main() { os.Exit(1) } - if err = (&controller.SleepScheduleReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - }).SetupWithManager(mgr); err != nil { + if err = controller.NewReconciler( + mgr.GetClient(), + mgr.GetScheme(), + util.RealTime{}, + ).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "SleepSchedule") os.Exit(1) } diff --git a/operator/config/crd/bases/snorlax.moonbeam.nyc_sleepschedules.yaml b/operator/config/crd/bases/snorlax.moonbeam.nyc_sleepschedules.yaml index 148fce5..5d7105b 100644 --- a/operator/config/crd/bases/snorlax.moonbeam.nyc_sleepschedules.yaml +++ b/operator/config/crd/bases/snorlax.moonbeam.nyc_sleepschedules.yaml @@ -37,7 +37,38 @@ spec: metadata: type: object spec: + description: SleepScheduleSpec defines optional and required fields for + SleepSchedule properties: + cronSchedule: + description: The cron schedule that will be used to determine when + the deployment will sleep/wake + properties: + sleepSchedule: + description: The cron schedule for when the deployment should + go to sleep + type: string + wakeSchedule: + description: The cron schedule for when the deployment should + wake up + type: string + required: + - sleepSchedule + - wakeSchedule + type: object + dailyWindow: + description: The time that the deployment will wake up and start sleeping + properties: + sleepTime: + description: The time that the deployment will start sleeping + type: string + wakeTime: + description: The time that the deployment will wake up + type: string + required: + - sleepTime + - wakeTime + type: object deployments: description: The deployments that will be slept/woken. items: @@ -75,20 +106,13 @@ spec: - name type: object type: array - sleepTime: - description: The time that the deployment will start sleeping - type: string timezone: description: The timezone that the input times are based in type: string - wakeTime: - description: The time that the deployment will wake up - type: string - required: - - sleepTime - - timezone - - wakeTime type: object + x-kubernetes-validations: + - message: exactly one of dailyWindow or cronSchedule must be specified + rule: has(self.dailyWindow) != has(self.cronSchedule) status: description: SleepScheduleStatus defines the observed state of SleepSchedule properties: diff --git a/operator/config/samples/snorlax_v1beta1_sleepschedule.yaml b/operator/config/samples/snorlax_v1beta1_sleepschedule.yaml index 14fd063..40b6a28 100644 --- a/operator/config/samples/snorlax_v1beta1_sleepschedule.yaml +++ b/operator/config/samples/snorlax_v1beta1_sleepschedule.yaml @@ -10,8 +10,9 @@ metadata: namespace: default name: sleepschedule-sample spec: - wakeTime: 7am - sleepTime: 11pm + dailyWindow: + wakeTime: 7am + sleepTime: 11pm timezone: America/New_York deploymentName: dummy-deployment wakeReplicas: 3 diff --git a/operator/go.mod b/operator/go.mod index a4e79d3..bdbc4e1 100644 --- a/operator/go.mod +++ b/operator/go.mod @@ -13,6 +13,7 @@ require ( ) require ( + github.com/adhocore/gronx v1.19.5 // indirect github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect diff --git a/operator/go.sum b/operator/go.sum index f65ed51..34bfc15 100644 --- a/operator/go.sum +++ b/operator/go.sum @@ -1,3 +1,5 @@ +github.com/adhocore/gronx v1.19.5 h1:cwIG4nT1v9DvadxtHBe6MzE+FZ1JDvAUC45U2fl4eSQ= +github.com/adhocore/gronx v1.19.5/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df h1:7RFfzj4SSt6nnvCPbCqijJi1nWCd+TqAT3bYCStRC18= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/operator/internal/controller/sleepschedule_controller.go b/operator/internal/controller/sleepschedule_controller.go index a8dcece..1ecf540 100644 --- a/operator/internal/controller/sleepschedule_controller.go +++ b/operator/internal/controller/sleepschedule_controller.go @@ -21,10 +21,13 @@ import ( "encoding/json" "fmt" snorlaxv1beta1 "moonbeam-nyc/snorlax/api/v1beta1" + util "moonbeam-nyc/snorlax/internal/util" "strconv" "sync" "time" + "github.com/adhocore/gronx" + "gopkg.in/yaml.v2" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -47,14 +50,23 @@ type key string type SleepScheduleReconciler struct { client.Client Scheme *runtime.Scheme + Clock util.Clock } type SleepScheduleData struct { - Location *time.Location - Now time.Time + Timezone *time.Location + DailyWindow *DailyWindow + CronSchedule *CronSchedule +} + +type DailyWindow struct { WakeTime time.Time SleepTime time.Time - Timezone *time.Location +} + +type CronSchedule struct { + WakeSchedule string + SleepSchedule string } const finalizer = "finalizer.snorlax.moonbeam.nyc" @@ -71,44 +83,43 @@ const finalizer = "finalizer.snorlax.moonbeam.nyc" //+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=get;watch;list;create;delete //+kubebuilder:rbac:groups=core,resources=pods,verbs=get;watch;list +// NewReconciler constructs a new SleepScheduleReconciler with required fields +func NewReconciler(client client.Client, scheme *runtime.Scheme, clock util.Clock) *SleepScheduleReconciler { + if clock == nil { + clock = util.RealTime{} + } + return &SleepScheduleReconciler{ + Client: client, + Scheme: scheme, + Clock: clock, + } +} + func (r *SleepScheduleReconciler) ProcessSleepSchedule(ctx context.Context, sleepSchedule *snorlaxv1beta1.SleepSchedule) (*SleepScheduleData, error) { log := log.FromContext(ctx) sleepScheduleData := &SleepScheduleData{} - // Load location + // Load the timezone + // NOTE: time.LoadLocation defaults to UTC if not specified var err error - sleepScheduleData.Location, err = time.LoadLocation(sleepSchedule.Spec.Timezone) + sleepScheduleData.Timezone, err = time.LoadLocation(sleepSchedule.Spec.Timezone) if err != nil { log.Error(err, "failed to load timezone") return nil, err } - // Parse the wake time - sleepScheduleData.WakeTime, err = time.Parse("3:04pm", sleepSchedule.Spec.WakeTime) - if err != nil { - log.Error(err, "failed to parse wake time") - return nil, err + // Load the wake time(s) and sleep time(s) + if sleepSchedule.Spec.DailyWindow != nil { + err = r.loadDailyWindow(sleepSchedule.Spec.DailyWindow, sleepScheduleData) + } else if sleepSchedule.Spec.CronSchedule != nil { + err = r.loadCronSchedule(sleepSchedule.Spec.CronSchedule, sleepScheduleData) } - // Parse the sleep time - sleepScheduleData.SleepTime, err = time.Parse("3:04pm", sleepSchedule.Spec.SleepTime) if err != nil { - log.Error(err, "failed to parse sleep time") + log.Error(err, "failed to load wake and sleep times") return nil, err } - // Load the timezone - if sleepSchedule.Spec.Timezone != "" { - var err error - sleepScheduleData.Timezone, err = time.LoadLocation(sleepSchedule.Spec.Timezone) - if err != nil { - log.Error(err, "failed to load time zone") - return nil, err - } - } else { - sleepScheduleData.Timezone = time.UTC - } - return sleepScheduleData, nil } @@ -152,11 +163,6 @@ func (r *SleepScheduleReconciler) Reconcile(ctx context.Context, req ctrl.Reques return ctrl.Result{}, err } - // Load current time - now := time.Now().In(sleepScheduleData.Location) - wakeDatetime := time.Date(now.Year(), now.Month(), now.Day(), sleepScheduleData.WakeTime.Hour(), sleepScheduleData.WakeTime.Minute(), 0, 0, sleepScheduleData.Timezone) - sleepDatetime := time.Date(now.Year(), now.Month(), now.Day(), sleepScheduleData.SleepTime.Hour(), sleepScheduleData.SleepTime.Minute(), 0, 0, sleepScheduleData.Timezone) - // Determine if the app is awake awake, err := r.isAppAwake(ctx, sleepSchedule) if err != nil { @@ -164,9 +170,10 @@ func (r *SleepScheduleReconciler) Reconcile(ctx context.Context, req ctrl.Reques return ctrl.Result{}, err } - // Update status based on the actual check + // Update status using patch to handle stale resource versions + original := sleepSchedule.DeepCopy() sleepSchedule.Status.Awake = awake - err = r.Status().Update(ctx, sleepSchedule) + err = r.Status().Patch(ctx, sleepSchedule, client.MergeFrom(original)) if err != nil { log.Error(err, "failed to update SleepSchedule status") return ctrl.Result{}, err @@ -197,11 +204,10 @@ func (r *SleepScheduleReconciler) Reconcile(ctx context.Context, req ctrl.Reques } // Determine if the app should be awake or asleep - var shouldSleep bool - if wakeDatetime.Before(sleepDatetime) { - shouldSleep = now.Before(wakeDatetime) || now.After(sleepDatetime) - } else { - shouldSleep = now.After(sleepDatetime) && now.Before(wakeDatetime) + shouldSleep, err := r.shouldSleep(sleepScheduleData) + if err != nil { + log.Error(err, "Failed to determine sleep state") + return ctrl.Result{}, err } // Determine if the app was woken up by a request @@ -362,6 +368,139 @@ func (r *SleepScheduleReconciler) isAppAwake(ctx context.Context, sleepSchedule return false, nil } +func (r *SleepScheduleReconciler) loadDailyWindow(spec *snorlaxv1beta1.DailyWindow, data *SleepScheduleData) error { + data.DailyWindow = &DailyWindow{} + + // Parse the wake time + wakeTime, err := time.Parse("3:04pm", spec.WakeTime) + if err != nil { + return fmt.Errorf("failed to parse wake time: %w", err) + } + + // Parse the sleep time + sleepTime, err := time.Parse("3:04pm", spec.SleepTime) + if err != nil { + return fmt.Errorf("failed to parse sleep time: %w", err) + } + + // Get current time in the timezone + now := r.Clock.Now(data.Timezone) + + // Set the wake and sleep datetimes + data.DailyWindow.WakeTime = time.Date(now.Year(), now.Month(), now.Day(), wakeTime.Hour(), wakeTime.Minute(), 0, 0, data.Timezone) + data.DailyWindow.SleepTime = time.Date(now.Year(), now.Month(), now.Day(), sleepTime.Hour(), sleepTime.Minute(), 0, 0, data.Timezone) + return nil +} + +func (r *SleepScheduleReconciler) loadCronSchedule(spec *snorlaxv1beta1.CronSchedule, data *SleepScheduleData) error { + data.CronSchedule = &CronSchedule{} + gron := gronx.New() + + // Validate wake schedule cron format + if !gron.IsValid(spec.WakeSchedule) { + return fmt.Errorf("invalid cron wake schedule: %s", spec.WakeSchedule) + } + + // Validate sleep schedule cron format + if !gron.IsValid(spec.SleepSchedule) { + return fmt.Errorf("invalid cron sleep schedule: %s", spec.SleepSchedule) + } + + data.CronSchedule.WakeSchedule = spec.WakeSchedule + data.CronSchedule.SleepSchedule = spec.SleepSchedule + return nil +} + +func findLastScheduledTime(cronExpr string, refTime time.Time) (time.Time, error) { + // Start looking from 7 days ago to handle weekly schedules + checkTime := refTime.AddDate(0, 0, -7) + var lastTime time.Time + + for checkTime.Before(refTime) { + nextTime, err := gronx.NextTickAfter(cronExpr, checkTime, false) + if err != nil { + return time.Time{}, err + } + + if nextTime.After(refTime) { + break + } + + lastTime = nextTime + checkTime = nextTime.Add(time.Second) + } + + return lastTime, nil +} + +func (r *SleepScheduleReconciler) shouldSleep(data *SleepScheduleData) (bool, error) { + shouldSleep := false + var err error + + // Check that either dailyWindow or cronSchedule is defined + if data.DailyWindow == nil && data.CronSchedule == nil { + err = fmt.Errorf("dailyWindow and cronSchedule not defined") + return shouldSleep, err + } + + // Get the current time + now := r.Clock.Now(data.Timezone) + + // Handle daily window case + if data.DailyWindow != nil { + wakeDatetime := data.DailyWindow.WakeTime + sleepDatetime := data.DailyWindow.SleepTime + + if wakeDatetime.Before(sleepDatetime) { + shouldSleep = now.Before(wakeDatetime) || now.After(sleepDatetime) + } else { + shouldSleep = now.After(sleepDatetime) && now.Before(wakeDatetime) + } + } else if data.CronSchedule != nil { + // Find the most recent wake and sleep times before now + var lastWake, lastSleep time.Time + lastWake, err = findLastScheduledTime(data.CronSchedule.WakeSchedule, now) + if err != nil { + err = fmt.Errorf("failed to get last wake schedule time: %w", err) + return shouldSleep, err + } + + lastSleep, err = findLastScheduledTime(data.CronSchedule.SleepSchedule, now) + if err != nil { + err = fmt.Errorf("failed to get last sleep schedule time: %w", err) + return shouldSleep, err + } + + // Find the next wake and sleep times after now + var nextWake, nextSleep time.Time + nextWake, err = gronx.NextTickAfter(data.CronSchedule.WakeSchedule, now, false) + if err != nil { + err = fmt.Errorf("failed to get next wake schedule time: %w", err) + return shouldSleep, err + } + + nextSleep, err = gronx.NextTickAfter(data.CronSchedule.SleepSchedule, now, false) + if err != nil { + err = fmt.Errorf("failed to get next sleep schedule time: %w", err) + return shouldSleep, err + } + + // NOTE: If two times (sleep and wake) overlap, implicityly defaults to wake + // If we have no previous events, use only the next events + if lastWake.IsZero() && lastSleep.IsZero() { + shouldSleep = !nextWake.Before(nextSleep) // Sleep until first wake + } else if lastSleep.After(lastWake) && now.Before(nextWake) { + // If the last event was asleep and we haven't reached the next wake time + shouldSleep = true + } else if lastWake.After(lastSleep) && now.Before(nextSleep) { + // If the last event was a wake and we haven't reached the next sleep time + shouldSleep = false + } + } + + return shouldSleep, err +} + func (r *SleepScheduleReconciler) wake(ctx context.Context, sleepSchedule *snorlaxv1beta1.SleepSchedule) error { // Scale up each deployment var wg sync.WaitGroup diff --git a/operator/internal/controller/sleepschedule_controller_test.go b/operator/internal/controller/sleepschedule_controller_test.go index d4aa494..e2c91dd 100644 --- a/operator/internal/controller/sleepschedule_controller_test.go +++ b/operator/internal/controller/sleepschedule_controller_test.go @@ -18,6 +18,7 @@ package controller import ( "context" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -28,6 +29,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" snorlaxv1beta1 "moonbeam-nyc/snorlax/api/v1beta1" + util "moonbeam-nyc/snorlax/internal/util" ) var _ = Describe("SleepSchedule Controller", func() { @@ -43,7 +45,7 @@ var _ = Describe("SleepSchedule Controller", func() { sleepschedule := &snorlaxv1beta1.SleepSchedule{} BeforeEach(func() { - By("creating the custom resource for the Kind SleepSchedule") + By("Creating the custom resource for the Kind SleepSchedule") err := k8sClient.Get(ctx, typeNamespacedName, sleepschedule) if err != nil && errors.IsNotFound(err) { resource := &snorlaxv1beta1.SleepSchedule{ @@ -51,10 +53,19 @@ var _ = Describe("SleepSchedule Controller", func() { Name: resourceName, Namespace: "default", }, + // NOTE: The kubebuilder validations are ignored during testing, but the controller + // logic requires exactly one of dailyWindow or cronSchedule. + Spec: snorlaxv1beta1.SleepScheduleSpec{ + DailyWindow: &snorlaxv1beta1.DailyWindow{ + WakeTime: "10:00am", + SleepTime: "10:01am", + }, + }, // TODO(user): Specify other spec details if needed. } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) } + }) AfterEach(func() { @@ -66,19 +77,275 @@ var _ = Describe("SleepSchedule Controller", func() { By("Cleanup the specific resource instance SleepSchedule") Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) }) + It("should successfully reconcile the resource", func() { - By("Reconciling the created resource") - controllerReconciler := &SleepScheduleReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + // Set the current time to 12:00am + location, err := time.LoadLocation(sleepschedule.Spec.Timezone) + Expect(err).NotTo(HaveOccurred()) + timeStub := util.MockTime{ + Time: time.Date(2025, 1, 1, 0, 0, 0, 0, location), } - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + // Create the reconciler + controllerReconciler := NewReconciler( + k8sClient, + k8sClient.Scheme(), + timeStub, + ) + + By("Reconciling the created resource") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: typeNamespacedName, }) Expect(err).NotTo(HaveOccurred()) + Expect(sleepschedule.Status.Awake).To(Equal(false)) // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. // Example: If you expect a certain status condition after reconciliation, verify it here. }) + + Context("with DailyWindow", func() { + BeforeEach(func() { + By("Setting default DailyWindow to 8:00am wake and 10:00pm sleep") + sleepschedule.Spec = snorlaxv1beta1.SleepScheduleSpec{ + DailyWindow: &snorlaxv1beta1.DailyWindow{ + WakeTime: "8:00am", + SleepTime: "10:00pm", + }, + } + Expect(k8sClient.Update(ctx, sleepschedule)).To(Succeed()) + }) + + It("should process DailyWindow spec correctly", func() { + // Get the existing SleepSchedule created by BeforeEach + existingSchedule := &snorlaxv1beta1.SleepSchedule{} + err := k8sClient.Get(ctx, typeNamespacedName, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + + // Create a time stub + location, err := time.LoadLocation(existingSchedule.Spec.Timezone) + Expect(err).NotTo(HaveOccurred()) + timeStub := util.MockTime{ + Time: time.Date(2025, 1, 1, 0, 0, 0, 0, location), + } + + // Create reconciler + reconciler := NewReconciler( + k8sClient, + k8sClient.Scheme(), + timeStub, + ) + + By("Processing the SleepSchedule with DailyWindow") + scheduleData, err := reconciler.ProcessSleepSchedule(ctx, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + Expect(scheduleData).NotTo(BeNil()) + Expect(scheduleData.DailyWindow).NotTo(BeNil()) + Expect(scheduleData.CronSchedule).To(BeNil()) + + By("Checking that the wake time is correct") + Expect(scheduleData.DailyWindow.WakeTime).To(Equal(time.Date(2025, 1, 1, 8, 0, 0, 0, location))) + + By("Checking that the sleep time is correct") + Expect(scheduleData.DailyWindow.SleepTime).To(Equal(time.Date(2025, 1, 1, 22, 0, 0, 0, location))) + }) + + It("should determine schedule is awake at 8:01am on a weekday", func() { + // Get the existing SleepSchedule created by BeforeEach + existingSchedule := &snorlaxv1beta1.SleepSchedule{} + err := k8sClient.Get(ctx, typeNamespacedName, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + + By("Setting the current time to 8:01am on a Wednesday") + location, err := time.LoadLocation(existingSchedule.Spec.Timezone) + Expect(err).NotTo(HaveOccurred()) + timeStub := util.MockTime{ + Time: time.Date(2025, 1, 1, 8, 1, 0, 0, location), + } + + // Create reconciler + reconciler := NewReconciler( + k8sClient, + k8sClient.Scheme(), + timeStub, + ) + + By("Processing the SleepSchedule with DailyWindow") + scheduleData, err := reconciler.ProcessSleepSchedule(ctx, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + + By("Checking that the SleepSchedule should be awake") + shouldBeAsleep, err := reconciler.shouldSleep(scheduleData) + Expect(err).NotTo(HaveOccurred()) + Expect(shouldBeAsleep).To(BeFalse()) + }) + + It("should determine schedule is asleep at 10:01pm on a weekday", func() { + // Get the existing SleepSchedule created by BeforeEach + existingSchedule := &snorlaxv1beta1.SleepSchedule{} + err := k8sClient.Get(ctx, typeNamespacedName, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + + By("Setting the current time to 10:01pm on a Wednesday") + location, err := time.LoadLocation(existingSchedule.Spec.Timezone) + Expect(err).NotTo(HaveOccurred()) + timeStub := util.MockTime{ + Time: time.Date(2025, 1, 1, 22, 1, 0, 0, location), + } + + // Create reconciler + reconciler := NewReconciler( + k8sClient, + k8sClient.Scheme(), + timeStub, + ) + + By("Processing the SleepSchedule with DailyWindow") + scheduleData, err := reconciler.ProcessSleepSchedule(ctx, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + + By("Checking that the SleepSchedule should be asleep") + shouldBeAsleep, err := reconciler.shouldSleep(scheduleData) + Expect(err).NotTo(HaveOccurred()) + Expect(shouldBeAsleep).To(BeTrue()) + }) + + }) + + Context("with CronSchedule", func() { + BeforeEach(func() { + By("Setting CronSchedule to wake at 8am on weekdays and sleep at 10pm daily") + sleepschedule.Spec = snorlaxv1beta1.SleepScheduleSpec{ + CronSchedule: &snorlaxv1beta1.CronSchedule{ + WakeSchedule: "0 8 * * 1-5", // 8 AM every weekday + SleepSchedule: "0 22 * * *", // 10 PM every day + }, + } + Expect(k8sClient.Update(ctx, sleepschedule)).To(Succeed()) + }) + + It("should process CronSchedule spec correctly", func() { + // Get the existing SleepSchedule created by BeforeEach + existingSchedule := &snorlaxv1beta1.SleepSchedule{} + err := k8sClient.Get(ctx, typeNamespacedName, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + + // Create a time stub + location, err := time.LoadLocation(existingSchedule.Spec.Timezone) + Expect(err).NotTo(HaveOccurred()) + timeStub := util.MockTime{ + Time: time.Date(2025, 1, 1, 0, 0, 0, 0, location), + } + + // Create reconciler + reconciler := NewReconciler( + k8sClient, + k8sClient.Scheme(), + timeStub, + ) + + By("Processing the SleepSchedule") + scheduleData, err := reconciler.ProcessSleepSchedule(ctx, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + Expect(scheduleData).NotTo(BeNil()) + Expect(scheduleData.CronSchedule).NotTo(BeNil()) + Expect(scheduleData.DailyWindow).To(BeNil()) + + By("Checking that the wake schedule is correct") + Expect(scheduleData.CronSchedule.WakeSchedule).To(Equal("0 8 * * 1-5")) + + By("Checking that the sleep schedule is correct") + Expect(scheduleData.CronSchedule.SleepSchedule).To(Equal("0 22 * * *")) + }) + + It("should determine schedule is awake at 8:01am on a weekday", func() { + // Get the existing SleepSchedule created by BeforeEach + existingSchedule := &snorlaxv1beta1.SleepSchedule{} + err := k8sClient.Get(ctx, typeNamespacedName, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + + By("Setting the current time to 8:01am on a Wednesday") + location, err := time.LoadLocation(existingSchedule.Spec.Timezone) + Expect(err).NotTo(HaveOccurred()) + timeStub := util.MockTime{ + Time: time.Date(2025, 1, 1, 8, 1, 0, 0, location), + } + + // Create reconciler + reconciler := NewReconciler( + k8sClient, + k8sClient.Scheme(), + timeStub, + ) + + By("Processing the SleepSchedule") + scheduleData, err := reconciler.ProcessSleepSchedule(ctx, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + + By("Checking that the SleepSchedule should be awake") + shouldBeAsleep, err := reconciler.shouldSleep(scheduleData) + Expect(err).NotTo(HaveOccurred()) + Expect(shouldBeAsleep).To(BeFalse()) + }) + + It("should determine schedule is asleep at 10:01pm on a weekday", func() { + // Get the existing SleepSchedule created by BeforeEach + existingSchedule := &snorlaxv1beta1.SleepSchedule{} + err := k8sClient.Get(ctx, typeNamespacedName, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + + By("Setting the current time to 10:01pm on a Wednesday") + location, err := time.LoadLocation(existingSchedule.Spec.Timezone) + Expect(err).NotTo(HaveOccurred()) + timeStub := util.MockTime{ + Time: time.Date(2025, 1, 1, 22, 1, 0, 0, location), + } + + // Create reconciler + reconciler := NewReconciler( + k8sClient, + k8sClient.Scheme(), + timeStub, + ) + + By("Processing the SleepSchedule") + scheduleData, err := reconciler.ProcessSleepSchedule(ctx, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + + By("Checking that the SleepSchedule should be asleep") + shouldBeAsleep, err := reconciler.shouldSleep(scheduleData) + Expect(err).NotTo(HaveOccurred()) + Expect(shouldBeAsleep).To(BeTrue()) + }) + + It("should determine schedule is asleep at 8:01am on the weekend", func() { + // Get the existing SleepSchedule created by BeforeEach + existingSchedule := &snorlaxv1beta1.SleepSchedule{} + err := k8sClient.Get(ctx, typeNamespacedName, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + + By("Setting the current time to 8:01am on a Saturday") + location, err := time.LoadLocation(existingSchedule.Spec.Timezone) + Expect(err).NotTo(HaveOccurred()) + timeStub := util.MockTime{ + Time: time.Date(2025, 1, 4, 8, 1, 0, 0, location), + } + + // Create reconciler + reconciler := NewReconciler( + k8sClient, + k8sClient.Scheme(), + timeStub, + ) + + By("Processing the SleepSchedule") + scheduleData, err := reconciler.ProcessSleepSchedule(ctx, existingSchedule) + Expect(err).NotTo(HaveOccurred()) + + By("Checking that the SleepSchedule should be asleep") + shouldBeAsleep, err := reconciler.shouldSleep(scheduleData) + Expect(err).NotTo(HaveOccurred()) + Expect(shouldBeAsleep).To(BeTrue()) + }) + }) }) }) diff --git a/operator/internal/util/clock.go b/operator/internal/util/clock.go new file mode 100644 index 0000000..5728199 --- /dev/null +++ b/operator/internal/util/clock.go @@ -0,0 +1,24 @@ +package util + +import "time" + +// Clock abstracts time based operations necessary for easier testing +type Clock interface { + Now(location *time.Location) time.Time +} + +// RealTime provides actual system time +type RealTime struct{} + +func (RealTime) Now(location *time.Location) time.Time { + return time.Now().In(location) +} + +// MockTime provides a mock time +type MockTime struct { + Time time.Time +} + +func (m MockTime) Now(location *time.Location) time.Time { + return m.Time.In(location) +}