Skip to content

Commit 6570400

Browse files
feat: add scheduling configuration for prebuilds (#408)
* feat: add autoscaling configuration for prebuilds * fix: improve schedule validation * fix: allow DOM and Month fields * docs: improve documentation for timezone field * docs: make gen * Update provider/workspace_preset.go Co-authored-by: Danny Kopping <dannykopping@gmail.com> * docs: improve doc comments * fix: tests * refactor: rename autoscaling to scheduling * docs: make gen * refactor: minor refactor after renaming * Update provider/helpers/schedule_validation.go Co-authored-by: Danny Kopping <dannykopping@gmail.com> * Update provider/helpers/schedule_validation.go Co-authored-by: Danny Kopping <dannykopping@gmail.com> * refactor: improve docs * refactor: improve docs * test: improve test coverage * test: improve test coverage * refactor: check for a specific error in tests * refactor: check for a specific error in tests --------- Co-authored-by: Danny Kopping <dannykopping@gmail.com>
1 parent eee4ed5 commit 6570400

File tree

7 files changed

+1246
-6
lines changed

7 files changed

+1246
-6
lines changed

docs/data-sources/workspace_preset.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,30 @@ Required:
5555
Optional:
5656

5757
- `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))
58+
- `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))
5859

5960
<a id="nestedblock--prebuilds--expiration_policy"></a>
6061
### Nested Schema for `prebuilds.expiration_policy`
6162

6263
Required:
6364

6465
- `ttl` (Number) Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.
66+
67+
68+
<a id="nestedblock--prebuilds--scheduling"></a>
69+
### Nested Schema for `prebuilds.scheduling`
70+
71+
Required:
72+
73+
- `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))
74+
- `timezone` (String) The timezone to use for the prebuild schedules (e.g., "UTC", "America/New_York").
75+
Timezone must be a valid timezone in the IANA timezone database.
76+
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.
77+
78+
<a id="nestedblock--prebuilds--scheduling--schedule"></a>
79+
### Nested Schema for `prebuilds.scheduling.schedule`
80+
81+
Required:
82+
83+
- `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.
84+
- `instances` (Number) The number of prebuild instances to maintain during this schedule period.

integration/integration_test.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,17 @@ func TestIntegration(t *testing.T) {
9090
// TODO (sasswart): the cli doesn't support presets yet.
9191
// once it does, the value for workspace_parameter.value
9292
// will be the preset value.
93-
"workspace_parameter.value": `param value`,
94-
"workspace_parameter.icon": `param icon`,
95-
"workspace_preset.name": `preset`,
96-
"workspace_preset.parameters.param": `preset param value`,
97-
"workspace_preset.prebuilds.instances": `1`,
98-
"workspace_preset.prebuilds.expiration_policy.ttl": `86400`,
93+
"workspace_parameter.value": `param value`,
94+
"workspace_parameter.icon": `param icon`,
95+
"workspace_preset.name": `preset`,
96+
"workspace_preset.parameters.param": `preset param value`,
97+
"workspace_preset.prebuilds.instances": `1`,
98+
"workspace_preset.prebuilds.expiration_policy.ttl": `86400`,
99+
"workspace_preset.prebuilds.scheduling.timezone": `UTC`,
100+
"workspace_preset.prebuilds.scheduling.schedule0.cron": `\* 8-18 \* \* 1-5`,
101+
"workspace_preset.prebuilds.scheduling.schedule0.instances": `3`,
102+
"workspace_preset.prebuilds.scheduling.schedule1.cron": `\* 8-14 \* \* 6`,
103+
"workspace_preset.prebuilds.scheduling.schedule1.instances": `1`,
99104
},
100105
},
101106
{

integration/test-data-source/main.tf

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ data "coder_workspace_preset" "preset" {
3030
expiration_policy {
3131
ttl = 86400
3232
}
33+
scheduling {
34+
timezone = "UTC"
35+
schedule {
36+
cron = "* 8-18 * * 1-5"
37+
instances = 3
38+
}
39+
schedule {
40+
cron = "* 8-14 * * 6"
41+
instances = 1
42+
}
43+
}
3344
}
3445
}
3546

@@ -56,6 +67,11 @@ locals {
5667
"workspace_preset.parameters.param" : data.coder_workspace_preset.preset.parameters.param,
5768
"workspace_preset.prebuilds.instances" : tostring(one(data.coder_workspace_preset.preset.prebuilds).instances),
5869
"workspace_preset.prebuilds.expiration_policy.ttl" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).expiration_policy).ttl),
70+
"workspace_preset.prebuilds.scheduling.timezone" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).timezone),
71+
"workspace_preset.prebuilds.scheduling.schedule0.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[0].cron),
72+
"workspace_preset.prebuilds.scheduling.schedule0.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[0].instances),
73+
"workspace_preset.prebuilds.scheduling.schedule1.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[1].cron),
74+
"workspace_preset.prebuilds.scheduling.schedule1.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[1].instances),
5975
}
6076
}
6177

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package helpers
2+
3+
import (
4+
"strconv"
5+
"strings"
6+
7+
"golang.org/x/xerrors"
8+
)
9+
10+
// ValidateSchedules checks if any schedules overlap
11+
func ValidateSchedules(schedules []string) error {
12+
for i := 0; i < len(schedules); i++ {
13+
for j := i + 1; j < len(schedules); j++ {
14+
overlap, err := SchedulesOverlap(schedules[i], schedules[j])
15+
if err != nil {
16+
return xerrors.Errorf("invalid schedule: %w", err)
17+
}
18+
if overlap {
19+
return xerrors.Errorf("schedules overlap: %s and %s",
20+
schedules[i], schedules[j])
21+
}
22+
}
23+
}
24+
return nil
25+
}
26+
27+
// SchedulesOverlap checks if two schedules overlap by checking
28+
// all cron fields separately
29+
func SchedulesOverlap(schedule1, schedule2 string) (bool, error) {
30+
// Get cron fields
31+
fields1 := strings.Fields(schedule1)
32+
fields2 := strings.Fields(schedule2)
33+
34+
if len(fields1) != 5 {
35+
return false, xerrors.Errorf("schedule %q has %d fields, expected 5 fields (minute hour day-of-month month day-of-week)", schedule1, len(fields1))
36+
}
37+
if len(fields2) != 5 {
38+
return false, xerrors.Errorf("schedule %q has %d fields, expected 5 fields (minute hour day-of-month month day-of-week)", schedule2, len(fields2))
39+
}
40+
41+
// Check if months overlap
42+
monthsOverlap, err := MonthsOverlap(fields1[3], fields2[3])
43+
if err != nil {
44+
return false, xerrors.Errorf("invalid month range: %w", err)
45+
}
46+
if !monthsOverlap {
47+
return false, nil
48+
}
49+
50+
// Check if days overlap (DOM OR DOW)
51+
daysOverlap, err := DaysOverlap(fields1[2], fields1[4], fields2[2], fields2[4])
52+
if err != nil {
53+
return false, xerrors.Errorf("invalid day range: %w", err)
54+
}
55+
if !daysOverlap {
56+
return false, nil
57+
}
58+
59+
// Check if hours overlap
60+
hoursOverlap, err := HoursOverlap(fields1[1], fields2[1])
61+
if err != nil {
62+
return false, xerrors.Errorf("invalid hour range: %w", err)
63+
}
64+
65+
return hoursOverlap, nil
66+
}
67+
68+
// MonthsOverlap checks if two month ranges overlap
69+
func MonthsOverlap(months1, months2 string) (bool, error) {
70+
return CheckOverlap(months1, months2, 12)
71+
}
72+
73+
// HoursOverlap checks if two hour ranges overlap
74+
func HoursOverlap(hours1, hours2 string) (bool, error) {
75+
return CheckOverlap(hours1, hours2, 23)
76+
}
77+
78+
// DomOverlap checks if two day-of-month ranges overlap
79+
func DomOverlap(dom1, dom2 string) (bool, error) {
80+
return CheckOverlap(dom1, dom2, 31)
81+
}
82+
83+
// DowOverlap checks if two day-of-week ranges overlap
84+
func DowOverlap(dow1, dow2 string) (bool, error) {
85+
return CheckOverlap(dow1, dow2, 6)
86+
}
87+
88+
// DaysOverlap checks if two day ranges overlap, considering both DOM and DOW.
89+
// Returns true if both DOM and DOW overlap, or if one is * and the other overlaps.
90+
func DaysOverlap(dom1, dow1, dom2, dow2 string) (bool, error) {
91+
// If either DOM is *, we only need to check DOW overlap
92+
if dom1 == "*" || dom2 == "*" {
93+
return DowOverlap(dow1, dow2)
94+
}
95+
96+
// If either DOW is *, we only need to check DOM overlap
97+
if dow1 == "*" || dow2 == "*" {
98+
return DomOverlap(dom1, dom2)
99+
}
100+
101+
// If both DOM and DOW are specified, we need to check both
102+
// because the schedule runs when either matches
103+
domOverlap, err := DomOverlap(dom1, dom2)
104+
if err != nil {
105+
return false, err
106+
}
107+
dowOverlap, err := DowOverlap(dow1, dow2)
108+
if err != nil {
109+
return false, err
110+
}
111+
112+
// If either DOM or DOW overlaps, the schedules overlap
113+
return domOverlap || dowOverlap, nil
114+
}
115+
116+
// CheckOverlap is a function to check if two ranges overlap
117+
func CheckOverlap(range1, range2 string, maxValue int) (bool, error) {
118+
set1, err := ParseRange(range1, maxValue)
119+
if err != nil {
120+
return false, err
121+
}
122+
set2, err := ParseRange(range2, maxValue)
123+
if err != nil {
124+
return false, err
125+
}
126+
127+
for value := range set1 {
128+
if set2[value] {
129+
return true, nil
130+
}
131+
}
132+
return false, nil
133+
}
134+
135+
// ParseRange converts a cron range to a set of integers
136+
// maxValue is the maximum allowed value (e.g., 23 for hours, 6 for DOW, 12 for months, 31 for DOM)
137+
func ParseRange(input string, maxValue int) (map[int]bool, error) {
138+
result := make(map[int]bool)
139+
140+
// Handle "*" case
141+
if input == "*" {
142+
for i := 0; i <= maxValue; i++ {
143+
result[i] = true
144+
}
145+
return result, nil
146+
}
147+
148+
// Parse ranges like "1-3,5,7-9"
149+
parts := strings.Split(input, ",")
150+
for _, part := range parts {
151+
if strings.Contains(part, "-") {
152+
// Handle range like "1-3"
153+
rangeParts := strings.Split(part, "-")
154+
start, err := strconv.Atoi(rangeParts[0])
155+
if err != nil {
156+
return nil, xerrors.Errorf("invalid start value in range: %w", err)
157+
}
158+
end, err := strconv.Atoi(rangeParts[1])
159+
if err != nil {
160+
return nil, xerrors.Errorf("invalid end value in range: %w", err)
161+
}
162+
163+
// Validate range
164+
if start < 0 || end > maxValue || start > end {
165+
return nil, xerrors.Errorf("invalid range %d-%d: values must be between 0 and %d", start, end, maxValue)
166+
}
167+
168+
for i := start; i <= end; i++ {
169+
result[i] = true
170+
}
171+
} else {
172+
// Handle single value
173+
value, err := strconv.Atoi(part)
174+
if err != nil {
175+
return nil, xerrors.Errorf("invalid value: %w", err)
176+
}
177+
178+
// Validate value
179+
if value < 0 || value > maxValue {
180+
return nil, xerrors.Errorf("invalid value %d: must be between 0 and %d", value, maxValue)
181+
}
182+
183+
result[value] = true
184+
}
185+
}
186+
return result, nil
187+
}

0 commit comments

Comments
 (0)