Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 543a735

Browse files
committedJun 11, 2025·
feat: add autoscaling configuration for prebuilds
1 parent cfa101d commit 543a735

File tree

5 files changed

+378
-6
lines changed

5 files changed

+378
-6
lines changed
 

‎docs/data-sources/workspace_preset.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,27 @@ Required:
5454

5555
Optional:
5656

57+
- `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))
5758
- `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))
5859

60+
<a id="nestedblock--prebuilds--autoscaling"></a>
61+
### Nested Schema for `prebuilds.autoscaling`
62+
63+
Required:
64+
65+
- `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))
66+
- `timezone` (String) The timezone to use for the autoscaling schedule (e.g., "UTC", "America/New_York").
67+
68+
<a id="nestedblock--prebuilds--autoscaling--schedule"></a>
69+
### Nested Schema for `prebuilds.autoscaling.schedule`
70+
71+
Required:
72+
73+
- `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 "*".
74+
- `instances` (Number) The number of prebuild instances to maintain during this schedule period.
75+
76+
77+
5978
<a id="nestedblock--prebuilds--expiration_policy"></a>
6079
### Nested Schema for `prebuilds.expiration_policy`
6180

‎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.autoscaling.timezone": `UTC`,
100+
"workspace_preset.prebuilds.autoscaling.schedule0.cron": `\* 8-18 \* \* 1-5`,
101+
"workspace_preset.prebuilds.autoscaling.schedule0.instances": `3`,
102+
"workspace_preset.prebuilds.autoscaling.schedule1.cron": `\* 8-14 \* \* 6`,
103+
"workspace_preset.prebuilds.autoscaling.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+
autoscaling {
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.autoscaling.timezone" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).timezone),
71+
"workspace_preset.prebuilds.autoscaling.schedule0.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).schedule[0].cron),
72+
"workspace_preset.prebuilds.autoscaling.schedule0.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).schedule[0].instances),
73+
"workspace_preset.prebuilds.autoscaling.schedule1.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).schedule[1].cron),
74+
"workspace_preset.prebuilds.autoscaling.schedule1.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).autoscaling).schedule[1].instances),
5975
}
6076
}
6177

‎provider/workspace_preset.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@ package provider
33
import (
44
"context"
55
"fmt"
6+
"strings"
7+
"time"
68

79
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
810
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
911
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
1012
"github.com/mitchellh/mapstructure"
13+
rbcron "github.com/robfig/cron/v3"
1114
)
1215

16+
var PrebuildsCRONParser = rbcron.NewParser(rbcron.Minute | rbcron.Hour | rbcron.Dom | rbcron.Month | rbcron.Dow)
17+
1318
type WorkspacePreset struct {
1419
Name string `mapstructure:"name"`
1520
Parameters map[string]string `mapstructure:"parameters"`
@@ -29,12 +34,23 @@ type WorkspacePrebuild struct {
2934
// for utilities that parse our terraform output using this type. To remain compatible
3035
// with those cases, we use a slice here.
3136
ExpirationPolicy []ExpirationPolicy `mapstructure:"expiration_policy"`
37+
Autoscaling []Autoscaling `json:"autoscaling,omitempty"`
3238
}
3339

3440
type ExpirationPolicy struct {
3541
TTL int `mapstructure:"ttl"`
3642
}
3743

44+
type Autoscaling struct {
45+
Timezone string `json:"timezone"`
46+
Schedule []Schedule `json:"schedule"`
47+
}
48+
49+
type Schedule struct {
50+
Cron string `json:"cron"`
51+
Instances int `json:"instances"`
52+
}
53+
3854
func workspacePresetDataSource() *schema.Resource {
3955
return &schema.Resource{
4056
SchemaVersion: 1,
@@ -119,9 +135,82 @@ func workspacePresetDataSource() *schema.Resource {
119135
},
120136
},
121137
},
138+
"autoscaling": {
139+
Type: schema.TypeList,
140+
Description: "Configuration block that defines autoscaling behavior for prebuilds. Use this to automatically adjust the number of prebuild instances based on a schedule.",
141+
Optional: true,
142+
MaxItems: 1,
143+
Elem: &schema.Resource{
144+
Schema: map[string]*schema.Schema{
145+
"timezone": {
146+
Type: schema.TypeString,
147+
Description: "The timezone to use for the autoscaling schedule (e.g., \"UTC\", \"America/New_York\").",
148+
Required: true,
149+
ValidateFunc: func(val interface{}, key string) ([]string, []error) {
150+
timezone := val.(string)
151+
152+
_, err := time.LoadLocation(timezone)
153+
if err != nil {
154+
return nil, []error{fmt.Errorf("failed to load location: %w", err)}
155+
}
156+
157+
return nil, nil
158+
},
159+
},
160+
"schedule": {
161+
Type: schema.TypeList,
162+
Description: "One or more schedule blocks that define when to scale the number of prebuild instances.",
163+
Required: true,
164+
MinItems: 1,
165+
Elem: &schema.Resource{
166+
Schema: map[string]*schema.Schema{
167+
"cron": {
168+
Type: schema.TypeString,
169+
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 \"*\".",
170+
Required: true,
171+
ValidateFunc: func(val interface{}, key string) ([]string, []error) {
172+
cronSpec := val.(string)
173+
174+
err := validatePrebuildsCronSpec(cronSpec)
175+
if err != nil {
176+
return nil, []error{fmt.Errorf("cron spec failed validation: %w", err)}
177+
}
178+
179+
_, err = PrebuildsCRONParser.Parse(cronSpec)
180+
if err != nil {
181+
return nil, []error{fmt.Errorf("failed to parse cron spec: %w", err)}
182+
}
183+
184+
return nil, nil
185+
},
186+
},
187+
"instances": {
188+
Type: schema.TypeInt,
189+
Description: "The number of prebuild instances to maintain during this schedule period.",
190+
Required: true,
191+
},
192+
},
193+
},
194+
},
195+
},
196+
},
197+
},
122198
},
123199
},
124200
},
125201
},
126202
}
127203
}
204+
205+
// validatePrebuildsCronSpec ensures that the minute, day-of-month and month options of spec are all set to *
206+
func validatePrebuildsCronSpec(spec string) error {
207+
parts := strings.Fields(spec)
208+
if len(parts) != 5 {
209+
return fmt.Errorf("cron specification should consist of 5 fields")
210+
}
211+
if parts[0] != "*" || parts[2] != "*" || parts[3] != "*" {
212+
return fmt.Errorf("minute, day-of-month and month should be *")
213+
}
214+
215+
return nil
216+
}

