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 cd4908c..d933793 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 3661290..0e51713 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 50274ff..8ebdbb6 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 e0f2276..b4dd641 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 b8e752a..c9e337d 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 0000000..ecfe46d --- /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 0000000..49dcaec --- /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 b4dd641..df4d805 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 c9e337d..19c37d0 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 df4d805..b575a17 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 19c37d0..40a05e3 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 b575a17..ea8baf2 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 d933793..bae3823 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 ea8baf2..ca98a78 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 ca98a78..7208345 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 40a05e3..ca7bb95 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 bae3823..ed298e5 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 0e51713..b075aeb 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 8ebdbb6..1234454 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 7208345..1b80fd6 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 ca7bb95..84dfec1 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 ed298e5..58e7eb3 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 58e7eb3..7ea8e14 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 1b80fd6..bebea5b 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 ecfe46d..7f3bc20 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 7f3bc20..c5a6972 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 7ea8e14..26e597e 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 bebea5b..0a44b1e 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 49dcaec..1d495d1 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 1d495d1..259eeeb 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 259eeeb..68b5f00 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 68b5f00..2971fd0 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) }) } }