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/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 e0f2276c..b575a17d 100644
--- a/provider/workspace_preset.go
+++ b/provider/workspace_preset.go
@@ -3,13 +3,20 @@ package provider
import (
"context"
"fmt"
+ "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"
"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 +36,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,
@@ -52,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
@@ -119,9 +143,143 @@ 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 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] != "*" {
+ return fmt.Errorf("minute field should be *")
+ }
+
+ 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 b8e752ae..40a05e31 100644
--- a/provider/workspace_preset_test.go
+++ b/provider/workspace_preset_test.go
@@ -265,6 +265,271 @@ 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 field 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`),
+ },
+ {
+ 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 {