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)
pFad - Phonifier reborn

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy