From cfa101df4635e405e66094fa7779f9a89d92f400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B1=E3=82=A4=E3=83=A9?= Date: Tue, 27 May 2025 14:47:34 -0600 Subject: [PATCH 01/15] fix: limit app group length (#407) --- provider/app.go | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/provider/app.go b/provider/app.go index fb686794..adbbf0e7 100644 --- a/provider/app.go +++ b/provider/app.go @@ -23,14 +23,17 @@ var ( appSlugRegex = regexp.MustCompile(`^[a-z0-9](-?[a-z0-9])*$`) ) -const appDisplayNameMaxLength = 64 // database column limit +const ( + appDisplayNameMaxLength = 64 // database column limit + appGroupNameMaxLength = 64 +) func appResource() *schema.Resource { return &schema.Resource{ SchemaVersion: 1, Description: "Use this resource to define shortcuts to access applications in a workspace.", - CreateContext: func(c context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { + CreateContext: func(c context.Context, resourceData *schema.ResourceData, i any) diag.Diagnostics { resourceData.SetId(uuid.NewString()) diags := diag.Diagnostics{} @@ -63,10 +66,10 @@ func appResource() *schema.Resource { return diags }, - ReadContext: func(c context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { + ReadContext: func(c context.Context, resourceData *schema.ResourceData, i any) diag.Diagnostics { return nil }, - DeleteContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics { + DeleteContext: func(ctx context.Context, rd *schema.ResourceData, i any) diag.Diagnostics { return nil }, Schema: map[string]*schema.Schema{ @@ -92,7 +95,7 @@ func appResource() *schema.Resource { "built-in icon with `\"${data.coder_workspace.me.access_url}/icon/\"`.", 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) @@ -228,6 +231,17 @@ func appResource() *schema.Resource { 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, @@ -250,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) From c1c33f6d556532e75662c0ca373ed8fdea220eb5 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 4 Jun 2025 08:05:38 -0700 Subject: [PATCH 02/15] chore: add blink-so to CLA allowlist (#410) --- .github/workflows/cla.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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*' From eee4ed569976995dd0b0d8d453dbecbb79b6b5ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Jun 2025 14:21:48 +0100 Subject: [PATCH 03/15] build(deps): Bump github.com/cloudflare/circl from 1.6.0 to 1.6.1 (#412) Bumps [github.com/cloudflare/circl](https://github.com/cloudflare/circl) from 1.6.0 to 1.6.1. - [Release notes](https://github.com/cloudflare/circl/releases) - [Commits](https://github.com/cloudflare/circl/compare/v1.6.0...v1.6.1) --- updated-dependencies: - dependency-name: github.com/cloudflare/circl dependency-version: 1.6.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 7b0b437c..fcb25b13 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect - github.com/cloudflare/circl v1.6.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect diff --git a/go.sum b/go.sum index 0531c5e1..31e83346 100644 --- a/go.sum +++ b/go.sum @@ -17,8 +17,8 @@ github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZ github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= -github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= From 657040094038f7a518ebfbfc85aa58f8433e85aa Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 18 Jun 2025 16:34:18 -0400 Subject: [PATCH 04/15] feat: add scheduling configuration for prebuilds (#408) * feat: add autoscaling configuration for prebuilds * fix: improve schedule validation * fix: allow DOM and Month fields * docs: improve documentation for timezone field * docs: make gen * Update provider/workspace_preset.go Co-authored-by: Danny Kopping * docs: improve doc comments * fix: tests * refactor: rename autoscaling to scheduling * docs: make gen * refactor: minor refactor after renaming * Update provider/helpers/schedule_validation.go Co-authored-by: Danny Kopping * Update provider/helpers/schedule_validation.go Co-authored-by: Danny Kopping * refactor: improve docs * refactor: improve docs * test: improve test coverage * test: improve test coverage * refactor: check for a specific error in tests * refactor: check for a specific error in tests --------- Co-authored-by: Danny Kopping --- docs/data-sources/workspace_preset.md | 20 + integration/integration_test.go | 17 +- integration/test-data-source/main.tf | 16 + provider/helpers/schedule_validation.go | 187 ++++++ provider/helpers/schedule_validation_test.go | 585 +++++++++++++++++++ provider/workspace_preset.go | 162 +++++ provider/workspace_preset_test.go | 265 +++++++++ 7 files changed, 1246 insertions(+), 6 deletions(-) create mode 100644 provider/helpers/schedule_validation.go create mode 100644 provider/helpers/schedule_validation_test.go diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index cd4908c2..26e597e2 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -55,6 +55,7 @@ Required: 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` @@ -62,3 +63,22 @@ Optional: 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/integration/integration_test.go b/integration/integration_test.go index 36612904..b075aebd 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.scheduling.timezone": `UTC`, + "workspace_preset.prebuilds.scheduling.schedule0.cron": `\* 8-18 \* \* 1-5`, + "workspace_preset.prebuilds.scheduling.schedule0.instances": `3`, + "workspace_preset.prebuilds.scheduling.schedule1.cron": `\* 8-14 \* \* 6`, + "workspace_preset.prebuilds.scheduling.schedule1.instances": `1`, }, }, { diff --git a/integration/test-data-source/main.tf b/integration/test-data-source/main.tf index 50274fff..12344546 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 } + scheduling { + 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.scheduling.timezone" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).timezone), + "workspace_preset.prebuilds.scheduling.schedule0.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[0].cron), + "workspace_preset.prebuilds.scheduling.schedule0.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[0].instances), + "workspace_preset.prebuilds.scheduling.schedule1.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[1].cron), + "workspace_preset.prebuilds.scheduling.schedule1.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[1].instances), } } 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/workspace_preset.go b/provider/workspace_preset.go index e0f2276c..0a44b1eb 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -3,13 +3,20 @@ 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"` Parameters map[string]string `mapstructure:"parameters"` @@ -29,12 +36,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"` + 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 { return &schema.Resource{ SchemaVersion: 1, @@ -52,6 +70,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 @@ -119,9 +143,147 @@ func workspacePresetDataSource() *schema.Resource { }, }, }, + "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 b8e752ae..84dfec17 100644 --- a/provider/workspace_preset_test.go +++ b/provider/workspace_preset_test.go @@ -265,6 +265,271 @@ func TestWorkspacePreset(t *testing.T) { }`, 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`), + }, } for _, testcase := range testcases { From c80041b152bd476fad51f3370eacfaf5e17ecbaf Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 19 Jun 2025 20:24:39 +0200 Subject: [PATCH 05/15] feat: add `coder_ai_task` resource (#413) --- docs/resources/ai_task.md | 31 +++++++++++++++ provider/ai_task.go | 61 +++++++++++++++++++++++++++++ provider/ai_task_test.go | 82 +++++++++++++++++++++++++++++++++++++++ provider/provider.go | 1 + 4 files changed, 175 insertions(+) create mode 100644 docs/resources/ai_task.md create mode 100644 provider/ai_task.go create mode 100644 provider/ai_task_test.go 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/provider/ai_task.go b/provider/ai_task.go new file mode 100644 index 00000000..76b19f3c --- /dev/null +++ b/provider/ai_task.go @@ -0,0 +1,61 @@ +package provider + +import ( + "context" + + "github.com/google/uuid" + "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" +) + +type AITask struct { + ID string `mapstructure:"id"` + SidebarApp []AITaskSidebarApp `mapstructure:"sidebar_app"` +} + +type AITaskSidebarApp struct { + ID string `mapstructure:"id"` +} + +// TaskPromptParameterName is the name of the parameter which is *required* to be defined when a coder_ai_task is used. +const TaskPromptParameterName = "AI Prompt" + +func aiTask() *schema.Resource { + return &schema.Resource{ + SchemaVersion: 1, + + Description: "Use this resource to define Coder tasks.", + CreateContext: func(c context.Context, resourceData *schema.ResourceData, i any) diag.Diagnostics { + resourceData.SetId(uuid.NewString()) + return nil + }, + ReadContext: schema.NoopContext, + DeleteContext: schema.NoopContext, + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Description: "A unique identifier for this resource.", + Computed: true, + }, + "sidebar_app": { + Type: schema.TypeSet, + Description: "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.", + ForceNew: true, + Required: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Description: "A reference to an existing `coder_app` resource in your template.", + Required: true, + ForceNew: true, + ValidateFunc: validation.IsUUID, + }, + }, + }, + }, + }, + } +} diff --git a/provider/ai_task_test.go b/provider/ai_task_test.go new file mode 100644 index 00000000..5f7a8a49 --- /dev/null +++ b/provider/ai_task_test.go @@ -0,0 +1,82 @@ +package provider_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/stretchr/testify/require" +) + +func TestAITask(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + } + resource "coder_app" "code-server" { + agent_id = coder_agent.dev.id + slug = "code-server" + display_name = "code-server" + icon = "builtin:vim" + url = "http://localhost:13337" + open_in = "slim-window" + } + resource "coder_ai_task" "test" { + sidebar_app { + id = coder_app.code-server.id + } + } + `, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + resource := state.Modules[0].Resources["coder_ai_task.test"] + require.NotNil(t, resource) + for _, key := range []string{ + "id", + "sidebar_app.#", + } { + value := resource.Primary.Attributes[key] + require.NotNil(t, value) + require.Greater(t, len(value), 0) + } + require.Equal(t, "1", resource.Primary.Attributes["sidebar_app.#"]) + return nil + }, + }}, + }) + }) + + t.Run("InvalidSidebarAppID", func(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + resource "coder_ai_task" "test" { + sidebar_app { + id = "not-a-uuid" + } + } + `, + ExpectError: regexp.MustCompile(`expected "sidebar_app.0.id" to be a valid UUID`), + }}, + }) + }) +} 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(), From e890833351e229dc226db1b7ebcbe7767c228de6 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 23 Jun 2025 21:33:13 +0200 Subject: [PATCH 06/15] feat: implement default preset (#414) --- docs/data-sources/workspace_preset.md | 11 ++++ .../coder_workspace_preset/data-source.tf | 10 ++++ integration/integration_test.go | 1 + integration/test-data-source/main.tf | 4 +- provider/workspace_preset.go | 7 +++ provider/workspace_preset_test.go | 56 +++++++++++++++++++ 6 files changed, 88 insertions(+), 1 deletion(-) diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index 26e597e2..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)) 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/integration/integration_test.go b/integration/integration_test.go index b075aebd..ec0b5e4f 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -93,6 +93,7 @@ func TestIntegration(t *testing.T) { "workspace_parameter.value": `param value`, "workspace_parameter.icon": `param icon`, "workspace_preset.name": `preset`, + "workspace_preset.default": `true`, "workspace_preset.parameters.param": `preset param value`, "workspace_preset.prebuilds.instances": `1`, "workspace_preset.prebuilds.expiration_policy.ttl": `86400`, diff --git a/integration/test-data-source/main.tf b/integration/test-data-source/main.tf index 12344546..5d312b1a 100644 --- a/integration/test-data-source/main.tf +++ b/integration/test-data-source/main.tf @@ -20,7 +20,8 @@ data "coder_parameter" "param" { icon = "param icon" } data "coder_workspace_preset" "preset" { - name = "preset" + name = "preset" + default = true parameters = { (data.coder_parameter.param.name) = "preset param value" } @@ -64,6 +65,7 @@ locals { "workspace_parameter.value" : data.coder_parameter.param.value, "workspace_parameter.icon" : data.coder_parameter.param.icon, "workspace_preset.name" : data.coder_workspace_preset.preset.name, + "workspace_preset.default" : tostring(data.coder_workspace_preset.preset.default), "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), diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index 0a44b1eb..1d7576e9 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -19,6 +19,7 @@ var PrebuildsCRONParser = rbcron.NewParser(rbcron.Minute | rbcron.Hour | rbcron. 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 @@ -92,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.", diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go index 84dfec17..073193c6 100644 --- a/provider/workspace_preset_test.go +++ b/provider/workspace_preset_test.go @@ -530,6 +530,62 @@ func TestWorkspacePreset(t *testing.T) { }`, 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 { From e6bbd8c7c72323518eb432be8b267ee8a3b6f674 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 19:47:44 +0500 Subject: [PATCH 07/15] build(deps): Bump golang.org/x/mod from 0.24.0 to 0.25.0 (#411) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fcb25b13..370fd5f3 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/stretchr/testify v1.10.0 golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 - golang.org/x/mod v0.24.0 + golang.org/x/mod v0.25.0 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 ) diff --git a/go.sum b/go.sum index 31e83346..ff0ed9c7 100644 --- a/go.sum +++ b/go.sum @@ -231,8 +231,8 @@ golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXy golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= From 0ce611a8d9fdba7955ebc7a3fe51d9d394758dc8 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 20:43:57 +0500 Subject: [PATCH 08/15] docs: clarify cron attribute format for coder_script resource (#409) Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> --- docs/resources/script.md | 19 ++++-- examples/resources/coder_script/resource.tf | 17 ++++- provider/script.go | 35 ++++++++-- provider/script_test.go | 72 +++++++++++++++++++++ 4 files changed, 130 insertions(+), 13 deletions(-) diff --git a/docs/resources/script.md b/docs/resources/script.md index 22ac1b50..9058fce4 100644 --- a/docs/resources/script.md +++ b/docs/resources/script.md @@ -43,15 +43,26 @@ resource "coder_script" "code-server" { }) } -resource "coder_script" "nightly_sleep_reminder" { +resource "coder_script" "nightly_update" { agent_id = coder_agent.dev.agent_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/resources/coder_script/resource.tf b/examples/resources/coder_script/resource.tf index b7fced38..8b3fa661 100644 --- a/examples/resources/coder_script/resource.tf +++ b/examples/resources/coder_script/resource.tf @@ -28,15 +28,26 @@ resource "coder_script" "code-server" { }) } -resource "coder_script" "nightly_sleep_reminder" { +resource "coder_script" "nightly_update" { agent_id = coder_agent.dev.agent_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 = < Date: Fri, 27 Jun 2025 21:02:05 +0500 Subject: [PATCH 09/15] Fix coder_script agent_id reference typos (#418) Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: matifali <10648092+matifali@users.noreply.github.com> --- docs/resources/script.md | 8 ++++---- examples/resources/coder_script/resource.tf | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/resources/script.md b/docs/resources/script.md index 9058fce4..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 @@ -44,7 +44,7 @@ resource "coder_script" "code-server" { } resource "coder_script" "nightly_update" { - agent_id = coder_agent.dev.agent_id + agent_id = coder_agent.dev.id display_name = "Nightly update" icon = "/icon/database.svg" cron = "0 0 22 * * *" # Run at 22:00 (10 PM) every day @@ -56,7 +56,7 @@ resource "coder_script" "nightly_update" { } resource "coder_script" "every_5_minutes" { - agent_id = coder_agent.dev.agent_id + agent_id = coder_agent.dev.id display_name = "Health check" icon = "/icon/heart.svg" cron = "0 */5 * * * *" # Run every 5 minutes diff --git a/examples/resources/coder_script/resource.tf b/examples/resources/coder_script/resource.tf index 8b3fa661..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 @@ -29,7 +29,7 @@ resource "coder_script" "code-server" { } resource "coder_script" "nightly_update" { - agent_id = coder_agent.dev.agent_id + agent_id = coder_agent.dev.id display_name = "Nightly update" icon = "/icon/database.svg" cron = "0 0 22 * * *" # Run at 22:00 (10 PM) every day @@ -41,7 +41,7 @@ resource "coder_script" "nightly_update" { } resource "coder_script" "every_5_minutes" { - agent_id = coder_agent.dev.agent_id + agent_id = coder_agent.dev.id display_name = "Health check" icon = "/icon/heart.svg" cron = "0 */5 * * * *" # Run every 5 minutes From f239e51ecbafcb7184e9965a1303fd62f8deb4b6 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 12:41:17 +0500 Subject: [PATCH 10/15] Mark tokens as sensitive in data sources (#416) * Mark tokens as sensitive in data sources Mark the following attributes as sensitive to prevent them from being logged or displayed in Terraform output: - data.coder_workspace_owner.me.oidc_access_token - data.coder_workspace_owner.me.session_token - data.coder_external_auth.example.access_token This follows the same pattern as ssh_private_key and agent token which are already marked as sensitive. Fixes #266 Co-authored-by: matifali <10648092+matifali@users.noreply.github.com> * Update documentation for sensitive token attributes Regenerate documentation to reflect that oidc_access_token, session_token, and access_token are now marked as sensitive in the schema. Co-authored-by: matifali <10648092+matifali@users.noreply.github.com> --------- Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: matifali <10648092+matifali@users.noreply.github.com> --- docs/data-sources/external_auth.md | 2 +- docs/data-sources/workspace_owner.md | 4 ++-- provider/externalauth.go | 1 + provider/workspace_owner.go | 2 ++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/data-sources/external_auth.md b/docs/data-sources/external_auth.md index e4089f24..d1e6d649 100644 --- a/docs/data-sources/external_auth.md +++ b/docs/data-sources/external_auth.md @@ -39,4 +39,4 @@ data "coder_external_auth" "azure-identity" { ### Read-Only -- `access_token` (String) The access token returned by the external auth provider. This can be used to pre-authenticate command-line tools. +- `access_token` (String, Sensitive) The access token returned by the external auth provider. This can be used to pre-authenticate command-line tools. diff --git a/docs/data-sources/workspace_owner.md b/docs/data-sources/workspace_owner.md index 2a912e1f..f16480ef 100644 --- a/docs/data-sources/workspace_owner.md +++ b/docs/data-sources/workspace_owner.md @@ -52,9 +52,9 @@ resource "coder_env" "git_author_email" { - `id` (String) The UUID of the workspace owner. - `login_type` (String) The type of login the user has. - `name` (String) The username of the user. -- `oidc_access_token` (String) A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string. +- `oidc_access_token` (String, Sensitive) A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string. - `rbac_roles` (List of Object) The RBAC roles of which the user is assigned. (see [below for nested schema](#nestedatt--rbac_roles)) -- `session_token` (String) Session token for authenticating with a Coder deployment. It is regenerated every time a workspace is started. +- `session_token` (String, Sensitive) Session token for authenticating with a Coder deployment. It is regenerated every time a workspace is started. - `ssh_private_key` (String, Sensitive) The user's generated SSH private key. - `ssh_public_key` (String) The user's generated SSH public key. diff --git a/provider/externalauth.go b/provider/externalauth.go index 915a21a9..b278ecc1 100644 --- a/provider/externalauth.go +++ b/provider/externalauth.go @@ -37,6 +37,7 @@ func externalAuthDataSource() *schema.Resource { Type: schema.TypeString, Description: "The access token returned by the external auth provider. This can be used to pre-authenticate command-line tools.", Computed: true, + Sensitive: true, }, "optional": { Type: schema.TypeBool, diff --git a/provider/workspace_owner.go b/provider/workspace_owner.go index 078047ff..109b0b93 100644 --- a/provider/workspace_owner.go +++ b/provider/workspace_owner.go @@ -113,6 +113,7 @@ func workspaceOwnerDataSource() *schema.Resource { Type: schema.TypeString, Computed: true, Description: "Session token for authenticating with a Coder deployment. It is regenerated every time a workspace is started.", + Sensitive: true, }, "oidc_access_token": { Type: schema.TypeString, @@ -120,6 +121,7 @@ func workspaceOwnerDataSource() *schema.Resource { Description: "A valid OpenID Connect access token of the workspace owner. " + "This is only available if the workspace owner authenticated with OpenID Connect. " + "If a valid token cannot be obtained, this value will be an empty string.", + Sensitive: true, }, "login_type": { Type: schema.TypeString, From d5496af252cbe36dc43b94c7d3bcba1257fb4e13 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Tue, 22 Jul 2025 13:19:33 +0100 Subject: [PATCH 11/15] fix: correct URL validation and centralize logic (#421) * fix: correct URL validation and centralize logic * fix: go imports order * tests: add validation URL tests * revert: parameter_test ValidDefaultWithOptions test update * test: update validation_test to use require * test: update validation_test tests --- provider/app.go | 15 +-- provider/app_test.go | 57 +++++++++++ provider/helpers/validation.go | 22 ++++ provider/helpers/validation_test.go | 151 ++++++++++++++++++++++++++++ provider/metadata.go | 15 +-- provider/parameter.go | 27 ++--- provider/parameter_test.go | 28 +++++- provider/provider.go | 12 +-- 8 files changed, 279 insertions(+), 48 deletions(-) create mode 100644 provider/helpers/validation.go create mode 100644 provider/helpers/validation_test.go diff --git a/provider/app.go b/provider/app.go index adbbf0e7..52996a72 100644 --- a/provider/app.go +++ b/provider/app.go @@ -2,13 +2,14 @@ package provider import ( "context" - "net/url" "regexp" "github.com/google/uuid" "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "github.com/coder/terraform-provider-coder/v2/provider/helpers" ) var ( @@ -93,15 +94,9 @@ func appResource() *schema.Resource { Description: "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/\"`.", - ForceNew: true, - Optional: true, - ValidateFunc: func(i any, s string) ([]string, []error) { - _, err := url.Parse(s) - if err != nil { - return nil, []error{err} - } - return nil, nil - }, + ForceNew: true, + Optional: true, + ValidateFunc: helpers.ValidateURL, }, "slug": { Type: schema.TypeString, diff --git a/provider/app_test.go b/provider/app_test.go index aeb42d08..2b9a5580 100644 --- a/provider/app_test.go +++ b/provider/app_test.go @@ -478,4 +478,61 @@ func TestApp(t *testing.T) { }) } }) + + t.Run("Icon", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + icon string + expectError *regexp.Regexp + }{ + { + name: "Empty", + icon: "", + }, + { + name: "ValidURL", + icon: "/icon/region.svg", + }, + { + name: "InvalidURL", + icon: "/icon%.svg", + expectError: regexp.MustCompile("invalid URL escape"), + }, + } + + for _, c := range cases { + c := c + + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + config := fmt.Sprintf(` + provider "coder" {} + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + } + resource "coder_app" "code-server" { + agent_id = coder_agent.dev.id + slug = "code-server" + display_name = "Testing" + url = "http://localhost:13337" + open_in = "slim-window" + icon = "%s" + } + `, c.icon) + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: config, + ExpectError: c.expectError, + }}, + }) + }) + } + }) } diff --git a/provider/helpers/validation.go b/provider/helpers/validation.go new file mode 100644 index 00000000..9cc21b89 --- /dev/null +++ b/provider/helpers/validation.go @@ -0,0 +1,22 @@ +package helpers + +import ( + "fmt" + "net/url" +) + +// ValidateURL validates that value is a valid URL string. +// Accepts empty strings, local file paths, file:// URLs, and http/https URLs. +// Example: for `icon = "/icon/region.svg"`, value is `/icon/region.svg` and label is `icon`. +func ValidateURL(value any, label string) ([]string, []error) { + val, ok := value.(string) + if !ok { + return nil, []error{fmt.Errorf("expected %q to be a string", label)} + } + + if _, err := url.Parse(val); err != nil { + return nil, []error{err} + } + + return nil, nil +} diff --git a/provider/helpers/validation_test.go b/provider/helpers/validation_test.go new file mode 100644 index 00000000..557bae41 --- /dev/null +++ b/provider/helpers/validation_test.go @@ -0,0 +1,151 @@ +package helpers + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateURL(t *testing.T) { + tests := []struct { + name string + value any + label string + expectError bool + errorContains string + }{ + // Valid cases + { + name: "empty string", + value: "", + label: "url", + expectError: false, + }, + { + name: "valid http URL", + value: "http://example.com", + label: "url", + expectError: false, + }, + { + name: "valid https URL", + value: "https://example.com/path", + label: "url", + expectError: false, + }, + { + name: "absolute file path", + value: "/path/to/file", + label: "url", + expectError: false, + }, + { + name: "relative file path", + value: "./file.txt", + label: "url", + expectError: false, + }, + { + name: "relative path up directory", + value: "../config.json", + label: "url", + expectError: false, + }, + { + name: "simple filename", + value: "file.txt", + label: "url", + expectError: false, + }, + { + name: "URL with query params", + value: "https://example.com/search?q=test", + label: "url", + expectError: false, + }, + { + name: "URL with fragment", + value: "https://example.com/page#section", + label: "url", + expectError: false, + }, + + // Various URL schemes that url.Parse accepts + { + name: "file URL scheme", + value: "file:///path/to/file", + label: "url", + expectError: false, + }, + { + name: "ftp scheme", + value: "ftp://files.example.com/file.txt", + label: "url", + expectError: false, + }, + { + name: "mailto scheme", + value: "mailto:user@example.com", + label: "url", + expectError: false, + }, + { + name: "tel scheme", + value: "tel:+1234567890", + label: "url", + expectError: false, + }, + { + name: "data scheme", + value: "data:text/plain;base64,SGVsbG8=", + label: "url", + expectError: false, + }, + + // Invalid cases + { + name: "non-string type - int", + value: 123, + label: "url", + expectError: true, + errorContains: "expected \"url\" to be a string", + }, + { + name: "non-string type - nil", + value: nil, + label: "config_url", + expectError: true, + errorContains: "expected \"config_url\" to be a string", + }, + { + name: "invalid URL with spaces", + value: "http://example .com", + label: "url", + expectError: true, + errorContains: "invalid character", + }, + { + name: "malformed URL", + value: "http://[::1:80", + label: "endpoint", + expectError: true, + errorContains: "missing ']'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + warnings, errors := ValidateURL(tt.value, tt.label) + + if tt.expectError { + require.Len(t, errors, 1, "expected an error but got none") + require.Contains(t, errors[0].Error(), tt.errorContains) + } else { + require.Empty(t, errors, "expected no errors but got: %v", errors) + } + + // Should always return nil for warnings + require.Nil(t, warnings, "expected warnings to be nil but got: %v", warnings) + }) + } +} diff --git a/provider/metadata.go b/provider/metadata.go index 535c700c..5ed6d478 100644 --- a/provider/metadata.go +++ b/provider/metadata.go @@ -2,12 +2,13 @@ package provider import ( "context" - "net/url" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "golang.org/x/xerrors" + + "github.com/coder/terraform-provider-coder/v2/provider/helpers" ) func metadataResource() *schema.Resource { @@ -56,15 +57,9 @@ func metadataResource() *schema.Resource { Description: "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/\"`.", - ForceNew: true, - Optional: true, - ValidateFunc: func(i interface{}, s string) ([]string, []error) { - _, err := url.Parse(s) - if err != nil { - return nil, []error{err} - } - return nil, nil - }, + ForceNew: true, + Optional: true, + ValidateFunc: helpers.ValidateURL, }, "daily_cost": { Type: schema.TypeInt, diff --git a/provider/parameter.go b/provider/parameter.go index c8284da1..2d3ea413 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -6,7 +6,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "net/url" "os" "regexp" "strconv" @@ -19,6 +18,8 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" "github.com/mitchellh/mapstructure" "golang.org/x/xerrors" + + "github.com/coder/terraform-provider-coder/v2/provider/helpers" ) var ( @@ -223,15 +224,9 @@ func parameterDataSource() *schema.Resource { Description: "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/\"`.", - ForceNew: true, - Optional: true, - ValidateFunc: func(i interface{}, s string) ([]string, []error) { - _, err := url.Parse(s) - if err != nil { - return nil, []error{err} - } - return nil, nil - }, + ForceNew: true, + Optional: true, + ValidateFunc: helpers.ValidateURL, }, "option": { Type: schema.TypeList, @@ -263,15 +258,9 @@ func parameterDataSource() *schema.Resource { Description: "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/\"`.", - ForceNew: true, - Optional: true, - ValidateFunc: func(i interface{}, s string) ([]string, []error) { - _, err := url.Parse(s) - if err != nil { - return nil, []error{err} - } - return nil, nil - }, + ForceNew: true, + Optional: true, + ValidateFunc: helpers.ValidateURL, }, }, }, diff --git a/provider/parameter_test.go b/provider/parameter_test.go index 9b5e76f1..35f045b9 100644 --- a/provider/parameter_test.go +++ b/provider/parameter_test.go @@ -665,7 +665,33 @@ data "coder_parameter" "region" { } `, ExpectError: regexp.MustCompile("ephemeral parameter requires the default property"), - }} { + }, { + Name: "InvalidIconURL", + Config: ` + data "coder_parameter" "region" { + name = "Region" + type = "string" + icon = "/icon%.svg" + } + `, + ExpectError: regexp.MustCompile("invalid URL escape"), + }, { + Name: "OptionInvalidIconURL", + Config: ` + data "coder_parameter" "region" { + name = "Region" + type = "string" + option { + name = "1" + value = "1" + icon = "/icon%.svg" + description = "Something!" + } + } + `, + ExpectError: regexp.MustCompile("invalid URL escape"), + }, + } { tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/provider/provider.go b/provider/provider.go index 43e3a6ac..a0ef63f9 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -10,6 +10,8 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "golang.org/x/xerrors" + + "github.com/coder/terraform-provider-coder/v2/provider/helpers" ) type config struct { @@ -26,14 +28,8 @@ func New() *schema.Provider { Optional: true, // The "CODER_AGENT_URL" environment variable is used by default // as the Access URL when generating scripts. - DefaultFunc: schema.EnvDefaultFunc("CODER_AGENT_URL", "https://mydeployment.coder.com"), - ValidateFunc: func(i interface{}, s string) ([]string, []error) { - _, err := url.Parse(s) - if err != nil { - return nil, []error{err} - } - return nil, nil - }, + DefaultFunc: schema.EnvDefaultFunc("CODER_AGENT_URL", "https://mydeployment.coder.com"), + ValidateFunc: helpers.ValidateURL, }, }, ConfigureContextFunc: func(c context.Context, resourceData *schema.ResourceData) (interface{}, diag.Diagnostics) { From c85b5f7fb831f892b12ec5022c0a79ce2fd8a334 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Tue, 22 Jul 2025 09:05:15 -0500 Subject: [PATCH 12/15] docs: add link to styling options documentation (#423) Add link to coder.com/docs styling options documentation in the styling parameter description to help users understand available styling attributes. The link is added in the Go source code so it gets properly generated into the documentation by tfplugindocs. Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: bpmct <22407953+bpmct@users.noreply.github.com> --- docs/data-sources/parameter.md | 2 +- provider/parameter.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/data-sources/parameter.md b/docs/data-sources/parameter.md index ecba3929..c1001835 100644 --- a/docs/data-sources/parameter.md +++ b/docs/data-sources/parameter.md @@ -150,7 +150,7 @@ data "coder_parameter" "home_volume_size" { - `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. +- `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. See [styling options documentation](https://coder.com/docs/admin/templates/extending-templates/dynamic-parameters#available-styling-options) for available styling attributes. - `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)) diff --git a/provider/parameter.go b/provider/parameter.go index 2d3ea413..ca1239f4 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -205,7 +205,8 @@ func parameterDataSource() *schema.Resource { Type: schema.TypeString, Default: `{}`, Description: "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.", + "This option is purely cosmetic and does not affect the function of the parameter in terraform. " + + "See [styling options documentation](https://coder.com/docs/admin/templates/extending-templates/dynamic-parameters#available-styling-options) for available styling attributes.", Optional: true, }, "mutable": { From e6d58d0ed91f1bcd614ecbac2db228b64c1de3d4 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Tue, 22 Jul 2025 18:11:12 +0100 Subject: [PATCH 13/15] feat: add icon and description fields to workspace preset (#422) * feat: add icon and description fields to workspace preset * chore: update preset.icon to use ValidateURL * docs: add description and icon to preset examples * chore: run make fmt * chore: run make gen * chore: add size limit to preset icon and description --- docs/data-sources/workspace_preset.md | 12 ++- .../coder_workspace_preset/data-source.tf | 10 ++- integration/integration_test.go | 2 + integration/test-data-source/main.tf | 8 +- provider/workspace_preset.go | 26 ++++++- provider/workspace_preset_test.go | 77 +++++++++++++++++++ 6 files changed, 124 insertions(+), 11 deletions(-) diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index 69057403..e7de98e4 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -21,7 +21,9 @@ provider "coder" {} # See the coder_parameter data source's documentation for examples of how to define # parameters like the ones used below. data "coder_workspace_preset" "example" { - name = "example" + name = "example" + description = "Example description of what this preset does." + icon = "/icon/example.svg" parameters = { (data.coder_parameter.example.name) = "us-central1-a" (data.coder_parameter.ami.name) = "ami-xxxxxxxx" @@ -30,8 +32,10 @@ data "coder_workspace_preset" "example" { # Example of a default preset that will be pre-selected for users data "coder_workspace_preset" "standard" { - name = "Standard" - default = true + name = "Standard" + description = "A workspace preset with medium compute in the US West region." + icon = "/icon/standard.svg" + default = true parameters = { (data.coder_parameter.instance_type.name) = "t3.medium" (data.coder_parameter.region.name) = "us-west-2" @@ -49,6 +53,8 @@ data "coder_workspace_preset" "standard" { ### 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. +- `description` (String) Describe what this preset does. +- `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/"`. - `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)) diff --git a/examples/data-sources/coder_workspace_preset/data-source.tf b/examples/data-sources/coder_workspace_preset/data-source.tf index 3c245f7a..89150761 100644 --- a/examples/data-sources/coder_workspace_preset/data-source.tf +++ b/examples/data-sources/coder_workspace_preset/data-source.tf @@ -6,7 +6,9 @@ provider "coder" {} # See the coder_parameter data source's documentation for examples of how to define # parameters like the ones used below. data "coder_workspace_preset" "example" { - name = "example" + name = "example" + description = "Example description of what this preset does." + icon = "/icon/example.svg" parameters = { (data.coder_parameter.example.name) = "us-central1-a" (data.coder_parameter.ami.name) = "ami-xxxxxxxx" @@ -15,8 +17,10 @@ data "coder_workspace_preset" "example" { # Example of a default preset that will be pre-selected for users data "coder_workspace_preset" "standard" { - name = "Standard" - default = true + name = "Standard" + description = "A workspace preset with medium compute in the US West region." + icon = "/icon/standard.svg" + default = true parameters = { (data.coder_parameter.instance_type.name) = "t3.medium" (data.coder_parameter.region.name) = "us-west-2" diff --git a/integration/integration_test.go b/integration/integration_test.go index ec0b5e4f..423447c9 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -93,6 +93,8 @@ func TestIntegration(t *testing.T) { "workspace_parameter.value": `param value`, "workspace_parameter.icon": `param icon`, "workspace_preset.name": `preset`, + "workspace_preset.description": `preset description`, + "workspace_preset.icon": `preset icon`, "workspace_preset.default": `true`, "workspace_preset.parameters.param": `preset param value`, "workspace_preset.prebuilds.instances": `1`, diff --git a/integration/test-data-source/main.tf b/integration/test-data-source/main.tf index 5d312b1a..cc4802d3 100644 --- a/integration/test-data-source/main.tf +++ b/integration/test-data-source/main.tf @@ -20,8 +20,10 @@ data "coder_parameter" "param" { icon = "param icon" } data "coder_workspace_preset" "preset" { - name = "preset" - default = true + name = "preset" + description = "preset description" + icon = "preset icon" + default = true parameters = { (data.coder_parameter.param.name) = "preset param value" } @@ -65,6 +67,8 @@ locals { "workspace_parameter.value" : data.coder_parameter.param.value, "workspace_parameter.icon" : data.coder_parameter.param.icon, "workspace_preset.name" : data.coder_workspace_preset.preset.name, + "workspace_preset.description" : data.coder_workspace_preset.preset.description, + "workspace_preset.icon" : data.coder_workspace_preset.preset.icon, "workspace_preset.default" : tostring(data.coder_workspace_preset.preset.default), "workspace_preset.parameters.param" : data.coder_workspace_preset.preset.parameters.param, "workspace_preset.prebuilds.instances" : tostring(one(data.coder_workspace_preset.preset.prebuilds).instances), diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index 1d7576e9..393c7b7f 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -18,9 +18,11 @@ import ( 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"` + Name string `mapstructure:"name"` + Description string `mapstructure:"description"` + Icon string `mapstructure:"icon"` + 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 // an anonymous type and rd.Get to avoid a slice here, but that would not be possible @@ -93,6 +95,24 @@ func workspacePresetDataSource() *schema.Resource { Required: true, ValidateFunc: validation.StringIsNotEmpty, }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "Describe what this preset does.", + ValidateFunc: validation.StringLenBetween(0, 128), + }, + "icon": { + Type: schema.TypeString, + Description: "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/\"`.", + ForceNew: true, + Optional: true, + ValidateFunc: validation.All( + helpers.ValidateURL, + validation.StringLenBetween(0, 256), + ), + }, "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.", diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go index 073193c6..d10b8126 100644 --- a/provider/workspace_preset_test.go +++ b/provider/workspace_preset_test.go @@ -23,6 +23,11 @@ func TestWorkspacePreset(t *testing.T) { Config: ` data "coder_workspace_preset" "preset_1" { name = "preset_1" + description = <<-EOT + # Select the machine image + See the [registry](https://container.registry.blah/namespace) for options. + EOT + icon = "/icon/region.svg" parameters = { "region" = "us-east1-a" } @@ -34,6 +39,8 @@ func TestWorkspacePreset(t *testing.T) { require.NotNil(t, resource) attrs := resource.Primary.Attributes require.Equal(t, attrs["name"], "preset_1") + require.Equal(t, attrs["description"], "# Select the machine image\nSee the [registry](https://container.registry.blah/namespace) for options.\n") + require.Equal(t, attrs["icon"], "/icon/region.svg") require.Equal(t, attrs["parameters.region"], "us-east1-a") return nil }, @@ -76,6 +83,76 @@ func TestWorkspacePreset(t *testing.T) { // So we test it here to make sure we don't regress. ExpectError: regexp.MustCompile("Incorrect attribute value type"), }, + { + Name: "Description field is empty", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + description = "" + parameters = { + "region" = "us-east1-a" + } + }`, + // This validation is done by Terraform, but it could still break if we misconfigure the schema. + // So we test it here to make sure we don't regress. + ExpectError: nil, + }, + { + Name: "Description field exceeds maximum supported length (128 characters)", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur vehicula leo sit amet mi laoreet, sed ornare velit tincidunt. Proin gravida lacinia blandit." + parameters = { + "region" = "us-east1-a" + } + }`, + // This validation is done by Terraform, but it could still break if we misconfigure the schema. + // So we test it here to make sure we don't regress. + ExpectError: regexp.MustCompile(`expected length of description to be in the range \(0 - 128\)`), + }, + { + Name: "Icon field is empty", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + icon = "" + parameters = { + "region" = "us-east1-a" + } + }`, + // This validation is done by Terraform, but it could still break if we misconfigure the schema. + // So we test it here to make sure we don't regress. + ExpectError: nil, + }, + { + Name: "Icon field is an invalid URL", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + icon = "/icon%.svg" + parameters = { + "region" = "us-east1-a" + } + }`, + // This validation is done by Terraform, but it could still break if we misconfigure the schema. + // So we test it here to make sure we don't regress. + ExpectError: regexp.MustCompile("invalid URL escape"), + }, + { + Name: "Icon field exceeds maximum supported length (256 characters)", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + icon = "https://example.com/path/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.svg" + parameters = { + "region" = "us-east1-a" + } + }`, + // This validation is done by Terraform, but it could still break if we misconfigure the schema. + // So we test it here to make sure we don't regress. + ExpectError: regexp.MustCompile(`expected length of icon to be in the range \(0 - 256\)`), + }, { Name: "Parameters field is not provided", Config: ` From e04ea9c09cae938ca568a14aadc5e65e1ac97026 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Fri, 8 Aug 2025 10:00:32 +0200 Subject: [PATCH 14/15] feat(coder-attach): add coder_external_agent resource (#424) * add coder_external_agent resource * Change token to agent_id --- docs/resources/external_agent.md | 24 ++++++++++++++++ provider/external_agent.go | 31 ++++++++++++++++++++ provider/external_agent_test.go | 49 ++++++++++++++++++++++++++++++++ provider/provider.go | 1 + 4 files changed, 105 insertions(+) create mode 100644 docs/resources/external_agent.md create mode 100644 provider/external_agent.go create mode 100644 provider/external_agent_test.go diff --git a/docs/resources/external_agent.md b/docs/resources/external_agent.md new file mode 100644 index 00000000..b6d0b8ae --- /dev/null +++ b/docs/resources/external_agent.md @@ -0,0 +1,24 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coder_external_agent Resource - terraform-provider-coder" +subcategory: "" +description: |- + Define an external agent to be used in a workspace. +--- + +# coder_external_agent (Resource) + +Define an external agent to be used in a workspace. + + + + +## Schema + +### Required + +- `agent_id` (String) The `id` property of a `coder_agent` resource to associate with. + +### Read-Only + +- `id` (String) The ID of this resource. diff --git a/provider/external_agent.go b/provider/external_agent.go new file mode 100644 index 00000000..2856c2bf --- /dev/null +++ b/provider/external_agent.go @@ -0,0 +1,31 @@ +package provider + +import ( + "context" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func externalAgentResource() *schema.Resource { + return &schema.Resource{ + SchemaVersion: 1, + + Description: "Define an external agent to be used in a workspace.", + CreateContext: func(ctx context.Context, rd *schema.ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId(uuid.NewString()) + return nil + }, + ReadContext: schema.NoopContext, + DeleteContext: schema.NoopContext, + Schema: map[string]*schema.Schema{ + "agent_id": { + Type: schema.TypeString, + Description: "The `id` property of a `coder_agent` resource to associate with.", + ForceNew: true, + Required: true, + }, + }, + } +} diff --git a/provider/external_agent_test.go b/provider/external_agent_test.go new file mode 100644 index 00000000..f0638b45 --- /dev/null +++ b/provider/external_agent_test.go @@ -0,0 +1,49 @@ +package provider_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/stretchr/testify/require" +) + +func TestExternalAgent(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + } + + resource "coder_external_agent" "dev" { + agent_id = coder_agent.dev.id + } + `, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 2) + + agentResource := state.Modules[0].Resources["coder_agent.dev"] + require.NotNil(t, agentResource) + externalAgentResource := state.Modules[0].Resources["coder_external_agent.dev"] + require.NotNil(t, externalAgentResource) + + require.Equal(t, agentResource.Primary.Attributes["id"], externalAgentResource.Primary.Attributes["agent_id"]) + return nil + }, + }}, + }) + }) +} diff --git a/provider/provider.go b/provider/provider.go index a0ef63f9..5a2f1972 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -74,6 +74,7 @@ func New() *schema.Provider { "coder_script": scriptResource(), "coder_env": envResource(), "coder_devcontainer": devcontainerResource(), + "coder_external_agent": externalAgentResource(), }, } } From b358b645ad48f3a2f51f93ed9aad3e660942caa8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 14:46:56 +0100 Subject: [PATCH 15/15] build(deps): Bump actions/checkout from 4 to 5 (#426) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 4 ++-- .github/workflows/test.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f731b412..a6b823b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: id: go - name: Check out code into the Go module directory - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Get dependencies run: | @@ -72,7 +72,7 @@ jobs: needs: test steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Unshallow run: git fetch --prune --unshallow diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e4df55d..8b27f78d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,7 +26,7 @@ jobs: id: go - name: Check out code into the Go module directory - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Get dependencies run: | @@ -104,7 +104,7 @@ jobs: terraform_wrapper: false - name: Check out code into the Go module directory - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Get dependencies run: | @@ -134,7 +134,7 @@ jobs: terraform_wrapper: false - name: Check out code into the Go module directory - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Get dependencies run: |