diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index e0f2276..88e5149 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 `json:"autoscaling,omitempty"` } type ExpirationPolicy struct { TTL int `mapstructure:"ttl"` } +type Autoscaling struct { + Timezone string `json:"timezone"` + Schedule []Schedule `json:"schedule"` +} + +type Schedule struct { + Cron string `json:"cron"` + Instances int `json:"instances"` +} + func workspacePresetDataSource() *schema.Resource { return &schema.Resource{ SchemaVersion: 1, @@ -119,9 +135,77 @@ func workspacePresetDataSource() *schema.Resource { }, }, }, + "autoscaling": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "timezone": { + Type: schema.TypeString, + 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, + Required: true, + MinItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "cron": { + Type: schema.TypeString, + 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, + 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..28eb3e3 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 cron", + 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 {