diff --git a/.github/workflows/cla.yaml b/.github/workflows/cla.yaml
index 71c2e905..45f72487 100644
--- a/.github/workflows/cla.yaml
+++ b/.github/workflows/cla.yaml
@@ -23,4 +23,4 @@ jobs:
path-to-document: 'https://github.com/coder/cla/blob/main/README.md'
# branch should not be protected
branch: 'main'
- allowlist: dependabot*
+ allowlist: 'dependabot*,blink-so*'
diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md
index cd4908c2..26e597e2 100644
--- a/docs/data-sources/workspace_preset.md
+++ b/docs/data-sources/workspace_preset.md
@@ -55,6 +55,7 @@ Required:
Optional:
- `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`
@@ -62,3 +63,22 @@ Optional:
Required:
- `ttl` (Number) Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.
+
+
+
+### 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--scheduling--schedule))
+- `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.
+
+
+### Nested Schema for `prebuilds.scheduling.schedule`
+
+Required:
+
+- `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.
diff --git a/go.mod b/go.mod
index 7b0b437c..fcb25b13 100644
--- a/go.mod
+++ b/go.mod
@@ -23,7 +23,7 @@ require (
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
- github.com/cloudflare/circl v1.6.0 // indirect
+ github.com/cloudflare/circl v1.6.1 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
diff --git a/go.sum b/go.sum
index 0531c5e1..31e83346 100644
--- a/go.sum
+++ b/go.sum
@@ -17,8 +17,8 @@ github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZ
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
-github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
-github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
+github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
+github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
diff --git a/integration/integration_test.go b/integration/integration_test.go
index 36612904..b075aebd 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.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 50274fff..12344546 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
}
+ scheduling {
+ 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.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/helpers/schedule_validation.go b/provider/helpers/schedule_validation.go
new file mode 100644
index 00000000..c5a6972f
--- /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
+// all cron fields 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 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..2971fd07
--- /dev/null
+++ b/provider/helpers/schedule_validation_test.go
@@ -0,0 +1,585 @@
+// 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
+ },
+ {
+ 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 {
+ 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
+ expectedErrMsg string
+ }{
+ // 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",
+ expectedErrMsg: "invalid hour range",
+ },
+ {
+ 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",
+ expectedErrMsg: "has 4 fields, expected 5 fields",
+ },
+ {
+ 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 * *",
+ expectedErrMsg: "has 4 fields, expected 5 fields",
+ },
+ }
+
+ 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.expectedErrMsg != "" {
+ require.Error(t, err)
+ require.Contains(t, err.Error(), testCase.expectedErrMsg)
+ } else {
+ require.NoError(t, err)
+ require.Equal(t, testCase.overlap, overlap)
+ }
+ })
+ }
+}
+
+func TestValidateSchedules(t *testing.T) {
+ t.Parallel()
+ testCases := []struct {
+ name string
+ schedules []string
+ expectedErrMsg string
+ }{
+ // Basic validation
+ {
+ name: "Empty schedules",
+ schedules: []string{},
+ },
+ {
+ name: "Single valid schedule",
+ schedules: []string{
+ "* 9-18 * * 1-5",
+ },
+ },
+
+ // Non-overlapping schedules
+ {
+ name: "Multiple valid non-overlapping schedules",
+ schedules: []string{
+ "* 9-12 * * 1-5",
+ "* 13-18 * * 1-5",
+ },
+ },
+ {
+ name: "Multiple valid non-overlapping schedules",
+ schedules: []string{
+ "* 9-18 * * 1-5",
+ "* 9-13 * * 6,0",
+ },
+ },
+
+ // Overlapping schedules
+ {
+ name: "Two overlapping schedules",
+ schedules: []string{
+ "* 9-14 * * 1-5",
+ "* 12-18 * * 1-5",
+ },
+ expectedErrMsg: "schedules overlap: * 9-14 * * 1-5 and * 12-18 * * 1-5",
+ },
+ {
+ 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)
+ },
+ expectedErrMsg: "schedules overlap: * 12-18 * * 1-5 and * 15-20 * * 1-5",
+ },
+ }
+
+ for _, testCase := range testCases {
+ testCase := testCase
+ t.Run(testCase.name, func(t *testing.T) {
+ t.Parallel()
+ err := helpers.ValidateSchedules(testCase.schedules)
+ if testCase.expectedErrMsg != "" {
+ require.Error(t, err)
+ require.Contains(t, err.Error(), testCase.expectedErrMsg)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go
index e0f2276c..0a44b1eb 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"`
+ Scheduling []Scheduling `mapstructure:"scheduling"`
}
type ExpirationPolicy struct {
TTL int `mapstructure:"ttl"`
}
+type Scheduling 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 scheduling 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,147 @@ func workspacePresetDataSource() *schema.Resource {
},
},
},
+ "scheduling": {
+ Type: schema.TypeList,
+ 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 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,
+ 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 timezone %q: %w", timezone, 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 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)
+
+ 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 *.
+// 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)
+ 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 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
+ 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
+ schedulingBlocks, ok := prebuild["scheduling"].([]interface{})
+ if !ok {
+ return fmt.Errorf("invalid scheduling configuration: expected []interface{}")
+ }
+ if len(schedulingBlocks) == 0 {
+ return nil
+ }
+
+ // Each element of TypeList with Elem: &schema.Resource{} should be map[string]interface{}
+ schedulingBlock, ok := schedulingBlocks[0].(map[string]interface{})
+ if !ok {
+ return fmt.Errorf("invalid scheduling configuration: expected map[string]interface{}")
+ }
+
+ // TypeList from schema definition
+ scheduleBlocks, ok := schedulingBlock["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..84dfec17 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 scheduling field",
+ Config: `
+ data "coder_workspace_preset" "preset_1" {
+ name = "preset_1"
+ prebuilds {
+ instances = 1
+ scheduling {}
+ }
+ }`,
+ ExpectError: regexp.MustCompile(`The argument "[^"]+" is required, but no definition was found.`),
+ },
+ {
+ Name: "Prebuilds is set with an scheduling field, but without timezone",
+ Config: `
+ data "coder_workspace_preset" "preset_1" {
+ name = "preset_1"
+ prebuilds {
+ instances = 1
+ scheduling {
+ 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 scheduling field, but without schedule",
+ Config: `
+ data "coder_workspace_preset" "preset_1" {
+ name = "preset_1"
+ prebuilds {
+ instances = 1
+ scheduling {
+ timezone = "UTC"
+ }
+ }
+ }`,
+ ExpectError: regexp.MustCompile(`At least 1 "schedule" blocks are required.`),
+ },
+ {
+ 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
+ scheduling {
+ timezone = "UTC"
+ schedule {
+ instances = 3
+ }
+ }
+ }
+ }`,
+ ExpectError: regexp.MustCompile(`The argument "cron" is required, but no definition was found.`),
+ },
+ {
+ 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
+ scheduling {
+ 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 scheduling.schedule field, but with invalid type for instances",
+ Config: `
+ data "coder_workspace_preset" "preset_1" {
+ name = "preset_1"
+ prebuilds {
+ instances = 1
+ scheduling {
+ 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 scheduling field with 1 schedule",
+ Config: `
+ data "coder_workspace_preset" "preset_1" {
+ name = "preset_1"
+ prebuilds {
+ instances = 1
+ scheduling {
+ 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.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 scheduling field with 2 schedules",
+ Config: `
+ data "coder_workspace_preset" "preset_1" {
+ name = "preset_1"
+ prebuilds {
+ instances = 1
+ scheduling {
+ 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.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 scheduling.schedule field, but the cron includes a disallowed minute field",
+ Config: `
+ data "coder_workspace_preset" "preset_1" {
+ name = "preset_1"
+ prebuilds {
+ instances = 1
+ scheduling {
+ 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 scheduling.schedule field, but the cron hour field is invalid",
+ Config: `
+ data "coder_workspace_preset" "preset_1" {
+ name = "preset_1"
+ prebuilds {
+ instances = 1
+ scheduling {
+ 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 scheduling.timezone field",
+ Config: `
+ data "coder_workspace_preset" "preset_1" {
+ name = "preset_1"
+ prebuilds {
+ instances = 1
+ scheduling {
+ 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.scheduling.0.timezone"], "America/Los_Angeles")
+ return nil
+ },
+ },
+ {
+ Name: "Prebuilds is set with an invalid scheduling.timezone field",
+ Config: `
+ data "coder_workspace_preset" "preset_1" {
+ name = "preset_1"
+ prebuilds {
+ instances = 1
+ scheduling {
+ timezone = "InvalidLocation"
+ schedule {
+ cron = "* 8-18 * * 1-5"
+ instances = 3
+ }
+ }
+ }
+ }`,
+ ExpectError: regexp.MustCompile(`failed to load timezone "InvalidLocation": unknown time zone InvalidLocation`),
+ },
+ {
+ 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
+ scheduling {
+ 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 {