diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md
index cd4908c..d933793 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 3661290..0e51713 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 50274ff..8ebdbb6 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/workspace_preset.go b/provider/workspace_preset.go
index e0f2276..b4dd641 100644
--- a/provider/workspace_preset.go
+++ b/provider/workspace_preset.go
@@ -3,13 +3,18 @@ package provider
import (
"context"
"fmt"
+ "strings"
+ "time"
"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 +34,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,
@@ -119,9 +135,82 @@ 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, day-of-month and month options of spec are all 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] != "*" || parts[2] != "*" || parts[3] != "*" {
+ return fmt.Errorf("minute, day-of-month and month should be *")
+ }
+
+ return nil
+}
diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go
index b8e752a..c9e337d 100644
--- a/provider/workspace_preset_test.go
+++ b/provider/workspace_preset_test.go
@@ -265,6 +265,249 @@ 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, day-of-month and month 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`),
+ },
}
for _, testcase := range testcases {