diff --git a/.github/workflows/cla.yaml b/.github/workflows/cla.yaml index 71c2e905..45f72487 100644 --- a/.github/workflows/cla.yaml +++ b/.github/workflows/cla.yaml @@ -23,4 +23,4 @@ jobs: path-to-document: 'https://github.com/coder/cla/blob/main/README.md' # branch should not be protected branch: 'main' - allowlist: dependabot* + allowlist: 'dependabot*,blink-so*' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33f17b8d..f731b412 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,14 +84,14 @@ jobs: - name: Import GPG key id: import_gpg - uses: crazy-max/ghaction-import-gpg@v6.2.0 + uses: crazy-max/ghaction-import-gpg@v6.3.0 with: # These secrets will need to be configured for the repository: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.PASSPHRASE }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6.2.1 + uses: goreleaser/goreleaser-action@v6.3.0 with: version: '~> v2' args: release --clean diff --git a/README.md b/README.md index f055961e..4ae9be15 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,13 @@ to setup your local Terraform to use your local version rather than the registry 2. Run `terraform init` and observe a warning like `Warning: Provider development overrides are in effect` 4. Run `make build` to build the provider binary, which Terraform will try locate and execute 5. All local Terraform runs will now use your local provider! -6. _**NOTE**: we vendor in this provider into `github.com/coder/coder`, so if you're testing with a local clone then you should also run `go mod edit -replace github.com/coder/terraform-provider-coder=/path/to/terraform-provider-coder` in your clone._ +6. **NOTE**: We vendor this provider into `github.com/coder/coder`, so if you're testing with a local clone, make sure to run the following in your local clone of `coder`: + ```console + go mod edit -replace github.com/coder/terraform-provider-coder/v2=/path/to/terraform-provider-coder + go mod tidy + ``` + ⚠️ Be sure to include `/v2` in the module path as it needs to match the version declared in the provider’s `go.mod`. + #### Terraform Acceptance Tests diff --git a/docs/data-sources/parameter.md b/docs/data-sources/parameter.md index 934ae77a..ecba3929 100644 --- a/docs/data-sources/parameter.md +++ b/docs/data-sources/parameter.md @@ -145,13 +145,13 @@ data "coder_parameter" "home_volume_size" { - `description` (String) Describe what this parameter does. - `display_name` (String) The displayed name of the parameter as it will appear in the interface. - `ephemeral` (Boolean) The value of an ephemeral parameter will not be preserved between consecutive workspace builds. -- `form_type` (String) The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error]. +- `form_type` (String) The type of this parameter. Must be one of: `"radio"`, `"slider"`, `"input"`, `"dropdown"`, `"checkbox"`, `"switch"`, `"multi-select"`, `"tag-select"`, `"textarea"`, `"error"`. - `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/"`. - `mutable` (Boolean) Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution! - `option` (Block List) Each `option` block defines a value for a user to select from. (see [below for nested schema](#nestedblock--option)) - `order` (Number) The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order). - `styling` (String) JSON encoded string containing the metadata for controlling the appearance of this parameter in the UI. This option is purely cosmetic and does not affect the function of the parameter in terraform. -- `type` (String) The type of this parameter. Must be one of: `"number"`, `"string"`, `"bool"`, or `"list(string)"`. +- `type` (String) The type of this parameter. Must be one of: `"string"`, `"number"`, `"bool"`, `"list(string)"`. - `validation` (Block List, Max: 1) Validate the input of a parameter. (see [below for nested schema](#nestedblock--validation)) ### Read-Only @@ -179,13 +179,13 @@ Optional: Optional: -- `error` (String) An error message to display if the value breaks the validation rules. The following placeholders are supported: {max}, {min}, and {value}. -- `max` (Number) The maximum of a number parameter. -- `min` (Number) The minimum of a number parameter. +- `error` (String) An error message to display if the value breaks the validation rules. The following placeholders are supported: `{max}`, `{min}`, and `{value}`. +- `max` (Number) The maximum value of a number parameter. +- `min` (Number) The minimum value of a number parameter. - `monotonic` (String) Number monotonicity, either increasing or decreasing. - `regex` (String) A regex for the input parameter to match against. Read-Only: -- `max_disabled` (Boolean) Helper field to check if max is present -- `min_disabled` (Boolean) Helper field to check if min is present +- `max_disabled` (Boolean) Helper field to check if `max` is present +- `min_disabled` (Boolean) Helper field to check if `min` is present diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index 14e235f9..69057403 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -27,6 +27,16 @@ data "coder_workspace_preset" "example" { (data.coder_parameter.ami.name) = "ami-xxxxxxxx" } } + +# Example of a default preset that will be pre-selected for users +data "coder_workspace_preset" "standard" { + name = "Standard" + default = true + parameters = { + (data.coder_parameter.instance_type.name) = "t3.medium" + (data.coder_parameter.region.name) = "us-west-2" + } +} ``` @@ -38,6 +48,7 @@ data "coder_workspace_preset" "example" { ### Optional +- `default` (Boolean) Whether this preset should be selected by default when creating a workspace. Only one preset per template can be marked as default. - `parameters` (Map of String) Workspace parameters that will be set by the workspace preset. For simple templates that only need prebuilds, you may define a preset with zero parameters. Because workspace parameters may change between Coder template versions, preset parameters are allowed to define values for parameters that do not exist in the current template version. - `prebuilds` (Block Set, Max: 1) Configuration for prebuilt workspaces associated with this preset. Coder will maintain a pool of standby workspaces based on this configuration. When a user creates a workspace using this preset, they are assigned a prebuilt workspace instead of waiting for a new one to build. See prebuilt workspace documentation [here](https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces.md) (see [below for nested schema](#nestedblock--prebuilds)) @@ -51,3 +62,34 @@ data "coder_workspace_preset" "example" { Required: - `instances` (Number) The number of workspaces to keep in reserve for this preset. + +Optional: + +- `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)) +- `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)) + + +### Nested Schema for `prebuilds.expiration_policy` + +Required: + +- `ttl` (Number) Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup. + + + +### Nested Schema for `prebuilds.scheduling` + +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--scheduling--schedule)) +- `timezone` (String) The timezone to use for the prebuild schedules (e.g., "UTC", "America/New_York"). +Timezone must be a valid timezone in the IANA timezone database. +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. + + +### Nested Schema for `prebuilds.scheduling.schedule` + +Required: + +- `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. +- `instances` (Number) The number of prebuild instances to maintain during this schedule period. diff --git a/docs/resources/agent.md b/docs/resources/agent.md index 7df11e34..87ab4239 100644 --- a/docs/resources/agent.md +++ b/docs/resources/agent.md @@ -72,7 +72,7 @@ resource "kubernetes_pod" "dev" { ### Optional -- `api_key_scope` (String) Controls what API routes the agent token can access. Options: 'all' (full access) or 'no_user_data' (blocks /external-auth, /gitsshkey, and /gitauth routes) +- `api_key_scope` (String) Controls what API routes the agent token can access. Options: `all` (full access) or `no_user_data` (blocks `/external-auth`, `/gitsshkey`, and `/gitauth` routes) - `auth` (String) The authentication type the agent will use. Must be one of: `"token"`, `"google-instance-identity"`, `"aws-instance-identity"`, `"azure-instance-identity"`. - `connection_timeout` (Number) Time in seconds until the agent is marked as timed out when a connection with the server cannot be established. A value of zero never marks the agent as timed out. - `dir` (String) The starting directory when a user creates a shell session. Defaults to `"$HOME"`. diff --git a/docs/resources/ai_task.md b/docs/resources/ai_task.md new file mode 100644 index 00000000..1922ef59 --- /dev/null +++ b/docs/resources/ai_task.md @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coder_ai_task Resource - terraform-provider-coder" +subcategory: "" +description: |- + Use this resource to define Coder tasks. +--- + +# coder_ai_task (Resource) + +Use this resource to define Coder tasks. + + + + +## Schema + +### Required + +- `sidebar_app` (Block Set, Min: 1, Max: 1) The coder_app to display in the sidebar. Usually a chat interface with the AI agent running in the workspace, like https://github.com/coder/agentapi. (see [below for nested schema](#nestedblock--sidebar_app)) + +### Read-Only + +- `id` (String) A unique identifier for this resource. + + +### Nested Schema for `sidebar_app` + +Required: + +- `id` (String) A reference to an existing `coder_app` resource in your template. diff --git a/docs/resources/app.md b/docs/resources/app.md index b3ac728f..6be99cf3 100644 --- a/docs/resources/app.md +++ b/docs/resources/app.md @@ -63,9 +63,10 @@ resource "coder_app" "vim" { - `command` (String) A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either `command` or `url` may be specified, but not both. - `display_name` (String) A display name to identify the app. Defaults to the slug. - `external` (Boolean) Specifies whether `url` is opened on the client machine instead of proxied through the workspace. +- `group` (String) The name of a group that this app belongs to. - `healthcheck` (Block Set, Max: 1) HTTP health checking to determine the application readiness. (see [below for nested schema](#nestedblock--healthcheck)) - `hidden` (Boolean) Determines if the app is visible in the UI (minimum Coder version: v2.16). -- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/"`. +- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/"`. - `open_in` (String) Determines where the app will be opened. Valid values are `"tab"` and `"slim-window" (default)`. `"tab"` opens in a new tab in the same browser window. `"slim-window"` opens a new browser window without navigation controls. - `order` (Number) The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order). - `share` (String) Determines the level which the application is shared at. Valid levels are `"owner"` (default), `"authenticated"` and `"public"`. Level `"owner"` disables sharing on the app, so only the workspace owner can access it. Level `"authenticated"` shares the app with all authenticated users. Level `"public"` shares it with any user, including unauthenticated users. Permitted application sharing levels can be configured site-wide via a flag on `coder server` (Enterprise only). diff --git a/docs/resources/devcontainer.md b/docs/resources/devcontainer.md index 93d5724b..06d7f6f3 100644 --- a/docs/resources/devcontainer.md +++ b/docs/resources/devcontainer.md @@ -3,12 +3,15 @@ page_title: "coder_devcontainer Resource - terraform-provider-coder" subcategory: "" description: |- - Define a Dev Container the agent should know of and attempt to autostart (minimum Coder version: v2.21). + Define a Dev Container the agent should know of and attempt to autostart. + -> This resource is only available in Coder v2.21 and later. --- # coder_devcontainer (Resource) -Define a Dev Container the agent should know of and attempt to autostart (minimum Coder version: v2.21). +Define a Dev Container the agent should know of and attempt to autostart. + +-> This resource is only available in Coder v2.21 and later. diff --git a/docs/resources/script.md b/docs/resources/script.md index 22ac1b50..21bfaec9 100644 --- a/docs/resources/script.md +++ b/docs/resources/script.md @@ -22,7 +22,7 @@ resource "coder_agent" "dev" { } resource "coder_script" "dotfiles" { - agent_id = coder_agent.dev.agent_id + agent_id = coder_agent.dev.id display_name = "Dotfiles" icon = "/icon/dotfiles.svg" run_on_start = true @@ -33,7 +33,7 @@ resource "coder_script" "dotfiles" { } resource "coder_script" "code-server" { - agent_id = coder_agent.dev.agent_id + agent_id = coder_agent.dev.id display_name = "code-server" icon = "/icon/code.svg" run_on_start = true @@ -43,15 +43,26 @@ resource "coder_script" "code-server" { }) } -resource "coder_script" "nightly_sleep_reminder" { - agent_id = coder_agent.dev.agent_id +resource "coder_script" "nightly_update" { + agent_id = coder_agent.dev.id display_name = "Nightly update" icon = "/icon/database.svg" - cron = "0 22 * * *" + cron = "0 0 22 * * *" # Run at 22:00 (10 PM) every day script = <"`. - `log_path` (String) The path of a file to write the logs to. If relative, it will be appended to tmp. - `run_on_start` (Boolean) This option defines whether or not the script should run when the agent starts. The script should exit when it is done to signal that the agent is ready. diff --git a/examples/data-sources/coder_workspace_preset/data-source.tf b/examples/data-sources/coder_workspace_preset/data-source.tf index 4f29a199..3c245f7a 100644 --- a/examples/data-sources/coder_workspace_preset/data-source.tf +++ b/examples/data-sources/coder_workspace_preset/data-source.tf @@ -12,3 +12,13 @@ data "coder_workspace_preset" "example" { (data.coder_parameter.ami.name) = "ami-xxxxxxxx" } } + +# Example of a default preset that will be pre-selected for users +data "coder_workspace_preset" "standard" { + name = "Standard" + default = true + parameters = { + (data.coder_parameter.instance_type.name) = "t3.medium" + (data.coder_parameter.region.name) = "us-west-2" + } +} diff --git a/examples/resources/coder_script/resource.tf b/examples/resources/coder_script/resource.tf index b7fced38..53c9dfb8 100644 --- a/examples/resources/coder_script/resource.tf +++ b/examples/resources/coder_script/resource.tf @@ -7,7 +7,7 @@ resource "coder_agent" "dev" { } resource "coder_script" "dotfiles" { - agent_id = coder_agent.dev.agent_id + agent_id = coder_agent.dev.id display_name = "Dotfiles" icon = "/icon/dotfiles.svg" run_on_start = true @@ -18,7 +18,7 @@ resource "coder_script" "dotfiles" { } resource "coder_script" "code-server" { - agent_id = coder_agent.dev.agent_id + agent_id = coder_agent.dev.id display_name = "code-server" icon = "/icon/code.svg" run_on_start = true @@ -28,15 +28,26 @@ resource "coder_script" "code-server" { }) } -resource "coder_script" "nightly_sleep_reminder" { - agent_id = coder_agent.dev.agent_id +resource "coder_script" "nightly_update" { + agent_id = coder_agent.dev.id display_name = "Nightly update" icon = "/icon/database.svg" - cron = "0 22 * * *" + cron = "0 0 22 * * *" # Run at 22:00 (10 PM) every day script = <\"`.", ForceNew: true, Optional: true, - ValidateFunc: func(i interface{}, s string) ([]string, []error) { + ValidateFunc: func(i any, s string) ([]string, []error) { _, err := url.Parse(s) if err != nil { return nil, []error{err} @@ -108,7 +111,7 @@ func appResource() *schema.Resource { "hyphen or contain two consecutive hyphens.", ForceNew: true, Required: true, - ValidateDiagFunc: func(val interface{}, c cty.Path) diag.Diagnostics { + ValidateDiagFunc: func(val any, c cty.Path) diag.Diagnostics { valStr, ok := val.(string) if !ok { return diag.Errorf("expected string, got %T", val) @@ -126,7 +129,7 @@ func appResource() *schema.Resource { Description: "A display name to identify the app. Defaults to the slug.", ForceNew: true, Optional: true, - ValidateDiagFunc: func(val interface{}, c cty.Path) diag.Diagnostics { + ValidateDiagFunc: func(val any, c cty.Path) diag.Diagnostics { valStr, ok := val.(string) if !ok { return diag.Errorf("expected string, got %T", val) @@ -161,7 +164,7 @@ func appResource() *schema.Resource { ForceNew: true, Optional: true, Default: "owner", - ValidateDiagFunc: func(val interface{}, c cty.Path) diag.Diagnostics { + ValidateDiagFunc: func(val any, c cty.Path) diag.Diagnostics { valStr, ok := val.(string) if !ok { return diag.Errorf("expected string, got %T", val) @@ -223,6 +226,23 @@ func appResource() *schema.Resource { }, }, }, + "group": { + Type: schema.TypeString, + Description: "The name of a group that this app belongs to.", + ForceNew: true, + Optional: true, + ValidateDiagFunc: func(val any, c cty.Path) diag.Diagnostics { + valStr, ok := val.(string) + if !ok { + return diag.Errorf("expected string, got %T", val) + } + + if len(valStr) > appGroupNameMaxLength { + return diag.Errorf("group name is too long (max %d characters)", appGroupNameMaxLength) + } + return nil + }, + }, "order": { Type: schema.TypeInt, Description: "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order).", @@ -244,7 +264,7 @@ func appResource() *schema.Resource { ForceNew: true, Optional: true, Default: "slim-window", - ValidateDiagFunc: func(val interface{}, c cty.Path) diag.Diagnostics { + ValidateDiagFunc: func(val any, c cty.Path) diag.Diagnostics { valStr, ok := val.(string) if !ok { return diag.Errorf("expected string, got %T", val) diff --git a/provider/app_test.go b/provider/app_test.go index 444b6b0d..aeb42d08 100644 --- a/provider/app_test.go +++ b/provider/app_test.go @@ -40,6 +40,7 @@ func TestApp(t *testing.T) { interval = 5 threshold = 6 } + group = "Apps" order = 4 hidden = false open_in = "slim-window" @@ -63,6 +64,7 @@ func TestApp(t *testing.T) { "healthcheck.0.url", "healthcheck.0.interval", "healthcheck.0.threshold", + "group", "order", "hidden", "open_in", @@ -476,5 +478,4 @@ func TestApp(t *testing.T) { }) } }) - } diff --git a/provider/devcontainer.go b/provider/devcontainer.go index 7d1fe0a4..81a31194 100644 --- a/provider/devcontainer.go +++ b/provider/devcontainer.go @@ -13,7 +13,7 @@ func devcontainerResource() *schema.Resource { return &schema.Resource{ SchemaVersion: 1, - Description: "Define a Dev Container the agent should know of and attempt to autostart (minimum Coder version: v2.21).", + Description: "Define a Dev Container the agent should know of and attempt to autostart.\n\n-> This resource is only available in Coder v2.21 and later.", CreateContext: func(_ context.Context, rd *schema.ResourceData, _ interface{}) diag.Diagnostics { rd.SetId(uuid.NewString()) diff --git a/provider/formtype.go b/provider/formtype.go index 75d32c46..931179da 100644 --- a/provider/formtype.go +++ b/provider/formtype.go @@ -99,7 +99,7 @@ var formTypeTruthTable = map[OptionType]map[bool][]ParameterFormType{ false: {ParameterFormTypeInput, ParameterFormTypeSlider}, }, OptionTypeBoolean: { - true: {ParameterFormTypeRadio}, + true: {ParameterFormTypeRadio, ParameterFormTypeDropdown}, false: {ParameterFormTypeCheckbox, ParameterFormTypeSwitch}, }, OptionTypeListString: { diff --git a/provider/formtype_test.go b/provider/formtype_test.go index eaf7b587..2f3dff53 100644 --- a/provider/formtype_test.go +++ b/provider/formtype_test.go @@ -193,6 +193,11 @@ func TestValidateFormType(t *testing.T) { optionType: provider.OptionTypeBoolean, formType: provider.ParameterFormTypeRadio, }), + expectSameFormType(formTypeCheck{ + options: true, + optionType: provider.OptionTypeBoolean, + formType: provider.ParameterFormTypeDropdown, + }), expectSameFormType(formTypeCheck{ options: false, optionType: provider.OptionTypeBoolean, diff --git a/provider/helpers/schedule_validation.go b/provider/helpers/schedule_validation.go new file mode 100644 index 00000000..c5a6972f --- /dev/null +++ b/provider/helpers/schedule_validation.go @@ -0,0 +1,187 @@ +package helpers + +import ( + "strconv" + "strings" + + "golang.org/x/xerrors" +) + +// ValidateSchedules checks if any schedules overlap +func ValidateSchedules(schedules []string) error { + for i := 0; i < len(schedules); i++ { + for j := i + 1; j < len(schedules); j++ { + overlap, err := SchedulesOverlap(schedules[i], schedules[j]) + if err != nil { + return xerrors.Errorf("invalid schedule: %w", err) + } + if overlap { + return xerrors.Errorf("schedules overlap: %s and %s", + schedules[i], schedules[j]) + } + } + } + return nil +} + +// SchedulesOverlap checks if two schedules overlap by checking +// all cron fields separately +func SchedulesOverlap(schedule1, schedule2 string) (bool, error) { + // Get cron fields + fields1 := strings.Fields(schedule1) + fields2 := strings.Fields(schedule2) + + if len(fields1) != 5 { + return false, xerrors.Errorf("schedule %q has %d fields, expected 5 fields (minute hour day-of-month month day-of-week)", schedule1, len(fields1)) + } + if len(fields2) != 5 { + return false, xerrors.Errorf("schedule %q has %d fields, expected 5 fields (minute hour day-of-month month day-of-week)", schedule2, len(fields2)) + } + + // Check if months overlap + monthsOverlap, err := MonthsOverlap(fields1[3], fields2[3]) + if err != nil { + return false, xerrors.Errorf("invalid month range: %w", err) + } + if !monthsOverlap { + return false, nil + } + + // Check if days overlap (DOM OR DOW) + daysOverlap, err := DaysOverlap(fields1[2], fields1[4], fields2[2], fields2[4]) + if err != nil { + return false, xerrors.Errorf("invalid day range: %w", err) + } + if !daysOverlap { + return false, nil + } + + // Check if hours overlap + hoursOverlap, err := HoursOverlap(fields1[1], fields2[1]) + if err != nil { + return false, xerrors.Errorf("invalid hour range: %w", err) + } + + return hoursOverlap, nil +} + +// MonthsOverlap checks if two month ranges overlap +func MonthsOverlap(months1, months2 string) (bool, error) { + return CheckOverlap(months1, months2, 12) +} + +// HoursOverlap checks if two hour ranges overlap +func HoursOverlap(hours1, hours2 string) (bool, error) { + return CheckOverlap(hours1, hours2, 23) +} + +// DomOverlap checks if two day-of-month ranges overlap +func DomOverlap(dom1, dom2 string) (bool, error) { + return CheckOverlap(dom1, dom2, 31) +} + +// DowOverlap checks if two day-of-week ranges overlap +func DowOverlap(dow1, dow2 string) (bool, error) { + return CheckOverlap(dow1, dow2, 6) +} + +// DaysOverlap checks if two day ranges overlap, considering both DOM and DOW. +// Returns true if both DOM and DOW overlap, or if one is * and the other overlaps. +func DaysOverlap(dom1, dow1, dom2, dow2 string) (bool, error) { + // If either DOM is *, we only need to check DOW overlap + if dom1 == "*" || dom2 == "*" { + return DowOverlap(dow1, dow2) + } + + // If either DOW is *, we only need to check DOM overlap + if dow1 == "*" || dow2 == "*" { + return DomOverlap(dom1, dom2) + } + + // If both DOM and DOW are specified, we need to check both + // because the schedule runs when either matches + domOverlap, err := DomOverlap(dom1, dom2) + if err != nil { + return false, err + } + dowOverlap, err := DowOverlap(dow1, dow2) + if err != nil { + return false, err + } + + // If either DOM or DOW overlaps, the schedules overlap + return domOverlap || dowOverlap, nil +} + +// CheckOverlap is a function to check if two ranges overlap +func CheckOverlap(range1, range2 string, maxValue int) (bool, error) { + set1, err := ParseRange(range1, maxValue) + if err != nil { + return false, err + } + set2, err := ParseRange(range2, maxValue) + if err != nil { + return false, err + } + + for value := range set1 { + if set2[value] { + return true, nil + } + } + return false, nil +} + +// ParseRange converts a cron range to a set of integers +// maxValue is the maximum allowed value (e.g., 23 for hours, 6 for DOW, 12 for months, 31 for DOM) +func ParseRange(input string, maxValue int) (map[int]bool, error) { + result := make(map[int]bool) + + // Handle "*" case + if input == "*" { + for i := 0; i <= maxValue; i++ { + result[i] = true + } + return result, nil + } + + // Parse ranges like "1-3,5,7-9" + parts := strings.Split(input, ",") + for _, part := range parts { + if strings.Contains(part, "-") { + // Handle range like "1-3" + rangeParts := strings.Split(part, "-") + start, err := strconv.Atoi(rangeParts[0]) + if err != nil { + return nil, xerrors.Errorf("invalid start value in range: %w", err) + } + end, err := strconv.Atoi(rangeParts[1]) + if err != nil { + return nil, xerrors.Errorf("invalid end value in range: %w", err) + } + + // Validate range + if start < 0 || end > maxValue || start > end { + return nil, xerrors.Errorf("invalid range %d-%d: values must be between 0 and %d", start, end, maxValue) + } + + for i := start; i <= end; i++ { + result[i] = true + } + } else { + // Handle single value + value, err := strconv.Atoi(part) + if err != nil { + return nil, xerrors.Errorf("invalid value: %w", err) + } + + // Validate value + if value < 0 || value > maxValue { + return nil, xerrors.Errorf("invalid value %d: must be between 0 and %d", value, maxValue) + } + + result[value] = true + } + } + return result, nil +} diff --git a/provider/helpers/schedule_validation_test.go b/provider/helpers/schedule_validation_test.go new file mode 100644 index 00000000..2971fd07 --- /dev/null +++ b/provider/helpers/schedule_validation_test.go @@ -0,0 +1,585 @@ +// schedule_validation_test.go + +package helpers_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/terraform-provider-coder/v2/provider/helpers" +) + +func TestParseRange(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + input string + maxValue int + expected map[int]bool + expectErr bool + }{ + { + name: "Wildcard", + input: "*", + maxValue: 5, + expected: map[int]bool{ + 0: true, 1: true, 2: true, 3: true, 4: true, 5: true, + }, + }, + { + name: "Single value", + input: "3", + maxValue: 5, + expected: map[int]bool{ + 3: true, + }, + }, + { + name: "Range", + input: "1-3", + maxValue: 5, + expected: map[int]bool{ + 1: true, 2: true, 3: true, + }, + }, + { + name: "Complex range", + input: "1-3,5,7-9", + maxValue: 9, + expected: map[int]bool{ + 1: true, 2: true, 3: true, 5: true, 7: true, 8: true, 9: true, + }, + }, + { + name: "Value too high", + input: "6", + maxValue: 5, + expectErr: true, + }, + { + name: "Range too high", + input: "4-6", + maxValue: 5, + expectErr: true, + }, + { + name: "Invalid range", + input: "3-1", + maxValue: 5, + expectErr: true, + }, + { + name: "Invalid value", + input: "abc", + maxValue: 5, + expectErr: true, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + result, err := helpers.ParseRange(testCase.input, testCase.maxValue) + if testCase.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, testCase.expected, result) + }) + } +} + +func TestCheckOverlap(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + range1 string + range2 string + maxValue int + overlap bool + expectErr bool + }{ + { + name: "Same range", + range1: "1-5", + range2: "1-5", + maxValue: 10, + overlap: true, + }, + { + name: "Different ranges", + range1: "1-3", + range2: "4-6", + maxValue: 10, + overlap: false, + }, + { + name: "Overlapping ranges", + range1: "1-5", + range2: "4-8", + maxValue: 10, + overlap: true, + }, + { + name: "Wildcard overlap", + range1: "*", + range2: "3-5", + maxValue: 10, + overlap: true, + }, + { + name: "Complex ranges", + range1: "1-3,5,7-9", + range2: "2-4,6,8-10", + maxValue: 10, + overlap: true, + }, + { + name: "Single values", + range1: "1", + range2: "1", + maxValue: 10, + overlap: true, + }, + { + name: "Single value vs range", + range1: "1", + range2: "1-3", + maxValue: 10, + overlap: true, + }, + { + name: "Invalid range - value too high", + range1: "11", + range2: "1-3", + maxValue: 10, + expectErr: true, + }, + { + name: "Invalid range - negative value", + range1: "-1", + range2: "1-3", + maxValue: 10, + expectErr: true, + }, + { + name: "Invalid range - malformed", + range1: "1-", + range2: "1-3", + maxValue: 10, + expectErr: true, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + overlap, err := helpers.CheckOverlap(testCase.range1, testCase.range2, testCase.maxValue) + if testCase.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, testCase.overlap, overlap) + }) + } +} + +func TestOverlapWrappers(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + range1 string + range2 string + overlap bool + expectErr bool + overlapFunc func(string, string) (bool, error) + }{ + // HoursOverlap tests (max 23) + { + name: "Valid hour range", + range1: "23", + range2: "23", + overlap: true, + overlapFunc: helpers.HoursOverlap, + }, + { + name: "Invalid hour range", + range1: "24", + range2: "24", + expectErr: true, + overlapFunc: helpers.HoursOverlap, + }, + + // MonthsOverlap tests (max 12) + { + name: "Valid month range", + range1: "12", + range2: "12", + overlap: true, + overlapFunc: helpers.MonthsOverlap, + }, + { + name: "Invalid month range", + range1: "13", + range2: "13", + expectErr: true, + overlapFunc: helpers.MonthsOverlap, + }, + + // DomOverlap tests (max 31) + { + name: "Valid day of month range", + range1: "31", + range2: "31", + overlap: true, + overlapFunc: helpers.DomOverlap, + }, + { + name: "Invalid day of month range", + range1: "32", + range2: "32", + expectErr: true, + overlapFunc: helpers.DomOverlap, + }, + + // DowOverlap tests (max 6) + { + name: "Valid day of week range", + range1: "6", + range2: "6", + overlap: true, + overlapFunc: helpers.DowOverlap, + }, + { + name: "Invalid day of week range", + range1: "7", + range2: "7", + expectErr: true, + overlapFunc: helpers.DowOverlap, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + overlap, err := testCase.overlapFunc(testCase.range1, testCase.range2) + if testCase.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, testCase.overlap, overlap) + }) + } +} + +func TestDaysOverlap(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + dom1 string + dow1 string + dom2 string + dow2 string + overlap bool + expectErr bool + }{ + { + name: "DOM overlap only", + dom1: "1-15", + dow1: "1-3", + dom2: "10-20", + dow2: "4-6", + overlap: true, // true because DOM overlaps (10-15) + }, + { + name: "DOW overlap only", + dom1: "1-15", + dow1: "1-3", + dom2: "16-31", + dow2: "3-5", + overlap: true, // true because DOW overlaps (3) + }, + { + name: "Both DOM and DOW overlap", + dom1: "1-15", + dow1: "1-3", + dom2: "10-20", + dow2: "3-5", + overlap: true, // true because both overlap + }, + { + name: "No overlap", + dom1: "1-15", + dow1: "1-3", + dom2: "16-31", + dow2: "4-6", + overlap: false, // false because neither overlaps + }, + { + name: "Both DOW wildcard - DOM overlaps", + dom1: "1-15", + dow1: "*", + dom2: "10-20", + dow2: "*", + overlap: true, // true because DOM overlaps (10-15) + }, + { + name: "Both DOW wildcard - DOM doesn't overlap", + dom1: "1-15", + dow1: "*", + dom2: "16-31", + dow2: "*", + overlap: false, // false because DOM doesn't overlap + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + overlap, err := helpers.DaysOverlap(testCase.dom1, testCase.dow1, testCase.dom2, testCase.dow2) + if testCase.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, testCase.overlap, overlap) + }) + } +} + +func TestSchedulesOverlap(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + s1 string + s2 string + overlap bool + expectedErrMsg string + }{ + // Basic overlap cases + { + name: "Same schedule", + s1: "* 9-18 * * 1-5", + s2: "* 9-18 * * 1-5", + overlap: true, + }, + { + name: "Different hours - no overlap", + s1: "* 9-12 * * 1-5", + s2: "* 13-18 * * 1-5", + overlap: false, + }, + { + name: "Different hours - partial overlap", + s1: "* 9-14 * * 1-5", + s2: "* 12-18 * * 1-5", + overlap: true, + }, + { + name: "Different hours - one contained in another", + s1: "* 9-18 * * 1-5", + s2: "* 12-14 * * 1-5", + overlap: true, + }, + + // Day of week overlap cases (with wildcard DOM) + { + name: "Different DOW with wildcard DOM", + s1: "* 9-18 * * 1,3,5", // Mon,Wed,Fri + s2: "* 9-18 * * 2,4,6", // Tue,Thu,Sat + overlap: false, // No overlap because DOW ranges don't overlap + }, + { + name: "Different DOW with wildcard DOM - complex ranges", + s1: "* 9-18 * * 1-3", // Mon-Wed + s2: "* 9-18 * * 4-5", // Thu-Fri + overlap: false, // No overlap because DOW ranges don't overlap + }, + + // Day of week overlap cases (with specific DOM) + { + name: "Different DOW with specific DOM - no overlap", + s1: "* 9-18 1 * 1-3", + s2: "* 9-18 2 * 4-5", + overlap: false, // No overlap because different DOM and DOW + }, + { + name: "Different DOW with specific DOM - partial overlap", + s1: "* 9-18 1 * 1-4", + s2: "* 9-18 1 * 3-5", + overlap: true, // Overlaps because same DOM + }, + { + name: "Different DOW with specific DOM - complex ranges", + s1: "* 9-18 1 * 1,3,5", + s2: "* 9-18 1 * 2,4,6", + overlap: true, // Overlaps because same DOM + }, + + // Wildcard cases + { + name: "Wildcard hours vs specific hours", + s1: "* * * * 1-5", + s2: "* 9-18 * * 1-5", + overlap: true, + }, + { + name: "Wildcard DOW vs specific DOW", + s1: "* 9-18 * * *", + s2: "* 9-18 * * 1-5", + overlap: true, + }, + { + name: "Both wildcard DOW", + s1: "* 9-18 * * *", + s2: "* 9-18 * * *", + overlap: true, + }, + + // Complex time ranges + { + name: "Complex hour ranges - no overlap", + s1: "* 9-11,13-15 * * 1-5", + s2: "* 12,16-18 * * 1-5", + overlap: false, + }, + { + name: "Complex hour ranges - partial overlap", + s1: "* 9-11,13-15 * * 1-5", + s2: "* 10-12,14-16 * * 1-5", + overlap: true, + }, + { + name: "Complex hour ranges - contained", + s1: "* 9-18 * * 1-5", + s2: "* 10-11,13-14 * * 1-5", + overlap: true, + }, + + // Error cases (keeping minimal) + { + name: "Invalid hour range", + s1: "* 25-26 * * 1-5", + s2: "* 9-18 * * 1-5", + expectedErrMsg: "invalid hour range", + }, + { + name: "Invalid month range", + s1: "* 9-18 * 13 1-5", + s2: "* 9-18 * * 1-5", + expectedErrMsg: "invalid month range", + }, + { + name: "Invalid field count - too few fields", + s1: "* 9-18 * *", + s2: "* 9-18 * * 1-5", + expectedErrMsg: "has 4 fields, expected 5 fields", + }, + { + name: "Invalid field count - too many fields", + s1: "* 9-18 * * 1-5 *", + s2: "* 9-18 * * 1-5", + expectedErrMsg: "has 6 fields, expected 5 fields", + }, + { + name: "Invalid field count - s2 has too few fields", + s1: "* 9-18 * * 1-5", + s2: "* 9-18 * *", + expectedErrMsg: "has 4 fields, expected 5 fields", + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + overlap, err := helpers.SchedulesOverlap(testCase.s1, testCase.s2) + if testCase.expectedErrMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), testCase.expectedErrMsg) + } else { + require.NoError(t, err) + require.Equal(t, testCase.overlap, overlap) + } + }) + } +} + +func TestValidateSchedules(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + schedules []string + expectedErrMsg string + }{ + // Basic validation + { + name: "Empty schedules", + schedules: []string{}, + }, + { + name: "Single valid schedule", + schedules: []string{ + "* 9-18 * * 1-5", + }, + }, + + // Non-overlapping schedules + { + name: "Multiple valid non-overlapping schedules", + schedules: []string{ + "* 9-12 * * 1-5", + "* 13-18 * * 1-5", + }, + }, + { + name: "Multiple valid non-overlapping schedules", + schedules: []string{ + "* 9-18 * * 1-5", + "* 9-13 * * 6,0", + }, + }, + + // Overlapping schedules + { + name: "Two overlapping schedules", + schedules: []string{ + "* 9-14 * * 1-5", + "* 12-18 * * 1-5", + }, + expectedErrMsg: "schedules overlap: * 9-14 * * 1-5 and * 12-18 * * 1-5", + }, + { + name: "Three schedules with only second and third overlapping", + schedules: []string{ + "* 9-11 * * 1-5", // 9AM-11AM (no overlap) + "* 12-18 * * 1-5", // 12PM-6PM + "* 15-20 * * 1-5", // 3PM-8PM (overlaps with second) + }, + expectedErrMsg: "schedules overlap: * 12-18 * * 1-5 and * 15-20 * * 1-5", + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + err := helpers.ValidateSchedules(testCase.schedules) + if testCase.expectedErrMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), testCase.expectedErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/provider/parameter.go b/provider/parameter.go index f0a26c99..c8284da1 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -54,7 +54,7 @@ type Parameter struct { DisplayName string `mapstructure:"display_name"` Description string Type OptionType - FormType ParameterFormType + FormType ParameterFormType `mapstructure:"form_type"` Mutable bool Default *string Icon string @@ -86,10 +86,10 @@ func parameterDataSource() *schema.Resource { var parameter Parameter err = mapstructure.Decode(struct { Name interface{} - DisplayName interface{} + DisplayName interface{} `mapstructure:"display_name"` Description interface{} Type interface{} - FormType interface{} + FormType interface{} `mapstructure:"form_type"` Mutable interface{} Default interface{} Icon interface{} @@ -191,14 +191,14 @@ func parameterDataSource() *schema.Resource { Default: "string", Optional: true, ValidateFunc: validation.StringInSlice(toStrings(OptionTypes()), false), - Description: "The type of this parameter. Must be one of: `\"number\"`, `\"string\"`, `\"bool\"`, or `\"list(string)\"`.", + Description: fmt.Sprintf("The type of this parameter. Must be one of: `\"%s\"`.", strings.Join(toStrings(OptionTypes()), "\"`, `\"")), }, "form_type": { Type: schema.TypeString, Default: ParameterFormTypeDefault, Optional: true, ValidateFunc: validation.StringInSlice(toStrings(ParameterFormTypes()), false), - Description: fmt.Sprintf("The type of this parameter. Must be one of: [%s].", strings.Join(toStrings(ParameterFormTypes()), ", ")), + Description: fmt.Sprintf("The type of this parameter. Must be one of: `\"%s\"`.", strings.Join(toStrings(ParameterFormTypes()), "\"`, `\"")), }, "styling": { Type: schema.TypeString, @@ -286,22 +286,22 @@ func parameterDataSource() *schema.Resource { "min": { Type: schema.TypeInt, Optional: true, - Description: "The minimum of a number parameter.", + Description: "The minimum value of a number parameter.", }, "min_disabled": { Type: schema.TypeBool, Computed: true, - Description: "Helper field to check if min is present", + Description: "Helper field to check if `min` is present", }, "max": { Type: schema.TypeInt, Optional: true, - Description: "The maximum of a number parameter.", + Description: "The maximum value of a number parameter.", }, "max_disabled": { Type: schema.TypeBool, Computed: true, - Description: "Helper field to check if max is present", + Description: "Helper field to check if `max` is present", }, "monotonic": { Type: schema.TypeString, @@ -317,7 +317,7 @@ func parameterDataSource() *schema.Resource { "error": { Type: schema.TypeString, Optional: true, - Description: "An error message to display if the value breaks the validation rules. The following placeholders are supported: {max}, {min}, and {value}.", + Description: "An error message to display if the value breaks the validation rules. The following placeholders are supported: `{max}`, `{min}`, and `{value}`.", }, }, }, diff --git a/provider/provider.go b/provider/provider.go index cc2644ef..43e3a6ac 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -72,6 +72,7 @@ func New() *schema.Provider { ResourcesMap: map[string]*schema.Resource{ "coder_agent": agentResource(), "coder_agent_instance": agentInstanceResource(), + "coder_ai_task": aiTask(), "coder_app": appResource(), "coder_metadata": metadataResource(), "coder_script": scriptResource(), diff --git a/provider/script.go b/provider/script.go index df436ead..23ddec6e 100644 --- a/provider/script.go +++ b/provider/script.go @@ -3,6 +3,7 @@ package provider import ( "context" "fmt" + "strings" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -13,6 +14,32 @@ import ( var ScriptCRONParser = cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.DowOptional | cron.Descriptor) +// ValidateCronExpression validates a cron expression and provides helpful warnings for common mistakes +func ValidateCronExpression(cronExpr string) (warnings []string, errors []error) { + // Check if it looks like a 5-field Unix cron expression + fields := strings.Fields(cronExpr) + if len(fields) == 5 { + // Try to parse as standard Unix cron (without seconds) + unixParser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.DowOptional | cron.Descriptor) + if _, err := unixParser.Parse(cronExpr); err == nil { + // It's a valid 5-field expression, provide a helpful warning + warnings = append(warnings, fmt.Sprintf( + "The cron expression '%s' appears to be in Unix 5-field format. "+ + "Coder uses 6-field format (seconds minutes hours day month day-of-week). "+ + "Consider prefixing with '0 ' to run at the start of each minute: '0 %s'", + cronExpr, cronExpr)) + } + } + + // Validate with the actual 6-field parser + _, err := ScriptCRONParser.Parse(cronExpr) + if err != nil { + errors = append(errors, fmt.Errorf("%s is not a valid cron expression: %w", cronExpr, err)) + } + + return warnings, errors +} + func scriptResource() *schema.Resource { return &schema.Resource{ SchemaVersion: 1, @@ -72,17 +99,13 @@ func scriptResource() *schema.Resource { ForceNew: true, Type: schema.TypeString, Optional: true, - Description: "The cron schedule to run the script on. This is a cron expression.", + Description: "The cron schedule to run the script on. This uses a 6-field cron expression format: `seconds minutes hours day-of-month month day-of-week`. Note that this differs from the standard Unix 5-field format by including seconds as the first field. Examples: `\"0 0 22 * * *\"` (daily at 10 PM), `\"0 */5 * * * *\"` (every 5 minutes), `\"30 0 9 * * 1-5\"` (weekdays at 9:30 AM).", ValidateFunc: func(i interface{}, _ string) ([]string, []error) { v, ok := i.(string) if !ok { return []string{}, []error{fmt.Errorf("got type %T instead of string", i)} } - _, err := ScriptCRONParser.Parse(v) - if err != nil { - return []string{}, []error{fmt.Errorf("%s is not a valid cron expression: %w", v, err)} - } - return nil, nil + return ValidateCronExpression(v) }, }, "start_blocks_login": { diff --git a/provider/script_test.go b/provider/script_test.go index 37f1a819..64808372 100644 --- a/provider/script_test.go +++ b/provider/script_test.go @@ -8,6 +8,8 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + + "github.com/coder/terraform-provider-coder/v2/provider" ) func TestScript(t *testing.T) { @@ -124,3 +126,73 @@ func TestScriptStartBlocksLoginRequiresRunOnStart(t *testing.T) { }}, }) } + +func TestValidateCronExpression(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cronExpr string + expectWarnings bool + expectErrors bool + warningContains string + }{ + { + name: "valid 6-field expression", + cronExpr: "0 0 22 * * *", + expectWarnings: false, + expectErrors: false, + }, + { + name: "valid 6-field expression with seconds", + cronExpr: "30 0 9 * * 1-5", + expectWarnings: false, + expectErrors: false, + }, + { + name: "5-field Unix format - should warn", + cronExpr: "0 22 * * *", + expectWarnings: true, + expectErrors: false, + warningContains: "appears to be in Unix 5-field format", + }, + { + name: "5-field every 5 minutes - should warn", + cronExpr: "*/5 * * * *", + expectWarnings: true, + expectErrors: false, + warningContains: "Consider prefixing with '0 '", + }, + { + name: "invalid expression", + cronExpr: "invalid", + expectErrors: true, + }, + { + name: "too many fields", + cronExpr: "0 0 0 0 0 0 0", + expectErrors: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + warnings, errors := provider.ValidateCronExpression(tt.cronExpr) + + if tt.expectWarnings { + require.NotEmpty(t, warnings, "Expected warnings but got none") + if tt.warningContains != "" { + require.Contains(t, warnings[0], tt.warningContains) + } + } else { + require.Empty(t, warnings, "Expected no warnings but got: %v", warnings) + } + + if tt.expectErrors { + require.NotEmpty(t, errors, "Expected errors but got none") + } else { + require.Empty(t, errors, "Expected no errors but got: %v", errors) + } + }) + } +} diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index 78466c10..1d7576e9 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -2,15 +2,24 @@ package provider import ( "context" + "fmt" + "strings" + "time" + + "github.com/coder/terraform-provider-coder/v2/provider/helpers" "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"` + Default bool `mapstructure:"default"` Parameters map[string]string `mapstructure:"parameters"` // There should always be only one prebuild block, but Terraform's type system // still parses them as a slice, so we need to handle it as such. We could use @@ -22,6 +31,27 @@ type WorkspacePreset struct { type WorkspacePrebuild struct { Instances int `mapstructure:"instances"` + // There should always be only one expiration_policy block, but Terraform's type system + // still parses them as a slice, so we need to handle it as such. We could use + // an anonymous type and rd.Get to avoid a slice here, but that would not be possible + // 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"` + Scheduling []Scheduling `mapstructure:"scheduling"` +} + +type ExpirationPolicy struct { + TTL int `mapstructure:"ttl"` +} + +type Scheduling struct { + Timezone string `mapstructure:"timezone"` + Schedule []Schedule `mapstructure:"schedule"` +} + +type Schedule struct { + Cron string `mapstructure:"cron"` + Instances int `mapstructure:"instances"` } func workspacePresetDataSource() *schema.Resource { @@ -41,6 +71,12 @@ func workspacePresetDataSource() *schema.Resource { return diag.Errorf("decode workspace preset: %s", err) } + // Validate schedule overlaps if scheduling is configured + err = validateSchedules(rd) + if err != nil { + return diag.Errorf("schedules overlap with each other: %s", err) + } + rd.SetId(preset.Name) return nil @@ -57,6 +93,12 @@ func workspacePresetDataSource() *schema.Resource { Required: true, ValidateFunc: validation.StringIsNotEmpty, }, + "default": { + Type: schema.TypeBool, + Description: "Whether this preset should be selected by default when creating a workspace. Only one preset per template can be marked as default.", + Optional: true, + Default: false, + }, "parameters": { Type: schema.TypeMap, Description: "Workspace parameters that will be set by the workspace preset. For simple templates that only need prebuilds, you may define a preset with zero parameters. Because workspace parameters may change between Coder template versions, preset parameters are allowed to define values for parameters that do not exist in the current template version.", @@ -81,9 +123,174 @@ func workspacePresetDataSource() *schema.Resource { ForceNew: true, ValidateFunc: validation.IntAtLeast(0), }, + "expiration_policy": { + Type: schema.TypeSet, + Description: "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.", + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "ttl": { + Type: schema.TypeInt, + Description: "Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.", + Required: true, + ForceNew: true, + // Ensure TTL is either 0 (to disable expiration) or between 3600 seconds (1 hour) and 31536000 seconds (1 year) + ValidateFunc: func(val interface{}, key string) ([]string, []error) { + v := val.(int) + if v == 0 { + return nil, nil + } + if v < 3600 || v > 31536000 { + return nil, []error{fmt.Errorf("%q must be 0 or between 3600 and 31536000, got %d", key, v)} + } + return nil, nil + }, + }, + }, + }, + }, + "scheduling": { + Type: schema.TypeList, + Description: "Configuration block that defines scheduling 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 prebuild schedules (e.g., "UTC", "America/New_York"). +Timezone must be a valid timezone in the IANA timezone database. +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.`, + 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 timezone %q: %w", timezone, 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 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.", + 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 field is set to *. +// This is required because prebuild schedules represent continuous time ranges, +// and we want the schedule to cover entire hours rather than specific minute intervals. +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] != "*" { + return fmt.Errorf("minute field should be *") + } + + return nil +} + +// validateSchedules checks if any of the configured prebuild schedules overlap with each other. +// It returns an error if overlaps are found, nil otherwise. +func validateSchedules(rd *schema.ResourceData) error { + // TypeSet from schema definition + prebuilds := rd.Get("prebuilds").(*schema.Set) + if prebuilds.Len() == 0 { + return nil + } + + // Each element of TypeSet with Elem: &schema.Resource{} should be map[string]interface{} + prebuild, ok := prebuilds.List()[0].(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid prebuild configuration: expected map[string]interface{}") + } + + // TypeList from schema definition + schedulingBlocks, ok := prebuild["scheduling"].([]interface{}) + if !ok { + return fmt.Errorf("invalid scheduling configuration: expected []interface{}") + } + if len(schedulingBlocks) == 0 { + return nil + } + + // Each element of TypeList with Elem: &schema.Resource{} should be map[string]interface{} + schedulingBlock, ok := schedulingBlocks[0].(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid scheduling configuration: expected map[string]interface{}") + } + + // TypeList from schema definition + scheduleBlocks, ok := schedulingBlock["schedule"].([]interface{}) + if !ok { + return fmt.Errorf("invalid schedule configuration: expected []interface{}") + } + if len(scheduleBlocks) == 0 { + return nil + } + + cronSpecs := make([]string, len(scheduleBlocks)) + for i, scheduleBlock := range scheduleBlocks { + // Each element of TypeList with Elem: &schema.Resource{} should be map[string]interface{} + schedule, ok := scheduleBlock.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid schedule configuration: expected map[string]interface{}") + } + + // TypeString from schema definition + cronSpec := schedule["cron"].(string) + + cronSpecs[i] = cronSpec + } + + err := helpers.ValidateSchedules(cronSpecs) + if err != nil { + return err + } + + return nil +} diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go index aa1ca0ce..073193c6 100644 --- a/provider/workspace_preset_test.go +++ b/provider/workspace_preset_test.go @@ -144,6 +144,448 @@ func TestWorkspacePreset(t *testing.T) { return nil }, }, + { + Name: "Prebuilds is set with a expiration_policy field without its required fields", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + instances = 1 + expiration_policy {} + } + }`, + ExpectError: regexp.MustCompile(`The argument "ttl" is required, but no definition was found.`), + }, + { + Name: "Prebuilds is set with a expiration_policy field with its required fields", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + instances = 1 + expiration_policy { + ttl = 86400 + } + } + }`, + 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.expiration_policy.0.ttl"], "86400") + return nil + }, + }, + { + Name: "Prebuilds block with expiration_policy.ttl set to 0 seconds (disables expiration)", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + instances = 1 + expiration_policy { + ttl = 0 + } + } + }`, + 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.expiration_policy.0.ttl"], "0") + return nil + }, + }, + { + Name: "Prebuilds block with expiration_policy.ttl set to 30 minutes (below 1 hour limit)", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + instances = 1 + expiration_policy { + ttl = 1800 + } + } + }`, + ExpectError: regexp.MustCompile(`"prebuilds.0.expiration_policy.0.ttl" must be 0 or between 3600 and 31536000, got 1800`), + }, + { + Name: "Prebuilds block with expiration_policy.ttl set to 2 years (exceeds 1 year limit)", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + instances = 1 + expiration_policy { + ttl = 63072000 + } + } + }`, + ExpectError: regexp.MustCompile(`"prebuilds.0.expiration_policy.0.ttl" must be 0 or between 3600 and 31536000, got 63072000`), + }, + { + Name: "Prebuilds is set with a expiration_policy field with its required fields and an unexpected argument", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + instances = 1 + expiration_policy { + ttl = 86400 + invalid_argument = "test" + } + } + }`, + ExpectError: regexp.MustCompile("An argument named \"invalid_argument\" is not expected here."), + }, + { + Name: "Prebuilds is set with an empty scheduling field", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + scheduling {} + } + }`, + ExpectError: regexp.MustCompile(`The argument "[^"]+" is required, but no definition was found.`), + }, + { + Name: "Prebuilds is set with an scheduling field, but without timezone", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + scheduling { + 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 scheduling field, but without schedule", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + scheduling { + timezone = "UTC" + } + } + }`, + ExpectError: regexp.MustCompile(`At least 1 "schedule" blocks are required.`), + }, + { + Name: "Prebuilds is set with an scheduling.schedule field, but without cron", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + scheduling { + timezone = "UTC" + schedule { + instances = 3 + } + } + } + }`, + ExpectError: regexp.MustCompile(`The argument "cron" is required, but no definition was found.`), + }, + { + Name: "Prebuilds is set with an scheduling.schedule field, but without instances", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + scheduling { + 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 scheduling.schedule field, but with invalid type for instances", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + scheduling { + 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 scheduling field with 1 schedule", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + scheduling { + 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.scheduling.0.timezone"], "UTC") + require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.0.cron"], "* 8-18 * * 1-5") + require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.0.instances"], "3") + return nil + }, + }, + { + Name: "Prebuilds is set with an scheduling field with 2 schedules", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + scheduling { + 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.scheduling.0.timezone"], "UTC") + require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.0.cron"], "* 8-18 * * 1-5") + require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.0.instances"], "3") + require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.1.cron"], "* 8-14 * * 6") + require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.1.instances"], "1") + return nil + }, + }, + { + Name: "Prebuilds is set with an scheduling.schedule field, but the cron includes a disallowed minute field", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + scheduling { + timezone = "UTC" + schedule { + cron = "30 8-18 * * 1-5" + instances = "1" + } + } + } + }`, + ExpectError: regexp.MustCompile(`cron spec failed validation: minute field should be *`), + }, + { + Name: "Prebuilds is set with an scheduling.schedule field, but the cron hour field is invalid", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + scheduling { + 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 scheduling.timezone field", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + scheduling { + 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.scheduling.0.timezone"], "America/Los_Angeles") + return nil + }, + }, + { + Name: "Prebuilds is set with an invalid scheduling.timezone field", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + scheduling { + timezone = "InvalidLocation" + schedule { + cron = "* 8-18 * * 1-5" + instances = 3 + } + } + } + }`, + ExpectError: regexp.MustCompile(`failed to load timezone "InvalidLocation": unknown time zone InvalidLocation`), + }, + { + Name: "Prebuilds is set with an scheduling field, with 2 overlapping schedules", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + prebuilds { + instances = 1 + scheduling { + timezone = "UTC" + schedule { + cron = "* 8-18 * * 1-5" + instances = 3 + } + schedule { + cron = "* 18-19 * * 5-6" + instances = 1 + } + } + } + }`, + ExpectError: regexp.MustCompile(`schedules overlap with each other: schedules overlap: \* 8-18 \* \* 1-5 and \* 18-19 \* \* 5-6`), + }, + { + Name: "Default field set to true", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + default = true + parameters = { + "region" = "us-east1-a" + } + }`, + 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) + require.Equal(t, resource.Primary.Attributes["default"], "true") + return nil + }, + }, + { + Name: "Default field set to false", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + default = false + parameters = { + "region" = "us-east1-a" + } + }`, + 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) + require.Equal(t, resource.Primary.Attributes["default"], "false") + return nil + }, + }, + { + Name: "Default field not provided (defaults to false)", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + }`, + 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) + require.Equal(t, resource.Primary.Attributes["default"], "false") + return nil + }, + }, } for _, testcase := range testcases {