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 {