‎provider/workspace_preset_test.go

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,249 @@ func TestWorkspacePreset(t *testing.T) {
265265
}`,
266266
ExpectError: regexp.MustCompile("An argument named \"invalid_argument\" is not expected here."),
267267
},
268+
{
269+
Name: "Prebuilds is set with an empty autoscaling field",
270+
Config: `
271+
data "coder_workspace_preset" "preset_1" {
272+
name = "preset_1"
273+
prebuilds {
274+
instances = 1
275+
autoscaling {}
276+
}
277+
}`,
278+
ExpectError: regexp.MustCompile(`The argument "[^"]+" is required, but no definition was found.`),
279+
},
280+
{
281+
Name: "Prebuilds is set with an autoscaling field, but without timezone",
282+
Config: `
283+
data "coder_workspace_preset" "preset_1" {
284+
name = "preset_1"
285+
prebuilds {
286+
instances = 1
287+
autoscaling {
288+
schedule {
289+
cron = "* 8-18 * * 1-5"
290+
instances = 3
291+
}
292+
}
293+
}
294+
}`,
295+
ExpectError: regexp.MustCompile(`The argument "timezone" is required, but no definition was found.`),
296+
},
297+
{
298+
Name: "Prebuilds is set with an autoscaling field, but without schedule",
299+
Config: `
300+
data "coder_workspace_preset" "preset_1" {
301+
name = "preset_1"
302+
prebuilds {
303+
instances = 1
304+
autoscaling {
305+
timezone = "UTC"
306+
}
307+
}
308+
}`,
309+
ExpectError: regexp.MustCompile(`At least 1 "schedule" blocks are required.`),
310+
},
311+
{
312+
Name: "Prebuilds is set with an autoscaling.schedule field, but without cron",
313+
Config: `
314+
data "coder_workspace_preset" "preset_1" {
315+
name = "preset_1"
316+
prebuilds {
317+
instances = 1
318+
autoscaling {
319+
timezone = "UTC"
320+
schedule {
321+
instances = 3
322+
}
323+
}
324+
}
325+
}`,
326+
ExpectError: regexp.MustCompile(`The argument "cron" is required, but no definition was found.`),
327+
},
328+
{
329+
Name: "Prebuilds is set with an autoscaling.schedule field, but without cron",
330+
Config: `
331+
data "coder_workspace_preset" "preset_1" {
332+
name = "preset_1"
333+
prebuilds {
334+
instances = 1
335+
autoscaling {
336+
timezone = "UTC"
337+
schedule {
338+
cron = "* 8-18 * * 1-5"
339+
}
340+
}
341+
}
342+
}`,
343+
ExpectError: regexp.MustCompile(`The argument "instances" is required, but no definition was found.`),
344+
},
345+
{
346+
Name: "Prebuilds is set with an autoscaling.schedule field, but with invalid type for instances",
347+
Config: `
348+
data "coder_workspace_preset" "preset_1" {
349+
name = "preset_1"
350+
prebuilds {
351+
instances = 1
352+
autoscaling {
353+
timezone = "UTC"
354+
schedule {
355+
cron = "* 8-18 * * 1-5"
356+
instances = "not_a_number"
357+
}
358+
}
359+
}
360+
}`,
361+
ExpectError: regexp.MustCompile(`Inappropriate value for attribute "instances": a number is required`),
362+
},
363+
{
364+
Name: "Prebuilds is set with an autoscaling field with 1 schedule",
365+
Config: `
366+
data "coder_workspace_preset" "preset_1" {
367+
name = "preset_1"
368+
prebuilds {
369+
instances = 1
370+
autoscaling {
371+
timezone = "UTC"
372+
schedule {
373+
cron = "* 8-18 * * 1-5"
374+
instances = 3
375+
}
376+
}
377+
}
378+
}`,
379+
ExpectError: nil,
380+
Check: func(state *terraform.State) error {
381+
require.Len(t, state.Modules, 1)
382+
require.Len(t, state.Modules[0].Resources, 1)
383+
resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
384+
require.NotNil(t, resource)
385+
attrs := resource.Primary.Attributes
386+
require.Equal(t, attrs["name"], "preset_1")
387+
require.Equal(t, attrs["prebuilds.0.autoscaling.0.timezone"], "UTC")
388+
require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.0.cron"], "* 8-18 * * 1-5")
389+
require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.0.instances"], "3")
390+
return nil
391+
},
392+
},
393+
{
394+
Name: "Prebuilds is set with an autoscaling field with 2 schedules",
395+
Config: `
396+
data "coder_workspace_preset" "preset_1" {
397+
name = "preset_1"
398+
prebuilds {
399+
instances = 1
400+
autoscaling {
401+
timezone = "UTC"
402+
schedule {
403+
cron = "* 8-18 * * 1-5"
404+
instances = 3
405+
}
406+
schedule {
407+
cron = "* 8-14 * * 6"
408+
instances = 1
409+
}
410+
}
411+
}
412+
}`,
413+
ExpectError: nil,
414+
Check: func(state *terraform.State) error {
415+
require.Len(t, state.Modules, 1)
416+
require.Len(t, state.Modules[0].Resources, 1)
417+
resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
418+
require.NotNil(t, resource)
419+
attrs := resource.Primary.Attributes
420+
require.Equal(t, attrs["name"], "preset_1")
421+
require.Equal(t, attrs["prebuilds.0.autoscaling.0.timezone"], "UTC")
422+
require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.0.cron"], "* 8-18 * * 1-5")
423+
require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.0.instances"], "3")
424+
require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.1.cron"], "* 8-14 * * 6")
425+
require.Equal(t, attrs["prebuilds.0.autoscaling.0.schedule.1.instances"], "1")
426+
return nil
427+
},
428+
},
429+
{
430+
Name: "Prebuilds is set with an autoscaling.schedule field, but the cron includes a disallowed minute field",
431+
Config: `
432+
data "coder_workspace_preset" "preset_1" {
433+
name = "preset_1"
434+
prebuilds {
435+
instances = 1
436+
autoscaling {
437+
timezone = "UTC"
438+
schedule {
439+
cron = "30 8-18 * * 1-5"
440+
instances = "1"
441+
}
442+
}
443+
}
444+
}`,
445+
ExpectError: regexp.MustCompile(`cron spec failed validation: minute, day-of-month and month should be *`),
446+
},
447+
{
448+
Name: "Prebuilds is set with an autoscaling.schedule field, but the cron hour field is invalid",
449+
Config: `
450+
data "coder_workspace_preset" "preset_1" {
451+
name = "preset_1"
452+
prebuilds {
453+
instances = 1
454+
autoscaling {
455+
timezone = "UTC"
456+
schedule {
457+
cron = "* 25-26 * * 1-5"
458+
instances = "1"
459+
}
460+
}
461+
}
462+
}`,
463+
ExpectError: regexp.MustCompile(`failed to parse cron spec: end of range \(26\) above maximum \(23\): 25-26`),
464+
},
465+
{
466+
Name: "Prebuilds is set with a valid autoscaling.timezone field",
467+
Config: `
468+
data "coder_workspace_preset" "preset_1" {
469+
name = "preset_1"
470+
prebuilds {
471+
instances = 1
472+
autoscaling {
473+
timezone = "America/Los_Angeles"
474+
schedule {
475+
cron = "* 8-18 * * 1-5"
476+
instances = 3
477+
}
478+
}
479+
}
480+
}`,
481+
ExpectError: nil,
482+
Check: func(state *terraform.State) error {
483+
require.Len(t, state.Modules, 1)
484+
require.Len(t, state.Modules[0].Resources, 1)
485+
resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
486+
require.NotNil(t, resource)
487+
attrs := resource.Primary.Attributes
488+
require.Equal(t, attrs["name"], "preset_1")
489+
require.Equal(t, attrs["prebuilds.0.autoscaling.0.timezone"], "America/Los_Angeles")
490+
return nil
491+
},
492+
},
493+
{
494+
Name: "Prebuilds is set with an invalid autoscaling.timezone field",
495+
Config: `
496+
data "coder_workspace_preset" "preset_1" {
497+
name = "preset_1"
498+
prebuilds {
499+
instances = 1
500+
autoscaling {
501+
timezone = "InvalidLocation"
502+
schedule {
503+
cron = "* 8-18 * * 1-5"
504+
instances = 3
505+
}
506+
}
507+
}
508+
}`,
509+
ExpectError: regexp.MustCompile(`failed to load location: unknown time zone InvalidLocation`),
510+
},
268511
}
269512

270513
for _, testcase := range testcases {

0 commit comments

Comments
 (0)
Please sign in to comment.