From d61894db849290683a8dab622806784c2fd80d1c Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Thu, 29 May 2025 10:45:13 -0400 Subject: [PATCH 01/19] feat: add autoscaling configuration for prebuilds --- docs/data-sources/workspace_preset.md | 19 ++ integration/integration_test.go | 17 +- integration/test-data-source/main.tf | 16 ++ provider/workspace_preset.go | 89 ++++++++++ provider/workspace_preset_test.go | 243 ++++++++++++++++++++++++++ 5 files changed, 378 insertions(+), 6 deletions(-) diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index cd4908c2..d9337934 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -54,8 +54,27 @@ Required: Optional: +- `autoscaling` (Block List, Max: 1) Configuration block that defines autoscaling behavior for prebuilds. Use this to automatically adjust the number of prebuild instances based on a schedule. (see [below for nested schema](#nestedblock--prebuilds--autoscaling)) - `expiration_policy` (Block Set, Max: 1) Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date. (see [below for nested schema](#nestedblock--prebuilds--expiration_policy)) + +### Nested Schema for `prebuilds.autoscaling` + +Required: + +- `schedule` (Block List, Min: 1) One or more schedule blocks that define when to scale the number of prebuild instances. (see [below for nested schema](#nestedblock--prebuilds--autoscaling--schedule)) +- `timezone` (String) The timezone to use for the autoscaling schedule (e.g., "UTC", "America/New_York"). + + +### Nested Schema for `prebuilds.autoscaling.schedule` + +Required: + +- `cron` (String) A cron expression that defines when this schedule should be active. The cron expression must be in the format "* HOUR * * DAY-OF-WEEK" where HOUR is 0-23 and DAY-OF-WEEK is 0-6 (Sunday-Saturday). The minute, day-of-month, and month fields must be "*". +- `instances` (Number) The number of prebuild instances to maintain during this schedule period. + + + ### Nested Schema for `prebuilds.expiration_policy` diff --git a/integration/integration_test.go b/integration/integration_test.go index 36612904..0e517133 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -90,12 +90,17 @@ func TestIntegration(t *testing.T) { // TODO (sasswart): the cli doesn't support presets yet. // once it does, the value for workspace_parameter.value // will be the preset value. - "workspace_parameter.value": `param value`, - "workspace_parameter.icon": `param icon`, - "workspace_preset.name": `preset`, - "workspace_preset.parameters.param": `preset param value`, - "workspace_preset.prebuilds.instances": `1`, - "workspace_preset.prebuilds.expiration_policy.ttl": `86400`, + "workspace_parameter.value": `param value`, + "workspace_parameter.icon": `param icon`, + "workspace_preset.name": `preset`, + "workspace_preset.parameters.param": `preset param value`, + "workspace_preset.prebuilds.instances": `1`, + "workspace_preset.prebuilds.expiration_policy.ttl": `86400`, + "workspace_preset.prebuilds.autoscaling.timezone": `UTC`, + "workspace_preset.prebuilds.autoscaling.schedule0.cron": `\* 8-18 \* \* 1-5`, + "workspace_preset.prebuilds.autoscaling.schedule0.instances": `3`, + "workspace_preset.prebuilds.autoscaling.schedule1.cron": `\* 8-14 \* \* 6`, + "workspace_preset.prebuilds.autoscaling.schedule1.instances": `1`, }, }, { diff --git a/integration/test-data-source/main.tf b/integration/test-data-source/main.tf index 50274fff..8ebdbb65 100644 --- a/integration/test-data-source/main.tf +++ b/integration/test-data-source/main.tf @@ -30,6 +30,17 @@ data "coder_workspace_preset" "preset" { expiration_policy { ttl = 86400 } + autoscaling { + timezone = "UTC" + schedule { + cron = "* 8-18 * * 1-5" + instances = 3 + } + schedule { + cron = "* 8-14 * * 6" + instances = 1 + } + } } } @@ -56,6 +67,11 @@ locals { "workspace_preset.parameters.param" : data.coder_workspace_preset.preset.parameters.param, "workspace_preset.prebuilds.instances" : tostring(one(data.coder_workspace_preset.preset.prebuilds).instances), "workspace_preset.prebuilds.expiration_policy.ttl" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).expiration_policy).ttl), + "workspace_preset.prebuilds.autoscaling.timezone" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).timezone), + "workspace_preset.prebuilds.autoscaling.schedule0.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).schedule[0].cron), + "workspace_preset.prebuilds.autoscaling.schedule0.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).schedule[0].instances), + "workspace_preset.prebuilds.autoscaling.schedule1.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).schedule[1].cron), + "workspace_preset.prebuilds.autoscaling.schedule1.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).schedule[1].instances), } } diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index e0f2276c..b4dd6418 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -3,13 +3,18 @@ package provider import ( "context" "fmt" + "strings" + "time" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" "github.com/mitchellh/mapstructure" + rbcron "github.com/robfig/cron/v3" ) +var PrebuildsCRONParser = rbcron.NewParser(rbcron.Minute | rbcron.Hour | rbcron.Dom | rbcron.Month | rbcron.Dow) + type WorkspacePreset struct { Name string `mapstructure:"name"` Parameters map[string]string `mapstructure:"parameters"` @@ -29,12 +34,23 @@ type WorkspacePrebuild struct { // for utilities that parse our terraform output using this type. To remain compatible // with those cases, we use a slice here. ExpirationPolicy []ExpirationPolicy `mapstructure:"expiration_policy"` + Autoscaling []Autoscaling `mapstructure:"autoscaling"` } type ExpirationPolicy struct { TTL int `mapstructure:"ttl"` } +type Autoscaling struct { + Timezone string `mapstructure:"timezone"` + Schedule []Schedule `mapstructure:"schedule"` +} + +type Schedule struct { + Cron string `mapstructure:"cron"` + Instances int `mapstructure:"instances"` +} + func workspacePresetDataSource() *schema.Resource { return &schema.Resource{ SchemaVersion: 1, @@ -119,9 +135,82 @@ func workspacePresetDataSource() *schema.Resource { }, }, }, + "autoscaling": { + Type: schema.TypeList, + Description: "Configuration block that defines autoscaling behavior for prebuilds. Use this to automatically adjust the number of prebuild instances based on a schedule.", + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "timezone": { + Type: schema.TypeString, + Description: "The timezone to use for the autoscaling schedule (e.g., \"UTC\", \"America/New_York\").", + Required: true, + ValidateFunc: func(val interface{}, key string) ([]string, []error) { + timezone := val.(string) + + _, err := time.LoadLocation(timezone) + if err != nil { + return nil, []error{fmt.Errorf("failed to load location: %w", err)} + } + + return nil, nil + }, + }, + "schedule": { + Type: schema.TypeList, + Description: "One or more schedule blocks that define when to scale the number of prebuild instances.", + Required: true, + MinItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "cron": { + Type: schema.TypeString, + Description: "A cron expression that defines when this schedule should be active. The cron expression must be in the format \"* HOUR * * DAY-OF-WEEK\" where HOUR is 0-23 and DAY-OF-WEEK is 0-6 (Sunday-Saturday). The minute, day-of-month, and month fields must be \"*\".", + Required: true, + ValidateFunc: func(val interface{}, key string) ([]string, []error) { + cronSpec := val.(string) + + err := validatePrebuildsCronSpec(cronSpec) + if err != nil { + return nil, []error{fmt.Errorf("cron spec failed validation: %w", err)} + } + + _, err = PrebuildsCRONParser.Parse(cronSpec) + if err != nil { + return nil, []error{fmt.Errorf("failed to parse cron spec: %w", err)} + } + + return nil, nil + }, + }, + "instances": { + Type: schema.TypeInt, + Description: "The number of prebuild instances to maintain during this schedule period.", + Required: true, + }, + }, + }, + }, + }, + }, + }, }, }, }, }, } } + +// validatePrebuildsCronSpec ensures that the minute, day-of-month and month options of spec are all set to * +func validatePrebuildsCronSpec(spec string) error { + parts := strings.Fields(spec) + if len(parts) != 5 { + return fmt.Errorf("cron specification should consist of 5 fields") + } + if parts[0] != "*" || parts[2] != "*" || parts[3] != "*" { + return fmt.Errorf("minute, day-of-month and month should be *") + } + + return nil +} diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go index b8e752ae..c9e337dc 100644 --- a/provider/workspace_preset_test.go +++ b/provider/workspace_preset_test.go @@ -265,6 +265,249 @@ func TestWorkspacePreset(t *testing.T) { }`, ExpectError: regexp.MustCompile("An argument named \"invalid_argument\" is not expected here."), }, + { + Name: "Prebuilds is set with an empty autoscaling field", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + autoscaling {} + } + }`, + ExpectError: regexp.MustCompile(`The argument "[^"]+" is required, but no definition was found.`), + }, + { + Name: "Prebuilds is set with an autoscaling field, but without timezone", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + autoscaling { + schedule { + cron = "* 8-18 * * 1-5" + instances = 3 + } + } + } + }`, + ExpectError: regexp.MustCompile(`The argument "timezone" is required, but no definition was found.`), + }, + { + Name: "Prebuilds is set with an autoscaling field, but without schedule", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + autoscaling { + timezone = "UTC" + } + } + }`, + ExpectError: regexp.MustCompile(`At least 1 "schedule" blocks are required.`), + }, + { + Name: "Prebuilds is set with an autoscaling.schedule field, but without cron", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + autoscaling { + timezone = "UTC" + schedule { + instances = 3 + } + } + } + }`, + ExpectError: regexp.MustCompile(`The argument "cron" is required, but no definition was found.`), + }, + { + Name: "Prebuilds is set with an autoscaling.schedule field, but without instances", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + autoscaling { + timezone = "UTC" + schedule { + cron = "* 8-18 * * 1-5" + } + } + } + }`, + ExpectError: regexp.MustCompile(`The argument "instances" is required, but no definition was found.`), + }, + { + Name: "Prebuilds is set with an autoscaling.schedule field, but with invalid type for instances", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + autoscaling { + timezone = "UTC" + schedule { + cron = "* 8-18 * * 1-5" + instances = "not_a_number" + } + } + } + }`, + ExpectError: regexp.MustCompile(`Inappropriate value for attribute "instances": a number is required`), + }, + { + Name: "Prebuilds is set with an autoscaling field with 1 schedule", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + autoscaling { + timezone = "UTC" + schedule { + cron = "* 8-18 * * 1-5" + instances = 3 + } + } + } + }`, + ExpectError: nil, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"] + require.NotNil(t, resource) + attrs := resource.Primary.Attributes + require.Equal(t, attrs["name"], "preset_1") + require.Equal(t, attrs["prebuilds.0.autoscaling.0.timezone"], "UTC") + require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.0.cron"], "* 8-18 * * 1-5") + require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.0.instances"], "3") + return nil + }, + }, + { + Name: "Prebuilds is set with an autoscaling field with 2 schedules", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + autoscaling { + timezone = "UTC" + schedule { + cron = "* 8-18 * * 1-5" + instances = 3 + } + schedule { + cron = "* 8-14 * * 6" + instances = 1 + } + } + } + }`, + ExpectError: nil, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"] + require.NotNil(t, resource) + attrs := resource.Primary.Attributes + require.Equal(t, attrs["name"], "preset_1") + require.Equal(t, attrs["prebuilds.0.autoscaling.0.timezone"], "UTC") + require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.0.cron"], "* 8-18 * * 1-5") + require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.0.instances"], "3") + require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.1.cron"], "* 8-14 * * 6") + require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.1.instances"], "1") + return nil + }, + }, + { + Name: "Prebuilds is set with an autoscaling.schedule field, but the cron includes a disallowed minute field", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + autoscaling { + timezone = "UTC" + schedule { + cron = "30 8-18 * * 1-5" + instances = "1" + } + } + } + }`, + ExpectError: regexp.MustCompile(`cron spec failed validation: minute, day-of-month and month should be *`), + }, + { + Name: "Prebuilds is set with an autoscaling.schedule field, but the cron hour field is invalid", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + autoscaling { + timezone = "UTC" + schedule { + cron = "* 25-26 * * 1-5" + instances = "1" + } + } + } + }`, + ExpectError: regexp.MustCompile(`failed to parse cron spec: end of range \(26\) above maximum \(23\): 25-26`), + }, + { + Name: "Prebuilds is set with a valid autoscaling.timezone field", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + autoscaling { + timezone = "America/Los_Angeles" + schedule { + cron = "* 8-18 * * 1-5" + instances = 3 + } + } + } + }`, + ExpectError: nil, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"] + require.NotNil(t, resource) + attrs := resource.Primary.Attributes + require.Equal(t, attrs["name"], "preset_1") + require.Equal(t, attrs["prebuilds.0.autoscaling.0.timezone"], "America/Los_Angeles") + return nil + }, + }, + { + Name: "Prebuilds is set with an invalid autoscaling.timezone field", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + autoscaling { + timezone = "InvalidLocation" + schedule { + cron = "* 8-18 * * 1-5" + instances = 3 + } + } + } + }`, + ExpectError: regexp.MustCompile(`failed to load location: unknown time zone InvalidLocation`), + }, } for _, testcase := range testcases { From 7853727f46d2a46f83b1c924bf853f2c94ddce07 Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Tue, 17 Jun 2025 15:31:14 +0000 Subject: [PATCH 02/19] fix: improve schedule validation --- provider/helpers/schedule_validation.go | 187 +++++++ provider/helpers/schedule_validation_test.go | 553 +++++++++++++++++++ provider/workspace_preset.go | 69 +++ provider/workspace_preset_test.go | 22 + 4 files changed, 831 insertions(+) create mode 100644 provider/helpers/schedule_validation.go create mode 100644 provider/helpers/schedule_validation_test.go diff --git a/provider/helpers/schedule_validation.go b/provider/helpers/schedule_validation.go new file mode 100644 index 00000000..ecfe46d5 --- /dev/null +++ b/provider/helpers/schedule_validation.go @@ -0,0 +1,187 @@ +package helpers + +import ( + "strconv" + "strings" + + "golang.org/x/xerrors" +) + +// ValidateSchedules checks if any schedules overlap +func ValidateSchedules(schedules []string) error { + for i := 0; i < len(schedules); i++ { + for j := i + 1; j < len(schedules); j++ { + overlap, err := SchedulesOverlap(schedules[i], schedules[j]) + if err != nil { + return xerrors.Errorf("invalid schedule: %w", err) + } + if overlap { + return xerrors.Errorf("schedules overlap: %s and %s", + schedules[i], schedules[j]) + } + } + } + return nil +} + +// SchedulesOverlap checks if two schedules overlap by checking +// days, months, and hours separately +func SchedulesOverlap(schedule1, schedule2 string) (bool, error) { + // Get cron fields + fields1 := strings.Fields(schedule1) + fields2 := strings.Fields(schedule2) + + if len(fields1) != 5 { + return false, xerrors.Errorf("schedule %q has %d fields, expected 5 fields (minute hour day-of-month month day-of-week)", schedule1, len(fields1)) + } + if len(fields2) != 5 { + return false, xerrors.Errorf("schedule %q has %d fields, expected 5 fields (minute hour day-of-month month day-of-week)", schedule2, len(fields2)) + } + + // Check if months overlap + monthsOverlap, err := MonthsOverlap(fields1[3], fields2[3]) + if err != nil { + return false, xerrors.Errorf("invalid month range: %w", err) + } + if !monthsOverlap { + return false, nil + } + + // Check if days overlap (DOM OR DOW) + daysOverlap, err := DaysOverlap(fields1[2], fields1[4], fields2[2], fields2[4]) + if err != nil { + return false, xerrors.Errorf("invalid day range: %w", err) + } + if !daysOverlap { + return false, nil + } + + // Check if hours overlap + hoursOverlap, err := HoursOverlap(fields1[1], fields2[1]) + if err != nil { + return false, xerrors.Errorf("invalid hour range: %w", err) + } + + return hoursOverlap, nil +} + +// MonthsOverlap checks if two month ranges overlap +func MonthsOverlap(months1, months2 string) (bool, error) { + return CheckOverlap(months1, months2, 12) +} + +// HoursOverlap checks if two hour ranges overlap +func HoursOverlap(hours1, hours2 string) (bool, error) { + return CheckOverlap(hours1, hours2, 23) +} + +// DomOverlap checks if two day-of-month ranges overlap +func DomOverlap(dom1, dom2 string) (bool, error) { + return CheckOverlap(dom1, dom2, 31) +} + +// DowOverlap checks if two day-of-week ranges overlap +func DowOverlap(dow1, dow2 string) (bool, error) { + return CheckOverlap(dow1, dow2, 6) +} + +// DaysOverlap checks if two day ranges overlap, considering both DOM and DOW. +// Returns true if both DOM and DOW overlap, or if one is * and the other overlaps. +func DaysOverlap(dom1, dow1, dom2, dow2 string) (bool, error) { + // If either DOM is *, we only need to check DOW overlap + if dom1 == "*" || dom2 == "*" { + return DowOverlap(dow1, dow2) + } + + // If either DOW is *, we only need to check DOM overlap + if dow1 == "*" || dow2 == "*" { + return DomOverlap(dom1, dom2) + } + + // If both DOM and DOW are specified, we need to check both + // because the schedule runs when either matches + domOverlap, err := DomOverlap(dom1, dom2) + if err != nil { + return false, err + } + dowOverlap, err := DowOverlap(dow1, dow2) + if err != nil { + return false, err + } + + // If either DOM or DOW overlaps, the schedules overlap + return domOverlap || dowOverlap, nil +} + +// CheckOverlap is a generic function to check if two ranges overlap +func CheckOverlap(range1, range2 string, maxValue int) (bool, error) { + set1, err := ParseRange(range1, maxValue) + if err != nil { + return false, err + } + set2, err := ParseRange(range2, maxValue) + if err != nil { + return false, err + } + + for value := range set1 { + if set2[value] { + return true, nil + } + } + return false, nil +} + +// ParseRange converts a cron range to a set of integers +// maxValue is the maximum allowed value (e.g., 23 for hours, 6 for DOW, 12 for months, 31 for DOM) +func ParseRange(input string, maxValue int) (map[int]bool, error) { + result := make(map[int]bool) + + // Handle "*" case + if input == "*" { + for i := 0; i <= maxValue; i++ { + result[i] = true + } + return result, nil + } + + // Parse ranges like "1-3,5,7-9" + parts := strings.Split(input, ",") + for _, part := range parts { + if strings.Contains(part, "-") { + // Handle range like "1-3" + rangeParts := strings.Split(part, "-") + start, err := strconv.Atoi(rangeParts[0]) + if err != nil { + return nil, xerrors.Errorf("invalid start value in range: %w", err) + } + end, err := strconv.Atoi(rangeParts[1]) + if err != nil { + return nil, xerrors.Errorf("invalid end value in range: %w", err) + } + + // Validate range + if start < 0 || end > maxValue || start > end { + return nil, xerrors.Errorf("invalid range %d-%d: values must be between 0 and %d", start, end, maxValue) + } + + for i := start; i <= end; i++ { + result[i] = true + } + } else { + // Handle single value + value, err := strconv.Atoi(part) + if err != nil { + return nil, xerrors.Errorf("invalid value: %w", err) + } + + // Validate value + if value < 0 || value > maxValue { + return nil, xerrors.Errorf("invalid value %d: must be between 0 and %d", value, maxValue) + } + + result[value] = true + } + } + return result, nil +} diff --git a/provider/helpers/schedule_validation_test.go b/provider/helpers/schedule_validation_test.go new file mode 100644 index 00000000..49dcaecd --- /dev/null +++ b/provider/helpers/schedule_validation_test.go @@ -0,0 +1,553 @@ +// schedule_validation_test.go + +package helpers_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/terraform-provider-coder/v2/provider/helpers" +) + +func TestParseRange(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + input string + maxValue int + expected map[int]bool + expectErr bool + }{ + { + name: "Wildcard", + input: "*", + maxValue: 5, + expected: map[int]bool{ + 0: true, 1: true, 2: true, 3: true, 4: true, 5: true, + }, + }, + { + name: "Single value", + input: "3", + maxValue: 5, + expected: map[int]bool{ + 3: true, + }, + }, + { + name: "Range", + input: "1-3", + maxValue: 5, + expected: map[int]bool{ + 1: true, 2: true, 3: true, + }, + }, + { + name: "Complex range", + input: "1-3,5,7-9", + maxValue: 9, + expected: map[int]bool{ + 1: true, 2: true, 3: true, 5: true, 7: true, 8: true, 9: true, + }, + }, + { + name: "Value too high", + input: "6", + maxValue: 5, + expectErr: true, + }, + { + name: "Range too high", + input: "4-6", + maxValue: 5, + expectErr: true, + }, + { + name: "Invalid range", + input: "3-1", + maxValue: 5, + expectErr: true, + }, + { + name: "Invalid value", + input: "abc", + maxValue: 5, + expectErr: true, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + result, err := helpers.ParseRange(testCase.input, testCase.maxValue) + if testCase.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, testCase.expected, result) + }) + } +} + +func TestCheckOverlap(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + range1 string + range2 string + maxValue int + overlap bool + expectErr bool + }{ + { + name: "Same range", + range1: "1-5", + range2: "1-5", + maxValue: 10, + overlap: true, + }, + { + name: "Different ranges", + range1: "1-3", + range2: "4-6", + maxValue: 10, + overlap: false, + }, + { + name: "Overlapping ranges", + range1: "1-5", + range2: "4-8", + maxValue: 10, + overlap: true, + }, + { + name: "Wildcard overlap", + range1: "*", + range2: "3-5", + maxValue: 10, + overlap: true, + }, + { + name: "Complex ranges", + range1: "1-3,5,7-9", + range2: "2-4,6,8-10", + maxValue: 10, + overlap: true, + }, + { + name: "Single values", + range1: "1", + range2: "1", + maxValue: 10, + overlap: true, + }, + { + name: "Single value vs range", + range1: "1", + range2: "1-3", + maxValue: 10, + overlap: true, + }, + { + name: "Invalid range - value too high", + range1: "11", + range2: "1-3", + maxValue: 10, + expectErr: true, + }, + { + name: "Invalid range - negative value", + range1: "-1", + range2: "1-3", + maxValue: 10, + expectErr: true, + }, + { + name: "Invalid range - malformed", + range1: "1-", + range2: "1-3", + maxValue: 10, + expectErr: true, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + overlap, err := helpers.CheckOverlap(testCase.range1, testCase.range2, testCase.maxValue) + if testCase.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, testCase.overlap, overlap) + }) + } +} + +func TestOverlapWrappers(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + range1 string + range2 string + overlap bool + expectErr bool + overlapFunc func(string, string) (bool, error) + }{ + // HoursOverlap tests (max 23) + { + name: "Valid hour range", + range1: "23", + range2: "23", + overlap: true, + overlapFunc: helpers.HoursOverlap, + }, + { + name: "Invalid hour range", + range1: "24", + range2: "24", + expectErr: true, + overlapFunc: helpers.HoursOverlap, + }, + + // MonthsOverlap tests (max 12) + { + name: "Valid month range", + range1: "12", + range2: "12", + overlap: true, + overlapFunc: helpers.MonthsOverlap, + }, + { + name: "Invalid month range", + range1: "13", + range2: "13", + expectErr: true, + overlapFunc: helpers.MonthsOverlap, + }, + + // DomOverlap tests (max 31) + { + name: "Valid day of month range", + range1: "31", + range2: "31", + overlap: true, + overlapFunc: helpers.DomOverlap, + }, + { + name: "Invalid day of month range", + range1: "32", + range2: "32", + expectErr: true, + overlapFunc: helpers.DomOverlap, + }, + + // DowOverlap tests (max 6) + { + name: "Valid day of week range", + range1: "6", + range2: "6", + overlap: true, + overlapFunc: helpers.DowOverlap, + }, + { + name: "Invalid day of week range", + range1: "7", + range2: "7", + expectErr: true, + overlapFunc: helpers.DowOverlap, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + overlap, err := testCase.overlapFunc(testCase.range1, testCase.range2) + if testCase.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, testCase.overlap, overlap) + }) + } +} + +func TestDaysOverlap(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + dom1 string + dow1 string + dom2 string + dow2 string + overlap bool + expectErr bool + }{ + { + name: "DOM overlap only", + dom1: "1-15", + dow1: "1-3", + dom2: "10-20", + dow2: "4-6", + overlap: true, // true because DOM overlaps (10-15) + }, + { + name: "DOW overlap only", + dom1: "1-15", + dow1: "1-3", + dom2: "16-31", + dow2: "3-5", + overlap: true, // true because DOW overlaps (3) + }, + { + name: "Both DOM and DOW overlap", + dom1: "1-15", + dow1: "1-3", + dom2: "10-20", + dow2: "3-5", + overlap: true, // true because both overlap + }, + { + name: "No overlap", + dom1: "1-15", + dow1: "1-3", + dom2: "16-31", + dow2: "4-6", + overlap: false, // false because neither overlaps + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + overlap, err := helpers.DaysOverlap(testCase.dom1, testCase.dow1, testCase.dom2, testCase.dow2) + if testCase.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, testCase.overlap, overlap) + }) + } +} + +func TestSchedulesOverlap(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + s1 string + s2 string + overlap bool + expectErr bool + }{ + // Basic overlap cases + { + name: "Same schedule", + s1: "* 9-18 * * 1-5", + s2: "* 9-18 * * 1-5", + overlap: true, + }, + { + name: "Different hours - no overlap", + s1: "* 9-12 * * 1-5", + s2: "* 13-18 * * 1-5", + overlap: false, + }, + { + name: "Different hours - partial overlap", + s1: "* 9-14 * * 1-5", + s2: "* 12-18 * * 1-5", + overlap: true, + }, + { + name: "Different hours - one contained in another", + s1: "* 9-18 * * 1-5", + s2: "* 12-14 * * 1-5", + overlap: true, + }, + + // Day of week overlap cases (with wildcard DOM) + { + name: "Different DOW with wildcard DOM", + s1: "* 9-18 * * 1,3,5", // Mon,Wed,Fri + s2: "* 9-18 * * 2,4,6", // Tue,Thu,Sat + overlap: false, // No overlap because DOW ranges don't overlap + }, + { + name: "Different DOW with wildcard DOM - complex ranges", + s1: "* 9-18 * * 1-3", // Mon-Wed + s2: "* 9-18 * * 4-5", // Thu-Fri + overlap: false, // No overlap because DOW ranges don't overlap + }, + + // Day of week overlap cases (with specific DOM) + { + name: "Different DOW with specific DOM - no overlap", + s1: "* 9-18 1 * 1-3", + s2: "* 9-18 2 * 4-5", + overlap: false, // No overlap because different DOM and DOW + }, + { + name: "Different DOW with specific DOM - partial overlap", + s1: "* 9-18 1 * 1-4", + s2: "* 9-18 1 * 3-5", + overlap: true, // Overlaps because same DOM + }, + { + name: "Different DOW with specific DOM - complex ranges", + s1: "* 9-18 1 * 1,3,5", + s2: "* 9-18 1 * 2,4,6", + overlap: true, // Overlaps because same DOM + }, + + // Wildcard cases + { + name: "Wildcard hours vs specific hours", + s1: "* * * * 1-5", + s2: "* 9-18 * * 1-5", + overlap: true, + }, + { + name: "Wildcard DOW vs specific DOW", + s1: "* 9-18 * * *", + s2: "* 9-18 * * 1-5", + overlap: true, + }, + { + name: "Both wildcard DOW", + s1: "* 9-18 * * *", + s2: "* 9-18 * * *", + overlap: true, + }, + + // Complex time ranges + { + name: "Complex hour ranges - no overlap", + s1: "* 9-11,13-15 * * 1-5", + s2: "* 12,16-18 * * 1-5", + overlap: false, + }, + { + name: "Complex hour ranges - partial overlap", + s1: "* 9-11,13-15 * * 1-5", + s2: "* 10-12,14-16 * * 1-5", + overlap: true, + }, + { + name: "Complex hour ranges - contained", + s1: "* 9-18 * * 1-5", + s2: "* 10-11,13-14 * * 1-5", + overlap: true, + }, + + // Error cases (keeping minimal) + { + name: "Invalid hour range", + s1: "* 25-26 * * 1-5", + s2: "* 9-18 * * 1-5", + expectErr: true, + }, + { + name: "Invalid month range", + s1: "* 9-18 * 13 1-5", + s2: "* 9-18 * * 1-5", + expectErr: true, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + overlap, err := helpers.SchedulesOverlap(testCase.s1, testCase.s2) + if testCase.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, testCase.overlap, overlap) + }) + } +} + +func TestValidateSchedules(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + schedules []string + expectErr bool + }{ + // Basic validation + { + name: "Empty schedules", + schedules: []string{}, + expectErr: false, + }, + { + name: "Single valid schedule", + schedules: []string{ + "* 9-18 * * 1-5", + }, + expectErr: false, + }, + + // Non-overlapping schedules + { + name: "Multiple valid non-overlapping schedules", + schedules: []string{ + "* 9-12 * * 1-5", + "* 13-18 * * 1-5", + }, + expectErr: false, + }, + { + name: "Multiple valid non-overlapping schedules", + schedules: []string{ + "* 9-18 * * 1-5", + "* 9-13 * * 6,0", + }, + expectErr: false, + }, + + // Overlapping schedules + { + name: "Two overlapping schedules", + schedules: []string{ + "* 9-14 * * 1-5", + "* 12-18 * * 1-5", + }, + expectErr: true, + }, + { + name: "Three schedules with only second and third overlapping", + schedules: []string{ + "* 9-11 * * 1-5", // 9AM-11AM (no overlap) + "* 12-18 * * 1-5", // 12PM-6PM + "* 15-20 * * 1-5", // 3PM-8PM (overlaps with second) + }, + expectErr: true, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + err := helpers.ValidateSchedules(testCase.schedules) + if testCase.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index b4dd6418..df4d805b 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -6,6 +6,8 @@ import ( "strings" "time" + "github.com/coder/terraform-provider-coder/v2/provider/helpers" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" @@ -68,6 +70,12 @@ func workspacePresetDataSource() *schema.Resource { return diag.Errorf("decode workspace preset: %s", err) } + // Validate schedule overlaps if autoscaling is configured + err = validateSchedules(rd) + if err != nil { + return diag.Errorf("schedules overlap with each other: %s", err) + } + rd.SetId(preset.Name) return nil @@ -214,3 +222,64 @@ func validatePrebuildsCronSpec(spec string) error { return nil } + +// validateSchedules checks if any of the configured autoscaling schedules overlap with each other. +// It returns an error if overlaps are found, nil otherwise. +func validateSchedules(rd *schema.ResourceData) error { + // TypeSet from schema definition + prebuilds := rd.Get("prebuilds").(*schema.Set) + if prebuilds.Len() == 0 { + return nil + } + + // Each element of TypeSet with Elem: &schema.Resource{} should be map[string]interface{} + prebuild, ok := prebuilds.List()[0].(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid prebuild configuration: expected map[string]interface{}") + } + + // TypeList from schema definition + autoscalingBlocks, ok := prebuild["autoscaling"].([]interface{}) + if !ok { + return fmt.Errorf("invalid autoscaling configuration: expected []interface{}") + } + if len(autoscalingBlocks) == 0 { + return nil + } + + // Each element of TypeList with Elem: &schema.Resource{} should be map[string]interface{} + autoscalingBlock, ok := autoscalingBlocks[0].(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid autoscaling configuration: expected map[string]interface{}") + } + + // TypeList from schema definition + scheduleBlocks, ok := autoscalingBlock["schedule"].([]interface{}) + if !ok { + return fmt.Errorf("invalid schedule configuration: expected []interface{}") + } + if len(scheduleBlocks) == 0 { + return nil + } + + cronSpecs := make([]string, len(scheduleBlocks)) + for i, scheduleBlock := range scheduleBlocks { + // Each element of TypeList with Elem: &schema.Resource{} should be map[string]interface{} + schedule, ok := scheduleBlock.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid schedule configuration: expected map[string]interface{}") + } + + // TypeString from schema definition + cronSpec := schedule["cron"].(string) + + cronSpecs[i] = cronSpec + } + + err := helpers.ValidateSchedules(cronSpecs) + if err != nil { + return err + } + + return nil +} diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go index c9e337dc..19c37d09 100644 --- a/provider/workspace_preset_test.go +++ b/provider/workspace_preset_test.go @@ -508,6 +508,28 @@ func TestWorkspacePreset(t *testing.T) { }`, ExpectError: regexp.MustCompile(`failed to load location: unknown time zone InvalidLocation`), }, + { + Name: "Prebuilds is set with an autoscaling field, with 2 overlapping schedules", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + autoscaling { + timezone = "UTC" + schedule { + cron = "* 8-18 * * 1-5" + instances = 3 + } + schedule { + cron = "* 18-19 * * 5-6" + instances = 1 + } + } + } + }`, + ExpectError: regexp.MustCompile(`schedules overlap with each other: schedules overlap: \* 8-18 \* \* 1-5 and \* 18-19 \* \* 5-6`), + }, } for _, testcase := range testcases { From 17b2adb931326813fa8302340331b7ea02e4b587 Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Tue, 17 Jun 2025 15:44:50 +0000 Subject: [PATCH 03/19] fix: allow DOM and Month fields --- provider/workspace_preset.go | 6 +++--- provider/workspace_preset_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index df4d805b..b575a17d 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -210,14 +210,14 @@ func workspacePresetDataSource() *schema.Resource { } } -// validatePrebuildsCronSpec ensures that the minute, day-of-month and month options of spec are all set to * +// validatePrebuildsCronSpec ensures that the minute field is set to * func validatePrebuildsCronSpec(spec string) error { parts := strings.Fields(spec) if len(parts) != 5 { return fmt.Errorf("cron specification should consist of 5 fields") } - if parts[0] != "*" || parts[2] != "*" || parts[3] != "*" { - return fmt.Errorf("minute, day-of-month and month should be *") + if parts[0] != "*" { + return fmt.Errorf("minute field should be *") } return nil diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go index 19c37d09..40a05e31 100644 --- a/provider/workspace_preset_test.go +++ b/provider/workspace_preset_test.go @@ -442,7 +442,7 @@ func TestWorkspacePreset(t *testing.T) { } } }`, - ExpectError: regexp.MustCompile(`cron spec failed validation: minute, day-of-month and month should be *`), + ExpectError: regexp.MustCompile(`cron spec failed validation: minute field should be *`), }, { Name: "Prebuilds is set with an autoscaling.schedule field, but the cron hour field is invalid", From 6403bc719d847d2f873a8f41b05d989dfdf6d8fd Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Jun 2025 12:50:15 +0000 Subject: [PATCH 04/19] docs: improve documentation for timezone field --- provider/workspace_preset.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index b575a17d..ea8baf2d 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -151,9 +151,11 @@ func workspacePresetDataSource() *schema.Resource { Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "timezone": { - Type: schema.TypeString, - Description: "The timezone to use for the autoscaling schedule (e.g., \"UTC\", \"America/New_York\").", - Required: true, + Type: schema.TypeString, + Description: `The timezone to use for the autoscaling schedule (e.g., "UTC", "America/New_York"). +Timezone must be a valid timezone in the IANA timezone database. +See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete list of valid timezone identifiers and https://www.iana.org/time-zones for the official IANA timezone database.`, + Required: true, ValidateFunc: func(val interface{}, key string) ([]string, []error) { timezone := val.(string) From 7bbe0d8d264a1c337ef2c4a7e3b7c3e75133c634 Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Jun 2025 12:54:31 +0000 Subject: [PATCH 05/19] docs: make gen --- docs/data-sources/workspace_preset.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index d9337934..bae38237 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -63,7 +63,9 @@ Optional: Required: - `schedule` (Block List, Min: 1) One or more schedule blocks that define when to scale the number of prebuild instances. (see [below for nested schema](#nestedblock--prebuilds--autoscaling--schedule)) -- `timezone` (String) The timezone to use for the autoscaling schedule (e.g., "UTC", "America/New_York"). +- `timezone` (String) The timezone to use for the autoscaling schedule (e.g., "UTC", "America/New_York"). +Timezone must be a valid timezone in the IANA timezone database. +See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete list of valid timezone identifiers and https://www.iana.org/time-zones for the official IANA timezone database. ### Nested Schema for `prebuilds.autoscaling.schedule` From 5b0b1f99a8b3382fa02de48bb7964ce7459d92f8 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 18 Jun 2025 09:32:25 -0400 Subject: [PATCH 06/19] Update provider/workspace_preset.go Co-authored-by: Danny Kopping --- provider/workspace_preset.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index ea8baf2d..ca98a78b 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -161,7 +161,7 @@ See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete _, err := time.LoadLocation(timezone) if err != nil { - return nil, []error{fmt.Errorf("failed to load location: %w", err)} + return nil, []error{fmt.Errorf("failed to load timezone %q: %w", timezone, err)} } return nil, nil From 4bd2f81fe8520e1c327fec8cb4339055452a15a2 Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Jun 2025 14:01:04 +0000 Subject: [PATCH 07/19] docs: improve doc comments --- provider/workspace_preset.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index ca98a78b..72083453 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -212,7 +212,9 @@ See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete } } -// validatePrebuildsCronSpec ensures that the minute field is set to * +// validatePrebuildsCronSpec ensures that the minute field is set to *. +// This is required because autoscaling schedules represent continuous time ranges, +// and we want the schedule to cover entire hours rather than specific minute intervals. func validatePrebuildsCronSpec(spec string) error { parts := strings.Fields(spec) if len(parts) != 5 { From d19bea1173039e5549a60353e0a673c438800aad Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Jun 2025 15:33:32 +0000 Subject: [PATCH 08/19] fix: tests --- provider/workspace_preset_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go index 40a05e31..ca7bb954 100644 --- a/provider/workspace_preset_test.go +++ b/provider/workspace_preset_test.go @@ -506,7 +506,7 @@ func TestWorkspacePreset(t *testing.T) { } } }`, - ExpectError: regexp.MustCompile(`failed to load location: unknown time zone InvalidLocation`), + ExpectError: regexp.MustCompile(`failed to load timezone "InvalidLocation": unknown time zone InvalidLocation`), }, { Name: "Prebuilds is set with an autoscaling field, with 2 overlapping schedules", From 99680b05780771160aa4777b046ad2e1af315c6e Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Jun 2025 15:42:03 +0000 Subject: [PATCH 09/19] refactor: rename autoscaling to scheduling --- docs/data-sources/workspace_preset.md | 14 +++--- integration/integration_test.go | 22 ++++----- integration/test-data-source/main.tf | 12 ++--- provider/workspace_preset.go | 28 +++++------ provider/workspace_preset_test.go | 70 +++++++++++++-------------- 5 files changed, 73 insertions(+), 73 deletions(-) diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index bae38237..ed298e5a 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -54,21 +54,21 @@ Required: Optional: -- `autoscaling` (Block List, Max: 1) Configuration block that defines autoscaling behavior for prebuilds. Use this to automatically adjust the number of prebuild instances based on a schedule. (see [below for nested schema](#nestedblock--prebuilds--autoscaling)) +- `scheduling` (Block List, Max: 1) Configuration block that defines scheduling behavior for prebuilds. Use this to automatically adjust the number of prebuild instances based on a schedule. (see [below for nested schema](#nestedblock--prebuilds--scheduling)) - `expiration_policy` (Block Set, Max: 1) Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date. (see [below for nested schema](#nestedblock--prebuilds--expiration_policy)) - -### Nested Schema for `prebuilds.autoscaling` + +### Nested Schema for `prebuilds.scheduling` Required: -- `schedule` (Block List, Min: 1) One or more schedule blocks that define when to scale the number of prebuild instances. (see [below for nested schema](#nestedblock--prebuilds--autoscaling--schedule)) -- `timezone` (String) The timezone to use for the autoscaling schedule (e.g., "UTC", "America/New_York"). +- `schedule` (Block List, Min: 1) One or more schedule blocks that define when to scale the number of prebuild instances. (see [below for nested schema](#nestedblock--prebuilds--scheduling--schedule)) +- `timezone` (String) The timezone to use for the scheduling schedule (e.g., "UTC", "America/New_York"). Timezone must be a valid timezone in the IANA timezone database. See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete list of valid timezone identifiers and https://www.iana.org/time-zones for the official IANA timezone database. - -### Nested Schema for `prebuilds.autoscaling.schedule` + +### Nested Schema for `prebuilds.scheduling.schedule` Required: diff --git a/integration/integration_test.go b/integration/integration_test.go index 0e517133..b075aebd 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -90,17 +90,17 @@ func TestIntegration(t *testing.T) { // TODO (sasswart): the cli doesn't support presets yet. // once it does, the value for workspace_parameter.value // will be the preset value. - "workspace_parameter.value": `param value`, - "workspace_parameter.icon": `param icon`, - "workspace_preset.name": `preset`, - "workspace_preset.parameters.param": `preset param value`, - "workspace_preset.prebuilds.instances": `1`, - "workspace_preset.prebuilds.expiration_policy.ttl": `86400`, - "workspace_preset.prebuilds.autoscaling.timezone": `UTC`, - "workspace_preset.prebuilds.autoscaling.schedule0.cron": `\* 8-18 \* \* 1-5`, - "workspace_preset.prebuilds.autoscaling.schedule0.instances": `3`, - "workspace_preset.prebuilds.autoscaling.schedule1.cron": `\* 8-14 \* \* 6`, - "workspace_preset.prebuilds.autoscaling.schedule1.instances": `1`, + "workspace_parameter.value": `param value`, + "workspace_parameter.icon": `param icon`, + "workspace_preset.name": `preset`, + "workspace_preset.parameters.param": `preset param value`, + "workspace_preset.prebuilds.instances": `1`, + "workspace_preset.prebuilds.expiration_policy.ttl": `86400`, + "workspace_preset.prebuilds.scheduling.timezone": `UTC`, + "workspace_preset.prebuilds.scheduling.schedule0.cron": `\* 8-18 \* \* 1-5`, + "workspace_preset.prebuilds.scheduling.schedule0.instances": `3`, + "workspace_preset.prebuilds.scheduling.schedule1.cron": `\* 8-14 \* \* 6`, + "workspace_preset.prebuilds.scheduling.schedule1.instances": `1`, }, }, { diff --git a/integration/test-data-source/main.tf b/integration/test-data-source/main.tf index 8ebdbb65..12344546 100644 --- a/integration/test-data-source/main.tf +++ b/integration/test-data-source/main.tf @@ -30,7 +30,7 @@ data "coder_workspace_preset" "preset" { expiration_policy { ttl = 86400 } - autoscaling { + scheduling { timezone = "UTC" schedule { cron = "* 8-18 * * 1-5" @@ -67,11 +67,11 @@ locals { "workspace_preset.parameters.param" : data.coder_workspace_preset.preset.parameters.param, "workspace_preset.prebuilds.instances" : tostring(one(data.coder_workspace_preset.preset.prebuilds).instances), "workspace_preset.prebuilds.expiration_policy.ttl" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).expiration_policy).ttl), - "workspace_preset.prebuilds.autoscaling.timezone" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).timezone), - "workspace_preset.prebuilds.autoscaling.schedule0.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).schedule[0].cron), - "workspace_preset.prebuilds.autoscaling.schedule0.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).schedule[0].instances), - "workspace_preset.prebuilds.autoscaling.schedule1.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).schedule[1].cron), - "workspace_preset.prebuilds.autoscaling.schedule1.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).schedule[1].instances), + "workspace_preset.prebuilds.scheduling.timezone" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).timezone), + "workspace_preset.prebuilds.scheduling.schedule0.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[0].cron), + "workspace_preset.prebuilds.scheduling.schedule0.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[0].instances), + "workspace_preset.prebuilds.scheduling.schedule1.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[1].cron), + "workspace_preset.prebuilds.scheduling.schedule1.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[1].instances), } } diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index 72083453..1b80fd65 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -36,14 +36,14 @@ type WorkspacePrebuild struct { // for utilities that parse our terraform output using this type. To remain compatible // with those cases, we use a slice here. ExpirationPolicy []ExpirationPolicy `mapstructure:"expiration_policy"` - Autoscaling []Autoscaling `mapstructure:"autoscaling"` + Scheduling []Scheduling `mapstructure:"scheduling"` } type ExpirationPolicy struct { TTL int `mapstructure:"ttl"` } -type Autoscaling struct { +type Scheduling struct { Timezone string `mapstructure:"timezone"` Schedule []Schedule `mapstructure:"schedule"` } @@ -70,7 +70,7 @@ func workspacePresetDataSource() *schema.Resource { return diag.Errorf("decode workspace preset: %s", err) } - // Validate schedule overlaps if autoscaling is configured + // Validate schedule overlaps if scheduling is configured err = validateSchedules(rd) if err != nil { return diag.Errorf("schedules overlap with each other: %s", err) @@ -143,16 +143,16 @@ func workspacePresetDataSource() *schema.Resource { }, }, }, - "autoscaling": { + "scheduling": { Type: schema.TypeList, - Description: "Configuration block that defines autoscaling behavior for prebuilds. Use this to automatically adjust the number of prebuild instances based on a schedule.", + Description: "Configuration block that defines scheduling behavior for prebuilds. Use this to automatically adjust the number of prebuild instances based on a schedule.", Optional: true, MaxItems: 1, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "timezone": { Type: schema.TypeString, - Description: `The timezone to use for the autoscaling schedule (e.g., "UTC", "America/New_York"). + Description: `The timezone to use for the scheduling schedule (e.g., "UTC", "America/New_York"). Timezone must be a valid timezone in the IANA timezone database. See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete list of valid timezone identifiers and https://www.iana.org/time-zones for the official IANA timezone database.`, Required: true, @@ -213,7 +213,7 @@ See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete } // validatePrebuildsCronSpec ensures that the minute field is set to *. -// This is required because autoscaling schedules represent continuous time ranges, +// This is required because scheduling schedules represent continuous time ranges, // and we want the schedule to cover entire hours rather than specific minute intervals. func validatePrebuildsCronSpec(spec string) error { parts := strings.Fields(spec) @@ -227,7 +227,7 @@ func validatePrebuildsCronSpec(spec string) error { return nil } -// validateSchedules checks if any of the configured autoscaling schedules overlap with each other. +// validateSchedules checks if any of the configured scheduling schedules overlap with each other. // It returns an error if overlaps are found, nil otherwise. func validateSchedules(rd *schema.ResourceData) error { // TypeSet from schema definition @@ -243,22 +243,22 @@ func validateSchedules(rd *schema.ResourceData) error { } // TypeList from schema definition - autoscalingBlocks, ok := prebuild["autoscaling"].([]interface{}) + schedulingBlocks, ok := prebuild["scheduling"].([]interface{}) if !ok { - return fmt.Errorf("invalid autoscaling configuration: expected []interface{}") + return fmt.Errorf("invalid scheduling configuration: expected []interface{}") } - if len(autoscalingBlocks) == 0 { + if len(schedulingBlocks) == 0 { return nil } // Each element of TypeList with Elem: &schema.Resource{} should be map[string]interface{} - autoscalingBlock, ok := autoscalingBlocks[0].(map[string]interface{}) + schedulingBlock, ok := schedulingBlocks[0].(map[string]interface{}) if !ok { - return fmt.Errorf("invalid autoscaling configuration: expected map[string]interface{}") + return fmt.Errorf("invalid scheduling configuration: expected map[string]interface{}") } // TypeList from schema definition - scheduleBlocks, ok := autoscalingBlock["schedule"].([]interface{}) + scheduleBlocks, ok := schedulingBlock["schedule"].([]interface{}) if !ok { return fmt.Errorf("invalid schedule configuration: expected []interface{}") } diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go index ca7bb954..84dfec17 100644 --- a/provider/workspace_preset_test.go +++ b/provider/workspace_preset_test.go @@ -266,25 +266,25 @@ func TestWorkspacePreset(t *testing.T) { ExpectError: regexp.MustCompile("An argument named \"invalid_argument\" is not expected here."), }, { - Name: "Prebuilds is set with an empty autoscaling field", + Name: "Prebuilds is set with an empty scheduling field", Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" prebuilds { instances = 1 - autoscaling {} + scheduling {} } }`, ExpectError: regexp.MustCompile(`The argument "[^"]+" is required, but no definition was found.`), }, { - Name: "Prebuilds is set with an autoscaling field, but without timezone", + Name: "Prebuilds is set with an scheduling field, but without timezone", Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" prebuilds { instances = 1 - autoscaling { + scheduling { schedule { cron = "* 8-18 * * 1-5" instances = 3 @@ -295,13 +295,13 @@ func TestWorkspacePreset(t *testing.T) { ExpectError: regexp.MustCompile(`The argument "timezone" is required, but no definition was found.`), }, { - Name: "Prebuilds is set with an autoscaling field, but without schedule", + Name: "Prebuilds is set with an scheduling field, but without schedule", Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" prebuilds { instances = 1 - autoscaling { + scheduling { timezone = "UTC" } } @@ -309,13 +309,13 @@ func TestWorkspacePreset(t *testing.T) { ExpectError: regexp.MustCompile(`At least 1 "schedule" blocks are required.`), }, { - Name: "Prebuilds is set with an autoscaling.schedule field, but without cron", + Name: "Prebuilds is set with an scheduling.schedule field, but without cron", Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" prebuilds { instances = 1 - autoscaling { + scheduling { timezone = "UTC" schedule { instances = 3 @@ -326,13 +326,13 @@ func TestWorkspacePreset(t *testing.T) { ExpectError: regexp.MustCompile(`The argument "cron" is required, but no definition was found.`), }, { - Name: "Prebuilds is set with an autoscaling.schedule field, but without instances", + Name: "Prebuilds is set with an scheduling.schedule field, but without instances", Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" prebuilds { instances = 1 - autoscaling { + scheduling { timezone = "UTC" schedule { cron = "* 8-18 * * 1-5" @@ -343,13 +343,13 @@ func TestWorkspacePreset(t *testing.T) { ExpectError: regexp.MustCompile(`The argument "instances" is required, but no definition was found.`), }, { - Name: "Prebuilds is set with an autoscaling.schedule field, but with invalid type for instances", + Name: "Prebuilds is set with an scheduling.schedule field, but with invalid type for instances", Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" prebuilds { instances = 1 - autoscaling { + scheduling { timezone = "UTC" schedule { cron = "* 8-18 * * 1-5" @@ -361,13 +361,13 @@ func TestWorkspacePreset(t *testing.T) { ExpectError: regexp.MustCompile(`Inappropriate value for attribute "instances": a number is required`), }, { - Name: "Prebuilds is set with an autoscaling field with 1 schedule", + Name: "Prebuilds is set with an scheduling field with 1 schedule", Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" prebuilds { instances = 1 - autoscaling { + scheduling { timezone = "UTC" schedule { cron = "* 8-18 * * 1-5" @@ -384,20 +384,20 @@ func TestWorkspacePreset(t *testing.T) { require.NotNil(t, resource) attrs := resource.Primary.Attributes require.Equal(t, attrs["name"], "preset_1") - require.Equal(t, attrs["prebuilds.0.autoscaling.0.timezone"], "UTC") - require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.0.cron"], "* 8-18 * * 1-5") - require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.0.instances"], "3") + require.Equal(t, attrs["prebuilds.0.scheduling.0.timezone"], "UTC") + require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.0.cron"], "* 8-18 * * 1-5") + require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.0.instances"], "3") return nil }, }, { - Name: "Prebuilds is set with an autoscaling field with 2 schedules", + Name: "Prebuilds is set with an scheduling field with 2 schedules", Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" prebuilds { instances = 1 - autoscaling { + scheduling { timezone = "UTC" schedule { cron = "* 8-18 * * 1-5" @@ -418,22 +418,22 @@ func TestWorkspacePreset(t *testing.T) { require.NotNil(t, resource) attrs := resource.Primary.Attributes require.Equal(t, attrs["name"], "preset_1") - require.Equal(t, attrs["prebuilds.0.autoscaling.0.timezone"], "UTC") - require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.0.cron"], "* 8-18 * * 1-5") - require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.0.instances"], "3") - require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.1.cron"], "* 8-14 * * 6") - require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.1.instances"], "1") + require.Equal(t, attrs["prebuilds.0.scheduling.0.timezone"], "UTC") + require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.0.cron"], "* 8-18 * * 1-5") + require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.0.instances"], "3") + require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.1.cron"], "* 8-14 * * 6") + require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.1.instances"], "1") return nil }, }, { - Name: "Prebuilds is set with an autoscaling.schedule field, but the cron includes a disallowed minute field", + Name: "Prebuilds is set with an scheduling.schedule field, but the cron includes a disallowed minute field", Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" prebuilds { instances = 1 - autoscaling { + scheduling { timezone = "UTC" schedule { cron = "30 8-18 * * 1-5" @@ -445,13 +445,13 @@ func TestWorkspacePreset(t *testing.T) { ExpectError: regexp.MustCompile(`cron spec failed validation: minute field should be *`), }, { - Name: "Prebuilds is set with an autoscaling.schedule field, but the cron hour field is invalid", + Name: "Prebuilds is set with an scheduling.schedule field, but the cron hour field is invalid", Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" prebuilds { instances = 1 - autoscaling { + scheduling { timezone = "UTC" schedule { cron = "* 25-26 * * 1-5" @@ -463,13 +463,13 @@ func TestWorkspacePreset(t *testing.T) { ExpectError: regexp.MustCompile(`failed to parse cron spec: end of range \(26\) above maximum \(23\): 25-26`), }, { - Name: "Prebuilds is set with a valid autoscaling.timezone field", + Name: "Prebuilds is set with a valid scheduling.timezone field", Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" prebuilds { instances = 1 - autoscaling { + scheduling { timezone = "America/Los_Angeles" schedule { cron = "* 8-18 * * 1-5" @@ -486,18 +486,18 @@ func TestWorkspacePreset(t *testing.T) { require.NotNil(t, resource) attrs := resource.Primary.Attributes require.Equal(t, attrs["name"], "preset_1") - require.Equal(t, attrs["prebuilds.0.autoscaling.0.timezone"], "America/Los_Angeles") + require.Equal(t, attrs["prebuilds.0.scheduling.0.timezone"], "America/Los_Angeles") return nil }, }, { - Name: "Prebuilds is set with an invalid autoscaling.timezone field", + Name: "Prebuilds is set with an invalid scheduling.timezone field", Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" prebuilds { instances = 1 - autoscaling { + scheduling { timezone = "InvalidLocation" schedule { cron = "* 8-18 * * 1-5" @@ -509,13 +509,13 @@ func TestWorkspacePreset(t *testing.T) { ExpectError: regexp.MustCompile(`failed to load timezone "InvalidLocation": unknown time zone InvalidLocation`), }, { - Name: "Prebuilds is set with an autoscaling field, with 2 overlapping schedules", + Name: "Prebuilds is set with an scheduling field, with 2 overlapping schedules", Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" prebuilds { instances = 1 - autoscaling { + scheduling { timezone = "UTC" schedule { cron = "* 8-18 * * 1-5" From 30979f0473e0f042cca37017da1e9118839ad89f Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Jun 2025 15:43:09 +0000 Subject: [PATCH 10/19] docs: make gen --- docs/data-sources/workspace_preset.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index ed298e5a..58e7eb38 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -54,8 +54,16 @@ Required: Optional: -- `scheduling` (Block List, Max: 1) Configuration block that defines scheduling behavior for prebuilds. Use this to automatically adjust the number of prebuild instances based on a schedule. (see [below for nested schema](#nestedblock--prebuilds--scheduling)) - `expiration_policy` (Block Set, Max: 1) Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date. (see [below for nested schema](#nestedblock--prebuilds--expiration_policy)) +- `scheduling` (Block List, Max: 1) Configuration block that defines scheduling behavior for prebuilds. Use this to automatically adjust the number of prebuild instances based on a schedule. (see [below for nested schema](#nestedblock--prebuilds--scheduling)) + + +### Nested Schema for `prebuilds.expiration_policy` + +Required: + +- `ttl` (Number) Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup. + ### Nested Schema for `prebuilds.scheduling` @@ -74,12 +82,3 @@ Required: - `cron` (String) A cron expression that defines when this schedule should be active. The cron expression must be in the format "* HOUR * * DAY-OF-WEEK" where HOUR is 0-23 and DAY-OF-WEEK is 0-6 (Sunday-Saturday). The minute, day-of-month, and month fields must be "*". - `instances` (Number) The number of prebuild instances to maintain during this schedule period. - - - - -### Nested Schema for `prebuilds.expiration_policy` - -Required: - -- `ttl` (Number) Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup. From 68bcb2f103817ab0c03f22fdd3c03b411f2ef919 Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Jun 2025 17:36:49 +0000 Subject: [PATCH 11/19] refactor: minor refactor after renaming --- docs/data-sources/workspace_preset.md | 2 +- provider/workspace_preset.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index 58e7eb38..7ea8e142 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -71,7 +71,7 @@ Required: Required: - `schedule` (Block List, Min: 1) One or more schedule blocks that define when to scale the number of prebuild instances. (see [below for nested schema](#nestedblock--prebuilds--scheduling--schedule)) -- `timezone` (String) The timezone to use for the scheduling schedule (e.g., "UTC", "America/New_York"). +- `timezone` (String) The timezone to use for the prebuild schedules (e.g., "UTC", "America/New_York"). Timezone must be a valid timezone in the IANA timezone database. See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete list of valid timezone identifiers and https://www.iana.org/time-zones for the official IANA timezone database. diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index 1b80fd65..bebea5b6 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -152,7 +152,7 @@ func workspacePresetDataSource() *schema.Resource { Schema: map[string]*schema.Schema{ "timezone": { Type: schema.TypeString, - Description: `The timezone to use for the scheduling schedule (e.g., "UTC", "America/New_York"). + Description: `The timezone to use for the prebuild schedules (e.g., "UTC", "America/New_York"). Timezone must be a valid timezone in the IANA timezone database. See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete list of valid timezone identifiers and https://www.iana.org/time-zones for the official IANA timezone database.`, Required: true, @@ -213,7 +213,7 @@ See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete } // validatePrebuildsCronSpec ensures that the minute field is set to *. -// This is required because scheduling schedules represent continuous time ranges, +// This is required because prebuild schedules represent continuous time ranges, // and we want the schedule to cover entire hours rather than specific minute intervals. func validatePrebuildsCronSpec(spec string) error { parts := strings.Fields(spec) @@ -227,7 +227,7 @@ func validatePrebuildsCronSpec(spec string) error { return nil } -// validateSchedules checks if any of the configured scheduling schedules overlap with each other. +// validateSchedules checks if any of the configured prebuild schedules overlap with each other. // It returns an error if overlaps are found, nil otherwise. func validateSchedules(rd *schema.ResourceData) error { // TypeSet from schema definition From ddd8b4cdf408dbcc7db26b3c1b9ff4440b22bd22 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 18 Jun 2025 14:45:00 -0400 Subject: [PATCH 12/19] Update provider/helpers/schedule_validation.go Co-authored-by: Danny Kopping --- provider/helpers/schedule_validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/helpers/schedule_validation.go b/provider/helpers/schedule_validation.go index ecfe46d5..7f3bc20f 100644 --- a/provider/helpers/schedule_validation.go +++ b/provider/helpers/schedule_validation.go @@ -25,7 +25,7 @@ func ValidateSchedules(schedules []string) error { } // SchedulesOverlap checks if two schedules overlap by checking -// days, months, and hours separately +// all cron fields separately func SchedulesOverlap(schedule1, schedule2 string) (bool, error) { // Get cron fields fields1 := strings.Fields(schedule1) From 1ae3fc72bb9c2a424c580fcf151272516f9ae3e3 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 18 Jun 2025 14:45:21 -0400 Subject: [PATCH 13/19] Update provider/helpers/schedule_validation.go Co-authored-by: Danny Kopping --- provider/helpers/schedule_validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/helpers/schedule_validation.go b/provider/helpers/schedule_validation.go index 7f3bc20f..c5a6972f 100644 --- a/provider/helpers/schedule_validation.go +++ b/provider/helpers/schedule_validation.go @@ -113,7 +113,7 @@ func DaysOverlap(dom1, dow1, dom2, dow2 string) (bool, error) { return domOverlap || dowOverlap, nil } -// CheckOverlap is a generic function to check if two ranges overlap +// CheckOverlap is a function to check if two ranges overlap func CheckOverlap(range1, range2 string, maxValue int) (bool, error) { set1, err := ParseRange(range1, maxValue) if err != nil { From 201cd1d843712c2507e98d1a9dcceed9c37ab8ad Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Jun 2025 18:51:50 +0000 Subject: [PATCH 14/19] refactor: improve docs --- docs/data-sources/workspace_preset.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index 7ea8e142..26e597e2 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -80,5 +80,5 @@ See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete Required: -- `cron` (String) A cron expression that defines when this schedule should be active. The cron expression must be in the format "* HOUR * * DAY-OF-WEEK" where HOUR is 0-23 and DAY-OF-WEEK is 0-6 (Sunday-Saturday). The minute, day-of-month, and month fields must be "*". +- `cron` (String) A cron expression that defines when this schedule should be active. The cron expression must be in the format "* HOUR DOM MONTH DAY-OF-WEEK" where HOUR is 0-23, DOM (day-of-month) is 1-31, MONTH is 1-12, and DAY-OF-WEEK is 0-6 (Sunday-Saturday). The minute field must be "*" to ensure the schedule covers entire hours rather than specific minute intervals. - `instances` (Number) The number of prebuild instances to maintain during this schedule period. From 604cb1eb6ce91dfa3f196c19e384f959b82ff2c8 Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Jun 2025 18:58:46 +0000 Subject: [PATCH 15/19] refactor: improve docs --- provider/workspace_preset.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index bebea5b6..0a44b1eb 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -176,7 +176,7 @@ See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete Schema: map[string]*schema.Schema{ "cron": { Type: schema.TypeString, - Description: "A cron expression that defines when this schedule should be active. The cron expression must be in the format \"* HOUR * * DAY-OF-WEEK\" where HOUR is 0-23 and DAY-OF-WEEK is 0-6 (Sunday-Saturday). The minute, day-of-month, and month fields must be \"*\".", + Description: "A cron expression that defines when this schedule should be active. The cron expression must be in the format \"* HOUR DOM MONTH DAY-OF-WEEK\" where HOUR is 0-23, DOM (day-of-month) is 1-31, MONTH is 1-12, and DAY-OF-WEEK is 0-6 (Sunday-Saturday). The minute field must be \"*\" to ensure the schedule covers entire hours rather than specific minute intervals.", Required: true, ValidateFunc: func(val interface{}, key string) ([]string, []error) { cronSpec := val.(string) From 9437525f7bddaf271b6b6a512463f355cb23907e Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Jun 2025 19:20:21 +0000 Subject: [PATCH 16/19] test: improve test coverage --- provider/helpers/schedule_validation_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/provider/helpers/schedule_validation_test.go b/provider/helpers/schedule_validation_test.go index 49dcaecd..1d495d10 100644 --- a/provider/helpers/schedule_validation_test.go +++ b/provider/helpers/schedule_validation_test.go @@ -322,6 +322,22 @@ func TestDaysOverlap(t *testing.T) { dow2: "4-6", overlap: false, // false because neither overlaps }, + { + name: "Both DOW wildcard - DOM overlaps", + dom1: "1-15", + dow1: "*", + dom2: "10-20", + dow2: "*", + overlap: true, // true because DOM overlaps (10-15) + }, + { + name: "Both DOW wildcard - DOM doesn't overlap", + dom1: "1-15", + dow1: "*", + dom2: "16-31", + dow2: "*", + overlap: false, // false because DOM doesn't overlap + }, } for _, testCase := range testCases { From 59bc618b59d0b1203fa26a4dcdfddb56ea6ae7ee Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Jun 2025 19:44:36 +0000 Subject: [PATCH 17/19] test: improve test coverage --- provider/helpers/schedule_validation_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/provider/helpers/schedule_validation_test.go b/provider/helpers/schedule_validation_test.go index 1d495d10..259eeebf 100644 --- a/provider/helpers/schedule_validation_test.go +++ b/provider/helpers/schedule_validation_test.go @@ -477,6 +477,24 @@ func TestSchedulesOverlap(t *testing.T) { s2: "* 9-18 * * 1-5", expectErr: true, }, + { + name: "Invalid field count - too few fields", + s1: "* 9-18 * *", + s2: "* 9-18 * * 1-5", + expectErr: true, + }, + { + name: "Invalid field count - too many fields", + s1: "* 9-18 * * 1-5 *", + s2: "* 9-18 * * 1-5", + expectErr: true, + }, + { + name: "Invalid field count - s2 has too few fields", + s1: "* 9-18 * * 1-5", + s2: "* 9-18 * *", + expectErr: true, + }, } for _, testCase := range testCases { From d040b816f82eb61159ccc49dd895706209799201 Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Jun 2025 20:09:56 +0000 Subject: [PATCH 18/19] refactor: check for a specific error in tests --- provider/helpers/schedule_validation_test.go | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/provider/helpers/schedule_validation_test.go b/provider/helpers/schedule_validation_test.go index 259eeebf..68b5f00e 100644 --- a/provider/helpers/schedule_validation_test.go +++ b/provider/helpers/schedule_validation_test.go @@ -516,22 +516,20 @@ func TestSchedulesOverlap(t *testing.T) { func TestValidateSchedules(t *testing.T) { t.Parallel() testCases := []struct { - name string - schedules []string - expectErr bool + name string + schedules []string + expectedErrMsg string }{ // Basic validation { name: "Empty schedules", schedules: []string{}, - expectErr: false, }, { name: "Single valid schedule", schedules: []string{ "* 9-18 * * 1-5", }, - expectErr: false, }, // Non-overlapping schedules @@ -541,7 +539,6 @@ func TestValidateSchedules(t *testing.T) { "* 9-12 * * 1-5", "* 13-18 * * 1-5", }, - expectErr: false, }, { name: "Multiple valid non-overlapping schedules", @@ -549,7 +546,6 @@ func TestValidateSchedules(t *testing.T) { "* 9-18 * * 1-5", "* 9-13 * * 6,0", }, - expectErr: false, }, // Overlapping schedules @@ -559,7 +555,7 @@ func TestValidateSchedules(t *testing.T) { "* 9-14 * * 1-5", "* 12-18 * * 1-5", }, - expectErr: true, + expectedErrMsg: "schedules overlap: * 9-14 * * 1-5 and * 12-18 * * 1-5", }, { name: "Three schedules with only second and third overlapping", @@ -568,7 +564,7 @@ func TestValidateSchedules(t *testing.T) { "* 12-18 * * 1-5", // 12PM-6PM "* 15-20 * * 1-5", // 3PM-8PM (overlaps with second) }, - expectErr: true, + expectedErrMsg: "schedules overlap: * 12-18 * * 1-5 and * 15-20 * * 1-5", }, } @@ -577,8 +573,9 @@ func TestValidateSchedules(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { t.Parallel() err := helpers.ValidateSchedules(testCase.schedules) - if testCase.expectErr { + if testCase.expectedErrMsg != "" { require.Error(t, err) + require.Contains(t, err.Error(), testCase.expectedErrMsg) } else { require.NoError(t, err) } From 79def3057ff623e84c7cc7d531b4822f8f4b4478 Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Wed, 18 Jun 2025 20:15:27 +0000 Subject: [PATCH 19/19] refactor: check for a specific error in tests --- provider/helpers/schedule_validation_test.go | 59 ++++++++++---------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/provider/helpers/schedule_validation_test.go b/provider/helpers/schedule_validation_test.go index 68b5f00e..2971fd07 100644 --- a/provider/helpers/schedule_validation_test.go +++ b/provider/helpers/schedule_validation_test.go @@ -358,11 +358,11 @@ func TestDaysOverlap(t *testing.T) { func TestSchedulesOverlap(t *testing.T) { t.Parallel() testCases := []struct { - name string - s1 string - s2 string - overlap bool - expectErr bool + name string + s1 string + s2 string + overlap bool + expectedErrMsg string }{ // Basic overlap cases { @@ -466,34 +466,34 @@ func TestSchedulesOverlap(t *testing.T) { // Error cases (keeping minimal) { - name: "Invalid hour range", - s1: "* 25-26 * * 1-5", - s2: "* 9-18 * * 1-5", - expectErr: true, + name: "Invalid hour range", + s1: "* 25-26 * * 1-5", + s2: "* 9-18 * * 1-5", + expectedErrMsg: "invalid hour range", }, { - name: "Invalid month range", - s1: "* 9-18 * 13 1-5", - s2: "* 9-18 * * 1-5", - expectErr: true, + name: "Invalid month range", + s1: "* 9-18 * 13 1-5", + s2: "* 9-18 * * 1-5", + expectedErrMsg: "invalid month range", }, { - name: "Invalid field count - too few fields", - s1: "* 9-18 * *", - s2: "* 9-18 * * 1-5", - expectErr: true, + name: "Invalid field count - too few fields", + s1: "* 9-18 * *", + s2: "* 9-18 * * 1-5", + expectedErrMsg: "has 4 fields, expected 5 fields", }, { - name: "Invalid field count - too many fields", - s1: "* 9-18 * * 1-5 *", - s2: "* 9-18 * * 1-5", - expectErr: true, + name: "Invalid field count - too many fields", + s1: "* 9-18 * * 1-5 *", + s2: "* 9-18 * * 1-5", + expectedErrMsg: "has 6 fields, expected 5 fields", }, { - name: "Invalid field count - s2 has too few fields", - s1: "* 9-18 * * 1-5", - s2: "* 9-18 * *", - expectErr: true, + name: "Invalid field count - s2 has too few fields", + s1: "* 9-18 * * 1-5", + s2: "* 9-18 * *", + expectedErrMsg: "has 4 fields, expected 5 fields", }, } @@ -503,12 +503,13 @@ func TestSchedulesOverlap(t *testing.T) { t.Parallel() overlap, err := helpers.SchedulesOverlap(testCase.s1, testCase.s2) - if testCase.expectErr { + if testCase.expectedErrMsg != "" { require.Error(t, err) - return + require.Contains(t, err.Error(), testCase.expectedErrMsg) + } else { + require.NoError(t, err) + require.Equal(t, testCase.overlap, overlap) } - require.NoError(t, err) - require.Equal(t, testCase.overlap, overlap) }) } } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy