diff --git a/kwok/charts/crds/karpenter.sh_nodepools.yaml b/kwok/charts/crds/karpenter.sh_nodepools.yaml index 3a3a834a3b..f2223a3a40 100644 --- a/kwok/charts/crds/karpenter.sh_nodepools.yaml +++ b/kwok/charts/crds/karpenter.sh_nodepools.yaml @@ -575,6 +575,13 @@ spec: a 0s at the end. pattern: ^((([0-9]+(h|m))|([0-9]+h[0-9]+m))(0s)?)$ type: string + endDateTime: + description: |- + EndDateTime specifies the specific ending datetime the budget is active, following + the RFC3339 standard of "2006-01-02T15:04:05" - "yyyy-MM-DDTHH:MM:SS". + Timezone here is UTC if no TZ is specified within the spec. + pattern: ^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})$ + type: string nodes: default: 10% description: |- @@ -608,6 +615,18 @@ spec: This field is required if Duration is set. pattern: ^(@(annually|yearly|monthly|weekly|daily|midnight|hourly))|((.+)\s(.+)\s(.+)\s(.+)\s(.+))$ type: string + startDateTime: + description: |- + StartDateTime specifies the specific starting datetime the budget is active, following + the RFC3339 standard of "2006-01-02T15:04:05" - "yyyy-MM-DDTHH:MM:SS". + Timezone here is UTC if no TZ is specified within the spec. + pattern: ^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})$ + type: string + tz: + description: |- + TZ specifies the timezone of the budget. Follows the standard IANA "America/New_York" format. If not specified + defaults to "UTC". + type: string required: - nodes type: object diff --git a/pkg/apis/crds/karpenter.sh_nodepools.yaml b/pkg/apis/crds/karpenter.sh_nodepools.yaml index ab6489043f..6c3a694cab 100644 --- a/pkg/apis/crds/karpenter.sh_nodepools.yaml +++ b/pkg/apis/crds/karpenter.sh_nodepools.yaml @@ -573,6 +573,13 @@ spec: a 0s at the end. pattern: ^((([0-9]+(h|m))|([0-9]+h[0-9]+m))(0s)?)$ type: string + endDateTime: + description: |- + EndDateTime specifies the specific ending datetime the budget is active, following + the RFC3339 standard of "2006-01-02T15:04:05" - "yyyy-MM-DDTHH:MM:SS". + Timezone here is UTC if no TZ is specified within the spec. + pattern: ^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})$ + type: string nodes: default: 10% description: |- @@ -606,6 +613,18 @@ spec: This field is required if Duration is set. pattern: ^(@(annually|yearly|monthly|weekly|daily|midnight|hourly))|((.+)\s(.+)\s(.+)\s(.+)\s(.+))$ type: string + startDateTime: + description: |- + StartDateTime specifies the specific starting datetime the budget is active, following + the RFC3339 standard of "2006-01-02T15:04:05" - "yyyy-MM-DDTHH:MM:SS". + Timezone here is UTC if no TZ is specified within the spec. + pattern: ^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})$ + type: string + tz: + description: |- + TZ specifies the timezone of the budget. Follows the standard IANA "America/New_York" format. If not specified + defaults to "UTC". + type: string required: - nodes type: object diff --git a/pkg/apis/v1beta1/nodepool.go b/pkg/apis/v1beta1/nodepool.go index 21c9d9a5c6..0d3c2b3e88 100644 --- a/pkg/apis/v1beta1/nodepool.go +++ b/pkg/apis/v1beta1/nodepool.go @@ -22,6 +22,9 @@ import ( "math" "sort" "strconv" + "time" + + _ "time/tzdata" "github.com/mitchellh/hashstructure/v2" "github.com/robfig/cron/v3" @@ -132,6 +135,25 @@ type Budget struct { // +kubebuilder:validation:Type="string" // +optional Duration *metav1.Duration `json:"duration,omitempty" hash:"ignore"` + // StartDateTime specifies the specific starting datetime the budget is active, following + // the RFC3339 standard of "2006-01-02T15:04:05" - "yyyy-MM-DDTHH:MM:SS". + // Timezone here is UTC if no TZ is specified within the spec. + // +kubebuilder:validation:Pattern=`^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})$` + // +kubebuilder:validation:Type="string" + // +optional + StartDateTime *string `json:"startDateTime,omitempty" hash:"ignore"` + // EndDateTime specifies the specific ending datetime the budget is active, following + // the RFC3339 standard of "2006-01-02T15:04:05" - "yyyy-MM-DDTHH:MM:SS". + // Timezone here is UTC if no TZ is specified within the spec. + // +kubebuilder:validation:Pattern=`^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})$` + // +kubebuilder:validation:Type="string" + // +optional + EndDateTime *string `json:"endDateTime,omitempty" hash:"ignore"` + // TZ specifies the timezone of the budget. Follows the standard IANA "America/New_York" format. If not specified + // defaults to "UTC". + // +kubebuilder:validation:Type="string" + // +optional + TZ *string `json:"tz,omitempty" hash:"ignore"` } type ConsolidationPolicy string @@ -308,6 +330,35 @@ func (in *Budget) GetAllowedDisruptions(c clock.Clock, numNodes int) (int, error return res, nil } +func (in *Budget) IsActiveDateTime(c clock.Clock, loc *time.Location) (bool, error) { + if in.StartDateTime != nil { + startTime, errST := time.ParseInLocation("2006-01-02T15:04:05", lo.FromPtr(in.StartDateTime), loc) + if errST != nil { + return false, fmt.Errorf("error parsing start datetime %v: %w", lo.FromPtr(in.StartDateTime), errST) + } + // Check to see if current datetime is before starting time + if !c.Now().In(loc).After(startTime) { + return false, nil + } + // If a defined startDateTime and duration exist, check that case + if in.Duration != nil && in.Schedule == nil { + if !c.Now().In(loc).Before(startTime.Add(-lo.FromPtr(in.Duration).Duration)) { + return false, nil + } + } + } + if in.EndDateTime != nil { + endTime, errET := time.ParseInLocation("2006-01-02T15:04:05", lo.FromPtr(in.EndDateTime), loc) + if errET != nil { + return false, fmt.Errorf("error parsing end datetime %v: %w", lo.FromPtr(in.EndDateTime), errET) + } + if !c.Now().In(loc).Before(endTime) { + return false, nil + } + } + return true, nil +} + // IsActive takes a clock as input and returns if a budget is active. // It walks back in time the time.Duration associated with the schedule, // and checks if the next time the schedule will hit is before the current time. @@ -315,19 +366,34 @@ func (in *Budget) GetAllowedDisruptions(c clock.Clock, numNodes int) (int, error // schedule is active, as any more schedule hits in between would only extend this // window. This ensures that any previous schedule hits for a schedule are considered. func (in *Budget) IsActive(c clock.Clock) (bool, error) { - if in.Schedule == nil && in.Duration == nil { + if in.Schedule == nil && in.Duration == nil && in.StartDateTime == nil && in.EndDateTime == nil { return true, nil } - schedule, err := cron.ParseStandard(fmt.Sprintf("TZ=UTC %s", lo.FromPtr(in.Schedule))) + tz := "UTC" + if in.TZ != nil { + tz = lo.FromPtr(in.TZ) + } + loc, err := time.LoadLocation(tz) + if err != nil { + return false, fmt.Errorf("error loading timezone %v: %w", tz, err) + } + isActiveDateTime, err := in.IsActiveDateTime(c, loc) + if err != nil { + return false, err + } + if !isActiveDateTime { + return false, nil + } + schedule, err := cron.ParseStandard(fmt.Sprintf("TZ=%s %s", tz, lo.FromPtr(in.Schedule))) if err != nil { // Should only occur if there's a discrepancy // with the validation regex and the cron package. return false, fmt.Errorf("invariant violated, invalid cron %s", schedule) } // Walk back in time for the duration associated with the schedule - checkPoint := c.Now().UTC().Add(-lo.FromPtr(in.Duration).Duration) + checkPoint := c.Now().In(loc).Add(-lo.FromPtr(in.Duration).Duration) nextHit := schedule.Next(checkPoint) - return !nextHit.After(c.Now().UTC()), nil + return !nextHit.After(c.Now().In(loc)), nil } func GetIntStrFromValue(str string) intstr.IntOrString { diff --git a/pkg/apis/v1beta1/nodepool_budgets_test.go b/pkg/apis/v1beta1/nodepool_budgets_test.go index 4ec983e78a..cf428f4967 100644 --- a/pkg/apis/v1beta1/nodepool_budgets_test.go +++ b/pkg/apis/v1beta1/nodepool_budgets_test.go @@ -199,7 +199,7 @@ var _ = Describe("Budgets", func() { }) Context("IsActive", func() { - It("should always consider a schedule and time in UTC", func() { + It("should consider a schedule and time in UTC when no TZ defined", func() { // Set the time to start of June 2000 in a time zone 1 hour ahead of UTC fakeClock = clock.NewFakeClock(time.Date(2000, time.June, 0, 0, 0, 0, 0, time.FixedZone("fake-zone", 3600))) budgets[0].Schedule = lo.ToPtr("@daily") @@ -209,9 +209,11 @@ var _ = Describe("Budgets", func() { Expect(err).To(Succeed()) Expect(active).To(BeFalse()) }) - It("should return that a schedule is active when schedule and duration are nil", func() { + It("should return that a schedule is active when schedule, duration, startDateTime, and endDateTime are nil", func() { budgets[0].Schedule = nil budgets[0].Duration = nil + budgets[0].StartDateTime = nil + budgets[0].EndDateTime = nil active, err := budgets[0].IsActive(fakeClock) Expect(err).To(Succeed()) Expect(active).To(BeTrue()) @@ -237,7 +239,7 @@ var _ = Describe("Budgets", func() { Expect(active).To(BeTrue()) }) It("should return that a schedule is active when the duration is longer than the recurrence", func() { - // Set the date to the first monday in 2024, the best year ever + // Set the date to the first SUNDAY in 2024, the best year ever fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) budgets[0].Schedule = lo.ToPtr("@daily") budgets[0].Duration = lo.ToPtr(metav1.Duration{Duration: lo.Must(time.ParseDuration("48h"))}) @@ -246,7 +248,7 @@ var _ = Describe("Budgets", func() { Expect(active).To(BeTrue()) }) It("should return that a schedule is inactive when the schedule hit is after the duration", func() { - // Set the date to the first monday in 2024, the best year ever + // Set the date to the first SUNDAY in 2024, the best year ever fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) budgets[0].Schedule = lo.ToPtr("30 6 * * SUN") budgets[0].Duration = lo.ToPtr(metav1.Duration{Duration: lo.Must(time.ParseDuration("6h"))}) @@ -254,5 +256,177 @@ var _ = Describe("Budgets", func() { Expect(err).To(Succeed()) Expect(active).ToNot(BeTrue()) }) + It("should return that a schedule is active when a startime is defined and time is after that startDateTime", func() { + // Set the date to the first SUNDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) + budgets[0].StartDateTime = lo.ToPtr("2024-01-06T05:00:00") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).To(BeTrue()) + }) + It("should return that a schedule is inactive when a startime is defined and time is before that startDateTime", func() { + // Set the date to the first SUNDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) + budgets[0].StartDateTime = lo.ToPtr("2024-01-08T05:00:00") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).ToNot(BeTrue()) + }) + It("should return that a schedule is active when an endDateTime is defined and time is before that endDateTime", func() { + // Set the date to the first SUNDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) + budgets[0].EndDateTime = lo.ToPtr("2024-01-08T05:00:00") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).To(BeTrue()) + }) + It("should return that a schedule is inactive when a endDateTime is defined and time is before that endDateTime", func() { + // Set the date to the first SUNDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) + budgets[0].EndDateTime = lo.ToPtr("2024-01-06T05:00:00") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).ToNot(BeTrue()) + }) + It("should return that a schedule is active when a startDateTime and endDateTime is defined and time is between those dateTimes", func() { + // Set the date to the first SUNDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) + budgets[0].StartDateTime = lo.ToPtr("2024-01-05T05:00:00") + budgets[0].EndDateTime = lo.ToPtr("2024-01-08T05:00:00") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).To(BeTrue()) + }) + It("should return that a schedule is inactive when a startDateTime and endDateTime is defined and time is between those dateTimes", func() { + // Set the date to the first THURSDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 4, 0, 0, 0, 0, time.UTC)) + budgets[0].StartDateTime = lo.ToPtr("2024-01-05T05:00:00") + budgets[0].EndDateTime = lo.ToPtr("2024-01-08T05:00:00") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).ToNot(BeTrue()) + }) + }) + Context("IsActiveWithTZ", func() { + It("should return that a schedule is active with tz set", func() { + budgets[0].TZ = lo.ToPtr("America/Los_Angeles") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).To(BeTrue()) + }) + It("should return that a schedule is inactive with tz set", func() { + budgets[0].TZ = lo.ToPtr("America/Los_Angeles") + budgets[0].Schedule = lo.ToPtr("@yearly") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).To(BeFalse()) + }) + It("should return that a schedule is active when the schedule hit is in the middle of the duration with tz set", func() { + // Set the date to the start of the year 1000, the best year ever + fakeClock = clock.NewFakeClock(time.Date(1000, time.January, 1, 12, 0, 0, 0, time.UTC)) + budgets[0].TZ = lo.ToPtr("America/Los_Angeles") + budgets[0].Schedule = lo.ToPtr("@yearly") + budgets[0].Duration = lo.ToPtr(metav1.Duration{Duration: lo.Must(time.ParseDuration("24h"))}) + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).To(BeTrue()) + }) + It("should return that a schedule is active when the duration is longer than the recurrence", func() { + // Set the date to the first SUNDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) + budgets[0].TZ = lo.ToPtr("America/Los_Angeles") + budgets[0].Schedule = lo.ToPtr("@daily") + budgets[0].Duration = lo.ToPtr(metav1.Duration{Duration: lo.Must(time.ParseDuration("48h"))}) + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).To(BeTrue()) + }) + It("should return that a schedule is inactive when the schedule hit is after the duration", func() { + // Set the date to the first MONDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) + budgets[0].TZ = lo.ToPtr("America/Los_Angeles") + budgets[0].Schedule = lo.ToPtr("30 6 * * SUN") + budgets[0].Duration = lo.ToPtr(metav1.Duration{Duration: lo.Must(time.ParseDuration("6h"))}) + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).ToNot(BeTrue()) + }) + It("should return that a schedule is active when the schedule hit is during the duration in set tz", func() { + // Set the date to the first SUNDAY in 2024, the best year ever, at 5:00 PM UTC, 9:00 AM PST + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 17, 0, 0, 0, time.UTC)) + budgets[0].TZ = lo.ToPtr("America/Los_Angeles") + budgets[0].Schedule = lo.ToPtr("30 6 * * SUN") + budgets[0].Duration = lo.ToPtr(metav1.Duration{Duration: lo.Must(time.ParseDuration("6h"))}) + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).To(BeTrue()) + }) + It("should return that a schedule is active when a startime is defined and time is after that startDateTime in set tz", func() { + // Set the date to the first SUNDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) + budgets[0].TZ = lo.ToPtr("America/Los_Angeles") + budgets[0].StartDateTime = lo.ToPtr("2024-01-06T05:00:00") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).To(BeTrue()) + }) + It("should return that a schedule is inactive when a startime is defined and time is before that startDateTime in set tz", func() { + // Set the date to the first SUNDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) + budgets[0].TZ = lo.ToPtr("America/Los_Angeles") + budgets[0].StartDateTime = lo.ToPtr("2024-01-08T05:00:00") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).ToNot(BeTrue()) + }) + It("should return that a schedule is active when an endDateTime is defined and time is before that endDateTime in set tz", func() { + // Set the date to the first SUNDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) + budgets[0].TZ = lo.ToPtr("America/Los_Angeles") + budgets[0].EndDateTime = lo.ToPtr("2024-01-08T05:00:00") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).To(BeTrue()) + }) + It("should return that a schedule is inactive when a endDateTime is defined and time is before that endDateTime in set tz", func() { + // Set the date to the first SUNDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) + budgets[0].TZ = lo.ToPtr("America/Los_Angeles") + budgets[0].EndDateTime = lo.ToPtr("2024-01-06T05:00:00") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).ToNot(BeTrue()) + }) + It("should return that a schedule is active when a startDateTime and endDateTime is defined and time is between those dateTimes in set tz", func() { + // Set the date to the first SUNDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 0, 0, 0, 0, time.UTC)) + budgets[0].TZ = lo.ToPtr("America/Los_Angeles") + budgets[0].StartDateTime = lo.ToPtr("2024-01-05T05:00:00") + budgets[0].EndDateTime = lo.ToPtr("2024-01-08T05:00:00") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).To(BeTrue()) + }) + It("should return that a schedule is inactive when a startDateTime and endDateTime is defined and time is between those dateTimes in set tz", func() { + // Set the date to the first THURSDAY in 2024, the best year ever + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 4, 0, 0, 0, 0, time.UTC)) + budgets[0].TZ = lo.ToPtr("America/Los_Angeles") + budgets[0].StartDateTime = lo.ToPtr("2024-01-05T05:00:00") + budgets[0].EndDateTime = lo.ToPtr("2024-01-08T05:00:00") + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).ToNot(BeTrue()) + }) + It("should return that a schedule is inactive when a endDateTime, schedule, duration is defined, but the endtime comes before the end of duration in set tz", func() { + // Set the date to the first SUNDAY in 2024, the best year ever, at 5:00 PM UTC, 9:00 AM PST + fakeClock = clock.NewFakeClock(time.Date(2024, time.January, 7, 17, 0, 0, 0, time.UTC)) + budgets[0].TZ = lo.ToPtr("America/Los_Angeles") + budgets[0].EndDateTime = lo.ToPtr("2024-01-07T08:00:00") + budgets[0].Schedule = lo.ToPtr("30 6 * * SUN") + budgets[0].Duration = lo.ToPtr(metav1.Duration{Duration: lo.Must(time.ParseDuration("6h"))}) + active, err := budgets[0].IsActive(fakeClock) + Expect(err).To(Succeed()) + Expect(active).ToNot(BeTrue()) + }) }) }) diff --git a/pkg/apis/v1beta1/zz_generated.deepcopy.go b/pkg/apis/v1beta1/zz_generated.deepcopy.go index cace3c5108..17105bc713 100644 --- a/pkg/apis/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/v1beta1/zz_generated.deepcopy.go @@ -46,6 +46,21 @@ func (in *Budget) DeepCopyInto(out *Budget) { *out = new(metav1.Duration) **out = **in } + if in.StartDateTime != nil { + in, out := &in.StartDateTime, &out.StartDateTime + *out = new(string) + **out = **in + } + if in.EndDateTime != nil { + in, out := &in.EndDateTime, &out.EndDateTime + *out = new(string) + **out = **in + } + if in.TZ != nil { + in, out := &in.TZ, &out.TZ + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Budget.