mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: implement scheduling mechanism for prebuilds (#18126)
Closes https://github.com/coder/internal/issues/312 Depends on https://github.com/coder/terraform-provider-coder/pull/408 This PR adds support for defining an **autoscaling block** for prebuilds, allowing number of desired instances to scale dynamically based on a schedule. Example usage: ``` data "coder_workspace_preset" "us-nix" { ... prebuilds = { instances = 0 # default to 0 instances scheduling = { timezone = "UTC" # a single timezone is used for simplicity # Scale to 3 instances during the work week schedule { cron = "* 8-18 * * 1-5" # from 8AM–6:59PM, Mon–Fri, UTC instances = 3 # scale to 3 instances } # Scale to 1 instance on Saturdays for urgent support queries schedule { cron = "* 8-14 * * 6" # from 8AM–2:59PM, Sat, UTC instances = 1 # scale to 1 instance } } } } ``` ### Behavior - Multiple `schedule` blocks per `prebuilds` block are supported. - If the current time matches any defined autoscaling schedule, the corresponding number of instances is used. - If no schedule matches, the **default instance count** (`prebuilds.instances`) is used as a fallback. ### Why This feature allows prebuild instance capacity to adapt to predictable usage patterns, such as: - Scaling up during business hours or high-demand periods - Reducing capacity during off-hours to save resources ### Cron specification The cron specification is interpreted as a **continuous time range.** For example, the expression: ``` * 9-18 * * 1-5 ``` is intended to represent a continuous range from **09:00 to 18:59**, Monday through Friday. However, due to minor implementation imprecision, it is currently interpreted as a range from **08:59:00 to 18:58:59**, Monday through Friday. This slight discrepancy arises because the evaluation is based on whether a specific **point in time** falls within the range, using the `github.com/coder/coder/v2/coderd/schedule/cron` library, which performs per-minute matching rather than strict range evaluation. --------- Co-authored-by: Danny Kopping <danny@coder.com>
This commit is contained in:
co-authored by
Danny Kopping
parent
511fd09582
commit
0f6ca55238
@@ -1686,6 +1686,13 @@ func (q *querier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Tim
|
||||
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetAPIKeysLastUsedAfter)(ctx, lastUsed)
|
||||
}
|
||||
|
||||
func (q *querier) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTemplate.All()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetActivePresetPrebuildSchedules(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return 0, err
|
||||
@@ -3661,6 +3668,15 @@ func (q *querier) InsertPresetParameters(ctx context.Context, arg database.Inser
|
||||
return q.db.InsertPresetParameters(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertPresetPrebuildSchedule(ctx context.Context, arg database.InsertPresetPrebuildScheduleParams) (database.TemplateVersionPresetPrebuildSchedule, error) {
|
||||
err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceTemplate)
|
||||
if err != nil {
|
||||
return database.TemplateVersionPresetPrebuildSchedule{}, err
|
||||
}
|
||||
|
||||
return q.db.InsertPresetPrebuildSchedule(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertProvisionerJob(ctx context.Context, arg database.InsertProvisionerJobParams) (database.ProvisionerJob, error) {
|
||||
// TODO: Remove this once we have a proper rbac check for provisioner jobs.
|
||||
// Details in https://github.com/coder/coder/issues/16160
|
||||
|
||||
@@ -979,6 +979,29 @@ func (s *MethodTestSuite) TestOrganization() {
|
||||
}
|
||||
check.Args(insertPresetParametersParams).Asserts(rbac.ResourceTemplate, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("InsertPresetPrebuildSchedule", s.Subtest(func(db database.Store, check *expects) {
|
||||
org := dbgen.Organization(s.T(), db, database.Organization{})
|
||||
user := dbgen.User(s.T(), db, database.User{})
|
||||
template := dbgen.Template(s.T(), db, database.Template{
|
||||
CreatedBy: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
})
|
||||
templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{
|
||||
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
|
||||
OrganizationID: org.ID,
|
||||
CreatedBy: user.ID,
|
||||
})
|
||||
preset := dbgen.Preset(s.T(), db, database.InsertPresetParams{
|
||||
TemplateVersionID: templateVersion.ID,
|
||||
Name: "test",
|
||||
})
|
||||
arg := database.InsertPresetPrebuildScheduleParams{
|
||||
PresetID: preset.ID,
|
||||
}
|
||||
check.Args(arg).
|
||||
Asserts(rbac.ResourceTemplate, policy.ActionUpdate).
|
||||
ErrorsWithInMemDB(dbmem.ErrUnimplemented)
|
||||
}))
|
||||
s.Run("DeleteOrganizationMember", s.Subtest(func(db database.Store, check *expects) {
|
||||
o := dbgen.Organization(s.T(), db, database.Organization{})
|
||||
u := dbgen.User(s.T(), db, database.User{})
|
||||
@@ -4916,6 +4939,12 @@ func (s *MethodTestSuite) TestPrebuilds() {
|
||||
Asserts(template.RBACObject(), policy.ActionRead).
|
||||
Returns(insertedParameters)
|
||||
}))
|
||||
s.Run("GetActivePresetPrebuildSchedules", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args().
|
||||
Asserts(rbac.ResourceTemplate.All(), policy.ActionRead).
|
||||
Returns([]database.TemplateVersionPresetPrebuildSchedule{}).
|
||||
ErrorsWithInMemDB(dbmem.ErrUnimplemented)
|
||||
}))
|
||||
s.Run("GetPresetsByTemplateVersionID", s.Subtest(func(db database.Store, check *expects) {
|
||||
ctx := context.Background()
|
||||
org := dbgen.Organization(s.T(), db, database.Organization{})
|
||||
|
||||
@@ -415,6 +415,7 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse {
|
||||
CreatedAt: version.CreatedAt,
|
||||
DesiredInstances: preset.DesiredInstances,
|
||||
InvalidateAfterSecs: preset.InvalidateAfterSecs,
|
||||
SchedulingTimezone: preset.SchedulingTimezone,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1302,11 +1302,22 @@ func Preset(t testing.TB, db database.Store, seed database.InsertPresetParams) d
|
||||
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
|
||||
DesiredInstances: seed.DesiredInstances,
|
||||
InvalidateAfterSecs: seed.InvalidateAfterSecs,
|
||||
SchedulingTimezone: seed.SchedulingTimezone,
|
||||
})
|
||||
require.NoError(t, err, "insert preset")
|
||||
return preset
|
||||
}
|
||||
|
||||
func PresetPrebuildSchedule(t testing.TB, db database.Store, seed database.InsertPresetPrebuildScheduleParams) database.TemplateVersionPresetPrebuildSchedule {
|
||||
schedule, err := db.InsertPresetPrebuildSchedule(genCtx, database.InsertPresetPrebuildScheduleParams{
|
||||
PresetID: takeFirst(seed.PresetID, uuid.New()),
|
||||
CronExpression: takeFirst(seed.CronExpression, "* 9-18 * * 1-5"),
|
||||
DesiredInstances: takeFirst(seed.DesiredInstances, 1),
|
||||
})
|
||||
require.NoError(t, err, "insert preset prebuild schedule")
|
||||
return schedule
|
||||
}
|
||||
|
||||
func PresetParameter(t testing.TB, db database.Store, seed database.InsertPresetParametersParams) []database.TemplateVersionPresetParameter {
|
||||
parameters, err := db.InsertPresetParameters(genCtx, database.InsertPresetParametersParams{
|
||||
TemplateVersionPresetID: takeFirst(seed.TemplateVersionPresetID, uuid.New()),
|
||||
|
||||
@@ -2778,6 +2778,10 @@ func (q *FakeQuerier) GetAPIKeysLastUsedAfter(_ context.Context, after time.Time
|
||||
return apiKeys, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) {
|
||||
return nil, ErrUnimplemented
|
||||
}
|
||||
|
||||
// nolint:revive // It's not a control flag, it's a filter.
|
||||
func (q *FakeQuerier) GetActiveUserCount(_ context.Context, includeSystem bool) (int64, error) {
|
||||
q.mutex.RLock()
|
||||
@@ -9191,6 +9195,15 @@ func (q *FakeQuerier) InsertPresetParameters(_ context.Context, arg database.Ins
|
||||
return presetParameters, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) InsertPresetPrebuildSchedule(ctx context.Context, arg database.InsertPresetPrebuildScheduleParams) (database.TemplateVersionPresetPrebuildSchedule, error) {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
return database.TemplateVersionPresetPrebuildSchedule{}, err
|
||||
}
|
||||
|
||||
return database.TemplateVersionPresetPrebuildSchedule{}, ErrUnimplemented
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) InsertProvisionerJob(_ context.Context, arg database.InsertProvisionerJobParams) (database.ProvisionerJob, error) {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return database.ProvisionerJob{}, err
|
||||
|
||||
@@ -564,6 +564,13 @@ func (m queryMetricsStore) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed
|
||||
return apiKeys, err
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetActivePresetPrebuildSchedules(ctx)
|
||||
m.queryLatencies.WithLabelValues("GetActivePresetPrebuildSchedules").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error) {
|
||||
start := time.Now()
|
||||
count, err := m.s.GetActiveUserCount(ctx, includeSystem)
|
||||
@@ -2237,6 +2244,13 @@ func (m queryMetricsStore) InsertPresetParameters(ctx context.Context, arg datab
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertPresetPrebuildSchedule(ctx context.Context, arg database.InsertPresetPrebuildScheduleParams) (database.TemplateVersionPresetPrebuildSchedule, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.InsertPresetPrebuildSchedule(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("InsertPresetPrebuildSchedule").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertProvisionerJob(ctx context.Context, arg database.InsertProvisionerJobParams) (database.ProvisionerJob, error) {
|
||||
start := time.Now()
|
||||
job, err := m.s.InsertProvisionerJob(ctx, arg)
|
||||
|
||||
@@ -1022,6 +1022,21 @@ func (mr *MockStoreMockRecorder) GetAPIKeysLastUsedAfter(ctx, lastUsed any) *gom
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAPIKeysLastUsedAfter", reflect.TypeOf((*MockStore)(nil).GetAPIKeysLastUsedAfter), ctx, lastUsed)
|
||||
}
|
||||
|
||||
// GetActivePresetPrebuildSchedules mocks base method.
|
||||
func (m *MockStore) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetActivePresetPrebuildSchedules", ctx)
|
||||
ret0, _ := ret[0].([]database.TemplateVersionPresetPrebuildSchedule)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetActivePresetPrebuildSchedules indicates an expected call of GetActivePresetPrebuildSchedules.
|
||||
func (mr *MockStoreMockRecorder) GetActivePresetPrebuildSchedules(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActivePresetPrebuildSchedules", reflect.TypeOf((*MockStore)(nil).GetActivePresetPrebuildSchedules), ctx)
|
||||
}
|
||||
|
||||
// GetActiveUserCount mocks base method.
|
||||
func (m *MockStore) GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -4722,6 +4737,21 @@ func (mr *MockStoreMockRecorder) InsertPresetParameters(ctx, arg any) *gomock.Ca
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertPresetParameters", reflect.TypeOf((*MockStore)(nil).InsertPresetParameters), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertPresetPrebuildSchedule mocks base method.
|
||||
func (m *MockStore) InsertPresetPrebuildSchedule(ctx context.Context, arg database.InsertPresetPrebuildScheduleParams) (database.TemplateVersionPresetPrebuildSchedule, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "InsertPresetPrebuildSchedule", ctx, arg)
|
||||
ret0, _ := ret[0].(database.TemplateVersionPresetPrebuildSchedule)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// InsertPresetPrebuildSchedule indicates an expected call of InsertPresetPrebuildSchedule.
|
||||
func (mr *MockStoreMockRecorder) InsertPresetPrebuildSchedule(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertPresetPrebuildSchedule", reflect.TypeOf((*MockStore)(nil).InsertPresetPrebuildSchedule), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertProvisionerJob mocks base method.
|
||||
func (m *MockStore) InsertProvisionerJob(ctx context.Context, arg database.InsertProvisionerJobParams) (database.ProvisionerJob, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+15
-1
@@ -1497,6 +1497,13 @@ CREATE TABLE template_version_preset_parameters (
|
||||
value text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE template_version_preset_prebuild_schedules (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
preset_id uuid NOT NULL,
|
||||
cron_expression text NOT NULL,
|
||||
desired_instances integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE template_version_presets (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
template_version_id uuid NOT NULL,
|
||||
@@ -1504,7 +1511,8 @@ CREATE TABLE template_version_presets (
|
||||
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
desired_instances integer,
|
||||
invalidate_after_secs integer DEFAULT 0,
|
||||
prebuild_status prebuild_status DEFAULT 'healthy'::prebuild_status NOT NULL
|
||||
prebuild_status prebuild_status DEFAULT 'healthy'::prebuild_status NOT NULL,
|
||||
scheduling_timezone text DEFAULT ''::text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE template_version_terraform_values (
|
||||
@@ -2510,6 +2518,9 @@ ALTER TABLE ONLY template_version_parameters
|
||||
ALTER TABLE ONLY template_version_preset_parameters
|
||||
ADD CONSTRAINT template_version_preset_parameters_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY template_version_preset_prebuild_schedules
|
||||
ADD CONSTRAINT template_version_preset_prebuild_schedules_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY template_version_presets
|
||||
ADD CONSTRAINT template_version_presets_pkey PRIMARY KEY (id);
|
||||
|
||||
@@ -2965,6 +2976,9 @@ ALTER TABLE ONLY template_version_parameters
|
||||
ALTER TABLE ONLY template_version_preset_parameters
|
||||
ADD CONSTRAINT template_version_preset_paramet_template_version_preset_id_fkey FOREIGN KEY (template_version_preset_id) REFERENCES template_version_presets(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY template_version_preset_prebuild_schedules
|
||||
ADD CONSTRAINT template_version_preset_prebuild_schedules_preset_id_fkey FOREIGN KEY (preset_id) REFERENCES template_version_presets(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY template_version_presets
|
||||
ADD CONSTRAINT template_version_presets_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ const (
|
||||
ForeignKeyTailnetTunnelsCoordinatorID ForeignKeyConstraint = "tailnet_tunnels_coordinator_id_fkey" // ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_coordinator_id_fkey FOREIGN KEY (coordinator_id) REFERENCES tailnet_coordinators(id) ON DELETE CASCADE;
|
||||
ForeignKeyTemplateVersionParametersTemplateVersionID ForeignKeyConstraint = "template_version_parameters_template_version_id_fkey" // ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE;
|
||||
ForeignKeyTemplateVersionPresetParametTemplateVersionPresetID ForeignKeyConstraint = "template_version_preset_paramet_template_version_preset_id_fkey" // ALTER TABLE ONLY template_version_preset_parameters ADD CONSTRAINT template_version_preset_paramet_template_version_preset_id_fkey FOREIGN KEY (template_version_preset_id) REFERENCES template_version_presets(id) ON DELETE CASCADE;
|
||||
ForeignKeyTemplateVersionPresetPrebuildSchedulesPresetID ForeignKeyConstraint = "template_version_preset_prebuild_schedules_preset_id_fkey" // ALTER TABLE ONLY template_version_preset_prebuild_schedules ADD CONSTRAINT template_version_preset_prebuild_schedules_preset_id_fkey FOREIGN KEY (preset_id) REFERENCES template_version_presets(id) ON DELETE CASCADE;
|
||||
ForeignKeyTemplateVersionPresetsTemplateVersionID ForeignKeyConstraint = "template_version_presets_template_version_id_fkey" // ALTER TABLE ONLY template_version_presets ADD CONSTRAINT template_version_presets_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE;
|
||||
ForeignKeyTemplateVersionTerraformValuesCachedModuleFiles ForeignKeyConstraint = "template_version_terraform_values_cached_module_files_fkey" // ALTER TABLE ONLY template_version_terraform_values ADD CONSTRAINT template_version_terraform_values_cached_module_files_fkey FOREIGN KEY (cached_module_files) REFERENCES files(id);
|
||||
ForeignKeyTemplateVersionTerraformValuesTemplateVersionID ForeignKeyConstraint = "template_version_terraform_values_template_version_id_fkey" // ALTER TABLE ONLY template_version_terraform_values ADD CONSTRAINT template_version_terraform_values_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Drop the prebuild schedules table
|
||||
DROP TABLE template_version_preset_prebuild_schedules;
|
||||
|
||||
-- Remove scheduling_timezone column from template_version_presets table
|
||||
ALTER TABLE template_version_presets
|
||||
DROP COLUMN scheduling_timezone;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Add scheduling_timezone column to template_version_presets table
|
||||
ALTER TABLE template_version_presets
|
||||
ADD COLUMN scheduling_timezone TEXT DEFAULT '' NOT NULL;
|
||||
|
||||
-- Add table for prebuild schedules
|
||||
CREATE TABLE template_version_preset_prebuild_schedules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
preset_id UUID NOT NULL,
|
||||
cron_expression TEXT NOT NULL,
|
||||
desired_instances INTEGER NOT NULL,
|
||||
FOREIGN KEY (preset_id) REFERENCES template_version_presets (id) ON DELETE CASCADE
|
||||
);
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
INSERT INTO
|
||||
template_version_preset_prebuild_schedules (
|
||||
id,
|
||||
preset_id,
|
||||
cron_expression,
|
||||
desired_instances
|
||||
)
|
||||
VALUES (
|
||||
'e387cac1-9bf1-4fb6-8a34-db8cfb750dd0',
|
||||
'28b42cc0-c4fe-4907-a0fe-e4d20f1e9bfe',
|
||||
'* 8-18 * * 1-5',
|
||||
1
|
||||
);
|
||||
@@ -3410,6 +3410,7 @@ type TemplateVersionPreset struct {
|
||||
DesiredInstances sql.NullInt32 `db:"desired_instances" json:"desired_instances"`
|
||||
InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"`
|
||||
PrebuildStatus PrebuildStatus `db:"prebuild_status" json:"prebuild_status"`
|
||||
SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"`
|
||||
}
|
||||
|
||||
type TemplateVersionPresetParameter struct {
|
||||
@@ -3419,6 +3420,13 @@ type TemplateVersionPresetParameter struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
}
|
||||
|
||||
type TemplateVersionPresetPrebuildSchedule struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
PresetID uuid.UUID `db:"preset_id" json:"preset_id"`
|
||||
CronExpression string `db:"cron_expression" json:"cron_expression"`
|
||||
DesiredInstances int32 `db:"desired_instances" json:"desired_instances"`
|
||||
}
|
||||
|
||||
type TemplateVersionTable struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
TemplateID uuid.NullUUID `db:"template_id" json:"template_id"`
|
||||
|
||||
@@ -137,6 +137,7 @@ type sqlcQuerier interface {
|
||||
GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error)
|
||||
GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error)
|
||||
GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error)
|
||||
GetActivePresetPrebuildSchedules(ctx context.Context) ([]TemplateVersionPresetPrebuildSchedule, error)
|
||||
GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error)
|
||||
GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error)
|
||||
GetAllTailnetAgents(ctx context.Context) ([]TailnetAgent, error)
|
||||
@@ -498,6 +499,7 @@ type sqlcQuerier interface {
|
||||
InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error)
|
||||
InsertPreset(ctx context.Context, arg InsertPresetParams) (TemplateVersionPreset, error)
|
||||
InsertPresetParameters(ctx context.Context, arg InsertPresetParametersParams) ([]TemplateVersionPresetParameter, error)
|
||||
InsertPresetPrebuildSchedule(ctx context.Context, arg InsertPresetPrebuildScheduleParams) (TemplateVersionPresetPrebuildSchedule, error)
|
||||
InsertProvisionerJob(ctx context.Context, arg InsertProvisionerJobParams) (ProvisionerJob, error)
|
||||
InsertProvisionerJobLogs(ctx context.Context, arg InsertProvisionerJobLogsParams) ([]ProvisionerJobLog, error)
|
||||
InsertProvisionerJobTimings(ctx context.Context, arg InsertProvisionerJobTimingsParams) ([]ProvisionerJobTiming, error)
|
||||
|
||||
@@ -6511,7 +6511,8 @@ SELECT
|
||||
tvp.id,
|
||||
tvp.name,
|
||||
tvp.desired_instances AS desired_instances,
|
||||
tvp.invalidate_after_secs AS ttl,
|
||||
tvp.scheduling_timezone,
|
||||
tvp.invalidate_after_secs AS ttl,
|
||||
tvp.prebuild_status,
|
||||
t.deleted,
|
||||
t.deprecated != '' AS deprecated
|
||||
@@ -6535,6 +6536,7 @@ type GetTemplatePresetsWithPrebuildsRow struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
DesiredInstances sql.NullInt32 `db:"desired_instances" json:"desired_instances"`
|
||||
SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"`
|
||||
Ttl sql.NullInt32 `db:"ttl" json:"ttl"`
|
||||
PrebuildStatus PrebuildStatus `db:"prebuild_status" json:"prebuild_status"`
|
||||
Deleted bool `db:"deleted" json:"deleted"`
|
||||
@@ -6564,6 +6566,7 @@ func (q *sqlQuerier) GetTemplatePresetsWithPrebuilds(ctx context.Context, templa
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.DesiredInstances,
|
||||
&i.SchedulingTimezone,
|
||||
&i.Ttl,
|
||||
&i.PrebuildStatus,
|
||||
&i.Deleted,
|
||||
@@ -6582,8 +6585,51 @@ func (q *sqlQuerier) GetTemplatePresetsWithPrebuilds(ctx context.Context, templa
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getActivePresetPrebuildSchedules = `-- name: GetActivePresetPrebuildSchedules :many
|
||||
SELECT
|
||||
tvpps.id, tvpps.preset_id, tvpps.cron_expression, tvpps.desired_instances
|
||||
FROM
|
||||
template_version_preset_prebuild_schedules tvpps
|
||||
INNER JOIN template_version_presets tvp ON tvp.id = tvpps.preset_id
|
||||
INNER JOIN template_versions tv ON tv.id = tvp.template_version_id
|
||||
INNER JOIN templates t ON t.id = tv.template_id
|
||||
WHERE
|
||||
-- Template version is active, and template is not deleted or deprecated
|
||||
tv.id = t.active_version_id
|
||||
AND NOT t.deleted
|
||||
AND t.deprecated = ''
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetActivePresetPrebuildSchedules(ctx context.Context) ([]TemplateVersionPresetPrebuildSchedule, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getActivePresetPrebuildSchedules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []TemplateVersionPresetPrebuildSchedule
|
||||
for rows.Next() {
|
||||
var i TemplateVersionPresetPrebuildSchedule
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.PresetID,
|
||||
&i.CronExpression,
|
||||
&i.DesiredInstances,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getPresetByID = `-- name: GetPresetByID :one
|
||||
SELECT tvp.id, tvp.template_version_id, tvp.name, tvp.created_at, tvp.desired_instances, tvp.invalidate_after_secs, tvp.prebuild_status, tv.template_id, tv.organization_id FROM
|
||||
SELECT tvp.id, tvp.template_version_id, tvp.name, tvp.created_at, tvp.desired_instances, tvp.invalidate_after_secs, tvp.prebuild_status, tvp.scheduling_timezone, tv.template_id, tv.organization_id FROM
|
||||
template_version_presets tvp
|
||||
INNER JOIN template_versions tv ON tvp.template_version_id = tv.id
|
||||
WHERE tvp.id = $1
|
||||
@@ -6597,6 +6643,7 @@ type GetPresetByIDRow struct {
|
||||
DesiredInstances sql.NullInt32 `db:"desired_instances" json:"desired_instances"`
|
||||
InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"`
|
||||
PrebuildStatus PrebuildStatus `db:"prebuild_status" json:"prebuild_status"`
|
||||
SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"`
|
||||
TemplateID uuid.NullUUID `db:"template_id" json:"template_id"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
}
|
||||
@@ -6612,6 +6659,7 @@ func (q *sqlQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (Get
|
||||
&i.DesiredInstances,
|
||||
&i.InvalidateAfterSecs,
|
||||
&i.PrebuildStatus,
|
||||
&i.SchedulingTimezone,
|
||||
&i.TemplateID,
|
||||
&i.OrganizationID,
|
||||
)
|
||||
@@ -6620,7 +6668,7 @@ func (q *sqlQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (Get
|
||||
|
||||
const getPresetByWorkspaceBuildID = `-- name: GetPresetByWorkspaceBuildID :one
|
||||
SELECT
|
||||
template_version_presets.id, template_version_presets.template_version_id, template_version_presets.name, template_version_presets.created_at, template_version_presets.desired_instances, template_version_presets.invalidate_after_secs, template_version_presets.prebuild_status
|
||||
template_version_presets.id, template_version_presets.template_version_id, template_version_presets.name, template_version_presets.created_at, template_version_presets.desired_instances, template_version_presets.invalidate_after_secs, template_version_presets.prebuild_status, template_version_presets.scheduling_timezone
|
||||
FROM
|
||||
template_version_presets
|
||||
INNER JOIN workspace_builds ON workspace_builds.template_version_preset_id = template_version_presets.id
|
||||
@@ -6639,6 +6687,7 @@ func (q *sqlQuerier) GetPresetByWorkspaceBuildID(ctx context.Context, workspaceB
|
||||
&i.DesiredInstances,
|
||||
&i.InvalidateAfterSecs,
|
||||
&i.PrebuildStatus,
|
||||
&i.SchedulingTimezone,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -6720,7 +6769,7 @@ func (q *sqlQuerier) GetPresetParametersByTemplateVersionID(ctx context.Context,
|
||||
|
||||
const getPresetsByTemplateVersionID = `-- name: GetPresetsByTemplateVersionID :many
|
||||
SELECT
|
||||
id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status
|
||||
id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone
|
||||
FROM
|
||||
template_version_presets
|
||||
WHERE
|
||||
@@ -6744,6 +6793,7 @@ func (q *sqlQuerier) GetPresetsByTemplateVersionID(ctx context.Context, template
|
||||
&i.DesiredInstances,
|
||||
&i.InvalidateAfterSecs,
|
||||
&i.PrebuildStatus,
|
||||
&i.SchedulingTimezone,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -6765,7 +6815,8 @@ INSERT INTO template_version_presets (
|
||||
name,
|
||||
created_at,
|
||||
desired_instances,
|
||||
invalidate_after_secs
|
||||
invalidate_after_secs,
|
||||
scheduling_timezone
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
@@ -6773,8 +6824,9 @@ VALUES (
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6
|
||||
) RETURNING id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status
|
||||
$6,
|
||||
$7
|
||||
) RETURNING id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone
|
||||
`
|
||||
|
||||
type InsertPresetParams struct {
|
||||
@@ -6784,6 +6836,7 @@ type InsertPresetParams struct {
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
DesiredInstances sql.NullInt32 `db:"desired_instances" json:"desired_instances"`
|
||||
InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"`
|
||||
SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) (TemplateVersionPreset, error) {
|
||||
@@ -6794,6 +6847,7 @@ func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) (
|
||||
arg.CreatedAt,
|
||||
arg.DesiredInstances,
|
||||
arg.InvalidateAfterSecs,
|
||||
arg.SchedulingTimezone,
|
||||
)
|
||||
var i TemplateVersionPreset
|
||||
err := row.Scan(
|
||||
@@ -6804,6 +6858,7 @@ func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) (
|
||||
&i.DesiredInstances,
|
||||
&i.InvalidateAfterSecs,
|
||||
&i.PrebuildStatus,
|
||||
&i.SchedulingTimezone,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -6852,6 +6907,37 @@ func (q *sqlQuerier) InsertPresetParameters(ctx context.Context, arg InsertPrese
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const insertPresetPrebuildSchedule = `-- name: InsertPresetPrebuildSchedule :one
|
||||
INSERT INTO template_version_preset_prebuild_schedules (
|
||||
preset_id,
|
||||
cron_expression,
|
||||
desired_instances
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3
|
||||
) RETURNING id, preset_id, cron_expression, desired_instances
|
||||
`
|
||||
|
||||
type InsertPresetPrebuildScheduleParams struct {
|
||||
PresetID uuid.UUID `db:"preset_id" json:"preset_id"`
|
||||
CronExpression string `db:"cron_expression" json:"cron_expression"`
|
||||
DesiredInstances int32 `db:"desired_instances" json:"desired_instances"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertPresetPrebuildSchedule(ctx context.Context, arg InsertPresetPrebuildScheduleParams) (TemplateVersionPresetPrebuildSchedule, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertPresetPrebuildSchedule, arg.PresetID, arg.CronExpression, arg.DesiredInstances)
|
||||
var i TemplateVersionPresetPrebuildSchedule
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.PresetID,
|
||||
&i.CronExpression,
|
||||
&i.DesiredInstances,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updatePresetPrebuildStatus = `-- name: UpdatePresetPrebuildStatus :exec
|
||||
UPDATE template_version_presets
|
||||
SET prebuild_status = $1
|
||||
|
||||
@@ -35,7 +35,8 @@ SELECT
|
||||
tvp.id,
|
||||
tvp.name,
|
||||
tvp.desired_instances AS desired_instances,
|
||||
tvp.invalidate_after_secs AS ttl,
|
||||
tvp.scheduling_timezone,
|
||||
tvp.invalidate_after_secs AS ttl,
|
||||
tvp.prebuild_status,
|
||||
t.deleted,
|
||||
t.deprecated != '' AS deprecated
|
||||
|
||||
@@ -5,7 +5,8 @@ INSERT INTO template_version_presets (
|
||||
name,
|
||||
created_at,
|
||||
desired_instances,
|
||||
invalidate_after_secs
|
||||
invalidate_after_secs,
|
||||
scheduling_timezone
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@@ -13,7 +14,8 @@ VALUES (
|
||||
@name,
|
||||
@created_at,
|
||||
@desired_instances,
|
||||
@invalidate_after_secs
|
||||
@invalidate_after_secs,
|
||||
@scheduling_timezone
|
||||
) RETURNING *;
|
||||
|
||||
-- name: InsertPresetParameters :many
|
||||
@@ -25,6 +27,18 @@ SELECT
|
||||
unnest(@values :: TEXT[])
|
||||
RETURNING *;
|
||||
|
||||
-- name: InsertPresetPrebuildSchedule :one
|
||||
INSERT INTO template_version_preset_prebuild_schedules (
|
||||
preset_id,
|
||||
cron_expression,
|
||||
desired_instances
|
||||
)
|
||||
VALUES (
|
||||
@preset_id,
|
||||
@cron_expression,
|
||||
@desired_instances
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdatePresetPrebuildStatus :exec
|
||||
UPDATE template_version_presets
|
||||
SET prebuild_status = @status
|
||||
@@ -69,3 +83,17 @@ SELECT tvp.*, tv.template_id, tv.organization_id FROM
|
||||
template_version_presets tvp
|
||||
INNER JOIN template_versions tv ON tvp.template_version_id = tv.id
|
||||
WHERE tvp.id = @preset_id;
|
||||
|
||||
-- name: GetActivePresetPrebuildSchedules :many
|
||||
SELECT
|
||||
tvpps.*
|
||||
FROM
|
||||
template_version_preset_prebuild_schedules tvpps
|
||||
INNER JOIN template_version_presets tvp ON tvp.id = tvpps.preset_id
|
||||
INNER JOIN template_versions tv ON tv.id = tvp.template_version_id
|
||||
INNER JOIN templates t ON t.id = tv.template_id
|
||||
WHERE
|
||||
-- Template version is active, and template is not deleted or deprecated
|
||||
tv.id = t.active_version_id
|
||||
AND NOT t.deleted
|
||||
AND t.deprecated = '';
|
||||
|
||||
@@ -61,6 +61,7 @@ const (
|
||||
UniqueTemplateUsageStatsPkey UniqueConstraint = "template_usage_stats_pkey" // ALTER TABLE ONLY template_usage_stats ADD CONSTRAINT template_usage_stats_pkey PRIMARY KEY (start_time, template_id, user_id);
|
||||
UniqueTemplateVersionParametersTemplateVersionIDNameKey UniqueConstraint = "template_version_parameters_template_version_id_name_key" // ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_name_key UNIQUE (template_version_id, name);
|
||||
UniqueTemplateVersionPresetParametersPkey UniqueConstraint = "template_version_preset_parameters_pkey" // ALTER TABLE ONLY template_version_preset_parameters ADD CONSTRAINT template_version_preset_parameters_pkey PRIMARY KEY (id);
|
||||
UniqueTemplateVersionPresetPrebuildSchedulesPkey UniqueConstraint = "template_version_preset_prebuild_schedules_pkey" // ALTER TABLE ONLY template_version_preset_prebuild_schedules ADD CONSTRAINT template_version_preset_prebuild_schedules_pkey PRIMARY KEY (id);
|
||||
UniqueTemplateVersionPresetsPkey UniqueConstraint = "template_version_presets_pkey" // ALTER TABLE ONLY template_version_presets ADD CONSTRAINT template_version_presets_pkey PRIMARY KEY (id);
|
||||
UniqueTemplateVersionTerraformValuesTemplateVersionIDKey UniqueConstraint = "template_version_terraform_values_template_version_id_key" // ALTER TABLE ONLY template_version_terraform_values ADD CONSTRAINT template_version_terraform_values_template_version_id_key UNIQUE (template_version_id);
|
||||
UniqueTemplateVersionVariablesTemplateVersionIDNameKey UniqueConstraint = "template_version_variables_template_version_id_name_key" // ALTER TABLE ONLY template_version_variables ADD CONSTRAINT template_version_variables_template_version_id_name_key UNIQUE (template_version_id, name);
|
||||
|
||||
@@ -6,6 +6,10 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/quartz"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
)
|
||||
@@ -13,18 +17,24 @@ import (
|
||||
// GlobalSnapshot represents a full point-in-time snapshot of state relating to prebuilds across all templates.
|
||||
type GlobalSnapshot struct {
|
||||
Presets []database.GetTemplatePresetsWithPrebuildsRow
|
||||
PrebuildSchedules []database.TemplateVersionPresetPrebuildSchedule
|
||||
RunningPrebuilds []database.GetRunningPrebuiltWorkspacesRow
|
||||
PrebuildsInProgress []database.CountInProgressPrebuildsRow
|
||||
Backoffs []database.GetPresetsBackoffRow
|
||||
HardLimitedPresetsMap map[uuid.UUID]database.GetPresetsAtFailureLimitRow
|
||||
clock quartz.Clock
|
||||
logger slog.Logger
|
||||
}
|
||||
|
||||
func NewGlobalSnapshot(
|
||||
presets []database.GetTemplatePresetsWithPrebuildsRow,
|
||||
prebuildSchedules []database.TemplateVersionPresetPrebuildSchedule,
|
||||
runningPrebuilds []database.GetRunningPrebuiltWorkspacesRow,
|
||||
prebuildsInProgress []database.CountInProgressPrebuildsRow,
|
||||
backoffs []database.GetPresetsBackoffRow,
|
||||
hardLimitedPresets []database.GetPresetsAtFailureLimitRow,
|
||||
clock quartz.Clock,
|
||||
logger slog.Logger,
|
||||
) GlobalSnapshot {
|
||||
hardLimitedPresetsMap := make(map[uuid.UUID]database.GetPresetsAtFailureLimitRow, len(hardLimitedPresets))
|
||||
for _, preset := range hardLimitedPresets {
|
||||
@@ -33,10 +43,13 @@ func NewGlobalSnapshot(
|
||||
|
||||
return GlobalSnapshot{
|
||||
Presets: presets,
|
||||
PrebuildSchedules: prebuildSchedules,
|
||||
RunningPrebuilds: runningPrebuilds,
|
||||
PrebuildsInProgress: prebuildsInProgress,
|
||||
Backoffs: backoffs,
|
||||
HardLimitedPresetsMap: hardLimitedPresetsMap,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +61,10 @@ func (s GlobalSnapshot) FilterByPreset(presetID uuid.UUID) (*PresetSnapshot, err
|
||||
return nil, xerrors.Errorf("no preset found with ID %q", presetID)
|
||||
}
|
||||
|
||||
prebuildSchedules := slice.Filter(s.PrebuildSchedules, func(schedule database.TemplateVersionPresetPrebuildSchedule) bool {
|
||||
return schedule.PresetID == presetID
|
||||
})
|
||||
|
||||
// Only include workspaces that have successfully started
|
||||
running := slice.Filter(s.RunningPrebuilds, func(prebuild database.GetRunningPrebuiltWorkspacesRow) bool {
|
||||
if !prebuild.CurrentPresetID.Valid {
|
||||
@@ -73,14 +90,19 @@ func (s GlobalSnapshot) FilterByPreset(presetID uuid.UUID) (*PresetSnapshot, err
|
||||
|
||||
_, isHardLimited := s.HardLimitedPresetsMap[preset.ID]
|
||||
|
||||
return &PresetSnapshot{
|
||||
Preset: preset,
|
||||
Running: nonExpired,
|
||||
Expired: expired,
|
||||
InProgress: inProgress,
|
||||
Backoff: backoffPtr,
|
||||
IsHardLimited: isHardLimited,
|
||||
}, nil
|
||||
presetSnapshot := NewPresetSnapshot(
|
||||
preset,
|
||||
prebuildSchedules,
|
||||
nonExpired,
|
||||
expired,
|
||||
inProgress,
|
||||
backoffPtr,
|
||||
isHardLimited,
|
||||
s.clock,
|
||||
s.logger,
|
||||
)
|
||||
|
||||
return &presetSnapshot, nil
|
||||
}
|
||||
|
||||
func (s GlobalSnapshot) IsHardLimited(presetID uuid.UUID) bool {
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
package prebuilds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/quartz"
|
||||
|
||||
tf_provider_helpers "github.com/coder/terraform-provider-coder/v2/provider/helpers"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/schedule/cron"
|
||||
)
|
||||
|
||||
// ActionType represents the type of action needed to reconcile prebuilds.
|
||||
@@ -36,12 +44,39 @@ const (
|
||||
// - InProgress: prebuilds currently in progress
|
||||
// - Backoff: holds failure info to decide if prebuild creation should be backed off
|
||||
type PresetSnapshot struct {
|
||||
Preset database.GetTemplatePresetsWithPrebuildsRow
|
||||
Running []database.GetRunningPrebuiltWorkspacesRow
|
||||
Expired []database.GetRunningPrebuiltWorkspacesRow
|
||||
InProgress []database.CountInProgressPrebuildsRow
|
||||
Backoff *database.GetPresetsBackoffRow
|
||||
IsHardLimited bool
|
||||
Preset database.GetTemplatePresetsWithPrebuildsRow
|
||||
PrebuildSchedules []database.TemplateVersionPresetPrebuildSchedule
|
||||
Running []database.GetRunningPrebuiltWorkspacesRow
|
||||
Expired []database.GetRunningPrebuiltWorkspacesRow
|
||||
InProgress []database.CountInProgressPrebuildsRow
|
||||
Backoff *database.GetPresetsBackoffRow
|
||||
IsHardLimited bool
|
||||
clock quartz.Clock
|
||||
logger slog.Logger
|
||||
}
|
||||
|
||||
func NewPresetSnapshot(
|
||||
preset database.GetTemplatePresetsWithPrebuildsRow,
|
||||
prebuildSchedules []database.TemplateVersionPresetPrebuildSchedule,
|
||||
running []database.GetRunningPrebuiltWorkspacesRow,
|
||||
expired []database.GetRunningPrebuiltWorkspacesRow,
|
||||
inProgress []database.CountInProgressPrebuildsRow,
|
||||
backoff *database.GetPresetsBackoffRow,
|
||||
isHardLimited bool,
|
||||
clock quartz.Clock,
|
||||
logger slog.Logger,
|
||||
) PresetSnapshot {
|
||||
return PresetSnapshot{
|
||||
Preset: preset,
|
||||
PrebuildSchedules: prebuildSchedules,
|
||||
Running: running,
|
||||
Expired: expired,
|
||||
InProgress: inProgress,
|
||||
Backoff: backoff,
|
||||
IsHardLimited: isHardLimited,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ReconciliationState represents the processed state of a preset's prebuilds,
|
||||
@@ -83,6 +118,92 @@ func (ra *ReconciliationActions) IsNoop() bool {
|
||||
return ra.Create == 0 && len(ra.DeleteIDs) == 0 && ra.BackoffUntil.IsZero()
|
||||
}
|
||||
|
||||
// MatchesCron interprets a cron spec as a continuous time range,
|
||||
// and returns whether the provided time value falls within that range.
|
||||
func MatchesCron(cronExpression string, at time.Time) (bool, error) {
|
||||
sched, err := cron.TimeRange(cronExpression)
|
||||
if err != nil {
|
||||
return false, xerrors.Errorf("failed to parse cron expression: %w", err)
|
||||
}
|
||||
|
||||
return sched.IsWithinRange(at), nil
|
||||
}
|
||||
|
||||
// CalculateDesiredInstances returns the number of desired instances based on the provided time.
|
||||
// If the time matches any defined prebuild schedule, the corresponding number of instances is returned.
|
||||
// Otherwise, it falls back to the default number of instances specified in the prebuild configuration.
|
||||
func (p PresetSnapshot) CalculateDesiredInstances(at time.Time) int32 {
|
||||
if len(p.PrebuildSchedules) == 0 {
|
||||
// If no schedules are defined, fall back to the default desired instance count
|
||||
return p.Preset.DesiredInstances.Int32
|
||||
}
|
||||
|
||||
if p.Preset.SchedulingTimezone == "" {
|
||||
p.logger.Error(context.Background(), "timezone is not set in prebuild scheduling configuration",
|
||||
slog.F("preset_id", p.Preset.ID),
|
||||
slog.F("timezone", p.Preset.SchedulingTimezone))
|
||||
|
||||
// If timezone is not set, fall back to the default desired instance count
|
||||
return p.Preset.DesiredInstances.Int32
|
||||
}
|
||||
|
||||
// Validate that the provided timezone is valid
|
||||
_, err := time.LoadLocation(p.Preset.SchedulingTimezone)
|
||||
if err != nil {
|
||||
p.logger.Error(context.Background(), "invalid timezone in prebuild scheduling configuration",
|
||||
slog.F("preset_id", p.Preset.ID),
|
||||
slog.F("timezone", p.Preset.SchedulingTimezone),
|
||||
slog.Error(err))
|
||||
|
||||
// If timezone is invalid, fall back to the default desired instance count
|
||||
return p.Preset.DesiredInstances.Int32
|
||||
}
|
||||
|
||||
// Validate that all prebuild schedules are valid and don't overlap with each other.
|
||||
// If any schedule is invalid or schedules overlap, fall back to the default desired instance count.
|
||||
cronSpecs := make([]string, len(p.PrebuildSchedules))
|
||||
for i, schedule := range p.PrebuildSchedules {
|
||||
cronSpecs[i] = schedule.CronExpression
|
||||
}
|
||||
err = tf_provider_helpers.ValidateSchedules(cronSpecs)
|
||||
if err != nil {
|
||||
p.logger.Error(context.Background(), "schedules are invalid or overlap with each other",
|
||||
slog.F("preset_id", p.Preset.ID),
|
||||
slog.F("cron_specs", cronSpecs),
|
||||
slog.Error(err))
|
||||
|
||||
// If schedules are invalid, fall back to the default desired instance count
|
||||
return p.Preset.DesiredInstances.Int32
|
||||
}
|
||||
|
||||
// Look for a schedule whose cron expression matches the provided time
|
||||
for _, schedule := range p.PrebuildSchedules {
|
||||
// Prefix the cron expression with timezone information
|
||||
cronExprWithTimezone := fmt.Sprintf("CRON_TZ=%s %s", p.Preset.SchedulingTimezone, schedule.CronExpression)
|
||||
matches, err := MatchesCron(cronExprWithTimezone, at)
|
||||
if err != nil {
|
||||
p.logger.Error(context.Background(), "cron expression is invalid",
|
||||
slog.F("preset_id", p.Preset.ID),
|
||||
slog.F("cron_expression", cronExprWithTimezone),
|
||||
slog.Error(err))
|
||||
continue
|
||||
}
|
||||
if matches {
|
||||
p.logger.Debug(context.Background(), "current time matched cron expression",
|
||||
slog.F("preset_id", p.Preset.ID),
|
||||
slog.F("current_time", at.String()),
|
||||
slog.F("cron_expression", cronExprWithTimezone),
|
||||
slog.F("desired_instances", schedule.DesiredInstances),
|
||||
)
|
||||
|
||||
return schedule.DesiredInstances
|
||||
}
|
||||
}
|
||||
|
||||
// If no schedule matches, fall back to the default desired instance count
|
||||
return p.Preset.DesiredInstances.Int32
|
||||
}
|
||||
|
||||
// CalculateState computes the current state of prebuilds for a preset, including:
|
||||
// - Actual: Number of currently running prebuilds, i.e., non-expired and expired prebuilds
|
||||
// - Expired: Number of currently running expired prebuilds
|
||||
@@ -111,7 +232,7 @@ func (p PresetSnapshot) CalculateState() *ReconciliationState {
|
||||
expired = int32(len(p.Expired))
|
||||
|
||||
if p.isActive() {
|
||||
desired = p.Preset.DesiredInstances.Int32
|
||||
desired = p.CalculateDesiredInstances(p.clock.Now())
|
||||
eligible = p.countEligible()
|
||||
extraneous = max(actual-expired-desired, 0)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -84,7 +86,7 @@ func TestNoPrebuilds(t *testing.T) {
|
||||
preset(true, 0, current),
|
||||
}
|
||||
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -106,7 +108,7 @@ func TestNetNew(t *testing.T) {
|
||||
preset(true, 1, current),
|
||||
}
|
||||
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -148,7 +150,7 @@ func TestOutdatedPrebuilds(t *testing.T) {
|
||||
var inProgress []database.CountInProgressPrebuildsRow
|
||||
|
||||
// WHEN: calculating the outdated preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, inProgress, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(outdated.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -214,7 +216,7 @@ func TestDeleteOutdatedPrebuilds(t *testing.T) {
|
||||
}
|
||||
|
||||
// WHEN: calculating the outdated preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, inProgress, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(outdated.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -459,7 +461,7 @@ func TestInProgressActions(t *testing.T) {
|
||||
}
|
||||
|
||||
// WHEN: calculating the current preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, inProgress, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -502,7 +504,7 @@ func TestExtraneous(t *testing.T) {
|
||||
var inProgress []database.CountInProgressPrebuildsRow
|
||||
|
||||
// WHEN: calculating the current preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, inProgress, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -683,7 +685,7 @@ func TestExpiredPrebuilds(t *testing.T) {
|
||||
}
|
||||
|
||||
// WHEN: calculating the current preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, nil, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, nil, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -719,7 +721,7 @@ func TestDeprecated(t *testing.T) {
|
||||
var inProgress []database.CountInProgressPrebuildsRow
|
||||
|
||||
// WHEN: calculating the current preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, inProgress, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -772,7 +774,7 @@ func TestLatestBuildFailed(t *testing.T) {
|
||||
}
|
||||
|
||||
// WHEN: calculating the current preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, inProgress, backoffs, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, backoffs, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
psCurrent, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -865,7 +867,7 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, inProgress, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
|
||||
// Nothing has to be created for preset 1.
|
||||
{
|
||||
@@ -905,6 +907,498 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrebuildScheduling(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The test includes 2 presets, each with 2 schedules.
|
||||
// It checks that the calculated actions match expectations for various provided times,
|
||||
// based on the corresponding schedules.
|
||||
testCases := []struct {
|
||||
name string
|
||||
// now specifies the current time.
|
||||
now time.Time
|
||||
// expected instances for preset1 and preset2, respectively.
|
||||
expectedInstances []int32
|
||||
}{
|
||||
{
|
||||
name: "Before the 1st schedule",
|
||||
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 01:00:00 UTC"),
|
||||
expectedInstances: []int32{1, 1},
|
||||
},
|
||||
{
|
||||
name: "1st schedule",
|
||||
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 03:00:00 UTC"),
|
||||
expectedInstances: []int32{2, 1},
|
||||
},
|
||||
{
|
||||
name: "2nd schedule",
|
||||
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 07:00:00 UTC"),
|
||||
expectedInstances: []int32{3, 1},
|
||||
},
|
||||
{
|
||||
name: "3rd schedule",
|
||||
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 11:00:00 UTC"),
|
||||
expectedInstances: []int32{1, 4},
|
||||
},
|
||||
{
|
||||
name: "4th schedule",
|
||||
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 15:00:00 UTC"),
|
||||
expectedInstances: []int32{1, 5},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
templateID := uuid.New()
|
||||
templateVersionID := uuid.New()
|
||||
presetOpts1 := options{
|
||||
templateID: templateID,
|
||||
templateVersionID: templateVersionID,
|
||||
presetID: uuid.New(),
|
||||
presetName: "my-preset-1",
|
||||
prebuiltWorkspaceID: uuid.New(),
|
||||
workspaceName: "prebuilds1",
|
||||
}
|
||||
presetOpts2 := options{
|
||||
templateID: templateID,
|
||||
templateVersionID: templateVersionID,
|
||||
presetID: uuid.New(),
|
||||
presetName: "my-preset-2",
|
||||
prebuiltWorkspaceID: uuid.New(),
|
||||
workspaceName: "prebuilds2",
|
||||
}
|
||||
|
||||
clock := quartz.NewMock(t)
|
||||
clock.Set(tc.now)
|
||||
enableScheduling := func(preset database.GetTemplatePresetsWithPrebuildsRow) database.GetTemplatePresetsWithPrebuildsRow {
|
||||
preset.SchedulingTimezone = "UTC"
|
||||
return preset
|
||||
}
|
||||
presets := []database.GetTemplatePresetsWithPrebuildsRow{
|
||||
preset(true, 1, presetOpts1, enableScheduling),
|
||||
preset(true, 1, presetOpts2, enableScheduling),
|
||||
}
|
||||
schedules := []database.TemplateVersionPresetPrebuildSchedule{
|
||||
schedule(presets[0].ID, "* 2-4 * * 1-5", 2),
|
||||
schedule(presets[0].ID, "* 6-8 * * 1-5", 3),
|
||||
schedule(presets[1].ID, "* 10-12 * * 1-5", 4),
|
||||
schedule(presets[1].ID, "* 14-16 * * 1-5", 5),
|
||||
}
|
||||
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, schedules, nil, nil, nil, nil, clock, testutil.Logger(t))
|
||||
|
||||
// Check 1st preset.
|
||||
{
|
||||
ps, err := snapshot.FilterByPreset(presetOpts1.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
state := ps.CalculateState()
|
||||
actions, err := ps.CalculateActions(clock, backoffInterval)
|
||||
require.NoError(t, err)
|
||||
|
||||
validateState(t, prebuilds.ReconciliationState{
|
||||
Starting: 0,
|
||||
Desired: tc.expectedInstances[0],
|
||||
}, *state)
|
||||
validateActions(t, []*prebuilds.ReconciliationActions{
|
||||
{
|
||||
ActionType: prebuilds.ActionTypeCreate,
|
||||
Create: tc.expectedInstances[0],
|
||||
},
|
||||
}, actions)
|
||||
}
|
||||
|
||||
// Check 2nd preset.
|
||||
{
|
||||
ps, err := snapshot.FilterByPreset(presetOpts2.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
state := ps.CalculateState()
|
||||
actions, err := ps.CalculateActions(clock, backoffInterval)
|
||||
require.NoError(t, err)
|
||||
|
||||
validateState(t, prebuilds.ReconciliationState{
|
||||
Starting: 0,
|
||||
Desired: tc.expectedInstances[1],
|
||||
}, *state)
|
||||
validateActions(t, []*prebuilds.ReconciliationActions{
|
||||
{
|
||||
ActionType: prebuilds.ActionTypeCreate,
|
||||
Create: tc.expectedInstances[1],
|
||||
},
|
||||
}, actions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesCron(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
at time.Time
|
||||
expectedMatches bool
|
||||
}{
|
||||
// A comprehensive test suite for time range evaluation is implemented in TestIsWithinRange.
|
||||
// This test provides only basic coverage.
|
||||
{
|
||||
name: "Right before the start of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 8:59:59 UTC"),
|
||||
expectedMatches: false,
|
||||
},
|
||||
{
|
||||
name: "Start of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 9:00:00 UTC"),
|
||||
expectedMatches: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
matches, err := prebuilds.MatchesCron(testCase.spec, testCase.at)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testCase.expectedMatches, matches)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDesiredInstances(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mkPreset := func(instances int32, timezone string) database.GetTemplatePresetsWithPrebuildsRow {
|
||||
return database.GetTemplatePresetsWithPrebuildsRow{
|
||||
DesiredInstances: sql.NullInt32{
|
||||
Int32: instances,
|
||||
Valid: true,
|
||||
},
|
||||
SchedulingTimezone: timezone,
|
||||
}
|
||||
}
|
||||
mkSchedule := func(cronExpr string, instances int32) database.TemplateVersionPresetPrebuildSchedule {
|
||||
return database.TemplateVersionPresetPrebuildSchedule{
|
||||
CronExpression: cronExpr,
|
||||
DesiredInstances: instances,
|
||||
}
|
||||
}
|
||||
mkSnapshot := func(preset database.GetTemplatePresetsWithPrebuildsRow, schedules ...database.TemplateVersionPresetPrebuildSchedule) prebuilds.PresetSnapshot {
|
||||
return prebuilds.NewPresetSnapshot(
|
||||
preset,
|
||||
schedules,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
false,
|
||||
quartz.NewMock(t),
|
||||
testutil.Logger(t),
|
||||
)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
snapshot prebuilds.PresetSnapshot
|
||||
at time.Time
|
||||
expectedCalculatedInstances int32
|
||||
}{
|
||||
// "* 9-18 * * 1-5" should be interpreted as a continuous time range from 09:00:00 to 18:59:59, Monday through Friday
|
||||
{
|
||||
name: "Right before the start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 8:59:59 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "Start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 9:00:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "9:01AM - One minute after the start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 9:01:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "2PM - The middle of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 14:00:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "6PM - One hour before the end of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 18:00:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "End of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 18:59:59 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "Right after the end of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 19:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "7:01PM - Around one minute after the end of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 19:01:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "2AM - Significantly outside the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 02:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "Outside the day range #1",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Sat, 07 Jun 2025 14:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "Outside the day range #2",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Sun, 08 Jun 2025 14:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
|
||||
// Test multiple schedules during the day
|
||||
// - "* 6-10 * * 1-5"
|
||||
// - "* 12-16 * * 1-5"
|
||||
// - "* 18-22 * * 1-5"
|
||||
{
|
||||
name: "Before the first schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 6-10 * * 1-5", 2),
|
||||
mkSchedule("* 12-16 * * 1-5", 3),
|
||||
mkSchedule("* 18-22 * * 1-5", 4),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 5:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "The middle of the first schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 6-10 * * 1-5", 2),
|
||||
mkSchedule("* 12-16 * * 1-5", 3),
|
||||
mkSchedule("* 18-22 * * 1-5", 4),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 8:00:00 UTC"),
|
||||
expectedCalculatedInstances: 2,
|
||||
},
|
||||
{
|
||||
name: "Between the first and second schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 6-10 * * 1-5", 2),
|
||||
mkSchedule("* 12-16 * * 1-5", 3),
|
||||
mkSchedule("* 18-22 * * 1-5", 4),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 11:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "The middle of the second schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 6-10 * * 1-5", 2),
|
||||
mkSchedule("* 12-16 * * 1-5", 3),
|
||||
mkSchedule("* 18-22 * * 1-5", 4),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 14:00:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "The middle of the third schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 6-10 * * 1-5", 2),
|
||||
mkSchedule("* 12-16 * * 1-5", 3),
|
||||
mkSchedule("* 18-22 * * 1-5", 4),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 20:00:00 UTC"),
|
||||
expectedCalculatedInstances: 4,
|
||||
},
|
||||
{
|
||||
name: "After the last schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 6-10 * * 1-5", 2),
|
||||
mkSchedule("* 12-16 * * 1-5", 3),
|
||||
mkSchedule("* 18-22 * * 1-5", 4),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 23:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
|
||||
// Test multiple schedules during the week
|
||||
// - "* 9-18 * * 1-5"
|
||||
// - "* 9-13 * * 6-7"
|
||||
{
|
||||
name: "First schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 2),
|
||||
mkSchedule("* 9-13 * * 6,0", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 14:00:00 UTC"),
|
||||
expectedCalculatedInstances: 2,
|
||||
},
|
||||
{
|
||||
name: "Second schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 2),
|
||||
mkSchedule("* 9-13 * * 6,0", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Sat, 07 Jun 2025 10:00:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "Outside schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 2),
|
||||
mkSchedule("* 9-13 * * 6,0", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Sat, 07 Jun 2025 14:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
|
||||
// Test different timezones
|
||||
{
|
||||
name: "3PM UTC - 8AM America/Los_Angeles; An hour before the start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "America/Los_Angeles"),
|
||||
mkSchedule("* 9-13 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 15:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "4PM UTC - 9AM America/Los_Angeles; Start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "America/Los_Angeles"),
|
||||
mkSchedule("* 9-13 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 16:00:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "8:59PM UTC - 1:58PM America/Los_Angeles; Right before the end of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "America/Los_Angeles"),
|
||||
mkSchedule("* 9-13 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 20:59:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "9PM UTC - 2PM America/Los_Angeles; Right after the end of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "America/Los_Angeles"),
|
||||
mkSchedule("* 9-13 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 21:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "11PM UTC - 4PM America/Los_Angeles; Outside the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "America/Los_Angeles"),
|
||||
mkSchedule("* 9-13 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 23:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
|
||||
// Verify support for time values specified in non-UTC time zones.
|
||||
{
|
||||
name: "8AM - before the start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123Z, "Mon, 02 Jun 2025 04:00:00 -0400"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "9AM - after the start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123Z, "Mon, 02 Jun 2025 05:00:00 -0400"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
desiredInstances := tc.snapshot.CalculateDesiredInstances(tc.at)
|
||||
require.Equal(t, tc.expectedCalculatedInstances, desiredInstances)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseTime(t *testing.T, layout, value string) time.Time {
|
||||
t.Helper()
|
||||
parsedTime, err := time.Parse(layout, value)
|
||||
require.NoError(t, err)
|
||||
return parsedTime
|
||||
}
|
||||
|
||||
func preset(active bool, instances int32, opts options, muts ...func(row database.GetTemplatePresetsWithPrebuildsRow) database.GetTemplatePresetsWithPrebuildsRow) database.GetTemplatePresetsWithPrebuildsRow {
|
||||
ttl := sql.NullInt32{}
|
||||
if opts.ttl > 0 {
|
||||
@@ -934,6 +1428,15 @@ func preset(active bool, instances int32, opts options, muts ...func(row databas
|
||||
return entry
|
||||
}
|
||||
|
||||
func schedule(presetID uuid.UUID, cronExpr string, instances int32) database.TemplateVersionPresetPrebuildSchedule {
|
||||
return database.TemplateVersionPresetPrebuildSchedule{
|
||||
ID: uuid.New(),
|
||||
PresetID: presetID,
|
||||
CronExpression: cronExpr,
|
||||
DesiredInstances: instances,
|
||||
}
|
||||
}
|
||||
|
||||
func prebuiltWorkspace(
|
||||
opts options,
|
||||
clock quartz.Clock,
|
||||
|
||||
@@ -2197,7 +2197,13 @@ func InsertWorkspacePresetsAndParameters(ctx context.Context, logger slog.Logger
|
||||
|
||||
func InsertWorkspacePresetAndParameters(ctx context.Context, db database.Store, templateVersionID uuid.UUID, protoPreset *sdkproto.Preset, t time.Time) error {
|
||||
err := db.InTx(func(tx database.Store) error {
|
||||
var desiredInstances, ttl sql.NullInt32
|
||||
var (
|
||||
desiredInstances sql.NullInt32
|
||||
ttl sql.NullInt32
|
||||
schedulingEnabled bool
|
||||
schedulingTimezone string
|
||||
prebuildSchedules []*sdkproto.Schedule
|
||||
)
|
||||
if protoPreset != nil && protoPreset.Prebuild != nil {
|
||||
desiredInstances = sql.NullInt32{
|
||||
Int32: protoPreset.Prebuild.Instances,
|
||||
@@ -2209,6 +2215,11 @@ func InsertWorkspacePresetAndParameters(ctx context.Context, db database.Store,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
if protoPreset.Prebuild.Scheduling != nil {
|
||||
schedulingEnabled = true
|
||||
schedulingTimezone = protoPreset.Prebuild.Scheduling.Timezone
|
||||
prebuildSchedules = protoPreset.Prebuild.Scheduling.Schedule
|
||||
}
|
||||
}
|
||||
dbPreset, err := tx.InsertPreset(ctx, database.InsertPresetParams{
|
||||
ID: uuid.New(),
|
||||
@@ -2217,11 +2228,25 @@ func InsertWorkspacePresetAndParameters(ctx context.Context, db database.Store,
|
||||
CreatedAt: t,
|
||||
DesiredInstances: desiredInstances,
|
||||
InvalidateAfterSecs: ttl,
|
||||
SchedulingTimezone: schedulingTimezone,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert preset: %w", err)
|
||||
}
|
||||
|
||||
if schedulingEnabled {
|
||||
for _, schedule := range prebuildSchedules {
|
||||
_, err := tx.InsertPresetPrebuildSchedule(ctx, database.InsertPresetPrebuildScheduleParams{
|
||||
PresetID: dbPreset.ID,
|
||||
CronExpression: schedule.Cron,
|
||||
DesiredInstances: schedule.Instances,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to insert preset prebuild schedule: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var presetParameterNames []string
|
||||
var presetParameterValues []string
|
||||
for _, parameter := range protoPreset.Parameters {
|
||||
|
||||
@@ -71,6 +71,29 @@ func Daily(raw string) (*Schedule, error) {
|
||||
return parse(raw)
|
||||
}
|
||||
|
||||
// TimeRange parses a Schedule from a cron specification interpreted as a continuous time range.
|
||||
//
|
||||
// For example, the expression "* 9-18 * * 1-5" represents a continuous time span
|
||||
// from 09:00:00 to 18:59:59, Monday through Friday.
|
||||
//
|
||||
// The specification consists of space-delimited fields in the following order:
|
||||
// - (Optional) Timezone, e.g., CRON_TZ=US/Central
|
||||
// - Minutes: must be "*" to represent the full range within each hour
|
||||
// - Hour of day: e.g., 9-18 (required)
|
||||
// - Day of month: e.g., * or 1-15 (required)
|
||||
// - Month: e.g., * or 1-6 (required)
|
||||
// - Day of week: e.g., * or 1-5 (required)
|
||||
//
|
||||
// Unlike standard cron, this function interprets the input as a continuous active period
|
||||
// rather than discrete scheduled times.
|
||||
func TimeRange(raw string) (*Schedule, error) {
|
||||
if err := validateTimeRangeSpec(raw); err != nil {
|
||||
return nil, xerrors.Errorf("validate time range schedule: %w", err)
|
||||
}
|
||||
|
||||
return parse(raw)
|
||||
}
|
||||
|
||||
func parse(raw string) (*Schedule, error) {
|
||||
// If schedule does not specify a timezone, default to UTC. Otherwise,
|
||||
// the library will default to time.Local which we want to avoid.
|
||||
@@ -155,6 +178,24 @@ func (s Schedule) Next(t time.Time) time.Time {
|
||||
return s.sched.Next(t)
|
||||
}
|
||||
|
||||
// IsWithinRange interprets a cron spec as a continuous time range,
|
||||
// and returns whether the provided time value falls within that range.
|
||||
//
|
||||
// For example, the expression "* 9-18 * * 1-5" represents a continuous time range
|
||||
// from 09:00:00 to 18:59:59, Monday through Friday.
|
||||
func (s Schedule) IsWithinRange(t time.Time) bool {
|
||||
// Truncate to the beginning of the current minute.
|
||||
currentMinute := t.Truncate(time.Minute)
|
||||
|
||||
// Go back 1 second from the current minute to find what the next scheduled time would be.
|
||||
justBefore := currentMinute.Add(-time.Second)
|
||||
next := s.Next(justBefore)
|
||||
|
||||
// If the next scheduled time is exactly at the current minute,
|
||||
// then we are within the range.
|
||||
return next.Equal(currentMinute)
|
||||
}
|
||||
|
||||
var (
|
||||
t0 = time.Date(1970, 1, 1, 1, 1, 1, 0, time.UTC)
|
||||
tMax = t0.Add(168 * time.Hour)
|
||||
@@ -263,3 +304,18 @@ func validateDailySpec(spec string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTimeRangeSpec ensures that the minutes field is set to *
|
||||
func validateTimeRangeSpec(spec string) error {
|
||||
parts := strings.Fields(spec)
|
||||
if len(parts) < 5 {
|
||||
return xerrors.Errorf("expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix")
|
||||
}
|
||||
if len(parts) == 6 {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if parts[0] != "*" {
|
||||
return xerrors.Errorf("expected minutes to be *")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -163,6 +163,120 @@ func Test_Weekly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWithinRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
at time.Time
|
||||
expectedWithinRange bool
|
||||
expectedError string
|
||||
}{
|
||||
// "* 9-18 * * 1-5" should be interpreted as a continuous time range from 09:00:00 to 18:59:59, Monday through Friday
|
||||
{
|
||||
name: "Right before the start of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 8:59:59 UTC"),
|
||||
expectedWithinRange: false,
|
||||
},
|
||||
{
|
||||
name: "Start of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 9:00:00 UTC"),
|
||||
expectedWithinRange: true,
|
||||
},
|
||||
{
|
||||
name: "9:01 AM - One minute after the start of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 9:01:00 UTC"),
|
||||
expectedWithinRange: true,
|
||||
},
|
||||
{
|
||||
name: "2PM - The middle of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 14:00:00 UTC"),
|
||||
expectedWithinRange: true,
|
||||
},
|
||||
{
|
||||
name: "6PM - One hour before the end of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 18:00:00 UTC"),
|
||||
expectedWithinRange: true,
|
||||
},
|
||||
{
|
||||
name: "End of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 18:59:59 UTC"),
|
||||
expectedWithinRange: true,
|
||||
},
|
||||
{
|
||||
name: "Right after the end of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 19:00:00 UTC"),
|
||||
expectedWithinRange: false,
|
||||
},
|
||||
{
|
||||
name: "7:01PM - One minute after the end of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 19:01:00 UTC"),
|
||||
expectedWithinRange: false,
|
||||
},
|
||||
{
|
||||
name: "2AM - Significantly outside the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 02:00:00 UTC"),
|
||||
expectedWithinRange: false,
|
||||
},
|
||||
{
|
||||
name: "Outside the day range #1",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Sat, 07 Jun 2025 14:00:00 UTC"),
|
||||
expectedWithinRange: false,
|
||||
},
|
||||
{
|
||||
name: "Outside the day range #2",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Sun, 08 Jun 2025 14:00:00 UTC"),
|
||||
expectedWithinRange: false,
|
||||
},
|
||||
{
|
||||
name: "Check that Sunday is supported with value 0",
|
||||
spec: "* 9-18 * * 0",
|
||||
at: mustParseTime(t, time.RFC1123, "Sun, 08 Jun 2025 14:00:00 UTC"),
|
||||
expectedWithinRange: true,
|
||||
},
|
||||
{
|
||||
name: "Check that value 7 is rejected as out of range",
|
||||
spec: "* 9-18 * * 7",
|
||||
at: mustParseTime(t, time.RFC1123, "Sun, 08 Jun 2025 14:00:00 UTC"),
|
||||
expectedError: "end of range (7) above maximum (6): 7",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sched, err := cron.Weekly(testCase.spec)
|
||||
if testCase.expectedError != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), testCase.expectedError)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
withinRange := sched.IsWithinRange(testCase.at)
|
||||
require.Equal(t, testCase.expectedWithinRange, withinRange)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseTime(t *testing.T, layout, value string) time.Time {
|
||||
t.Helper()
|
||||
parsedTime, err := time.Parse(layout, value)
|
||||
require.NoError(t, err)
|
||||
return parsedTime
|
||||
}
|
||||
|
||||
func mustLocation(t *testing.T, s string) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation(s)
|
||||
|
||||
Reference in New Issue
Block a user