From 3d46b7343232c606a18b4ffb0632cebcfb3a5615 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Wed, 4 Dec 2024 01:25:46 +0500 Subject: [PATCH 01/63] fix(.goreleaser.yml): use semver to sort git tags (#319) --- .goreleaser.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index 69029533..89118d2c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -61,3 +61,5 @@ changelog: use: github-native sort: asc abbrev: 0 +git: + tag_sort: semver From c9dbd6f7639765999b3e84c2f966b3a171d31a4d Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Wed, 4 Dec 2024 01:34:11 +0500 Subject: [PATCH 02/63] Revert "fix(.goreleaser.yml): use semver to sort git tags" (#320) --- .goreleaser.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 89118d2c..69029533 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -61,5 +61,3 @@ changelog: use: github-native sort: asc abbrev: 0 -git: - tag_sort: semver From 8349a69f9512f67805f8544c311b4fa8a70dd4f5 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 16 Dec 2024 13:50:06 +0000 Subject: [PATCH 03/63] chore: update golang.org/x/crypto to v0.31.0 (#323) --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 609e1889..9087a1a7 100644 --- a/go.mod +++ b/go.mod @@ -80,11 +80,11 @@ require ( go.opentelemetry.io/otel/metric v1.27.0 // indirect go.opentelemetry.io/otel/sdk v1.27.0 // indirect go.opentelemetry.io/otel/trace v1.27.0 // indirect - golang.org/x/crypto v0.28.0 // indirect + golang.org/x/crypto v0.31.0 // indirect golang.org/x/net v0.28.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.19.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.22.0 // indirect google.golang.org/appengine v1.6.8 // indirect diff --git a/go.sum b/go.sum index 524564a1..1922dad3 100644 --- a/go.sum +++ b/go.sum @@ -223,8 +223,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= -golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -245,8 +245,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -261,8 +261,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -270,8 +270,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From d933a7149b87898dba196342be5dd9df3fc07bcf Mon Sep 17 00:00:00 2001 From: Vincent Vielle Date: Thu, 19 Dec 2024 16:35:25 +0100 Subject: [PATCH 04/63] add open_in option to coder_app (#321) * add open_in option to coder_app * work on tests * add missing example * rename test * lint * generate docs --- docs/resources/app.md | 2 + examples/resources/coder_app/resource.tf | 1 + integration/coder-app-open-in/main.tf | 62 +++++++++++++ integration/integration_test.go | 9 ++ provider/app.go | 22 +++++ provider/app_test.go | 111 +++++++++++++++++++++++ 6 files changed, 207 insertions(+) create mode 100644 integration/coder-app-open-in/main.tf diff --git a/docs/resources/app.md b/docs/resources/app.md index 6b8e99f4..e2bbe435 100644 --- a/docs/resources/app.md +++ b/docs/resources/app.md @@ -33,6 +33,7 @@ resource "coder_app" "code-server" { url = "http://localhost:13337" share = "owner" subdomain = false + open_in = "window" healthcheck { url = "http://localhost:13337/healthz" interval = 5 @@ -65,6 +66,7 @@ resource "coder_app" "vim" { - `healthcheck` (Block Set, Max: 1) HTTP health checking to determine the application readiness. (see [below for nested schema](#nestedblock--healthcheck)) - `hidden` (Boolean) Determines if the app is visible in the UI (minimum Coder version: v2.16). - `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/"`. +- `open_in` (String) Determines where the app will be opened. Valid values are `"tab"`, `"window"`, and `"slim-window" (default)`. `"tab"` opens in a new tab in the same browser window. `"window"` opens a fresh browser window with navigation options. `"slim-window"` opens a new browser window without navigation controls. - `order` (Number) The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order). - `share` (String) Determines the level which the application is shared at. Valid levels are `"owner"` (default), `"authenticated"` and `"public"`. Level `"owner"` disables sharing on the app, so only the workspace owner can access it. Level `"authenticated"` shares the app with all authenticated users. Level `"public"` shares it with any user, including unauthenticated users. Permitted application sharing levels can be configured site-wide via a flag on `coder server` (Enterprise only). - `subdomain` (Boolean) Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. If wildcards have not been setup by the administrator then apps with `subdomain` set to `true` will not be accessible. Defaults to `false`. diff --git a/examples/resources/coder_app/resource.tf b/examples/resources/coder_app/resource.tf index 9345dfc5..8aea7b99 100644 --- a/examples/resources/coder_app/resource.tf +++ b/examples/resources/coder_app/resource.tf @@ -18,6 +18,7 @@ resource "coder_app" "code-server" { url = "http://localhost:13337" share = "owner" subdomain = false + open_in = "window" healthcheck { url = "http://localhost:13337/healthz" interval = 5 diff --git a/integration/coder-app-open-in/main.tf b/integration/coder-app-open-in/main.tf new file mode 100644 index 00000000..529d9bef --- /dev/null +++ b/integration/coder-app-open-in/main.tf @@ -0,0 +1,62 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + local = { + source = "hashicorp/local" + } + } +} + +data "coder_workspace" "me" {} + +resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + dir = "/workspace" +} + +resource "coder_app" "window" { + agent_id = coder_agent.dev.id + slug = "window" + share = "owner" + open_in = "window" +} + +resource "coder_app" "slim-window" { + agent_id = coder_agent.dev.id + slug = "slim-window" + share = "owner" + open_in = "slim-window" +} + +resource "coder_app" "defaulted" { + agent_id = coder_agent.dev.id + slug = "defaulted" + share = "owner" +} + +locals { + # NOTE: these must all be strings in the output + output = { + "coder_app.window.open_in" = tostring(coder_app.window.open_in) + "coder_app.slim-window.open_in" = tostring(coder_app.slim-window.open_in) + "coder_app.defaulted.open_in" = tostring(coder_app.defaulted.open_in) + } +} + +variable "output_path" { + type = string +} + +resource "local_file" "output" { + filename = var.output_path + content = jsonencode(local.output) +} + +output "output" { + value = local.output + sensitive = true +} + diff --git a/integration/integration_test.go b/integration/integration_test.go index f1596eee..50ef71b5 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -143,6 +143,15 @@ func TestIntegration(t *testing.T) { "workspace_owner.login_type": `password`, }, }, + { + name: "coder-app-open-in", + minVersion: "v2.19.0", + expectedOutput: map[string]string{ + "coder_app.window.open_in": "window", + "coder_app.slim-window.open_in": "slim-window", + "coder_app.defaulted.open_in": "slim-window", + }, + }, { name: "coder-app-hidden", minVersion: "v0.0.0", diff --git a/provider/app.go b/provider/app.go index 3fd71692..391bdfc3 100644 --- a/provider/app.go +++ b/provider/app.go @@ -223,6 +223,28 @@ func appResource() *schema.Resource { ForceNew: true, Optional: true, }, + "open_in": { + Type: schema.TypeString, + Description: "Determines where the app will be opened. Valid values are `\"tab\"`, `\"window\"`, and `\"slim-window\" (default)`. " + + "`\"tab\"` opens in a new tab in the same browser window. `\"window\"` opens a fresh browser window with navigation options. " + + "`\"slim-window\"` opens a new browser window without navigation controls.", + ForceNew: true, + Optional: true, + Default: "slim-window", + ValidateDiagFunc: func(val interface{}, c cty.Path) diag.Diagnostics { + valStr, ok := val.(string) + if !ok { + return diag.Errorf("expected string, got %T", val) + } + + switch valStr { + case "tab", "window", "slim-window": + return nil + } + + return diag.Errorf(`invalid "coder_app" open_in value, must be one of "tab", "window", "slim-window": %q`, valStr) + }, + }, }, } } diff --git a/provider/app_test.go b/provider/app_test.go index 6a17ca0c..aaa4f631 100644 --- a/provider/app_test.go +++ b/provider/app_test.go @@ -42,6 +42,7 @@ func TestApp(t *testing.T) { } order = 4 hidden = false + open_in = "slim-window" } `, Check: func(state *terraform.State) error { @@ -64,6 +65,7 @@ func TestApp(t *testing.T) { "healthcheck.0.threshold", "order", "hidden", + "open_in", } { value := resource.Primary.Attributes[key] t.Logf("%q = %q", key, value) @@ -98,6 +100,7 @@ func TestApp(t *testing.T) { display_name = "Testing" url = "https://google.com" external = true + open_in = "slim-window" } `, external: true, @@ -116,6 +119,7 @@ func TestApp(t *testing.T) { url = "https://google.com" external = true subdomain = true + open_in = "slim-window" } `, expectError: regexp.MustCompile("conflicts with subdomain"), @@ -209,6 +213,7 @@ func TestApp(t *testing.T) { interval = 5 threshold = 6 } + open_in = "slim-window" } `, sharingLine) @@ -241,6 +246,106 @@ func TestApp(t *testing.T) { } }) + t.Run("OpenIn", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + value string + expectValue string + expectError *regexp.Regexp + }{ + { + name: "default", + value: "", // default + expectValue: "slim-window", + }, + { + name: "InvalidValue", + value: "nonsense", + expectError: regexp.MustCompile(`invalid "coder_app" open_in value, must be one of "tab", "window", "slim-window": "nonsense"`), + }, + { + name: "ExplicitWindow", + value: "window", + expectValue: "window", + }, + { + name: "ExplicitSlimWindow", + value: "slim-window", + expectValue: "slim-window", + }, + { + name: "ExplicitTab", + value: "tab", + expectValue: "tab", + }, + } + + for _, c := range cases { + c := c + + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + 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" + healthcheck { + url = "http://localhost:13337/healthz" + interval = 5 + threshold = 6 + }` + + if c.value != "" { + config += fmt.Sprintf(` + open_in = %q + `, c.value) + } + + config += ` + } + ` + + checkFn := func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 2) + resource := state.Modules[0].Resources["coder_app.code-server"] + require.NotNil(t, resource) + + // Read share and ensure it matches the expected + // value. + value := resource.Primary.Attributes["open_in"] + require.Equal(t, c.expectValue, value) + return nil + } + if c.expectError != nil { + checkFn = nil + } + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: config, + Check: checkFn, + ExpectError: c.expectError, + }}, + }) + }) + } + }) + t.Run("Hidden", func(t *testing.T) { t.Parallel() @@ -248,6 +353,7 @@ func TestApp(t *testing.T) { name string config string hidden bool + openIn string }{{ name: "Is Hidden", config: ` @@ -263,9 +369,11 @@ func TestApp(t *testing.T) { url = "https://google.com" external = true hidden = true + open_in = "slim-window" } `, hidden: true, + openIn: "slim-window", }, { name: "Is Not Hidden", config: ` @@ -281,9 +389,11 @@ func TestApp(t *testing.T) { url = "https://google.com" external = true hidden = false + open_in = "window" } `, hidden: false, + openIn: "window", }} for _, tc := range cases { tc := tc @@ -300,6 +410,7 @@ func TestApp(t *testing.T) { resource := state.Modules[0].Resources["coder_app.test"] require.NotNil(t, resource) require.Equal(t, strconv.FormatBool(tc.hidden), resource.Primary.Attributes["hidden"]) + require.Equal(t, tc.openIn, resource.Primary.Attributes["open_in"]) return nil }, ExpectError: nil, From b7ab1bb824ae90b447f6183c671cb0ca5580bb8d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 20 Dec 2024 13:47:58 +0000 Subject: [PATCH 05/63] chore: update paths to /v2 (#324) --- go.mod | 2 +- main.go | 2 +- provider/agent.go | 2 +- provider/decode_test.go | 2 +- provider/externalauth.go | 2 +- provider/parameter_test.go | 2 +- provider/provider_test.go | 2 +- provider/workspace.go | 2 +- scripts/docsgen/main.go | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 9087a1a7..f88cd289 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/coder/terraform-provider-coder +module github.com/coder/terraform-provider-coder/v2 go 1.22.9 diff --git a/main.go b/main.go index a16c9951..2eaa5dc5 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,7 @@ package main import ( "github.com/hashicorp/terraform-plugin-sdk/v2/plugin" - "github.com/coder/terraform-provider-coder/provider" + "github.com/coder/terraform-provider-coder/v2/provider" ) // Run the docs generation tool, check its repository for more information on how it works and how docs diff --git a/provider/agent.go b/provider/agent.go index 01fb5801..ac012bf1 100644 --- a/provider/agent.go +++ b/provider/agent.go @@ -12,7 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" "golang.org/x/xerrors" - "github.com/coder/terraform-provider-coder/provider/helpers" + "github.com/coder/terraform-provider-coder/v2/provider/helpers" ) func agentResource() *schema.Resource { diff --git a/provider/decode_test.go b/provider/decode_test.go index a5e20b14..947ebf79 100644 --- a/provider/decode_test.go +++ b/provider/decode_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/terraform-provider-coder/provider" + "github.com/coder/terraform-provider-coder/v2/provider" ) func TestDecode(t *testing.T) { diff --git a/provider/externalauth.go b/provider/externalauth.go index a11a67c4..915a21a9 100644 --- a/provider/externalauth.go +++ b/provider/externalauth.go @@ -7,7 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/coder/terraform-provider-coder/provider/helpers" + "github.com/coder/terraform-provider-coder/v2/provider/helpers" ) // externalAuthDataSource returns a schema for an external authentication data source. diff --git a/provider/parameter_test.go b/provider/parameter_test.go index fc814cba..b1f164a0 100644 --- a/provider/parameter_test.go +++ b/provider/parameter_test.go @@ -8,7 +8,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/stretchr/testify/require" - "github.com/coder/terraform-provider-coder/provider" + "github.com/coder/terraform-provider-coder/v2/provider" ) func TestParameter(t *testing.T) { diff --git a/provider/provider_test.go b/provider/provider_test.go index 3367e7f4..4bf98b32 100644 --- a/provider/provider_test.go +++ b/provider/provider_test.go @@ -8,7 +8,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/stretchr/testify/require" - "github.com/coder/terraform-provider-coder/provider" + "github.com/coder/terraform-provider-coder/v2/provider" ) func TestProvider(t *testing.T) { diff --git a/provider/workspace.go b/provider/workspace.go index 575fd60f..fde742b6 100644 --- a/provider/workspace.go +++ b/provider/workspace.go @@ -9,7 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/coder/terraform-provider-coder/provider/helpers" + "github.com/coder/terraform-provider-coder/v2/provider/helpers" ) func workspaceDataSource() *schema.Resource { diff --git a/scripts/docsgen/main.go b/scripts/docsgen/main.go index d83cf123..53b43ca4 100644 --- a/scripts/docsgen/main.go +++ b/scripts/docsgen/main.go @@ -9,7 +9,7 @@ import ( "regexp" "strings" - "github.com/coder/terraform-provider-coder/provider" + "github.com/coder/terraform-provider-coder/v2/provider" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "golang.org/x/xerrors" ) From 464d613873cab9b1424c0c5a4d5c74ae3bf52927 Mon Sep 17 00:00:00 2001 From: Vincent Vielle Date: Mon, 20 Jan 2025 13:59:24 +0100 Subject: [PATCH 06/63] fix: remove window option from open_in parameter (#327) * remove window option * fix missing integration test * fix tests * run linter --- docs/resources/app.md | 2 +- integration/coder-app-open-in/main.tf | 18 +++++------------- integration/integration_test.go | 5 ++--- provider/app.go | 8 ++++---- provider/app_test.go | 11 +++-------- 5 files changed, 15 insertions(+), 29 deletions(-) diff --git a/docs/resources/app.md b/docs/resources/app.md index e2bbe435..b3ac728f 100644 --- a/docs/resources/app.md +++ b/docs/resources/app.md @@ -66,7 +66,7 @@ resource "coder_app" "vim" { - `healthcheck` (Block Set, Max: 1) HTTP health checking to determine the application readiness. (see [below for nested schema](#nestedblock--healthcheck)) - `hidden` (Boolean) Determines if the app is visible in the UI (minimum Coder version: v2.16). - `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/"`. -- `open_in` (String) Determines where the app will be opened. Valid values are `"tab"`, `"window"`, and `"slim-window" (default)`. `"tab"` opens in a new tab in the same browser window. `"window"` opens a fresh browser window with navigation options. `"slim-window"` opens a new browser window without navigation controls. +- `open_in` (String) Determines where the app will be opened. Valid values are `"tab"` and `"slim-window" (default)`. `"tab"` opens in a new tab in the same browser window. `"slim-window"` opens a new browser window without navigation controls. - `order` (Number) The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order). - `share` (String) Determines the level which the application is shared at. Valid levels are `"owner"` (default), `"authenticated"` and `"public"`. Level `"owner"` disables sharing on the app, so only the workspace owner can access it. Level `"authenticated"` shares the app with all authenticated users. Level `"public"` shares it with any user, including unauthenticated users. Permitted application sharing levels can be configured site-wide via a flag on `coder server` (Enterprise only). - `subdomain` (Boolean) Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. If wildcards have not been setup by the administrator then apps with `subdomain` set to `true` will not be accessible. Defaults to `false`. diff --git a/integration/coder-app-open-in/main.tf b/integration/coder-app-open-in/main.tf index 529d9bef..f06947ae 100644 --- a/integration/coder-app-open-in/main.tf +++ b/integration/coder-app-open-in/main.tf @@ -17,18 +17,11 @@ resource "coder_agent" "dev" { dir = "/workspace" } -resource "coder_app" "window" { +resource "coder_app" "tab" { agent_id = coder_agent.dev.id - slug = "window" + slug = "tab" share = "owner" - open_in = "window" -} - -resource "coder_app" "slim-window" { - agent_id = coder_agent.dev.id - slug = "slim-window" - share = "owner" - open_in = "slim-window" + open_in = "tab" } resource "coder_app" "defaulted" { @@ -40,9 +33,8 @@ resource "coder_app" "defaulted" { locals { # NOTE: these must all be strings in the output output = { - "coder_app.window.open_in" = tostring(coder_app.window.open_in) - "coder_app.slim-window.open_in" = tostring(coder_app.slim-window.open_in) - "coder_app.defaulted.open_in" = tostring(coder_app.defaulted.open_in) + "coder_app.tab.open_in" = tostring(coder_app.tab.open_in) + "coder_app.defaulted.open_in" = tostring(coder_app.defaulted.open_in) } } diff --git a/integration/integration_test.go b/integration/integration_test.go index 50ef71b5..8ac68f28 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -147,9 +147,8 @@ func TestIntegration(t *testing.T) { name: "coder-app-open-in", minVersion: "v2.19.0", expectedOutput: map[string]string{ - "coder_app.window.open_in": "window", - "coder_app.slim-window.open_in": "slim-window", - "coder_app.defaulted.open_in": "slim-window", + "coder_app.tab.open_in": "tab", + "coder_app.defaulted.open_in": "slim-window", }, }, { diff --git a/provider/app.go b/provider/app.go index 391bdfc3..cd64db54 100644 --- a/provider/app.go +++ b/provider/app.go @@ -225,8 +225,8 @@ func appResource() *schema.Resource { }, "open_in": { Type: schema.TypeString, - Description: "Determines where the app will be opened. Valid values are `\"tab\"`, `\"window\"`, and `\"slim-window\" (default)`. " + - "`\"tab\"` opens in a new tab in the same browser window. `\"window\"` opens a fresh browser window with navigation options. " + + Description: "Determines where the app will be opened. Valid values are `\"tab\"` and `\"slim-window\" (default)`. " + + "`\"tab\"` opens in a new tab in the same browser window. " + "`\"slim-window\"` opens a new browser window without navigation controls.", ForceNew: true, Optional: true, @@ -238,11 +238,11 @@ func appResource() *schema.Resource { } switch valStr { - case "tab", "window", "slim-window": + case "tab", "slim-window": return nil } - return diag.Errorf(`invalid "coder_app" open_in value, must be one of "tab", "window", "slim-window": %q`, valStr) + return diag.Errorf(`invalid "coder_app" open_in value, must be one of "tab", "slim-window": %q`, valStr) }, }, }, diff --git a/provider/app_test.go b/provider/app_test.go index aaa4f631..005e8377 100644 --- a/provider/app_test.go +++ b/provider/app_test.go @@ -263,12 +263,7 @@ func TestApp(t *testing.T) { { name: "InvalidValue", value: "nonsense", - expectError: regexp.MustCompile(`invalid "coder_app" open_in value, must be one of "tab", "window", "slim-window": "nonsense"`), - }, - { - name: "ExplicitWindow", - value: "window", - expectValue: "window", + expectError: regexp.MustCompile(`invalid "coder_app" open_in value, must be one of "tab", "slim-window": "nonsense"`), }, { name: "ExplicitSlimWindow", @@ -389,11 +384,11 @@ func TestApp(t *testing.T) { url = "https://google.com" external = true hidden = false - open_in = "window" + open_in = "tab" } `, hidden: false, - openIn: "window", + openIn: "tab", }} for _, tc := range cases { tc := tc From 3c23e37fe329803dcb5763acc035402bc1dd8d34 Mon Sep 17 00:00:00 2001 From: Vincent Vielle Date: Wed, 22 Jan 2025 14:19:14 +0100 Subject: [PATCH 07/63] doc: add release section (#328) * update readme release section * Update README.md Co-authored-by: Muhammad Atif Ali --------- Co-authored-by: Muhammad Atif Ali --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 6f13efef..cf012c46 100644 --- a/README.md +++ b/README.md @@ -77,3 +77,18 @@ To run these integration tests locally: > **Note:** you can specify `CODER_IMAGE` if the Coder image you wish to test is hosted somewhere other than `ghcr.io/coder/coder`. > For example, `CODER_IMAGE=example.com/repo/coder CODER_VERSION=foobar make test-integration`. + +### How to create a new release +> **Warning:** Before creating a new release, make sure you have pulled the latest commit from the main branch i.e. `git pull origin main` + +1. Create a new tag with a version number (following semantic versioning): + ```console + git tag -a v2.1.2 -m "v2.1.2" + ``` + +2. Push the tag to the remote repository: + ```console + git push origin tag v2.1.2 + ``` + +A GitHub Actions workflow named "Release" will automatically trigger, run integration tests, and publish the new release. \ No newline at end of file From 054e9bca452d7715f169c48d1c169314151118e2 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Wed, 22 Jan 2025 23:38:54 +0500 Subject: [PATCH 08/63] chore: Prettify README.md (#329) --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cf012c46..b8ee8840 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,8 @@ to setup your local Terraform to use your local version rather than the registry To run Terraform acceptance tests, run `make testacc`. This will test the provider against the locally installed version of Terraform. -> **Note:** our [CI workflow](./github/workflows/test.yml) runs a test matrix against multiple Terraform versions. +> [!Note] +> Our [CI workflow](./github/workflows/test.yml) runs a test matrix against multiple Terraform versions. #### Integration Tests @@ -75,11 +76,13 @@ To run these integration tests locally: 1. Run `CODER_IMAGE=ghcr.io/coder/coder-preview CODER_VERSION=main-x.y.z-devel-abcd1234 make test-integration`. -> **Note:** you can specify `CODER_IMAGE` if the Coder image you wish to test is hosted somewhere other than `ghcr.io/coder/coder`. +> [!Note] +> You can specify `CODER_IMAGE` if the Coder image you wish to test is hosted somewhere other than `ghcr.io/coder/coder`. > For example, `CODER_IMAGE=example.com/repo/coder CODER_VERSION=foobar make test-integration`. ### How to create a new release -> **Warning:** Before creating a new release, make sure you have pulled the latest commit from the main branch i.e. `git pull origin main` +> [!Warning] +> Before creating a new release, make sure you have pulled the latest commit from the main branch i.e. `git pull origin main` 1. Create a new tag with a version number (following semantic versioning): ```console @@ -91,4 +94,4 @@ To run these integration tests locally: git push origin tag v2.1.2 ``` -A GitHub Actions workflow named "Release" will automatically trigger, run integration tests, and publish the new release. \ No newline at end of file +A GitHub Actions workflow named "Release" will automatically trigger, run integration tests, and publish the new release. From d7d2b705fc00673bfd1a4f55efe97dfa7f89da84 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 4 Feb 2025 10:26:06 +0200 Subject: [PATCH 09/63] feat: add a workspace preset datasource (#332) * add a workspace preset datasource * add workspace preset type * add validation and tests for coder_workspace_presets * make -B gen * make -B gen * idiomatic tests and review notes * make fmt * make -B gen * add integration tests * make gen fmt --- docs/data-sources/workspace_preset.md | 42 ++++++ .../coder_workspace_preset/data-source.tf | 14 ++ integration/integration_test.go | 45 ++++-- integration/test-data-source/main.tf | 17 +++ integration/test-data-source/parameters.yaml | 1 + provider/provider.go | 13 +- provider/workspace_preset.go | 70 ++++++++++ provider/workspace_preset_test.go | 128 ++++++++++++++++++ 8 files changed, 311 insertions(+), 19 deletions(-) create mode 100644 docs/data-sources/workspace_preset.md create mode 100644 examples/data-sources/coder_workspace_preset/data-source.tf create mode 100644 integration/test-data-source/parameters.yaml create mode 100644 provider/workspace_preset.go create mode 100644 provider/workspace_preset_test.go diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md new file mode 100644 index 00000000..28f90faa --- /dev/null +++ b/docs/data-sources/workspace_preset.md @@ -0,0 +1,42 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coder_workspace_preset Data Source - terraform-provider-coder" +subcategory: "" +description: |- + Use this data source to predefine common configurations for workspaces. +--- + +# coder_workspace_preset (Data Source) + +Use this data source to predefine common configurations for workspaces. + +## Example Usage + +```terraform +provider "coder" {} + +# presets can be used to predefine common configurations for workspaces +# Parameters are referenced by their name. Each parameter must be defined in the preset. +# Values defined by the preset must pass validation for the parameter. +# 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" + parameters = { + (data.coder_parameter.example.name) = "us-central1-a" + (data.coder_parameter.ami.name) = "ami-xxxxxxxx" + } +} +``` + + +## Schema + +### Required + +- `name` (String) Name of the workspace preset. +- `parameters` (Map of String) Parameters of the workspace preset. + +### Read-Only + +- `id` (String) ID of the workspace preset. diff --git a/examples/data-sources/coder_workspace_preset/data-source.tf b/examples/data-sources/coder_workspace_preset/data-source.tf new file mode 100644 index 00000000..4f29a199 --- /dev/null +++ b/examples/data-sources/coder_workspace_preset/data-source.tf @@ -0,0 +1,14 @@ +provider "coder" {} + +# presets can be used to predefine common configurations for workspaces +# Parameters are referenced by their name. Each parameter must be defined in the preset. +# Values defined by the preset must pass validation for the parameter. +# 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" + parameters = { + (data.coder_parameter.example.name) = "us-central1-a" + (data.coder_parameter.ami.name) = "ami-xxxxxxxx" + } +} diff --git a/integration/integration_test.go b/integration/integration_test.go index 8ac68f28..bbbd5587 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -73,18 +73,27 @@ func TestIntegration(t *testing.T) { name: "test-data-source", minVersion: "v0.0.0", expectedOutput: map[string]string{ - "provisioner.arch": runtime.GOARCH, - "provisioner.id": `[a-zA-Z0-9-]+`, - "provisioner.os": runtime.GOOS, - "workspace.access_port": `\d+`, - "workspace.access_url": `https?://\D+:\d+`, - "workspace.id": `[a-zA-z0-9-]+`, - "workspace.name": `test-data-source`, - "workspace.start_count": `1`, - "workspace.template_id": `[a-zA-Z0-9-]+`, - "workspace.template_name": `test-data-source`, - "workspace.template_version": `.+`, - "workspace.transition": `start`, + "provisioner.arch": runtime.GOARCH, + "provisioner.id": `[a-zA-Z0-9-]+`, + "provisioner.os": runtime.GOOS, + "workspace.access_port": `\d+`, + "workspace.access_url": `https?://\D+:\d+`, + "workspace.id": `[a-zA-z0-9-]+`, + "workspace.name": `test-data-source`, + "workspace.start_count": `1`, + "workspace.template_id": `[a-zA-Z0-9-]+`, + "workspace.template_name": `test-data-source`, + "workspace.template_version": `.+`, + "workspace.transition": `start`, + "workspace_parameter.name": `param`, + "workspace_parameter.description": `param description`, + // 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`, }, }, { @@ -179,8 +188,18 @@ func TestIntegration(t *testing.T) { } _, rc := execContainer(ctx, t, ctrID, fmt.Sprintf(`coder templates %s %s --directory /src/integration/%s --var output_path=/tmp/%s.json --yes`, templateCreateCmd, tt.name, tt.name, tt.name)) require.Equal(t, 0, rc) + + // Check if parameters.yaml exists + _, rc = execContainer(ctx, t, ctrID, fmt.Sprintf(`stat /src/integration/%s/parameters.yaml 2>/dev/null > /dev/null`, tt.name)) + hasParameters := rc == 0 + var includeParameters string + if hasParameters { + // If it exists, include it in the create command + includeParameters = fmt.Sprintf(`--rich-parameter-file /src/integration/%s/parameters.yaml`, tt.name) + } + // Create a workspace - _, rc = execContainer(ctx, t, ctrID, fmt.Sprintf(`coder create %s -t %s --yes`, tt.name, tt.name)) + _, rc = execContainer(ctx, t, ctrID, fmt.Sprintf(`coder create %s -t %s %s --yes`, tt.name, tt.name, includeParameters)) require.Equal(t, 0, rc) // Fetch the output created by the template out, rc := execContainer(ctx, t, ctrID, fmt.Sprintf(`cat /tmp/%s.json`, tt.name)) diff --git a/integration/test-data-source/main.tf b/integration/test-data-source/main.tf index 6d4b85cd..5fb2e0e6 100644 --- a/integration/test-data-source/main.tf +++ b/integration/test-data-source/main.tf @@ -14,6 +14,17 @@ terraform { data "coder_provisioner" "me" {} data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} +data "coder_parameter" "param" { + name = "param" + description = "param description" + icon = "param icon" +} +data "coder_workspace_preset" "preset" { + name = "preset" + parameters = { + (data.coder_parameter.param.name) = "preset param value" + } +} locals { # NOTE: these must all be strings in the output @@ -30,6 +41,12 @@ locals { "workspace.template_name" : data.coder_workspace.me.template_name, "workspace.template_version" : data.coder_workspace.me.template_version, "workspace.transition" : data.coder_workspace.me.transition, + "workspace_parameter.name" : data.coder_parameter.param.name, + "workspace_parameter.description" : data.coder_parameter.param.description, + "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.parameters.param" : data.coder_workspace_preset.preset.parameters.param, } } diff --git a/integration/test-data-source/parameters.yaml b/integration/test-data-source/parameters.yaml new file mode 100644 index 00000000..0e75c133 --- /dev/null +++ b/integration/test-data-source/parameters.yaml @@ -0,0 +1 @@ +param: "param value" \ No newline at end of file diff --git a/provider/provider.go b/provider/provider.go index 1d78f2dd..d9780d76 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -61,12 +61,13 @@ func New() *schema.Provider { }, nil }, DataSourcesMap: map[string]*schema.Resource{ - "coder_workspace": workspaceDataSource(), - "coder_workspace_tags": workspaceTagDataSource(), - "coder_provisioner": provisionerDataSource(), - "coder_parameter": parameterDataSource(), - "coder_external_auth": externalAuthDataSource(), - "coder_workspace_owner": workspaceOwnerDataSource(), + "coder_workspace": workspaceDataSource(), + "coder_workspace_tags": workspaceTagDataSource(), + "coder_provisioner": provisionerDataSource(), + "coder_parameter": parameterDataSource(), + "coder_external_auth": externalAuthDataSource(), + "coder_workspace_owner": workspaceOwnerDataSource(), + "coder_workspace_preset": workspacePresetDataSource(), }, ResourcesMap: map[string]*schema.Resource{ "coder_agent": agentResource(), diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go new file mode 100644 index 00000000..cd56c980 --- /dev/null +++ b/provider/workspace_preset.go @@ -0,0 +1,70 @@ +package provider + +import ( + "context" + + "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" +) + +type WorkspacePreset struct { + Name string `mapstructure:"name"` + Parameters map[string]string `mapstructure:"parameters"` +} + +func workspacePresetDataSource() *schema.Resource { + return &schema.Resource{ + SchemaVersion: 1, + + Description: "Use this data source to predefine common configurations for workspaces.", + ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics { + var preset WorkspacePreset + err := mapstructure.Decode(struct { + Name interface{} + Parameters interface{} + }{ + Name: rd.Get("name"), + Parameters: rd.Get("parameters"), + }, &preset) + if err != nil { + return diag.Errorf("decode workspace preset: %s", err) + } + + // MinItems doesn't work with maps, so we need to check the length + // of the map manually. All other validation is handled by the + // schema. + if len(preset.Parameters) == 0 { + return diag.Errorf("expected \"parameters\" to not be an empty map") + } + + rd.SetId(preset.Name) + + return nil + }, + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Description: "ID of the workspace preset.", + Computed: true, + }, + "name": { + Type: schema.TypeString, + Description: "Name of the workspace preset.", + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + "parameters": { + Type: schema.TypeMap, + Description: "Parameters of the workspace preset.", + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + }, + }, + } +} diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go new file mode 100644 index 00000000..876e2044 --- /dev/null +++ b/provider/workspace_preset_test.go @@ -0,0 +1,128 @@ +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 TestWorkspacePreset(t *testing.T) { + t.Parallel() + type testcase struct { + Name string + Config string + ExpectError *regexp.Regexp + Check func(state *terraform.State) error + } + testcases := []testcase{ + { + Name: "Happy Path", + 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) + attrs := resource.Primary.Attributes + require.Equal(t, attrs["name"], "preset_1") + require.Equal(t, attrs["parameters.region"], "us-east1-a") + return nil + }, + }, + { + Name: "Name field is not provided", + Config: ` + data "coder_workspace_preset" "preset_1" { + 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("The argument \"name\" is required, but no definition was found"), + }, + { + Name: "Name field is empty", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "" + 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 \"name\" to not be an empty string"), + }, + { + Name: "Name field is not a string", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = [1, 2, 3] + 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("Incorrect attribute value type"), + }, + { + Name: "Parameters field is not provided", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + }`, + // 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("The argument \"parameters\" is required, but no definition was found"), + }, + { + Name: "Parameters field is empty", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = {} + }`, + // This validation is *not* done by Terraform, because MinItems doesn't work with maps. + // We've implemented the validation in ReadContext, so we test it here to make sure we don't regress. + ExpectError: regexp.MustCompile("expected \"parameters\" to not be an empty map"), + }, + { + Name: "Parameters field is not a map", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = "not a map" + }`, + // 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("Inappropriate value for attribute \"parameters\": map of string required"), + }, + } + + for _, testcase := range testcases { + t.Run(testcase.Name, func(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: testcase.Config, + ExpectError: testcase.ExpectError, + Check: testcase.Check, + }}, + }) + }) + } +} From b73223c16edef7c407eacbded79e8a0b99a8f0a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Feb 2025 11:24:29 +0000 Subject: [PATCH 10/63] build(deps): Bump golang.org/x/mod from 0.22.0 to 0.23.0 (#337) Bumps [golang.org/x/mod](https://github.com/golang/mod) from 0.22.0 to 0.23.0. - [Commits](https://github.com/golang/mod/compare/v0.22.0...v0.23.0) --- updated-dependencies: - dependency-name: golang.org/x/mod dependency-type: direct:production update-type: version-update:semver-minor ... 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 f88cd289..f4f8d4b0 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,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.22.0 + golang.org/x/mod v0.23.0 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 ) diff --git a/go.sum b/go.sum index 1922dad3..ab31763e 100644 --- a/go.sum +++ b/go.sum @@ -230,8 +230,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.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= -golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= +golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 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 ec48bcb742656c13ec21fd3b2be15b1138565425 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Feb 2025 11:25:06 +0000 Subject: [PATCH 11/63] build(deps): Bump github.com/hashicorp/terraform-plugin-sdk/v2 (#338) Bumps [github.com/hashicorp/terraform-plugin-sdk/v2](https://github.com/hashicorp/terraform-plugin-sdk) from 2.35.0 to 2.36.0. - [Release notes](https://github.com/hashicorp/terraform-plugin-sdk/releases) - [Changelog](https://github.com/hashicorp/terraform-plugin-sdk/blob/main/CHANGELOG.md) - [Commits](https://github.com/hashicorp/terraform-plugin-sdk/compare/v2.35.0...v2.36.0) --- updated-dependencies: - dependency-name: github.com/hashicorp/terraform-plugin-sdk/v2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 37 +++++++++++------------ go.sum | 94 ++++++++++++++++++++++++++++++---------------------------- 2 files changed, 66 insertions(+), 65 deletions(-) diff --git a/go.mod b/go.mod index f4f8d4b0..c1033b5e 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/docker/docker v26.1.5+incompatible github.com/google/uuid v1.6.0 github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 - github.com/hashicorp/terraform-plugin-sdk/v2 v2.35.0 + github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.0 github.com/masterminds/semver v1.5.0 github.com/mitchellh/mapstructure v1.5.0 github.com/robfig/cron/v3 v3.0.1 @@ -19,7 +19,7 @@ require ( require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect - github.com/ProtonMail/go-crypto v1.1.0-alpha.2 // indirect + github.com/ProtonMail/go-crypto v1.1.3 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/cloudflare/circl v1.3.7 // indirect @@ -30,7 +30,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/fatih/color v1.16.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -44,14 +44,14 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-version v1.7.0 // indirect - github.com/hashicorp/hc-install v0.9.0 // indirect - github.com/hashicorp/hcl/v2 v2.22.0 // indirect + github.com/hashicorp/hc-install v0.9.1 // indirect + github.com/hashicorp/hcl/v2 v2.23.0 // indirect github.com/hashicorp/logutils v1.0.0 // indirect - github.com/hashicorp/terraform-exec v0.21.0 // indirect - github.com/hashicorp/terraform-json v0.23.0 // indirect - github.com/hashicorp/terraform-plugin-go v0.25.0 // indirect + github.com/hashicorp/terraform-exec v0.22.0 // indirect + github.com/hashicorp/terraform-json v0.24.0 // indirect + github.com/hashicorp/terraform-plugin-go v0.26.0 // indirect github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect - github.com/hashicorp/terraform-registry-address v0.2.3 // indirect + github.com/hashicorp/terraform-registry-address v0.2.4 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/kr/pretty v0.3.0 // indirect @@ -73,24 +73,23 @@ require ( github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/zclconf/go-cty v1.15.0 // indirect + github.com/zclconf/go-cty v1.16.2 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect - go.opentelemetry.io/otel v1.27.0 // indirect + go.opentelemetry.io/otel v1.31.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect - go.opentelemetry.io/otel/metric v1.27.0 // indirect - go.opentelemetry.io/otel/sdk v1.27.0 // indirect - go.opentelemetry.io/otel/trace v1.27.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/net v0.28.0 // indirect + go.opentelemetry.io/otel/metric v1.31.0 // indirect + go.opentelemetry.io/otel/trace v1.31.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.22.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect - google.golang.org/grpc v1.67.1 // indirect - google.golang.org/protobuf v1.35.1 // indirect + google.golang.org/grpc v1.69.4 // indirect + google.golang.org/protobuf v1.36.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.1 // indirect ) diff --git a/go.sum b/go.sum index ab31763e..a8819092 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3Q github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= -github.com/ProtonMail/go-crypto v1.1.0-alpha.2 h1:bkyFVUP+ROOARdgCiJzNQo2V2kiB97LyUpzH9P6Hrlg= -github.com/ProtonMail/go-crypto v1.1.0-alpha.2/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= +github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= @@ -22,8 +22,8 @@ github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBS github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= -github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo= +github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -44,13 +44,13 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= -github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= -github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= -github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= +github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8= +github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= +github.com/go-git/go-git/v5 v5.13.0 h1:vLn5wlGIh/X78El6r3Jr+30W16Blk0CTcxTYcYPWi5E= +github.com/go-git/go-git/v5 v5.13.0/go.mod h1:Wjo7/JyVKtQgUNdXYXIepzWfJQkUEIGvkvVkiXRR/zw= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= @@ -95,24 +95,24 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/hc-install v0.9.0 h1:2dIk8LcvANwtv3QZLckxcjyF5w8KVtiMxu6G6eLhghE= -github.com/hashicorp/hc-install v0.9.0/go.mod h1:+6vOP+mf3tuGgMApVYtmsnDoKWMDcFXeTxCACYZ8SFg= -github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M= -github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/hashicorp/hc-install v0.9.1 h1:gkqTfE3vVbafGQo6VZXcy2v5yoz2bE0+nhZXruCuODQ= +github.com/hashicorp/hc-install v0.9.1/go.mod h1:pWWvN/IrfeBK4XPeXXYkL6EjMufHkCK5DvwxeLKuBf0= +github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= +github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVWkd/RG0D2XQ= -github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg= -github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI= -github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c= -github.com/hashicorp/terraform-plugin-go v0.25.0 h1:oi13cx7xXA6QciMcpcFi/rwA974rdTxjqEhXJjbAyks= -github.com/hashicorp/terraform-plugin-go v0.25.0/go.mod h1:+SYagMYadJP86Kvn+TGeV+ofr/R3g4/If0O5sO96MVw= +github.com/hashicorp/terraform-exec v0.22.0 h1:G5+4Sz6jYZfRYUCg6eQgDsqTzkNXV+fP8l+uRmZHj64= +github.com/hashicorp/terraform-exec v0.22.0/go.mod h1:bjVbsncaeh8jVdhttWYZuBGj21FcYw6Ia/XfHcNO7lQ= +github.com/hashicorp/terraform-json v0.24.0 h1:rUiyF+x1kYawXeRth6fKFm/MdfBS6+lW4NbeATsYz8Q= +github.com/hashicorp/terraform-json v0.24.0/go.mod h1:Nfj5ubo9xbu9uiAoZVBsNOjvNKB66Oyrvtit74kC7ow= +github.com/hashicorp/terraform-plugin-go v0.26.0 h1:cuIzCv4qwigug3OS7iKhpGAbZTiypAfFQmw8aE65O2M= +github.com/hashicorp/terraform-plugin-go v0.26.0/go.mod h1:+CXjuLDiFgqR+GcrM5a2E2Kal5t5q2jb0E3D57tTdNY= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= -github.com/hashicorp/terraform-plugin-sdk/v2 v2.35.0 h1:wyKCCtn6pBBL46c1uIIBNUOWlNfYXfXpVo16iDyLp8Y= -github.com/hashicorp/terraform-plugin-sdk/v2 v2.35.0/go.mod h1:B0Al8NyYVr8Mp/KLwssKXG1RqnTk7FySqSn4fRuLNgw= -github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= -github.com/hashicorp/terraform-registry-address v0.2.3/go.mod h1:lFHA76T8jfQteVfT7caREqguFrW3c4MFSPhZB7HHgUM= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.0 h1:7/iejAPyCRBhqAg3jOx+4UcAhY0A+Sg8B+0+d/GxSfM= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.0/go.mod h1:TiQwXAjFrgBf5tg5rvBRz8/ubPULpU0HjSaVi5UoJf8= +github.com/hashicorp/terraform-registry-address v0.2.4 h1:JXu/zHB2Ymg/TGVCRu10XqNa4Sh2bWcqCNyKWjnCPJA= +github.com/hashicorp/terraform-registry-address v0.2.4/go.mod h1:tUNYTVyCtU4OIGXXMDp7WNcJ+0W1B4nmstVDgHMjfAU= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= @@ -181,8 +181,8 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= -github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= +github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -199,32 +199,34 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ= -github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70= +github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0= -go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= -go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY= -go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= -go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= -go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI= -go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= -go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= -go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -238,8 +240,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -261,8 +263,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -291,12 +293,12 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= -google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= -google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= +google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= +google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 5e4c5966ba27873ec498ba83139d0cfde142899f Mon Sep 17 00:00:00 2001 From: M Atif Ali Date: Fri, 7 Feb 2025 15:49:10 +0500 Subject: [PATCH 12/63] fix(docs): fix parameter options icons (#339) --- docs/data-sources/parameter.md | 10 +++++----- examples/data-sources/coder_parameter/data-source.tf | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/data-sources/parameter.md b/docs/data-sources/parameter.md index f40a9c83..4da9dac2 100644 --- a/docs/data-sources/parameter.md +++ b/docs/data-sources/parameter.md @@ -20,16 +20,16 @@ data "coder_parameter" "example" { description = "Specify a region to place your workspace." mutable = false type = "string" - default = "asia-central1-a" + default = "us-central1-a" option { value = "us-central1-a" name = "US Central" - icon = "/icon/usa.svg" + icon = "/icons/1f1fa-1f1f8.png" } option { - value = "asia-central1-a" - name = "Asia" - icon = "/icon/asia.svg" + value = "asia-southeast1-a" + name = "Singapore" + icon = "/icons/1f1f8-1f1ec.png" } } diff --git a/examples/data-sources/coder_parameter/data-source.tf b/examples/data-sources/coder_parameter/data-source.tf index 4efc3320..ac0de7cb 100644 --- a/examples/data-sources/coder_parameter/data-source.tf +++ b/examples/data-sources/coder_parameter/data-source.tf @@ -5,16 +5,16 @@ data "coder_parameter" "example" { description = "Specify a region to place your workspace." mutable = false type = "string" - default = "asia-central1-a" + default = "us-central1-a" option { value = "us-central1-a" name = "US Central" - icon = "/icon/usa.svg" + icon = "/icons/1f1fa-1f1f8.png" } option { - value = "asia-central1-a" - name = "Asia" - icon = "/icon/asia.svg" + value = "asia-southeast1-a" + name = "Singapore" + icon = "/icons/1f1f8-1f1ec.png" } } From c9b396f1997db20188ce674dd6335e8b6ac7b6f3 Mon Sep 17 00:00:00 2001 From: M Atif Ali Date: Fri, 7 Feb 2025 15:54:08 +0500 Subject: [PATCH 13/63] ci: drop support for EOL terraform version (#340) --- .github/workflows/test.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69ca7ada..88ccde05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -88,11 +88,8 @@ jobs: fail-fast: false matrix: terraform: - - "1.5.*" - - "1.6.*" - - "1.7.*" - - "1.8.*" - "1.9.*" + - "1.10.*" steps: - name: Set up Go uses: actions/setup-go@v5 From 1f958079cf6996d72fbdd1d8b0ecff0815b4680f Mon Sep 17 00:00:00 2001 From: Vincent Vielle Date: Fri, 7 Feb 2025 14:58:34 +0100 Subject: [PATCH 14/63] feat: add resources_monitoring field to agent (#331) * add resources_monitoring logic * add resources_monitoring logic * improve testing logic * improve testing logic * gen doc * improved testing and validation * improved testing and validation * improved testing and validation * regenerate documentation with validation * add more test cases * add validation methods * improve test verbose --- docs/resources/agent.md | 28 ++ .../coder_resources_monitoring/data-source.tf | 26 ++ provider/agent.go | 143 ++++++++- provider/agent_test.go | 296 ++++++++++++++++++ provider/examples_test.go | 1 + 5 files changed, 479 insertions(+), 15 deletions(-) create mode 100644 examples/data-sources/coder_resources_monitoring/data-source.tf diff --git a/docs/resources/agent.md b/docs/resources/agent.md index 8c786d6e..7c28b1f4 100644 --- a/docs/resources/agent.md +++ b/docs/resources/agent.md @@ -79,6 +79,7 @@ resource "kubernetes_pod" "dev" { - `metadata` (Block List) Each `metadata` block defines a single item consisting of a key/value pair. This feature is in alpha and may break in future releases. (see [below for nested schema](#nestedblock--metadata)) - `motd_file` (String) The path to a file within the workspace containing a message to display to users when they login via SSH. A typical value would be `"/etc/motd"`. - `order` (Number) The order determines the position of agents in the UI presentation. The lowest order is shown first and agents with equal order are sorted by name (ascending order). +- `resources_monitoring` (Block Set, Max: 1) The resources monitoring configuration for this agent. (see [below for nested schema](#nestedblock--resources_monitoring)) - `shutdown_script` (String) A script to run before the agent is stopped. The script should exit when it is done to signal that the workspace can be stopped. This option is an alias for defining a `coder_script` resource with `run_on_stop` set to `true`. - `startup_script` (String) A script to run after the agent starts. The script should exit when it is done to signal that the agent is ready. This option is an alias for defining a `coder_script` resource with `run_on_start` set to `true`. - `startup_script_behavior` (String) This option sets the behavior of the `startup_script`. When set to `"blocking"`, the `startup_script` must exit before the workspace is ready. When set to `"non-blocking"`, the `startup_script` may run in the background and the workspace will be ready immediately. Default is `"non-blocking"`, although `"blocking"` is recommended. This option is an alias for defining a `coder_script` resource with `start_blocks_login` set to `true` (blocking). @@ -116,3 +117,30 @@ Optional: - `display_name` (String) The user-facing name of this value. - `order` (Number) The order determines the position of agent metadata in the UI presentation. The lowest order is shown first and metadata with equal order are sorted by key (ascending order). - `timeout` (Number) The maximum time the command is allowed to run in seconds. + + + +### Nested Schema for `resources_monitoring` + +Optional: + +- `memory` (Block Set, Max: 1) The memory monitoring configuration for this agent. (see [below for nested schema](#nestedblock--resources_monitoring--memory)) +- `volume` (Block Set) The volumes monitoring configuration for this agent. (see [below for nested schema](#nestedblock--resources_monitoring--volume)) + + +### Nested Schema for `resources_monitoring.memory` + +Required: + +- `enabled` (Boolean) Enable memory monitoring for this agent. +- `threshold` (Number) The memory usage threshold in percentage at which to trigger an alert. Value should be between 0 and 100. + + + +### Nested Schema for `resources_monitoring.volume` + +Required: + +- `enabled` (Boolean) Enable volume monitoring for this agent. +- `path` (String) The path of the volume to monitor. +- `threshold` (Number) The volume usage threshold in percentage at which to trigger an alert. Value should be between 0 and 100. diff --git a/examples/data-sources/coder_resources_monitoring/data-source.tf b/examples/data-sources/coder_resources_monitoring/data-source.tf new file mode 100644 index 00000000..94bc55ed --- /dev/null +++ b/examples/data-sources/coder_resources_monitoring/data-source.tf @@ -0,0 +1,26 @@ +provider "coder" {} + +data "coder_provisioner" "dev" {} + +data "coder_workspace" "dev" {} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.dev.arch + os = data.coder_provisioner.dev.os + resources_monitoring { + memory { + enabled = true + threshold = 80 + } + volume { + path = "/volume1" + enabled = true + threshold = 80 + } + volume { + path = "/volume2" + enabled = true + threshold = 100 + } + } +} \ No newline at end of file diff --git a/provider/agent.go b/provider/agent.go index ac012bf1..3ddae235 100644 --- a/provider/agent.go +++ b/provider/agent.go @@ -3,10 +3,12 @@ package provider import ( "context" "fmt" + "path/filepath" "reflect" "strings" "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/hashicorp/terraform-plugin-sdk/v2/helper/validation" @@ -259,31 +261,142 @@ func agentResource() *schema.Resource { ForceNew: true, Optional: true, }, + "resources_monitoring": { + Type: schema.TypeSet, + Description: "The resources monitoring configuration for this agent.", + ForceNew: true, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "memory": { + Type: schema.TypeSet, + Description: "The memory monitoring configuration for this agent.", + ForceNew: true, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Description: "Enable memory monitoring for this agent.", + ForceNew: true, + Required: true, + }, + "threshold": { + Type: schema.TypeInt, + Description: "The memory usage threshold in percentage at which to trigger an alert. Value should be between 0 and 100.", + ForceNew: true, + Required: true, + ValidateFunc: validation.IntBetween(0, 100), + }, + }, + }, + }, + "volume": { + Type: schema.TypeSet, + Description: "The volumes monitoring configuration for this agent.", + ForceNew: true, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "path": { + Type: schema.TypeString, + Description: "The path of the volume to monitor.", + ForceNew: true, + Required: true, + ValidateDiagFunc: func(i interface{}, s cty.Path) diag.Diagnostics { + path, ok := i.(string) + if !ok { + return diag.Errorf("volume path must be a string") + } + if path == "" { + return diag.Errorf("volume path must not be empty") + } + + if !filepath.IsAbs(i.(string)) { + return diag.Errorf("volume path must be an absolute path") + } + + return nil + }, + }, + "enabled": { + Type: schema.TypeBool, + Description: "Enable volume monitoring for this agent.", + ForceNew: true, + Required: true, + }, + "threshold": { + Type: schema.TypeInt, + Description: "The volume usage threshold in percentage at which to trigger an alert. Value should be between 0 and 100.", + ForceNew: true, + Required: true, + ValidateFunc: validation.IntBetween(0, 100), + }, + }, + }, + }, + }, + }, + }, }, CustomizeDiff: func(ctx context.Context, rd *schema.ResourceDiff, i any) error { - if !rd.HasChange("metadata") { - return nil + if rd.HasChange("metadata") { + keys := map[string]bool{} + metadata, ok := rd.Get("metadata").([]any) + if !ok { + return xerrors.Errorf("unexpected type %T for metadata, expected []any", rd.Get("metadata")) + } + for _, t := range metadata { + obj, ok := t.(map[string]any) + if !ok { + return xerrors.Errorf("unexpected type %T for metadata, expected map[string]any", t) + } + key, ok := obj["key"].(string) + if !ok { + return xerrors.Errorf("unexpected type %T for metadata key, expected string", obj["key"]) + } + if keys[key] { + return xerrors.Errorf("duplicate agent metadata key %q", key) + } + keys[key] = true + } } - keys := map[string]bool{} - metadata, ok := rd.Get("metadata").([]any) - if !ok { - return xerrors.Errorf("unexpected type %T for metadata, expected []any", rd.Get("metadata")) - } - for _, t := range metadata { - obj, ok := t.(map[string]any) + if rd.HasChange("resources_monitoring") { + monitors, ok := rd.Get("resources_monitoring").(*schema.Set) if !ok { - return xerrors.Errorf("unexpected type %T for metadata, expected map[string]any", t) + return xerrors.Errorf("unexpected type %T for resources_monitoring.0.volume, expected []any", rd.Get("resources_monitoring.0.volume")) } - key, ok := obj["key"].(string) + + monitor := monitors.List()[0].(map[string]any) + + volumes, ok := monitor["volume"].(*schema.Set) if !ok { - return xerrors.Errorf("unexpected type %T for metadata key, expected string", obj["key"]) + return xerrors.Errorf("unexpected type %T for resources_monitoring.0.volume, expected []any", monitor["volume"]) } - if keys[key] { - return xerrors.Errorf("duplicate agent metadata key %q", key) + + paths := map[string]bool{} + for _, volume := range volumes.List() { + obj, ok := volume.(map[string]any) + if !ok { + return xerrors.Errorf("unexpected type %T for volume, expected map[string]any", volume) + } + + // print path for debug purpose + + path, ok := obj["path"].(string) + if !ok { + return xerrors.Errorf("unexpected type %T for volume path, expected string", obj["path"]) + } + if paths[path] { + return xerrors.Errorf("duplicate volume path %q", path) + } + paths[path] = true } - keys[key] = true } + return nil }, } diff --git a/provider/agent_test.go b/provider/agent_test.go index d40caf56..a45ac86a 100644 --- a/provider/agent_test.go +++ b/provider/agent_test.go @@ -211,6 +211,302 @@ func TestAgent_Metadata(t *testing.T) { }) } +func TestAgent_ResourcesMonitoring(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + resources_monitoring { + memory { + enabled = true + threshold = 80 + } + volume { + path = "/volume1" + enabled = true + threshold = 80 + } + volume { + path = "/volume2" + enabled = true + threshold = 100 + } + } + }`, + 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["coder_agent.dev"] + require.NotNil(t, resource) + + attr := resource.Primary.Attributes + require.Equal(t, "1", attr["resources_monitoring.#"]) + require.Equal(t, "1", attr["resources_monitoring.0.memory.#"]) + require.Equal(t, "2", attr["resources_monitoring.0.volume.#"]) + require.Equal(t, "80", attr["resources_monitoring.0.memory.0.threshold"]) + require.Equal(t, "/volume1", attr["resources_monitoring.0.volume.0.path"]) + require.Equal(t, "100", attr["resources_monitoring.0.volume.1.threshold"]) + require.Equal(t, "/volume2", attr["resources_monitoring.0.volume.1.path"]) + return nil + }, + }}, + }) + }) + + t.Run("OnlyMemory", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + resources_monitoring { + memory { + enabled = true + threshold = 80 + } + } + }`, + 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["coder_agent.dev"] + require.NotNil(t, resource) + + attr := resource.Primary.Attributes + require.Equal(t, "1", attr["resources_monitoring.#"]) + require.Equal(t, "1", attr["resources_monitoring.0.memory.#"]) + require.Equal(t, "80", attr["resources_monitoring.0.memory.0.threshold"]) + return nil + }, + }}, + }) + }) + t.Run("MultipleMemory", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + resources_monitoring { + memory { + enabled = true + threshold = 80 + } + memory { + enabled = true + threshold = 90 + } + } + }`, + ExpectError: regexp.MustCompile(`No more than 1 "memory" blocks are allowed`), + }}, + }) + }) + + t.Run("InvalidThreshold", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + resources_monitoring { + memory { + enabled = true + threshold = 101 + } + } + }`, + Check: nil, + ExpectError: regexp.MustCompile(`expected resources_monitoring\.0\.memory\.0\.threshold to be in the range \(0 - 100\), got 101`), + }}, + }) + }) + + t.Run("DuplicatePaths", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + resources_monitoring { + volume { + path = "/volume1" + enabled = true + threshold = 80 + } + volume { + path = "/volume1" + enabled = true + threshold = 100 + } + } + }`, + ExpectError: regexp.MustCompile("duplicate volume path"), + }}, + }) + }) + + t.Run("NoPath", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + resources_monitoring { + volume { + enabled = true + threshold = 80 + } + } + }`, + ExpectError: regexp.MustCompile(`The argument "path" is required, but no definition was found.`), + }}, + }) + }) + + t.Run("NonAbsPath", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + resources_monitoring { + volume { + path = "tmp" + enabled = true + threshold = 80 + } + } + }`, + Check: nil, + ExpectError: regexp.MustCompile(`volume path must be an absolute path`), + }}, + }) + }) + + t.Run("EmptyPath", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + resources_monitoring { + volume { + path = "" + enabled = true + threshold = 80 + } + } + }`, + Check: nil, + ExpectError: regexp.MustCompile(`volume path must not be empty`), + }}, + }) + }) + + t.Run("ThresholdMissing", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + resources_monitoring { + volume { + path = "/volume1" + enabled = true + } + } + }`, + Check: nil, + ExpectError: regexp.MustCompile(`The argument "threshold" is required, but no definition was found.`), + }}, + }) + }) + t.Run("EnabledMissing", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + resources_monitoring { + memory { + threshold = 80 + } + } + }`, + Check: nil, + ExpectError: regexp.MustCompile(`The argument "enabled" is required, but no definition was found.`), + }}, + }) + }) +} + func TestAgent_MetadataDuplicateKeys(t *testing.T) { t.Parallel() resource.Test(t, resource.TestCase{ diff --git a/provider/examples_test.go b/provider/examples_test.go index c6931ae3..1d17b1ba 100644 --- a/provider/examples_test.go +++ b/provider/examples_test.go @@ -15,6 +15,7 @@ func TestExamples(t *testing.T) { for _, testDir := range []string{ "coder_parameter", "coder_workspace_tags", + "coder_resources_monitoring", } { t.Run(testDir, func(t *testing.T) { testDir := testDir From aef62206d070e05bd7770cedba532592cb9e42e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Feb 2025 09:18:37 +0000 Subject: [PATCH 15/63] build(deps): Bump goreleaser/goreleaser-action from 6.1.0 to 6.2.1 (#342) Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 6.1.0 to 6.2.1. - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/v6.1.0...v6.2.1) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bb2f731..d85ab5e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,7 +90,7 @@ jobs: passphrase: ${{ secrets.PASSPHRASE }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6.1.0 + uses: goreleaser/goreleaser-action@v6.2.1 with: version: latest args: release --clean From 4b3fc653e348c324d680c817eceeeccf06c9b877 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 13 Feb 2025 12:32:48 +0100 Subject: [PATCH 16/63] fix: app display name validation (#344) --- provider/app.go | 13 ++++++++++ provider/app_test.go | 62 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/provider/app.go b/provider/app.go index cd64db54..2d0d6b09 100644 --- a/provider/app.go +++ b/provider/app.go @@ -23,6 +23,8 @@ var ( appSlugRegex = regexp.MustCompile(`^[a-z0-9](-?[a-z0-9])*$`) ) +const appDisplayNameMaxLength = 64 // database column limit + func appResource() *schema.Resource { return &schema.Resource{ SchemaVersion: 1, @@ -124,6 +126,17 @@ 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 { + valStr, ok := val.(string) + if !ok { + return diag.Errorf("expected string, got %T", val) + } + + if len(valStr) > appDisplayNameMaxLength { + return diag.Errorf("display name is too long (max %d characters)", appDisplayNameMaxLength) + } + return nil + }, }, "subdomain": { Type: schema.TypeBool, diff --git a/provider/app_test.go b/provider/app_test.go index 005e8377..444b6b0d 100644 --- a/provider/app_test.go +++ b/provider/app_test.go @@ -415,4 +415,66 @@ func TestApp(t *testing.T) { } }) + t.Run("DisplayName", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + displayName string + expectValue string + expectError *regexp.Regexp + }{ + { + name: "Empty", + displayName: "", + }, + { + name: "Regular", + displayName: "Regular Application", + }, + { + name: "DisplayNameStillOK", + displayName: "0123456789012345678901234567890123456789012345678901234567890123", + }, + { + name: "DisplayNameTooLong", + displayName: "01234567890123456789012345678901234567890123456789012345678901234", + expectError: regexp.MustCompile("display name is too long"), + }, + } + + 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 = "%s" + url = "http://localhost:13337" + open_in = "slim-window" + } + `, c.displayName) + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: config, + ExpectError: c.expectError, + }}, + }) + }) + } + }) + } From a9b64aab99d7815addbe25498a161c29ac1e62da Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 13 Feb 2025 13:42:47 +0100 Subject: [PATCH 17/63] fix: support unlimited parameter options (#345) --- docs/data-sources/parameter.md | 2 +- provider/parameter.go | 1 - provider/parameter_test.go | 42 ++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/data-sources/parameter.md b/docs/data-sources/parameter.md index 4da9dac2..e46cf86d 100644 --- a/docs/data-sources/parameter.md +++ b/docs/data-sources/parameter.md @@ -147,7 +147,7 @@ data "coder_parameter" "home_volume_size" { - `ephemeral` (Boolean) The value of an ephemeral parameter will not be preserved between consecutive workspace builds. - `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/"`. - `mutable` (Boolean) Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution! -- `option` (Block List, Max: 64) Each `option` block defines a value for a user to select from. (see [below for nested schema](#nestedblock--option)) +- `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). - `type` (String) The type of this parameter. Must be one of: `"number"`, `"string"`, `"bool"`, or `"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 00dd5f34..1345f4d6 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -237,7 +237,6 @@ func parameterDataSource() *schema.Resource { Description: "Each `option` block defines a value for a user to select from.", ForceNew: true, Optional: true, - MaxItems: 64, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "name": { diff --git a/provider/parameter_test.go b/provider/parameter_test.go index b1f164a0..7bcea8fd 100644 --- a/provider/parameter_test.go +++ b/provider/parameter_test.go @@ -1,7 +1,9 @@ package provider_test import ( + "fmt" "regexp" + "strings" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" @@ -816,3 +818,43 @@ func TestValueValidatesType(t *testing.T) { }) } } + +func TestParameterWithManyOptions(t *testing.T) { + t.Parallel() + + const maxItemsInTest = 1024 + + var options strings.Builder + for i := 0; i < maxItemsInTest; i++ { + _, _ = options.WriteString(fmt.Sprintf(`option { + name = "%d" + value = "%d" + } +`, i, i)) + } + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: fmt.Sprintf(`data "coder_parameter" "region" { + name = "Region" + type = "string" + %s + }`, options.String()), + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + param := state.Modules[0].Resources["data.coder_parameter.region"] + + for i := 0; i < maxItemsInTest; i++ { + name, _ := param.Primary.Attributes[fmt.Sprintf("option.%d.name", i)] + value, _ := param.Primary.Attributes[fmt.Sprintf("option.%d.value", i)] + require.Equal(t, fmt.Sprintf("%d", i), name) + require.Equal(t, fmt.Sprintf("%d", i), value) + } + return nil + }, + }}, + }) +} From 249863dcc1977f6ee8869e50a12929787e3e3305 Mon Sep 17 00:00:00 2001 From: M Atif Ali Date: Wed, 19 Feb 2025 17:55:06 +0500 Subject: [PATCH 18/63] chore: test auto prerelease (#347) --- .goreleaser.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index 69029533..31a6b24c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -56,6 +56,8 @@ release: name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' # If you want to manually examine the release before its live, uncomment this line: # draft: true + prerelease: auto + make_latest: {{ not .Prerelease }} changelog: # see https://goreleaser.com/customization/changelog/ use: github-native From 87755fcac79b787db7c5b82593e66fb29db58fe0 Mon Sep 17 00:00:00 2001 From: M Atif Ali Date: Wed, 19 Feb 2025 18:45:52 +0500 Subject: [PATCH 19/63] chore: set GoReleaser version to 2 in config (#348) --- .github/workflows/release.yml | 2 +- .goreleaser.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d85ab5e7..82a7bb3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,7 +92,7 @@ jobs: - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6.2.1 with: - version: latest + version: '~> v2' args: release --clean env: GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 31a6b24c..c17bc698 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,5 +1,6 @@ # Visit https://goreleaser.com for documentation on how to customize this # behavior. +version: 2 before: hooks: # this is just an example and not a requirement for provider building/publishing From 72bed805debb8863657e98dae77073a0ea87f4ed Mon Sep 17 00:00:00 2001 From: M Atif Ali Date: Wed, 19 Feb 2025 19:26:26 +0500 Subject: [PATCH 20/63] chore: fix GoReleaser config for version 2 (#349) --- .goreleaser.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index c17bc698..34b92f3f 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -31,7 +31,7 @@ builds: goarch: '386' binary: '{{ .ProjectName }}_v{{ .Version }}' archives: -- format: zip +- formats: [ zip ] name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' checksum: extra_files: @@ -58,7 +58,7 @@ release: # If you want to manually examine the release before its live, uncomment this line: # draft: true prerelease: auto - make_latest: {{ not .Prerelease }} + make_latest: '{{ not .Prerelease }}' changelog: # see https://goreleaser.com/customization/changelog/ use: github-native From f95a77f8993ffb01bb90c051f38a5ef4ed93716f Mon Sep 17 00:00:00 2001 From: M Atif Ali Date: Wed, 5 Mar 2025 13:49:15 +0500 Subject: [PATCH 21/63] chore: do not use GitHub prereleases (#355) --- .goreleaser.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 34b92f3f..658a715c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -55,10 +55,6 @@ release: extra_files: - glob: 'terraform-registry-manifest.json' name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' - # If you want to manually examine the release before its live, uncomment this line: - # draft: true - prerelease: auto - make_latest: '{{ not .Prerelease }}' changelog: # see https://goreleaser.com/customization/changelog/ use: github-native From 68f7cf0346c636298844b947ef2d7b5b09d8a0f2 Mon Sep 17 00:00:00 2001 From: M Atif Ali Date: Wed, 5 Mar 2025 14:04:42 +0500 Subject: [PATCH 22/63] chore: include terraform 1.11.* in tests (#356) --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 88ccde05..8e4df55d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -90,6 +90,7 @@ jobs: terraform: - "1.9.*" - "1.10.*" + - "1.11.*" steps: - name: Set up Go uses: actions/setup-go@v5 From ea1c3d7bf71e739a01a4655897cde23d5d8b4c8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Mar 2025 09:13:59 +0000 Subject: [PATCH 23/63] build(deps): Bump github.com/hashicorp/terraform-plugin-sdk/v2 (#351) Bumps [github.com/hashicorp/terraform-plugin-sdk/v2](https://github.com/hashicorp/terraform-plugin-sdk) from 2.36.0 to 2.36.1. - [Release notes](https://github.com/hashicorp/terraform-plugin-sdk/releases) - [Changelog](https://github.com/hashicorp/terraform-plugin-sdk/blob/main/CHANGELOG.md) - [Commits](https://github.com/hashicorp/terraform-plugin-sdk/compare/v2.36.0...v2.36.1) --- updated-dependencies: - dependency-name: github.com/hashicorp/terraform-plugin-sdk/v2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index c1033b5e..02ea7137 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/docker/docker v26.1.5+incompatible github.com/google/uuid v1.6.0 github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 - github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.0 + github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.1 github.com/masterminds/semver v1.5.0 github.com/mitchellh/mapstructure v1.5.0 github.com/robfig/cron/v3 v3.0.1 @@ -79,11 +79,11 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect go.opentelemetry.io/otel/metric v1.31.0 // indirect go.opentelemetry.io/otel/trace v1.31.0 // indirect - golang.org/x/crypto v0.32.0 // indirect + golang.org/x/crypto v0.33.0 // indirect golang.org/x/net v0.34.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.22.0 // indirect google.golang.org/appengine v1.6.8 // indirect diff --git a/go.sum b/go.sum index a8819092..2b5ad9c5 100644 --- a/go.sum +++ b/go.sum @@ -109,8 +109,8 @@ github.com/hashicorp/terraform-plugin-go v0.26.0 h1:cuIzCv4qwigug3OS7iKhpGAbZTiy github.com/hashicorp/terraform-plugin-go v0.26.0/go.mod h1:+CXjuLDiFgqR+GcrM5a2E2Kal5t5q2jb0E3D57tTdNY= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= -github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.0 h1:7/iejAPyCRBhqAg3jOx+4UcAhY0A+Sg8B+0+d/GxSfM= -github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.0/go.mod h1:TiQwXAjFrgBf5tg5rvBRz8/ubPULpU0HjSaVi5UoJf8= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.1 h1:WNMsTLkZf/3ydlgsuXePa3jvZFwAJhruxTxP/c1Viuw= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.1/go.mod h1:P6o64QS97plG44iFzSM6rAn6VJIC/Sy9a9IkEtl79K4= github.com/hashicorp/terraform-registry-address v0.2.4 h1:JXu/zHB2Ymg/TGVCRu10XqNa4Sh2bWcqCNyKWjnCPJA= github.com/hashicorp/terraform-registry-address v0.2.4/go.mod h1:tUNYTVyCtU4OIGXXMDp7WNcJ+0W1B4nmstVDgHMjfAU= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= @@ -225,8 +225,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -247,8 +247,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -263,8 +263,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -272,8 +272,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From 552eb5e9a1a564f19cd3ba3745f2abb5d205e74c Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Wed, 5 Mar 2025 11:18:00 +0200 Subject: [PATCH 24/63] chore: only release if tests pass (#357) We've generally been lucky that builds are slower than integration tests, but we need explicit ordering here Signed-off-by: Danny Kopping --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82a7bb3b..33f17b8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,7 @@ jobs: goreleaser: runs-on: ubuntu-latest + needs: test steps: - name: Checkout uses: actions/checkout@v4 From eab8698d5b34d712a15cbaa99addf3698aa87577 Mon Sep 17 00:00:00 2001 From: Nick Fisher Date: Fri, 14 Mar 2025 14:40:14 -0400 Subject: [PATCH 25/63] feat: Add rbac_roles to coder_workspace_owner data source (#330) --- docs/data-sources/workspace_owner.md | 1 + integration/integration_test.go | 2 ++ integration/workspace-owner-filled/main.tf | 1 + integration/workspace-owner/main.tf | 1 + provider/workspace_owner.go | 27 ++++++++++++++++++++++ provider/workspace_owner_test.go | 6 ++++- 6 files changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/data-sources/workspace_owner.md b/docs/data-sources/workspace_owner.md index fbe4f205..71e39ed1 100644 --- a/docs/data-sources/workspace_owner.md +++ b/docs/data-sources/workspace_owner.md @@ -53,6 +53,7 @@ resource "coder_env" "git_author_email" { - `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. +- `rbac_roles` (List of Map) The RBAC roles and associated org ids of which the user is assigned. - `session_token` (String) 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/integration/integration_test.go b/integration/integration_test.go index bbbd5587..89fdda7e 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -122,6 +122,7 @@ func TestIntegration(t *testing.T) { "workspace_owner.ssh_private_key": `(?s)^.+?BEGIN OPENSSH PRIVATE KEY.+?END OPENSSH PRIVATE KEY.+?$`, "workspace_owner.ssh_public_key": `(?s)^ssh-ed25519.+$`, "workspace_owner.login_type": ``, + "workspace_owner.rbac_roles": `\[\]`, }, }, { @@ -150,6 +151,7 @@ func TestIntegration(t *testing.T) { "workspace_owner.ssh_private_key": `(?s)^.+?BEGIN OPENSSH PRIVATE KEY.+?END OPENSSH PRIVATE KEY.+?$`, "workspace_owner.ssh_public_key": `(?s)^ssh-ed25519.+$`, "workspace_owner.login_type": `password`, + "workspace_owner.rbac_roles": `\[\]`, }, }, { diff --git a/integration/workspace-owner-filled/main.tf b/integration/workspace-owner-filled/main.tf index fd923a3d..d2de5661 100644 --- a/integration/workspace-owner-filled/main.tf +++ b/integration/workspace-owner-filled/main.tf @@ -40,6 +40,7 @@ locals { "workspace_owner.ssh_private_key" : data.coder_workspace_owner.me.ssh_private_key, "workspace_owner.ssh_public_key" : data.coder_workspace_owner.me.ssh_public_key, "workspace_owner.login_type" : data.coder_workspace_owner.me.login_type, + "workspace_owner.rbac_roles" : jsonencode(data.coder_workspace_owner.me.rbac_roles), } } diff --git a/integration/workspace-owner/main.tf b/integration/workspace-owner/main.tf index fd923a3d..d2de5661 100644 --- a/integration/workspace-owner/main.tf +++ b/integration/workspace-owner/main.tf @@ -40,6 +40,7 @@ locals { "workspace_owner.ssh_private_key" : data.coder_workspace_owner.me.ssh_private_key, "workspace_owner.ssh_public_key" : data.coder_workspace_owner.me.ssh_public_key, "workspace_owner.login_type" : data.coder_workspace_owner.me.login_type, + "workspace_owner.rbac_roles" : jsonencode(data.coder_workspace_owner.me.rbac_roles), } } diff --git a/provider/workspace_owner.go b/provider/workspace_owner.go index 52b1ef8c..078047ff 100644 --- a/provider/workspace_owner.go +++ b/provider/workspace_owner.go @@ -59,6 +59,14 @@ func workspaceOwnerDataSource() *schema.Resource { _ = rd.Set("login_type", loginType) } + var rbacRoles []map[string]string + if rolesRaw, ok := os.LookupEnv("CODER_WORKSPACE_OWNER_RBAC_ROLES"); ok { + if err := json.NewDecoder(strings.NewReader(rolesRaw)).Decode(&rbacRoles); err != nil { + return diag.Errorf("invalid user rbac roles: %s", err.Error()) + } + } + _ = rd.Set("rbac_roles", rbacRoles) + return diags }, Schema: map[string]*schema.Schema{ @@ -118,6 +126,25 @@ func workspaceOwnerDataSource() *schema.Resource { Computed: true, Description: "The type of login the user has.", }, + "rbac_roles": { + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the RBAC role.", + }, + "org_id": { + Type: schema.TypeString, + Computed: true, + Description: "The organization ID associated with the RBAC role.", + }, + }, + }, + Computed: true, + Description: "The RBAC roles of which the user is assigned.", + }, }, } } diff --git a/provider/workspace_owner_test.go b/provider/workspace_owner_test.go index ad371570..de23b3e7 100644 --- a/provider/workspace_owner_test.go +++ b/provider/workspace_owner_test.go @@ -34,6 +34,7 @@ func TestWorkspaceOwnerDatasource(t *testing.T) { t.Setenv("CODER_WORKSPACE_OWNER_SESSION_TOKEN", `supersecret`) t.Setenv("CODER_WORKSPACE_OWNER_OIDC_ACCESS_TOKEN", `alsosupersecret`) t.Setenv("CODER_WORKSPACE_OWNER_LOGIN_TYPE", `github`) + t.Setenv("CODER_WORKSPACE_OWNER_RBAC_ROLES", `[{"name":"member","org_id":"00000000-0000-0000-0000-000000000000"}]`) resource.Test(t, resource.TestCase{ ProviderFactories: coderFactory(), @@ -61,7 +62,8 @@ func TestWorkspaceOwnerDatasource(t *testing.T) { assert.Equal(t, `supersecret`, attrs["session_token"]) assert.Equal(t, `alsosupersecret`, attrs["oidc_access_token"]) assert.Equal(t, `github`, attrs["login_type"]) - + assert.Equal(t, `member`, attrs["rbac_roles.0.name"]) + assert.Equal(t, `00000000-0000-0000-0000-000000000000`, attrs["rbac_roles.0.org_id"]) return nil }, }}, @@ -80,6 +82,7 @@ func TestWorkspaceOwnerDatasource(t *testing.T) { "CODER_WORKSPACE_OWNER_SSH_PUBLIC_KEY", "CODER_WORKSPACE_OWNER_SSH_PRIVATE_KEY", "CODER_WORKSPACE_OWNER_LOGIN_TYPE", + "CODER_WORKSPACE_OWNER_RBAC_ROLES", } { // https://github.com/golang/go/issues/52817 t.Setenv(v, "") os.Unsetenv(v) @@ -110,6 +113,7 @@ func TestWorkspaceOwnerDatasource(t *testing.T) { assert.Empty(t, attrs["session_token"]) assert.Empty(t, attrs["oidc_access_token"]) assert.Empty(t, attrs["login_type"]) + assert.Empty(t, attrs["rbac_roles.0"]) return nil }, }}, From f75fb0b273851decf878920d04d12f16e7e9d743 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 19 Mar 2025 14:53:20 +0000 Subject: [PATCH 26/63] chore(integration): fix failing tests referencing workspace_owner.rbac_roles (#369) --- docs/data-sources/workspace_owner.md | 10 +++- integration/integration_test.go | 33 ++++++++++- .../workspace-owner-rbac-roles/main.tf | 57 +++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 integration/workspace-owner-rbac-roles/main.tf diff --git a/docs/data-sources/workspace_owner.md b/docs/data-sources/workspace_owner.md index 71e39ed1..2a912e1f 100644 --- a/docs/data-sources/workspace_owner.md +++ b/docs/data-sources/workspace_owner.md @@ -53,7 +53,15 @@ resource "coder_env" "git_author_email" { - `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. -- `rbac_roles` (List of Map) The RBAC roles and associated org ids of which the user is assigned. +- `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. - `ssh_private_key` (String, Sensitive) The user's generated SSH private key. - `ssh_public_key` (String) The user's generated SSH public key. + + +### Nested Schema for `rbac_roles` + +Read-Only: + +- `name` (String) +- `org_id` (String) diff --git a/integration/integration_test.go b/integration/integration_test.go index 89fdda7e..65aa5aed 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -122,7 +122,7 @@ func TestIntegration(t *testing.T) { "workspace_owner.ssh_private_key": `(?s)^.+?BEGIN OPENSSH PRIVATE KEY.+?END OPENSSH PRIVATE KEY.+?$`, "workspace_owner.ssh_public_key": `(?s)^ssh-ed25519.+$`, "workspace_owner.login_type": ``, - "workspace_owner.rbac_roles": `\[\]`, + "workspace_owner.rbac_roles": ``, }, }, { @@ -151,7 +151,36 @@ func TestIntegration(t *testing.T) { "workspace_owner.ssh_private_key": `(?s)^.+?BEGIN OPENSSH PRIVATE KEY.+?END OPENSSH PRIVATE KEY.+?$`, "workspace_owner.ssh_public_key": `(?s)^ssh-ed25519.+$`, "workspace_owner.login_type": `password`, - "workspace_owner.rbac_roles": `\[\]`, + "workspace_owner.rbac_roles": ``, + }, + }, + { + name: "workspace-owner-rbac-roles", + minVersion: "v2.21.0", // anticipated version, update as required + expectedOutput: map[string]string{ + "provisioner.arch": runtime.GOARCH, + "provisioner.id": `[a-zA-Z0-9-]+`, + "provisioner.os": runtime.GOOS, + "workspace.access_port": `\d+`, + "workspace.access_url": `https?://\D+:\d+`, + "workspace.id": `[a-zA-z0-9-]+`, + "workspace.name": ``, + "workspace.start_count": `1`, + "workspace.template_id": `[a-zA-Z0-9-]+`, + "workspace.template_name": `workspace-owner`, + "workspace.template_version": `.+`, + "workspace.transition": `start`, + "workspace_owner.email": `testing@coder\.com`, + "workspace_owner.full_name": `default`, + "workspace_owner.groups": `\[(\"Everyone\")?\]`, + "workspace_owner.id": `[a-zA-Z0-9-]+`, + "workspace_owner.name": `testing`, + "workspace_owner.oidc_access_token": `^$`, // TODO: test OIDC integration + "workspace_owner.session_token": `.+`, + "workspace_owner.ssh_private_key": `(?s)^.+?BEGIN OPENSSH PRIVATE KEY.+?END OPENSSH PRIVATE KEY.+?$`, + "workspace_owner.ssh_public_key": `(?s)^ssh-ed25519.+$`, + "workspace_owner.login_type": `password`, + "workspace_owner.rbac_roles": `(?is)\[(\{"name":"[a-z0-9-:]+","org_id":"[a-f0-9-]+"\},?)+\]`, }, }, { diff --git a/integration/workspace-owner-rbac-roles/main.tf b/integration/workspace-owner-rbac-roles/main.tf new file mode 100644 index 00000000..66b79282 --- /dev/null +++ b/integration/workspace-owner-rbac-roles/main.tf @@ -0,0 +1,57 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + local = { + source = "hashicorp/local" + } + } +} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +locals { + # NOTE: these must all be strings in the output + output = { + "provisioner.arch" : data.coder_provisioner.me.arch, + "provisioner.id" : data.coder_provisioner.me.id, + "provisioner.os" : data.coder_provisioner.me.os, + "workspace.access_port" : tostring(data.coder_workspace.me.access_port), + "workspace.access_url" : data.coder_workspace.me.access_url, + "workspace.id" : data.coder_workspace.me.id, + "workspace.name" : data.coder_workspace.me.name, + "workspace.start_count" : tostring(data.coder_workspace.me.start_count), + "workspace.template_id" : data.coder_workspace.me.template_id, + "workspace.template_name" : data.coder_workspace.me.template_name, + "workspace.template_version" : data.coder_workspace.me.template_version, + "workspace.transition" : data.coder_workspace.me.transition, + "workspace_owner.email" : data.coder_workspace_owner.me.email, + "workspace_owner.full_name" : data.coder_workspace_owner.me.full_name, + "workspace_owner.groups" : jsonencode(data.coder_workspace_owner.me.groups), + "workspace_owner.id" : data.coder_workspace_owner.me.id, + "workspace_owner.name" : data.coder_workspace_owner.me.name, + "workspace_owner.oidc_access_token" : data.coder_workspace_owner.me.oidc_access_token, + "workspace_owner.session_token" : data.coder_workspace_owner.me.session_token, + "workspace_owner.ssh_private_key" : data.coder_workspace_owner.me.ssh_private_key, + "workspace_owner.ssh_public_key" : data.coder_workspace_owner.me.ssh_public_key, + "workspace_owner.login_type" : data.coder_workspace_owner.me.login_type, + "workspace_owner.rbac_roles" : jsonencode(data.coder_workspace_owner.me.rbac_roles), + } +} + +variable "output_path" { + type = string +} + +resource "local_file" "output" { + filename = var.output_path + content = jsonencode(local.output) +} + +output "output" { + value = local.output + sensitive = true +} From 9a745586b23a9cb5de2f65a2dcac12e48b134ffa Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 20 Mar 2025 19:09:06 +0200 Subject: [PATCH 27/63] feat: add `coder_devcontainer` resource (#368) * feat: add `coder_devcontainer` resource This change will allow autostarting Dev Containers. This is implemented as a resource instead of a `schema.TypeSet` on the agent resource to allow definition via Coder modules. Updates coder/coder#16423 --- docs/resources/devcontainer.md | 29 ++++++++++ provider/devcontainer.go | 46 ++++++++++++++++ provider/devcontainer_test.go | 98 ++++++++++++++++++++++++++++++++++ provider/provider.go | 1 + 4 files changed, 174 insertions(+) create mode 100644 docs/resources/devcontainer.md create mode 100644 provider/devcontainer.go create mode 100644 provider/devcontainer_test.go diff --git a/docs/resources/devcontainer.md b/docs/resources/devcontainer.md new file mode 100644 index 00000000..93d5724b --- /dev/null +++ b/docs/resources/devcontainer.md @@ -0,0 +1,29 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coder_devcontainer Resource - terraform-provider-coder" +subcategory: "" +description: |- + Define a Dev Container the agent should know of and attempt to autostart (minimum Coder version: v2.21). +--- + +# coder_devcontainer (Resource) + +Define a Dev Container the agent should know of and attempt to autostart (minimum Coder version: v2.21). + + + + +## Schema + +### Required + +- `agent_id` (String) The `id` property of a `coder_agent` resource to associate with. +- `workspace_folder` (String) The workspace folder to for the Dev Container. + +### Optional + +- `config_path` (String) The path to the Dev Container configuration file (devcontainer.json). + +### Read-Only + +- `id` (String) The ID of this resource. diff --git a/provider/devcontainer.go b/provider/devcontainer.go new file mode 100644 index 00000000..7d1fe0a4 --- /dev/null +++ b/provider/devcontainer.go @@ -0,0 +1,46 @@ +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" +) + +func devcontainerResource() *schema.Resource { + return &schema.Resource{ + SchemaVersion: 1, + + Description: "Define a Dev Container the agent should know of and attempt to autostart (minimum Coder version: v2.21).", + CreateContext: func(_ 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, + }, + "workspace_folder": { + Type: schema.TypeString, + Description: "The workspace folder to for the Dev Container.", + ForceNew: true, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + "config_path": { + Type: schema.TypeString, + Description: "The path to the Dev Container configuration file (devcontainer.json).", + ForceNew: true, + Optional: true, + }, + }, + } +} diff --git a/provider/devcontainer_test.go b/provider/devcontainer_test.go new file mode 100644 index 00000000..784cfb0d --- /dev/null +++ b/provider/devcontainer_test.go @@ -0,0 +1,98 @@ +package provider_test + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestDevcontainer(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + resource "coder_devcontainer" "example" { + agent_id = "king" + workspace_folder = "/workspace" + config_path = "/workspace/devcontainer.json" + } + `, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + script := state.Modules[0].Resources["coder_devcontainer.example"] + require.NotNil(t, script) + t.Logf("script attributes: %#v", script.Primary.Attributes) + for key, expected := range map[string]string{ + "agent_id": "king", + "workspace_folder": "/workspace", + "config_path": "/workspace/devcontainer.json", + } { + require.Equal(t, expected, script.Primary.Attributes[key]) + } + return nil + }, + }}, + }) +} + +func TestDevcontainerNoConfigPath(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + resource "coder_devcontainer" "example" { + agent_id = "king" + workspace_folder = "/workspace" + } + `, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + script := state.Modules[0].Resources["coder_devcontainer.example"] + require.NotNil(t, script) + t.Logf("script attributes: %#v", script.Primary.Attributes) + for key, expected := range map[string]string{ + "agent_id": "king", + "workspace_folder": "/workspace", + } { + require.Equal(t, expected, script.Primary.Attributes[key]) + } + return nil + }, + }}, + }) +} + +func TestDevcontainerNoWorkspaceFolder(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + resource "coder_devcontainer" "example" { + agent_id = "" + } + `, + ExpectError: regexp.MustCompile(`The argument "workspace_folder" is required, but no definition was found.`), + }}, + }) +} diff --git a/provider/provider.go b/provider/provider.go index d9780d76..cc2644ef 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -76,6 +76,7 @@ func New() *schema.Provider { "coder_metadata": metadataResource(), "coder_script": scriptResource(), "coder_env": envResource(), + "coder_devcontainer": devcontainerResource(), }, } } From ef92eea9d88a63ecda165a3f17f891845d3a7da2 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Mon, 7 Apr 2025 08:20:00 +0200 Subject: [PATCH 28/63] feat: allow presets to define prebuilds (#373) * feat: allow presets to define prebuilds * document prebuild parameters * remove todo * make gen * Add integration testing * Allow presets to define 0 parameters * additional documentation for prebuilds --- README.md | 2 +- docs/data-sources/workspace.md | 2 ++ docs/data-sources/workspace_preset.md | 21 +++++++++--- integration/integration_test.go | 9 ++--- integration/test-data-source/main.tf | 5 +++ provider/workspace.go | 22 +++++++++++++ provider/workspace_preset.go | 47 ++++++++++++++++++++------- provider/workspace_preset_test.go | 40 +++++++++++++++++++++-- 8 files changed, 124 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index b8ee8840..f055961e 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ to setup your local Terraform to use your local version rather than the registry } ``` 2. Run `terraform init` and observe a warning like `Warning: Provider development overrides are in effect` -4. Run `go build -o terraform-provider-coder` to build the provider binary, which Terraform will try locate and execute +4. Run `make build` to build the provider binary, which Terraform will try locate and execute 5. All local Terraform runs will now use your local provider! 6. _**NOTE**: we vendor in this provider into `github.com/coder/coder`, so if you're testing with a local clone then you should also run `go mod edit -replace github.com/coder/terraform-provider-coder=/path/to/terraform-provider-coder` in your clone._ diff --git a/docs/data-sources/workspace.md b/docs/data-sources/workspace.md index 26396ba1..29fd9179 100644 --- a/docs/data-sources/workspace.md +++ b/docs/data-sources/workspace.md @@ -69,7 +69,9 @@ resource "docker_container" "workspace" { - `access_port` (Number) The access port of the Coder deployment provisioning this workspace. - `access_url` (String) The access URL of the Coder deployment provisioning this workspace. - `id` (String) UUID of the workspace. +- `is_prebuild` (Boolean) Similar to `prebuild_count`, but a boolean value instead of a count. This is set to true if the workspace is a currently unassigned prebuild. Once the workspace is assigned, this value will be false. - `name` (String) Name of the workspace. +- `prebuild_count` (Number) A computed count, equal to 1 if the workspace is a currently unassigned prebuild. Use this to conditionally act on the status of a prebuild. Actions that do not require user identity can be taken when this value is set to 1. Actions that should only be taken once the workspace has been assigned to a user may be taken when this value is set to 0. - `start_count` (Number) A computed count based on `transition` state. If `start`, count will equal 1. - `template_id` (String) ID of the workspace's template. - `template_name` (String) Name of the workspace's template. diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index 28f90faa..edd61f18 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -3,12 +3,12 @@ page_title: "coder_workspace_preset Data Source - terraform-provider-coder" subcategory: "" description: |- - Use this data source to predefine common configurations for workspaces. + Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are not defined by the preset will still be configurable when creating a workspace. --- # coder_workspace_preset (Data Source) -Use this data source to predefine common configurations for workspaces. +Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are not defined by the preset will still be configurable when creating a workspace. ## Example Usage @@ -34,9 +34,20 @@ data "coder_workspace_preset" "example" { ### Required -- `name` (String) Name of the workspace preset. -- `parameters` (Map of String) Parameters of the workspace preset. +- `name` (String) The name of the workspace preset. + +### Optional + +- `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) Prebuilt workspace configuration related to this workspace preset. Coder will build and maintain workspaces in reserve based on this configuration. When a user creates a new workspace using a preset, they will be assigned a prebuilt workspace, instead of waiting for a new workspace to build. (see [below for nested schema](#nestedblock--prebuilds)) ### Read-Only -- `id` (String) ID of the workspace preset. +- `id` (String) The preset ID is automatically generated and may change between runs. It is recommended to use the `name` attribute to identify the preset. + + +### Nested Schema for `prebuilds` + +Required: + +- `instances` (Number) The number of workspaces to keep in reserve for this preset. diff --git a/integration/integration_test.go b/integration/integration_test.go index 65aa5aed..9803aa41 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -90,10 +90,11 @@ 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_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`, }, }, { diff --git a/integration/test-data-source/main.tf b/integration/test-data-source/main.tf index 5fb2e0e6..f18fa347 100644 --- a/integration/test-data-source/main.tf +++ b/integration/test-data-source/main.tf @@ -24,6 +24,10 @@ data "coder_workspace_preset" "preset" { parameters = { (data.coder_parameter.param.name) = "preset param value" } + + prebuilds { + instances = 1 + } } locals { @@ -47,6 +51,7 @@ locals { "workspace_parameter.icon" : data.coder_parameter.param.icon, "workspace_preset.name" : data.coder_workspace_preset.preset.name, "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.go b/provider/workspace.go index fde742b6..5ddd3ee8 100644 --- a/provider/workspace.go +++ b/provider/workspace.go @@ -27,6 +27,14 @@ func workspaceDataSource() *schema.Resource { } _ = rd.Set("start_count", count) + prebuild := helpers.OptionalEnv(IsPrebuildEnvironmentVariable()) + prebuildCount := 0 + if prebuild == "true" { + prebuildCount = 1 + _ = rd.Set("is_prebuild", true) + } + _ = rd.Set("prebuild_count", prebuildCount) + name := helpers.OptionalEnvOrDefault("CODER_WORKSPACE_NAME", "default") rd.Set("name", name) @@ -83,6 +91,11 @@ func workspaceDataSource() *schema.Resource { Computed: true, Description: "The access port of the Coder deployment provisioning this workspace.", }, + "prebuild_count": { + Type: schema.TypeInt, + Computed: true, + Description: "A computed count, equal to 1 if the workspace is a currently unassigned prebuild. Use this to conditionally act on the status of a prebuild. Actions that do not require user identity can be taken when this value is set to 1. Actions that should only be taken once the workspace has been assigned to a user may be taken when this value is set to 0.", + }, "start_count": { Type: schema.TypeInt, Computed: true, @@ -98,6 +111,11 @@ func workspaceDataSource() *schema.Resource { Computed: true, Description: "UUID of the workspace.", }, + "is_prebuild": { + Type: schema.TypeBool, + Computed: true, + Description: "Similar to `prebuild_count`, but a boolean value instead of a count. This is set to true if the workspace is a currently unassigned prebuild. Once the workspace is assigned, this value will be false.", + }, "name": { Type: schema.TypeString, Computed: true, @@ -121,3 +139,7 @@ func workspaceDataSource() *schema.Resource { }, } } + +func IsPrebuildEnvironmentVariable() string { + return "CODER_WORKSPACE_IS_PREBUILD" +} diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index cd56c980..004489e7 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -12,33 +12,39 @@ import ( type WorkspacePreset struct { Name string `mapstructure:"name"` Parameters map[string]string `mapstructure:"parameters"` + Prebuilds WorkspacePrebuild `mapstructure:"prebuilds"` +} + +type WorkspacePrebuild struct { + Instances int `mapstructure:"instances"` } func workspacePresetDataSource() *schema.Resource { return &schema.Resource{ SchemaVersion: 1, - Description: "Use this data source to predefine common configurations for workspaces.", + Description: "Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are not defined by the preset will still be configurable when creating a workspace.", ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics { var preset WorkspacePreset err := mapstructure.Decode(struct { Name interface{} Parameters interface{} + Prebuilds struct { + Instances interface{} + } }{ Name: rd.Get("name"), Parameters: rd.Get("parameters"), + Prebuilds: struct { + Instances interface{} + }{ + Instances: rd.Get("prebuilds.0.instances"), + }, }, &preset) if err != nil { return diag.Errorf("decode workspace preset: %s", err) } - // MinItems doesn't work with maps, so we need to check the length - // of the map manually. All other validation is handled by the - // schema. - if len(preset.Parameters) == 0 { - return diag.Errorf("expected \"parameters\" to not be an empty map") - } - rd.SetId(preset.Name) return nil @@ -46,25 +52,42 @@ func workspacePresetDataSource() *schema.Resource { Schema: map[string]*schema.Schema{ "id": { Type: schema.TypeString, - Description: "ID of the workspace preset.", + Description: "The preset ID is automatically generated and may change between runs. It is recommended to use the `name` attribute to identify the preset.", Computed: true, }, "name": { Type: schema.TypeString, - Description: "Name of the workspace preset.", + Description: "The name of the workspace preset.", Required: true, ValidateFunc: validation.StringIsNotEmpty, }, "parameters": { Type: schema.TypeMap, - Description: "Parameters of the workspace preset.", - Required: true, + 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.", + Optional: true, Elem: &schema.Schema{ Type: schema.TypeString, Required: true, ValidateFunc: validation.StringIsNotEmpty, }, }, + "prebuilds": { + Type: schema.TypeSet, + Description: "Prebuilt workspace configuration related to this workspace preset. Coder will build and maintain workspaces in reserve based on this configuration. When a user creates a new workspace using a preset, they will be assigned a prebuilt workspace, instead of waiting for a new workspace to build.", + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "instances": { + Type: schema.TypeInt, + Description: "The number of workspaces to keep in reserve for this preset.", + Required: true, + ForceNew: true, + ValidateFunc: validation.IntAtLeast(0), + }, + }, + }, + }, }, } } diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go index 876e2044..aa1ca0ce 100644 --- a/provider/workspace_preset_test.go +++ b/provider/workspace_preset_test.go @@ -84,7 +84,7 @@ func TestWorkspacePreset(t *testing.T) { }`, // 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("The argument \"parameters\" is required, but no definition was found"), + ExpectError: nil, }, { Name: "Parameters field is empty", @@ -95,7 +95,7 @@ func TestWorkspacePreset(t *testing.T) { }`, // This validation is *not* done by Terraform, because MinItems doesn't work with maps. // We've implemented the validation in ReadContext, so we test it here to make sure we don't regress. - ExpectError: regexp.MustCompile("expected \"parameters\" to not be an empty map"), + ExpectError: nil, }, { Name: "Parameters field is not a map", @@ -108,6 +108,42 @@ func TestWorkspacePreset(t *testing.T) { // So we test it here to make sure we don't regress. ExpectError: regexp.MustCompile("Inappropriate value for attribute \"parameters\": map of string required"), }, + { + Name: "Prebuilds is set, but not its required fields", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds {} + }`, + ExpectError: regexp.MustCompile("The argument \"instances\" is required, but no definition was found."), + }, + { + Name: "Prebuilds is set, and so are its required fields", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + 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.instances"], "1") + return nil + }, + }, } for _, testcase := range testcases { From 3a2c18dab13ebd36ddab31a97e2c566696b1d3e3 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Mon, 7 Apr 2025 09:55:38 +0200 Subject: [PATCH 29/63] Correct the prebuilds type (#376) --- provider/workspace_preset.go | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index 004489e7..2fab0b8a 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -12,7 +12,12 @@ import ( type WorkspacePreset struct { Name string `mapstructure:"name"` Parameters map[string]string `mapstructure:"parameters"` - Prebuilds WorkspacePrebuild `mapstructure:"prebuilds"` + // 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 + // for utilities that parse our terraform output using this type. To remain compatible + // with those cases, we use a slice here. + Prebuilds []WorkspacePrebuild `mapstructure:"prebuilds"` } type WorkspacePrebuild struct { @@ -27,19 +32,9 @@ func workspacePresetDataSource() *schema.Resource { ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics { var preset WorkspacePreset err := mapstructure.Decode(struct { - Name interface{} - Parameters interface{} - Prebuilds struct { - Instances interface{} - } + Name interface{} }{ - Name: rd.Get("name"), - Parameters: rd.Get("parameters"), - Prebuilds: struct { - Instances interface{} - }{ - Instances: rd.Get("prebuilds.0.instances"), - }, + Name: rd.Get("name"), }, &preset) if err != nil { return diag.Errorf("decode workspace preset: %s", err) From 8804c44f070b366bb29e0e4b8d44b6eba9473b0c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 8 Apr 2025 13:04:02 -0500 Subject: [PATCH 30/63] feat: `form_type` and `styling` metadata arguments added (#375) Adds `form_type` as a field to parameters. By default, legacy behavior is maintained by deducing the default `form_type` value from the existing inputs. `styling` argument also added for cosmetic usage on the frontend form --- docs/data-sources/parameter.md | 2 + provider/formtype.go | 148 ++++++++++++ provider/formtype_test.go | 428 +++++++++++++++++++++++++++++++++ provider/parameter.go | 94 ++++++-- provider/parameter_test.go | 17 +- 5 files changed, 664 insertions(+), 25 deletions(-) create mode 100644 provider/formtype.go create mode 100644 provider/formtype_test.go diff --git a/docs/data-sources/parameter.md b/docs/data-sources/parameter.md index e46cf86d..934ae77a 100644 --- a/docs/data-sources/parameter.md +++ b/docs/data-sources/parameter.md @@ -145,10 +145,12 @@ data "coder_parameter" "home_volume_size" { - `description` (String) Describe what this parameter does. - `display_name` (String) The displayed name of the parameter as it will appear in the interface. - `ephemeral` (Boolean) The value of an ephemeral parameter will not be preserved between consecutive workspace builds. +- `form_type` (String) The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error]. - `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/"`. - `mutable` (Boolean) Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution! - `option` (Block List) Each `option` block defines a value for a user to select from. (see [below for nested schema](#nestedblock--option)) - `order` (Number) The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order). +- `styling` (String) JSON encoded string containing the metadata for controlling the appearance of this parameter in the UI. This option is purely cosmetic and does not affect the function of the parameter in terraform. - `type` (String) The type of this parameter. Must be one of: `"number"`, `"string"`, `"bool"`, or `"list(string)"`. - `validation` (Block List, Max: 1) Validate the input of a parameter. (see [below for nested schema](#nestedblock--validation)) diff --git a/provider/formtype.go b/provider/formtype.go new file mode 100644 index 00000000..48fbeed3 --- /dev/null +++ b/provider/formtype.go @@ -0,0 +1,148 @@ +package provider + +import ( + "slices" + + "golang.org/x/xerrors" +) + +// OptionType is a type of option that can be used in the 'type' argument of +// a parameter. These should match types as defined in terraform: +// +// https://developer.hashicorp.com/terraform/language/expressions/types +// +// The value have to be string literals, as type constraint keywords are not +// supported in providers. +type OptionType string + +const ( + OptionTypeString OptionType = "string" + OptionTypeNumber OptionType = "number" + OptionTypeBoolean OptionType = "bool" + OptionTypeListString OptionType = "list(string)" +) + +func OptionTypes() []OptionType { + return []OptionType{ + OptionTypeString, + OptionTypeNumber, + OptionTypeBoolean, + OptionTypeListString, + } +} + +// ParameterFormType is the list of supported form types for display in +// the Coder "create workspace" form. These form types are functional as well +// as cosmetic. Refer to `formTypeTruthTable` for the allowed pairings. +// For example, "multi-select" has the type "list(string)" but the option +// values are "string". +type ParameterFormType string + +const ( + ParameterFormTypeDefault ParameterFormType = "" + ParameterFormTypeRadio ParameterFormType = "radio" + ParameterFormTypeSlider ParameterFormType = "slider" + ParameterFormTypeInput ParameterFormType = "input" + ParameterFormTypeDropdown ParameterFormType = "dropdown" + ParameterFormTypeCheckbox ParameterFormType = "checkbox" + ParameterFormTypeSwitch ParameterFormType = "switch" + ParameterFormTypeMultiSelect ParameterFormType = "multi-select" + ParameterFormTypeTagSelect ParameterFormType = "tag-select" + ParameterFormTypeTextArea ParameterFormType = "textarea" + ParameterFormTypeError ParameterFormType = "error" +) + +// ParameterFormTypes should be kept in sync with the enum list above. +func ParameterFormTypes() []ParameterFormType { + return []ParameterFormType{ + // Intentionally omit "ParameterFormTypeDefault" from this set. + // It is a valid enum, but will always be mapped to a real value when + // being used. + ParameterFormTypeRadio, + ParameterFormTypeSlider, + ParameterFormTypeInput, + ParameterFormTypeDropdown, + ParameterFormTypeCheckbox, + ParameterFormTypeSwitch, + ParameterFormTypeMultiSelect, + ParameterFormTypeTagSelect, + ParameterFormTypeTextArea, + ParameterFormTypeError, + } +} + +// formTypeTruthTable is a map of [`type`][`optionCount` > 0] to `form_type`. +// The first value in the slice is the default value assuming `form_type` is +// not specified. +// +// The boolean key indicates whether the `options` field is specified. +// | Type | Options | Specified Form Type | form_type | Notes | +// |-------------------|---------|---------------------|----------------|--------------------------------| +// | `string` `number` | Y | | `radio` | | +// | `string` `number` | Y | `dropdown` | `dropdown` | | +// | `string` `number` | N | | `input` | | +// | `string` | N | 'textarea' | `textarea` | | +// | `number` | N | 'slider' | `slider` | min/max validation | +// | `bool` | Y | | `radio` | | +// | `bool` | N | | `checkbox` | | +// | `bool` | N | `switch` | `switch` | | +// | `list(string)` | Y | | `radio` | | +// | `list(string)` | N | | `tag-select` | | +// | `list(string)` | Y | `multi-select` | `multi-select` | Option values will be `string` | +var formTypeTruthTable = map[OptionType]map[bool][]ParameterFormType{ + OptionTypeString: { + true: {ParameterFormTypeRadio, ParameterFormTypeDropdown}, + false: {ParameterFormTypeInput, ParameterFormTypeTextArea}, + }, + OptionTypeNumber: { + true: {ParameterFormTypeRadio, ParameterFormTypeDropdown}, + false: {ParameterFormTypeInput, ParameterFormTypeSlider}, + }, + OptionTypeBoolean: { + true: {ParameterFormTypeRadio}, + false: {ParameterFormTypeCheckbox, ParameterFormTypeSwitch}, + }, + OptionTypeListString: { + true: {ParameterFormTypeRadio, ParameterFormTypeMultiSelect}, + false: {ParameterFormTypeTagSelect}, + }, +} + +// ValidateFormType handles the truth table for the valid set of `type` and +// `form_type` options. +// The OptionType is also returned because it is possible the 'type' of the +// 'value' & 'default' fields is different from the 'type' of the options. +// The use case is when using multi-select. The options are 'string' and the +// value is 'list(string)'. +func ValidateFormType(paramType OptionType, optionCount int, specifiedFormType ParameterFormType) (OptionType, ParameterFormType, error) { + allowed, ok := formTypeTruthTable[paramType][optionCount > 0] + if !ok || len(allowed) == 0 { + return paramType, specifiedFormType, xerrors.Errorf("value type %q is not supported for 'form_types'", paramType) + } + + if specifiedFormType == ParameterFormTypeDefault { + // handle the default case + specifiedFormType = allowed[0] + } + + if !slices.Contains(allowed, specifiedFormType) { + return paramType, specifiedFormType, xerrors.Errorf("value type %q is not supported for 'form_types'", specifiedFormType) + } + + // This is the only current special case. If 'multi-select' is selected, the type + // of 'value' and an options 'value' are different. The type of the parameter is + // `list(string)` but the type of the individual options is `string`. + if paramType == OptionTypeListString && specifiedFormType == ParameterFormTypeMultiSelect { + return OptionTypeString, ParameterFormTypeMultiSelect, nil + } + + return paramType, specifiedFormType, nil +} + +func toStrings[A ~string](l []A) []string { + var r []string + for _, v := range l { + r = append(r, string(v)) + } + return r +} diff --git a/provider/formtype_test.go b/provider/formtype_test.go new file mode 100644 index 00000000..599c2e41 --- /dev/null +++ b/provider/formtype_test.go @@ -0,0 +1,428 @@ +package provider_test + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/terraform-provider-coder/v2/provider" +) + +// formTypeTestCase is the config for a single test case. +type formTypeTestCase struct { + name string + config formTypeCheck + assert paramAssert + expectError *regexp.Regexp +} + +// paramAssert is asserted on the provider's parsed terraform state. +type paramAssert struct { + FormType provider.ParameterFormType + Type provider.OptionType + Styling json.RawMessage +} + +// formTypeCheck is a struct that helps build the terraform config +type formTypeCheck struct { + formType provider.ParameterFormType + optionType provider.OptionType + options bool + + // optional to inform the assert + customOptions []string + defValue string + styling json.RawMessage +} + +func (c formTypeCheck) String() string { + return fmt.Sprintf("%s_%s_%t", c.formType, c.optionType, c.options) +} + +func TestValidateFormType(t *testing.T) { + t.Parallel() + + // formTypesChecked keeps track of all checks run. It will be used to + // ensure all combinations of form_type and option_type are tested. + // All untested options are assumed to throw an error. + formTypesChecked := make(map[string]struct{}) + + expectType := func(expected provider.ParameterFormType, opts formTypeCheck) formTypeTestCase { + ftname := opts.formType + if ftname == "" { + ftname = "default" + } + + if opts.styling == nil { + // Try passing arbitrary data in, as anything should be accepted + opts.styling, _ = json.Marshal(map[string]any{ + "foo": "bar", + "disabled": true, + "nested": map[string]any{ + "foo": "bar", + }, + }) + } + + return formTypeTestCase{ + name: fmt.Sprintf("%s_%s_%t", + ftname, + opts.optionType, + opts.options, + ), + config: opts, + assert: paramAssert{ + FormType: expected, + Type: opts.optionType, + Styling: opts.styling, + }, + expectError: nil, + } + } + + // expectSameFormType just assumes the FormType in the check is the expected + // FormType. Using `expectType` these fields can differ + expectSameFormType := func(opts formTypeCheck) formTypeTestCase { + return expectType(opts.formType, opts) + } + + cases := []formTypeTestCase{ + { + // When nothing is specified + name: "defaults", + config: formTypeCheck{}, + assert: paramAssert{ + FormType: provider.ParameterFormTypeInput, + Type: provider.OptionTypeString, + Styling: []byte("{}"), + }, + }, + // All default behaviors. Essentially legacy behavior. + // String + expectType(provider.ParameterFormTypeRadio, formTypeCheck{ + options: true, + optionType: provider.OptionTypeString, + }), + expectType(provider.ParameterFormTypeInput, formTypeCheck{ + options: false, + optionType: provider.OptionTypeString, + }), + // Number + expectType(provider.ParameterFormTypeRadio, formTypeCheck{ + options: true, + optionType: provider.OptionTypeNumber, + }), + expectType(provider.ParameterFormTypeInput, formTypeCheck{ + options: false, + optionType: provider.OptionTypeNumber, + }), + // Boolean + expectType(provider.ParameterFormTypeRadio, formTypeCheck{ + options: true, + optionType: provider.OptionTypeBoolean, + }), + expectType(provider.ParameterFormTypeCheckbox, formTypeCheck{ + options: false, + optionType: provider.OptionTypeBoolean, + }), + // List(string) + expectType(provider.ParameterFormTypeRadio, formTypeCheck{ + options: true, + optionType: provider.OptionTypeListString, + }), + expectType(provider.ParameterFormTypeTagSelect, formTypeCheck{ + options: false, + optionType: provider.OptionTypeListString, + }), + + // ---- New Behavior + // String + expectSameFormType(formTypeCheck{ + options: true, + optionType: provider.OptionTypeString, + formType: provider.ParameterFormTypeDropdown, + }), + expectSameFormType(formTypeCheck{ + options: true, + optionType: provider.OptionTypeString, + formType: provider.ParameterFormTypeRadio, + }), + expectSameFormType(formTypeCheck{ + options: false, + optionType: provider.OptionTypeString, + formType: provider.ParameterFormTypeInput, + }), + expectSameFormType(formTypeCheck{ + options: false, + optionType: provider.OptionTypeString, + formType: provider.ParameterFormTypeTextArea, + }), + // Number + expectSameFormType(formTypeCheck{ + options: true, + optionType: provider.OptionTypeNumber, + formType: provider.ParameterFormTypeDropdown, + }), + expectSameFormType(formTypeCheck{ + options: true, + optionType: provider.OptionTypeNumber, + formType: provider.ParameterFormTypeRadio, + }), + expectSameFormType(formTypeCheck{ + options: false, + optionType: provider.OptionTypeNumber, + formType: provider.ParameterFormTypeInput, + }), + expectSameFormType(formTypeCheck{ + options: false, + optionType: provider.OptionTypeNumber, + formType: provider.ParameterFormTypeSlider, + }), + // Boolean + expectSameFormType(formTypeCheck{ + options: true, + optionType: provider.OptionTypeBoolean, + formType: provider.ParameterFormTypeRadio, + }), + expectSameFormType(formTypeCheck{ + options: false, + optionType: provider.OptionTypeBoolean, + formType: provider.ParameterFormTypeSwitch, + }), + expectSameFormType(formTypeCheck{ + options: false, + optionType: provider.OptionTypeBoolean, + formType: provider.ParameterFormTypeCheckbox, + }), + // List(string) + expectSameFormType(formTypeCheck{ + options: true, + optionType: provider.OptionTypeListString, + formType: provider.ParameterFormTypeRadio, + }), + expectSameFormType(formTypeCheck{ + options: true, + optionType: provider.OptionTypeListString, + formType: provider.ParameterFormTypeMultiSelect, + customOptions: []string{"red", "blue", "green"}, + defValue: `["red", "blue"]`, + }), + expectSameFormType(formTypeCheck{ + options: false, + optionType: provider.OptionTypeListString, + formType: provider.ParameterFormTypeTagSelect, + }), + + // Some manual test cases + { + name: "list_string_bad_default", + config: formTypeCheck{ + formType: provider.ParameterFormTypeMultiSelect, + optionType: provider.OptionTypeListString, + customOptions: []string{"red", "blue", "green"}, + defValue: `["red", "yellow"]`, + styling: nil, + }, + expectError: regexp.MustCompile("is not a valid option"), + }, + } + + passed := t.Run("TabledTests", func(t *testing.T) { + // TabledCases runs through all the manual test cases + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + if _, ok := formTypesChecked[c.config.String()]; ok { + t.Log("Duplicated form type check, delete this extra test case") + t.Fatalf("form type %q already checked", c.config.String()) + } + + formTypesChecked[c.config.String()] = struct{}{} + formTypeTest(t, c) + }) + } + }) + + if !passed { + // Do not run additional tests and pollute the output + t.Log("Tests failed, will not run the assumed error cases") + return + } + + // AssumeErrorCases assumes any uncovered test will return an error. Not covered + // cases in the truth table are assumed to be invalid. So if the tests above + // cover all valid cases, this asserts all the invalid cases. + // + // This test consequentially ensures all valid cases are covered manually above. + t.Run("AssumeErrorCases", func(t *testing.T) { + // requiredChecks loops through all possible form_type and option_type + // combinations. + requiredChecks := make([]formTypeCheck, 0) + for _, ft := range append(provider.ParameterFormTypes(), "") { + for _, ot := range provider.OptionTypes() { + requiredChecks = append(requiredChecks, formTypeCheck{ + formType: ft, + optionType: ot, + options: false, + }) + requiredChecks = append(requiredChecks, formTypeCheck{ + formType: ft, + optionType: ot, + options: true, + }) + } + } + + for _, check := range requiredChecks { + if _, alreadyChecked := formTypesChecked[check.String()]; alreadyChecked { + continue + } + + ftName := check.formType + if ftName == "" { + ftName = "default" + } + fc := formTypeTestCase{ + name: fmt.Sprintf("%s_%s_%t", + ftName, + check.optionType, + check.options, + ), + config: check, + assert: paramAssert{}, + expectError: regexp.MustCompile("is not supported"), + } + + t.Run(fc.name, func(t *testing.T) { + t.Parallel() + + // This is just helpful log output to give the boilerplate + // to write the manual test. + tcText := fmt.Sprintf(` + expectSameFormType(%s, ezconfigOpts{ + Options: %t, + OptionType: %q, + FormType: %q, + }), + //`, "", check.options, check.optionType, check.formType) + + logDebugInfo := formTypeTest(t, fc) + if !logDebugInfo { + t.Logf("To construct this test case:\n%s", tcText) + } + }) + + } + }) +} + +// createTF converts a formTypeCheck into a terraform config string. +func createTF(paramName string, cfg formTypeCheck) (defaultValue string, tf string) { + options := cfg.customOptions + if cfg.options && len(cfg.customOptions) == 0 { + switch cfg.optionType { + case provider.OptionTypeString: + options = []string{"foo"} + defaultValue = "foo" + case provider.OptionTypeBoolean: + options = []string{"true", "false"} + defaultValue = "true" + case provider.OptionTypeNumber: + options = []string{"1"} + defaultValue = "1" + case provider.OptionTypeListString: + options = []string{`["red", "blue"]`} + defaultValue = `["red", "blue"]` + default: + panic(fmt.Sprintf("unknown option type %q when generating options", cfg.optionType)) + } + } + + if cfg.defValue == "" { + cfg.defValue = defaultValue + } + + var body strings.Builder + if cfg.defValue != "" { + body.WriteString(fmt.Sprintf("default = %q\n", cfg.defValue)) + } + if cfg.formType != "" { + body.WriteString(fmt.Sprintf("form_type = %q\n", cfg.formType)) + } + if cfg.optionType != "" { + body.WriteString(fmt.Sprintf("type = %q\n", cfg.optionType)) + } + if cfg.styling != nil { + body.WriteString(fmt.Sprintf("styling = %s\n", strconv.Quote(string(cfg.styling)))) + } + + for i, opt := range options { + body.WriteString("option {\n") + body.WriteString(fmt.Sprintf("name = \"val_%d\"\n", i)) + body.WriteString(fmt.Sprintf("value = %q\n", opt)) + body.WriteString("}\n") + } + + return cfg.defValue, fmt.Sprintf(` + provider "coder" { + } + data "coder_parameter" "%s" { + name = "%s" + %s + } + `, paramName, paramName, body.String()) +} + +func formTypeTest(t *testing.T, c formTypeTestCase) bool { + t.Helper() + const paramName = "test_param" + // logDebugInfo is just a guess used for logging. It's not important. It cannot + // determine for sure if the test passed because the terraform test runner is a + // black box. It does not indicate if the test passed or failed. Since this is + // just used for logging, this is good enough. + logDebugInfo := true + + def, tf := createTF(paramName, c.config) + checkFn := func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + + key := strings.Join([]string{"data", "coder_parameter", paramName}, ".") + param := state.Modules[0].Resources[key] + + logDebugInfo = logDebugInfo && assert.Equal(t, def, param.Primary.Attributes["default"], "default value") + logDebugInfo = logDebugInfo && assert.Equal(t, string(c.assert.FormType), param.Primary.Attributes["form_type"], "form_type") + logDebugInfo = logDebugInfo && assert.Equal(t, string(c.assert.Type), param.Primary.Attributes["type"], "type") + logDebugInfo = logDebugInfo && assert.JSONEq(t, string(c.assert.Styling), param.Primary.Attributes["styling"], "styling") + + return nil + } + if c.expectError != nil { + checkFn = nil + } + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProviderFactories: coderFactory(), + Steps: []resource.TestStep{ + { + Config: tf, + Check: checkFn, + ExpectError: c.expectError, + }, + }, + }) + + if !logDebugInfo { + t.Logf("Terraform config:\n%s", tf) + } + return logDebugInfo +} diff --git a/provider/parameter.go b/provider/parameter.go index 1345f4d6..4eb8a282 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -50,7 +50,8 @@ type Parameter struct { Name string DisplayName string `mapstructure:"display_name"` Description string - Type string + Type OptionType + FormType ParameterFormType Mutable bool Default string Icon string @@ -86,6 +87,7 @@ func parameterDataSource() *schema.Resource { DisplayName interface{} Description interface{} Type interface{} + FormType interface{} Mutable interface{} Default interface{} Icon interface{} @@ -100,6 +102,7 @@ func parameterDataSource() *schema.Resource { DisplayName: rd.Get("display_name"), Description: rd.Get("description"), Type: rd.Get("type"), + FormType: rd.Get("form_type"), Mutable: rd.Get("mutable"), Default: rd.Get("default"), Icon: rd.Get("icon"), @@ -149,6 +152,20 @@ func parameterDataSource() *schema.Resource { } } + // Validate options + + // optionType might differ from parameter.Type. This is ok, and parameter.Type + // should be used for the value type, and optionType for options. + var optionType OptionType + optionType, parameter.FormType, err = ValidateFormType(parameter.Type, len(parameter.Option), parameter.FormType) + if err != nil { + return diag.FromErr(err) + } + // Set the form_type back in case the value was changed. + // Eg via a default. If a user does not specify, a default value + // is used and saved. + rd.Set("form_type", parameter.FormType) + if len(parameter.Option) > 0 { names := map[string]interface{}{} values := map[string]interface{}{} @@ -161,7 +178,7 @@ func parameterDataSource() *schema.Resource { if exists { return diag.Errorf("multiple options cannot have the same value %q", option.Value) } - err := valueIsType(parameter.Type, option.Value) + err := valueIsType(optionType, option.Value) if err != nil { return err } @@ -170,9 +187,36 @@ func parameterDataSource() *schema.Resource { } if parameter.Default != "" { - _, defaultIsValid := values[parameter.Default] - if !defaultIsValid { - return diag.Errorf("default value %q must be defined as one of options", parameter.Default) + if parameter.Type == OptionTypeListString && optionType == OptionTypeString { + // If the type is list(string) and optionType is string, we have + // to ensure all elements of the default exist as options. + var defaultValues []string + // TODO: We do this unmarshal in a few spots. It should be standardized. + err = json.Unmarshal([]byte(parameter.Default), &defaultValues) + if err != nil { + return diag.Errorf("default value %q is not a list of strings", parameter.Default) + } + + // missing is used to construct a more helpful error message + var missing []string + for _, defaultValue := range defaultValues { + _, defaultIsValid := values[defaultValue] + if !defaultIsValid { + missing = append(missing, defaultValue) + } + } + + if len(missing) > 0 { + return diag.Errorf( + "default value %q is not a valid option, values %q are missing from the option", + parameter.Default, strings.Join(missing, ", "), + ) + } + } else { + _, defaultIsValid := values[parameter.Default] + if !defaultIsValid { + return diag.Errorf("%q default value %q must be defined as one of options", parameter.FormType, parameter.Default) + } } } } @@ -203,9 +247,23 @@ func parameterDataSource() *schema.Resource { Type: schema.TypeString, Default: "string", Optional: true, - ValidateFunc: validation.StringInSlice([]string{"number", "string", "bool", "list(string)"}, false), + ValidateFunc: validation.StringInSlice(toStrings(OptionTypes()), false), Description: "The type of this parameter. Must be one of: `\"number\"`, `\"string\"`, `\"bool\"`, or `\"list(string)\"`.", }, + "form_type": { + Type: schema.TypeString, + Default: ParameterFormTypeDefault, + Optional: true, + ValidateFunc: validation.StringInSlice(toStrings(ParameterFormTypes()), false), + Description: fmt.Sprintf("The type of this parameter. Must be one of: [%s].", strings.Join(toStrings(ParameterFormTypes()), ", ")), + }, + "styling": { + 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.", + Optional: true, + }, "mutable": { Type: schema.TypeBool, Optional: true, @@ -375,25 +433,25 @@ func fixValidationResourceData(rawConfig cty.Value, validation interface{}) (int return vArr, nil } -func valueIsType(typ, value string) diag.Diagnostics { +func valueIsType(typ OptionType, value string) diag.Diagnostics { switch typ { - case "number": + case OptionTypeNumber: _, err := strconv.ParseFloat(value, 64) if err != nil { return diag.Errorf("%q is not a number", value) } - case "bool": + case OptionTypeBoolean: _, err := strconv.ParseBool(value) if err != nil { return diag.Errorf("%q is not a bool", value) } - case "list(string)": + case OptionTypeListString: var items []string err := json.Unmarshal([]byte(value), &items) if err != nil { return diag.Errorf("%q is not an array of strings", value) } - case "string": + case OptionTypeString: // Anything is a string! default: return diag.Errorf("invalid type %q", typ) @@ -401,8 +459,8 @@ func valueIsType(typ, value string) diag.Diagnostics { return nil } -func (v *Validation) Valid(typ, value string) error { - if typ != "number" { +func (v *Validation) Valid(typ OptionType, value string) error { + if typ != OptionTypeNumber { if !v.MinDisabled { return fmt.Errorf("a min cannot be specified for a %s type", typ) } @@ -413,16 +471,16 @@ func (v *Validation) Valid(typ, value string) error { return fmt.Errorf("monotonic validation can only be specified for number types, not %s types", typ) } } - if typ != "string" && v.Regex != "" { + if typ != OptionTypeString && v.Regex != "" { return fmt.Errorf("a regex cannot be specified for a %s type", typ) } switch typ { - case "bool": + case OptionTypeBoolean: if value != "true" && value != "false" { return fmt.Errorf(`boolean value can be either "true" or "false"`) } return nil - case "string": + case OptionTypeString: if v.Regex == "" { return nil } @@ -437,7 +495,7 @@ func (v *Validation) Valid(typ, value string) error { if !matched { return fmt.Errorf("%s (value %q does not match %q)", v.Error, value, regex) } - case "number": + case OptionTypeNumber: num, err := strconv.Atoi(value) if err != nil { return takeFirstError(v.errorRendered(value), fmt.Errorf("value %q is not a number", value)) @@ -451,7 +509,7 @@ func (v *Validation) Valid(typ, value string) error { if v.Monotonic != "" && v.Monotonic != ValidationMonotonicIncreasing && v.Monotonic != ValidationMonotonicDecreasing { return fmt.Errorf("number monotonicity can be either %q or %q", ValidationMonotonicIncreasing, ValidationMonotonicDecreasing) } - case "list(string)": + case OptionTypeListString: var listOfStrings []string err := json.Unmarshal([]byte(value), &listOfStrings) if err != nil { diff --git a/provider/parameter_test.go b/provider/parameter_test.go index 7bcea8fd..f817280e 100644 --- a/provider/parameter_test.go +++ b/provider/parameter_test.go @@ -27,6 +27,7 @@ func TestParameter(t *testing.T) { name = "region" display_name = "Region" type = "string" + form_type = "dropdown" description = <<-EOT # Select the machine image See the [registry](https://container.registry.blah/namespace) for options. @@ -56,6 +57,7 @@ func TestParameter(t *testing.T) { "name": "region", "display_name": "Region", "type": "string", + "form_type": "dropdown", "description": "# Select the machine image\nSee the [registry](https://container.registry.blah/namespace) for options.\n", "mutable": "true", "icon": "/icon/region.svg", @@ -137,6 +139,7 @@ func TestParameter(t *testing.T) { for key, expected := range map[string]string{ "name": "Region", "type": "number", + "form_type": "input", "validation.#": "1", "default": "2", "validation.0.min": "1", @@ -686,13 +689,13 @@ data "coder_parameter" "region" { func TestValueValidatesType(t *testing.T) { t.Parallel() for _, tc := range []struct { - Name, - Type, - Value, - Regex, - RegexError string - Min, - Max int + Name string + Type provider.OptionType + Value string + Regex string + RegexError string + Min int + Max int MinDisabled, MaxDisabled bool Monotonic string Error *regexp.Regexp From d90c1817de41ff9768ce7be861a66ea02773c2ee Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 9 Apr 2025 09:04:53 -0500 Subject: [PATCH 31/63] chore: change OptionType to alias for usage as string (#377) To be backwards compatible using this repo as a library, use a string alias. This allows using the 'type' as a `string` or the new enum. Since enum validation is quite weak in Go, this is baiscally functionally equvialent. --- provider/formtype.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/formtype.go b/provider/formtype.go index 48fbeed3..753f8945 100644 --- a/provider/formtype.go +++ b/provider/formtype.go @@ -13,7 +13,7 @@ import ( // // The value have to be string literals, as type constraint keywords are not // supported in providers. -type OptionType string +type OptionType = string const ( OptionTypeString OptionType = "string" From 53a68cd7496371d6f325f9c7bd8c6808069c4664 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 10 Apr 2025 11:52:25 +0200 Subject: [PATCH 32/63] feat: add the option to debug the coder terraform provider (#378) Setting the debug flag runs the provider as a standalone process and provides an environment variable that can be set for Terraform to use it. One can then attach a debugger to the provider process. --- main.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 2eaa5dc5..ef606a6d 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + "flag" + "github.com/hashicorp/terraform-plugin-sdk/v2/plugin" "github.com/coder/terraform-provider-coder/v2/provider" @@ -11,8 +13,15 @@ import ( //go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs func main() { - servePprof() - plugin.Serve(&plugin.ServeOpts{ + debug := flag.Bool("debug", false, "Enable debug mode for the provider") + flag.Parse() + + opts := &plugin.ServeOpts{ + Debug: *debug, + ProviderAddr: "registry.terraform.io/coder/coder", ProviderFunc: provider.New, - }) + } + + servePprof() + plugin.Serve(opts) } From f66adaca2adfb6b19108200483855c4023fcc982 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 14 Apr 2025 09:05:16 -0500 Subject: [PATCH 33/63] chore: enhance parameter validation error messages (#379) * chore: implement validation in an exported function * chore: enhance parameter validation error messages --- provider/formtype.go | 28 +++++- provider/parameter.go | 197 +++++++++++++++++++++++++------------ provider/parameter_test.go | 4 +- 3 files changed, 159 insertions(+), 70 deletions(-) diff --git a/provider/formtype.go b/provider/formtype.go index 753f8945..75d32c46 100644 --- a/provider/formtype.go +++ b/provider/formtype.go @@ -115,9 +115,11 @@ var formTypeTruthTable = map[OptionType]map[bool][]ParameterFormType{ // The use case is when using multi-select. The options are 'string' and the // value is 'list(string)'. func ValidateFormType(paramType OptionType, optionCount int, specifiedFormType ParameterFormType) (OptionType, ParameterFormType, error) { - allowed, ok := formTypeTruthTable[paramType][optionCount > 0] + optionsExist := optionCount > 0 + allowed, ok := formTypeTruthTable[paramType][optionsExist] if !ok || len(allowed) == 0 { - return paramType, specifiedFormType, xerrors.Errorf("value type %q is not supported for 'form_types'", paramType) + // This error should really never be hit, as the provider sdk does an enum validation. + return paramType, specifiedFormType, xerrors.Errorf("\"type\" attribute=%q is not supported, choose one of %v", paramType, OptionTypes()) } if specifiedFormType == ParameterFormTypeDefault { @@ -126,7 +128,27 @@ func ValidateFormType(paramType OptionType, optionCount int, specifiedFormType P } if !slices.Contains(allowed, specifiedFormType) { - return paramType, specifiedFormType, xerrors.Errorf("value type %q is not supported for 'form_types'", specifiedFormType) + optionMsg := "" + opposite := formTypeTruthTable[paramType][!optionsExist] + + // This extra message tells a user if they are using a valid form_type + // for a 'type', but it is invalid because options do/do-not exist. + // It serves as a more helpful error message. + // + // Eg: form_type=slider is valid for type=number, but invalid if options exist. + // And this error message is more accurate than just saying "form_type=slider is + // not valid for type=number". + if slices.Contains(opposite, specifiedFormType) { + if optionsExist { + optionMsg = " when options exist" + } else { + optionMsg = " when options do not exist" + } + } + return paramType, specifiedFormType, + xerrors.Errorf("\"form_type\" attribute=%q is not supported for \"type\"=%q%s, choose one of %v", + specifiedFormType, paramType, + optionMsg, toStrings(allowed)) } // This is the only current special case. If 'multi-select' is selected, the type diff --git a/provider/parameter.go b/provider/parameter.go index 4eb8a282..2f7dc662 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -124,7 +124,7 @@ func parameterDataSource() *schema.Resource { } var value string if parameter.Default != "" { - err := valueIsType(parameter.Type, parameter.Default) + err := valueIsType(parameter.Type, parameter.Default, cty.Path{cty.GetAttrStep{Name: "default"}}) if err != nil { return err } @@ -144,6 +144,8 @@ func parameterDataSource() *schema.Resource { return diag.Errorf("ephemeral parameter requires the default property") } + // TODO: Should we move this into the Valid() function on + // Parameter? if len(parameter.Validation) == 1 { validation := ¶meter.Validation[0] err = validation.Valid(parameter.Type, value) @@ -153,73 +155,27 @@ func parameterDataSource() *schema.Resource { } // Validate options - - // optionType might differ from parameter.Type. This is ok, and parameter.Type - // should be used for the value type, and optionType for options. - var optionType OptionType - optionType, parameter.FormType, err = ValidateFormType(parameter.Type, len(parameter.Option), parameter.FormType) + _, parameter.FormType, err = ValidateFormType(parameter.Type, len(parameter.Option), parameter.FormType) if err != nil { - return diag.FromErr(err) + return diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid form_type for parameter", + Detail: err.Error(), + AttributePath: cty.Path{cty.GetAttrStep{Name: "form_type"}}, + }, + } } // Set the form_type back in case the value was changed. // Eg via a default. If a user does not specify, a default value // is used and saved. rd.Set("form_type", parameter.FormType) - if len(parameter.Option) > 0 { - names := map[string]interface{}{} - values := map[string]interface{}{} - for _, option := range parameter.Option { - _, exists := names[option.Name] - if exists { - return diag.Errorf("multiple options cannot have the same name %q", option.Name) - } - _, exists = values[option.Value] - if exists { - return diag.Errorf("multiple options cannot have the same value %q", option.Value) - } - err := valueIsType(optionType, option.Value) - if err != nil { - return err - } - values[option.Value] = nil - names[option.Name] = nil - } - - if parameter.Default != "" { - if parameter.Type == OptionTypeListString && optionType == OptionTypeString { - // If the type is list(string) and optionType is string, we have - // to ensure all elements of the default exist as options. - var defaultValues []string - // TODO: We do this unmarshal in a few spots. It should be standardized. - err = json.Unmarshal([]byte(parameter.Default), &defaultValues) - if err != nil { - return diag.Errorf("default value %q is not a list of strings", parameter.Default) - } - - // missing is used to construct a more helpful error message - var missing []string - for _, defaultValue := range defaultValues { - _, defaultIsValid := values[defaultValue] - if !defaultIsValid { - missing = append(missing, defaultValue) - } - } - - if len(missing) > 0 { - return diag.Errorf( - "default value %q is not a valid option, values %q are missing from the option", - parameter.Default, strings.Join(missing, ", "), - ) - } - } else { - _, defaultIsValid := values[parameter.Default] - if !defaultIsValid { - return diag.Errorf("%q default value %q must be defined as one of options", parameter.FormType, parameter.Default) - } - } - } + diags := parameter.Valid() + if diags.HasError() { + return diags } + return nil }, Schema: map[string]*schema.Schema{ @@ -433,7 +389,7 @@ func fixValidationResourceData(rawConfig cty.Value, validation interface{}) (int return vArr, nil } -func valueIsType(typ OptionType, value string) diag.Diagnostics { +func valueIsType(typ OptionType, value string, attrPath cty.Path) diag.Diagnostics { switch typ { case OptionTypeNumber: _, err := strconv.ParseFloat(value, 64) @@ -446,10 +402,9 @@ func valueIsType(typ OptionType, value string) diag.Diagnostics { return diag.Errorf("%q is not a bool", value) } case OptionTypeListString: - var items []string - err := json.Unmarshal([]byte(value), &items) - if err != nil { - return diag.Errorf("%q is not an array of strings", value) + _, diags := valueIsListString(value, attrPath) + if diags.HasError() { + return diags } case OptionTypeString: // Anything is a string! @@ -459,6 +414,102 @@ func valueIsType(typ OptionType, value string) diag.Diagnostics { return nil } +func (v *Parameter) Valid() diag.Diagnostics { + // optionType might differ from parameter.Type. This is ok, and parameter.Type + // should be used for the value type, and optionType for options. + optionType, _, err := ValidateFormType(v.Type, len(v.Option), v.FormType) + if err != nil { + return diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid form_type for parameter", + Detail: err.Error(), + AttributePath: cty.Path{cty.GetAttrStep{Name: "form_type"}}, + }, + } + } + + optionNames := map[string]any{} + optionValues := map[string]any{} + if len(v.Option) > 0 { + for _, option := range v.Option { + _, exists := optionNames[option.Name] + if exists { + return diag.Diagnostics{{ + Severity: diag.Error, + Summary: "Option names must be unique.", + Detail: fmt.Sprintf("multiple options found with the same name %q", option.Name), + }, + } + } + _, exists = optionValues[option.Value] + if exists { + return diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Option values must be unique.", + Detail: fmt.Sprintf("multiple options found with the same value %q", option.Value), + }, + } + } + diags := valueIsType(optionType, option.Value, cty.Path{}) + if diags.HasError() { + return diags + } + optionValues[option.Value] = nil + optionNames[option.Name] = nil + } + } + + if v.Default != "" && len(v.Option) > 0 { + if v.Type == OptionTypeListString && optionType == OptionTypeString { + // If the type is list(string) and optionType is string, we have + // to ensure all elements of the default exist as options. + defaultValues, diags := valueIsListString(v.Default, cty.Path{cty.GetAttrStep{Name: "default"}}) + if diags.HasError() { + return diags + } + + // missing is used to construct a more helpful error message + var missing []string + for _, defaultValue := range defaultValues { + _, defaultIsValid := optionValues[defaultValue] + if !defaultIsValid { + missing = append(missing, defaultValue) + } + } + + if len(missing) > 0 { + return diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Default values must be a valid option", + Detail: fmt.Sprintf( + "default value %q is not a valid option, values %q are missing from the options", + v.Default, strings.Join(missing, ", "), + ), + AttributePath: cty.Path{cty.GetAttrStep{Name: "default"}}, + }, + } + } + } else { + _, defaultIsValid := optionValues[v.Default] + if !defaultIsValid { + return diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Default value must be a valid option", + Detail: fmt.Sprintf("the value %q must be defined as one of options", v.Default), + AttributePath: cty.Path{cty.GetAttrStep{Name: "default"}}, + }, + } + } + } + } + + return nil +} + func (v *Validation) Valid(typ OptionType, value string) error { if typ != OptionTypeNumber { if !v.MinDisabled { @@ -519,6 +570,22 @@ func (v *Validation) Valid(typ OptionType, value string) error { return nil } +func valueIsListString(value string, path cty.Path) ([]string, diag.Diagnostics) { + var items []string + err := json.Unmarshal([]byte(value), &items) + if err != nil { + return nil, diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "When using list(string) type, value must be a json encoded list of strings", + Detail: fmt.Sprintf("value %q is not a valid list of strings", value), + AttributePath: path, + }, + } + } + return items, nil +} + // ParameterEnvironmentVariable returns the environment variable to specify for // a parameter by it's name. It's hashed because spaces and special characters // can be used in parameter names that may not be valid in env vars. diff --git a/provider/parameter_test.go b/provider/parameter_test.go index f817280e..4b52d943 100644 --- a/provider/parameter_test.go +++ b/provider/parameter_test.go @@ -291,7 +291,7 @@ func TestParameter(t *testing.T) { } } `, - ExpectError: regexp.MustCompile("cannot have the same name"), + ExpectError: regexp.MustCompile("Option names must be unique"), }, { Name: "DuplicateOptionValue", Config: ` @@ -308,7 +308,7 @@ func TestParameter(t *testing.T) { } } `, - ExpectError: regexp.MustCompile("cannot have the same value"), + ExpectError: regexp.MustCompile("Option values must be unique"), }, { Name: "RequiredParameterNoDefault", Config: ` From e40c9b9278ad0a7574bbeb067e2119f850b0df5c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 15 Apr 2025 10:54:10 +0100 Subject: [PATCH 34/63] chore: update to go 1.24.2 (#382) Co-authored-by: Danny Kopping --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 02ea7137..993ba093 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/coder/terraform-provider-coder/v2 -go 1.22.9 +go 1.24.2 require ( github.com/docker/docker v26.1.5+incompatible From e51ae3aff8c4c0c3c0559841fabb59eafea1f86a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Apr 2025 10:59:50 +0100 Subject: [PATCH 35/63] build(deps): Bump golang.org/x/crypto from 0.33.0 to 0.35.0 (#380) Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.33.0 to 0.35.0. - [Commits](https://github.com/golang/crypto/compare/v0.33.0...v0.35.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-version: 0.35.0 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 993ba093..cb415c2b 100644 --- a/go.mod +++ b/go.mod @@ -79,7 +79,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect go.opentelemetry.io/otel/metric v1.31.0 // indirect go.opentelemetry.io/otel/trace v1.31.0 // indirect - golang.org/x/crypto v0.33.0 // indirect + golang.org/x/crypto v0.35.0 // indirect golang.org/x/net v0.34.0 // indirect golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect diff --git a/go.sum b/go.sum index 2b5ad9c5..870d770d 100644 --- a/go.sum +++ b/go.sum @@ -225,8 +225,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= From c86bb5c3ddcd0735fefe644b264c04f9ecafbd4f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:02:58 +0100 Subject: [PATCH 36/63] build(deps): Bump golang.org/x/net from 0.34.0 to 0.38.0 (#383) Bumps [golang.org/x/net](https://github.com/golang/net) from 0.34.0 to 0.38.0. - [Commits](https://github.com/golang/net/compare/v0.34.0...v0.38.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.38.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index cb415c2b..848423f4 100644 --- a/go.mod +++ b/go.mod @@ -79,11 +79,11 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect go.opentelemetry.io/otel/metric v1.31.0 // indirect go.opentelemetry.io/otel/trace v1.31.0 // indirect - golang.org/x/crypto v0.35.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.22.0 // indirect google.golang.org/appengine v1.6.8 // indirect diff --git a/go.sum b/go.sum index 870d770d..77c61771 100644 --- a/go.sum +++ b/go.sum @@ -225,8 +225,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -240,15 +240,15 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -263,8 +263,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -272,8 +272,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From f871a43ee9a2615a195da72821148a2045ff0d6d Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 23 Apr 2025 08:44:20 -0500 Subject: [PATCH 37/63] test: rbac role test assertion to handle site wide roles (#385) Site wide roles have empty string org_ids --- integration/integration_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/integration/integration_test.go b/integration/integration_test.go index 9803aa41..a5019635 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -181,7 +181,8 @@ func TestIntegration(t *testing.T) { "workspace_owner.ssh_private_key": `(?s)^.+?BEGIN OPENSSH PRIVATE KEY.+?END OPENSSH PRIVATE KEY.+?$`, "workspace_owner.ssh_public_key": `(?s)^ssh-ed25519.+$`, "workspace_owner.login_type": `password`, - "workspace_owner.rbac_roles": `(?is)\[(\{"name":"[a-z0-9-:]+","org_id":"[a-f0-9-]+"\},?)+\]`, + // org_id will either be a uuid or an empty string for site wide roles. + "workspace_owner.rbac_roles": `(?is)\[(\{"name":"[a-z0-9-:]+","org_id":"[a-f0-9-]*"\},?)+\]`, }, }, { From ac2f9bf28b20a387661cf9f45444a5ceda0dc689 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 30 Apr 2025 14:30:36 -0500 Subject: [PATCH 38/63] test: unit test to document validation behavior of parameters (#387) Documenting validation behavior with a unit test --- provider/parameter_test.go | 232 +++++++++++++++++++++++++++ provider/testdata/parameter_table.md | 70 ++++++++ 2 files changed, 302 insertions(+) create mode 100644 provider/testdata/parameter_table.md diff --git a/provider/parameter_test.go b/provider/parameter_test.go index 4b52d943..37a46796 100644 --- a/provider/parameter_test.go +++ b/provider/parameter_test.go @@ -2,7 +2,9 @@ package provider_test import ( "fmt" + "os" "regexp" + "strconv" "strings" "testing" @@ -686,6 +688,217 @@ data "coder_parameter" "region" { } } +// TestParameterValidationEnforcement tests various parameter states and the +// validation enforcement that should be applied to them. The table is described +// by a markdown table. This is done so that the test cases can be more easily +// edited and read. +// +// Copy and paste the table to https://www.tablesgenerator.com/markdown_tables for easier editing +// +//nolint:paralleltest,tparallel // Parameters load values from env vars +func TestParameterValidationEnforcement(t *testing.T) { + // Some interesting observations: + // - Validation logic does not apply to the value of 'options' + // - [NumDefInvOpt] So an invalid option can be present and selected, but would fail + // - Validation logic does not apply to the default if a value is given + // - [NumIns/DefInv] So the default can be invalid if an input value is valid. + // The value is therefore not really optional, but it is marked as such. + // - [NumInsNotOptsVal | NumsInsNotOpts] values do not need to be in the option set? + table, err := os.ReadFile("testdata/parameter_table.md") + require.NoError(t, err) + + type row struct { + Name string + Types []string + InputValue string + Default string + Options []string + Validation *provider.Validation + OutputValue string + Optional bool + Error *regexp.Regexp + } + + rows := make([]row, 0) + lines := strings.Split(string(table), "\n") + validMinMax := regexp.MustCompile("^[0-9]*-[0-9]*$") + for _, line := range lines[2:] { + columns := strings.Split(line, "|") + columns = columns[1 : len(columns)-1] + for i := range columns { + // Trim the whitespace from all columns + columns[i] = strings.TrimSpace(columns[i]) + } + + if columns[0] == "" { + continue // Skip rows with empty names + } + + optional, err := strconv.ParseBool(columns[8]) + if columns[8] != "" { + // Value does not matter if not specified + require.NoError(t, err) + } + + var rerr *regexp.Regexp + if columns[9] != "" { + rerr, err = regexp.Compile(columns[9]) + if err != nil { + t.Fatalf("failed to parse error column %q: %v", columns[9], err) + } + } + var options []string + if columns[4] != "" { + options = strings.Split(columns[4], ",") + } + + var validation *provider.Validation + if columns[5] != "" { + // Min-Max validation should look like: + // 1-10 :: min=1, max=10 + // -10 :: max=10 + // 1- :: min=1 + if validMinMax.MatchString(columns[5]) { + parts := strings.Split(columns[5], "-") + min, _ := strconv.ParseInt(parts[0], 10, 64) + max, _ := strconv.ParseInt(parts[1], 10, 64) + validation = &provider.Validation{ + Min: int(min), + MinDisabled: parts[0] == "", + Max: int(max), + MaxDisabled: parts[1] == "", + Monotonic: "", + Regex: "", + Error: "{min} < {value} < {max}", + } + } else { + validation = &provider.Validation{ + Min: 0, + MinDisabled: true, + Max: 0, + MaxDisabled: true, + Monotonic: "", + Regex: columns[5], + Error: "regex error", + } + } + } + + rows = append(rows, row{ + Name: columns[0], + Types: strings.Split(columns[1], ","), + InputValue: columns[2], + Default: columns[3], + Options: options, + Validation: validation, + OutputValue: columns[7], + Optional: optional, + Error: rerr, + }) + } + + stringLiteral := func(s string) string { + if s == "" { + return `""` + } + return fmt.Sprintf("%q", s) + } + + for rowIndex, row := range rows { + for _, rt := range row.Types { + //nolint:paralleltest,tparallel // Parameters load values from env vars + t.Run(fmt.Sprintf("%d|%s:%s", rowIndex, row.Name, rt), func(t *testing.T) { + if row.InputValue != "" { + t.Setenv(provider.ParameterEnvironmentVariable("parameter"), row.InputValue) + } + + if row.Error != nil { + if row.OutputValue != "" { + t.Errorf("output value %q should not be set if error is set", row.OutputValue) + } + } + + var cfg strings.Builder + cfg.WriteString("data \"coder_parameter\" \"parameter\" {\n") + cfg.WriteString("\tname = \"parameter\"\n") + if rt == "multi-select" || rt == "tag-select" { + cfg.WriteString(fmt.Sprintf("\ttype = \"%s\"\n", "list(string)")) + cfg.WriteString(fmt.Sprintf("\tform_type = \"%s\"\n", rt)) + } else { + cfg.WriteString(fmt.Sprintf("\ttype = \"%s\"\n", rt)) + } + if row.Default != "" { + cfg.WriteString(fmt.Sprintf("\tdefault = %s\n", stringLiteral(row.Default))) + } + + for _, opt := range row.Options { + cfg.WriteString("\toption {\n") + cfg.WriteString(fmt.Sprintf("\t\tname = %s\n", stringLiteral(opt))) + cfg.WriteString(fmt.Sprintf("\t\tvalue = %s\n", stringLiteral(opt))) + cfg.WriteString("\t}\n") + } + + if row.Validation != nil { + cfg.WriteString("\tvalidation {\n") + if !row.Validation.MinDisabled { + cfg.WriteString(fmt.Sprintf("\t\tmin = %d\n", row.Validation.Min)) + } + if !row.Validation.MaxDisabled { + cfg.WriteString(fmt.Sprintf("\t\tmax = %d\n", row.Validation.Max)) + } + if row.Validation.Monotonic != "" { + cfg.WriteString(fmt.Sprintf("\t\tmonotonic = \"%s\"\n", row.Validation.Monotonic)) + } + if row.Validation.Regex != "" { + cfg.WriteString(fmt.Sprintf("\t\tregex = %q\n", row.Validation.Regex)) + } + cfg.WriteString(fmt.Sprintf("\t\terror = %q\n", row.Validation.Error)) + cfg.WriteString("\t}\n") + } + + cfg.WriteString("}\n") + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: cfg.String(), + ExpectError: row.Error, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + param := state.Modules[0].Resources["data.coder_parameter.parameter"] + require.NotNil(t, param) + + if row.Default == "" { + _, ok := param.Primary.Attributes["default"] + require.False(t, ok, "default should not be set") + } else { + require.Equal(t, strings.Trim(row.Default, `"`), param.Primary.Attributes["default"]) + } + + if row.OutputValue == "" { + _, ok := param.Primary.Attributes["value"] + require.False(t, ok, "output value should not be set") + } else { + require.Equal(t, strings.Trim(row.OutputValue, `"`), param.Primary.Attributes["value"]) + } + + for key, expected := range map[string]string{ + "optional": strconv.FormatBool(row.Optional), + } { + require.Equal(t, expected, param.Primary.Attributes[key], "optional") + } + + return nil + }, + }}, + }) + }) + } + } +} + func TestValueValidatesType(t *testing.T) { t.Parallel() for _, tc := range []struct { @@ -798,6 +1011,25 @@ func TestValueValidatesType(t *testing.T) { Value: `[]`, MinDisabled: true, MaxDisabled: true, + }, { + Name: "ValidListOfStrings", + Type: "list(string)", + Value: `["first","second","third"]`, + MinDisabled: true, + MaxDisabled: true, + }, { + Name: "InvalidListOfStrings", + Type: "list(string)", + Value: `["first","second","third"`, + MinDisabled: true, + MaxDisabled: true, + Error: regexp.MustCompile("is not valid list of strings"), + }, { + Name: "EmptyListOfStrings", + Type: "list(string)", + Value: `[]`, + MinDisabled: true, + MaxDisabled: true, }} { tc := tc t.Run(tc.Name, func(t *testing.T) { diff --git a/provider/testdata/parameter_table.md b/provider/testdata/parameter_table.md new file mode 100644 index 00000000..4c9ee458 --- /dev/null +++ b/provider/testdata/parameter_table.md @@ -0,0 +1,70 @@ +| Name | Type | Input | Default | Options | Validation | -> | Output | Optional | Error | +|----------------------|---------------|-----------|---------|-------------------|------------|----|--------|----------|--------------| +| | Empty Vals | | | | | | | | | +| Empty | string,number | | | | | | "" | false | | +| EmptyList | list(string) | | | | | | "" | false | | +| EmptyMulti | tag-select | | | | | | "" | false | | +| EmptyOpts | string,number | | | 1,2,3 | | | "" | false | | +| EmptyRegex | string | | | | world | | | | regex error | +| EmptyMin | number | | | | 1-10 | | | | 1 < < 10 | +| EmptyMinOpt | number | | | 1,2,3 | 2-5 | | | | 2 < < 5 | +| EmptyRegexOpt | string | | | "hello","goodbye" | goodbye | | | | regex error | +| EmptyRegexOk | string | | | | .* | | "" | false | | +| | | | | | | | | | | +| | Default Set | No inputs | | | | | | | | +| NumDef | number | | 5 | | | | 5 | true | | +| NumDefVal | number | | 5 | | 3-7 | | 5 | true | | +| NumDefInv | number | | 5 | | 10- | | | | 10 < 5 < 0 | +| NumDefOpts | number | | 5 | 1,3,5,7 | 2-6 | | 5 | true | | +| NumDefNotOpts | number | | 5 | 1,3,7,9 | 2-6 | | | | valid option | +| NumDefInvOpt | number | | 5 | 1,3,5,7 | 6-10 | | | | 6 < 5 < 10 | +| | | | | | | | | | | +| StrDef | string | | hello | | | | hello | true | | +| StrDefInv | string | | hello | | world | | | | regex error | +| StrDefOpts | string | | a | a,b,c | | | a | true | | +| StrDefNotOpts | string | | a | b,c,d | | | | | valid option | +| StrDefValOpts | string | | a | a,b,c,d,e,f | [a-c] | | a | true | | +| StrDefInvOpt | string | | d | a,b,c,d,e,f | [a-c] | | | | regex error | +| | | | | | | | | | | +| LStrDef | list(string) | | ["a"] | | | | ["a"] | true | | +| LStrDefOpts | list(string) | | ["a"] | ["a"], ["b"] | | | ["a"] | true | | +| LStrDefNotOpts | list(string) | | ["a"] | ["b"], ["c"] | | | | | valid option | +| | | | | | | | | | | +| MulDef | tag-select | | ["a"] | | | | ["a"] | true | | +| MulDefOpts | multi-select | | ["a"] | a,b | | | ["a"] | true | | +| MulDefNotOpts | multi-select | | ["a"] | b,c | | | | | valid option | +| | | | | | | | | | | +| | Input Vals | | | | | | | | | +| NumIns | number | 3 | | | | | 3 | false | | +| NumInsDef | number | 3 | 5 | | | | 3 | true | | +| NumIns/DefInv | number | 3 | 5 | | 1-3 | | 3 | true | | +| NumIns=DefInv | number | 5 | 5 | | 1-3 | | | | 1 < 5 < 3 | +| NumInsOpts | number | 3 | 5 | 1,2,3,4,5 | 1-3 | | 3 | true | | +| NumInsNotOptsVal | number | 3 | 5 | 1,2,4,5 | 1-3 | | 3 | true | | +| NumInsNotOptsInv | number | 3 | 5 | 1,2,4,5 | 1-2 | | | true | 1 < 3 < 2 | +| NumInsNotOpts | number | 3 | 5 | 1,2,4,5 | | | 3 | true | | +| NumInsNotOpts/NoDef | number | 3 | | 1,2,4,5 | | | 3 | false | | +| | | | | | | | | | | +| StrIns | string | c | | | | | c | false | | +| StrInsDef | string | c | e | | | | c | true | | +| StrIns/DefInv | string | c | e | | [a-c] | | c | true | | +| StrIns=DefInv | string | e | e | | [a-c] | | | | regex error | +| StrInsOpts | string | c | e | a,b,c,d,e | [a-c] | | c | true | | +| StrInsNotOptsVal | string | c | e | a,b,d,e | [a-c] | | c | true | | +| StrInsNotOptsInv | string | c | e | a,b,d,e | [a-b] | | | | regex error | +| StrInsNotOpts | string | c | e | a,b,d,e | | | c | true | | +| StrInsNotOpts/NoDef | string | c | | a,b,d,e | | | c | false | | +| StrInsBadVal | string | c | | a,b,c,d,e | 1-10 | | | | min cannot | +| | | | | | | | | | | +| | list(string) | | | | | | | | | +| LStrIns | list(string) | ["c"] | | | | | ["c"] | false | | +| LStrInsDef | list(string) | ["c"] | ["e"] | | | | ["c"] | true | | +| LStrIns/DefInv | list(string) | ["c"] | ["e"] | | [a-c] | | | | regex cannot | +| LStrInsOpts | list(string) | ["c"] | ["e"] | ["c"],["d"],["e"] | | | ["c"] | true | | +| LStrInsNotOpts | list(string) | ["c"] | ["e"] | ["d"],["e"] | | | ["c"] | true | | +| LStrInsNotOpts/NoDef | list(string) | ["c"] | | ["d"],["e"] | | | ["c"] | false | | +| | | | | | | | | | | +| MulInsOpts | multi-select | ["c"] | ["e"] | c,d,e | | | ["c"] | true | | +| MulInsNotOpts | multi-select | ["c"] | ["e"] | d,e | | | ["c"] | true | | +| MulInsNotOpts/NoDef | multi-select | ["c"] | | d,e | | | ["c"] | false | | +| MulInsInvOpts | multi-select | ["c"] | ["e"] | c,d,e | [a-c] | | | | regex cannot | \ No newline at end of file From 4e7da25b24d24b43a0540296a2c50b80002b722a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 30 Apr 2025 16:30:22 -0500 Subject: [PATCH 39/63] test: add more validation test cases (#388) Some of these cases you would expect it to be an error, but the validation passes --- provider/parameter_test.go | 3 +++ provider/testdata/parameter_table.md | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/provider/parameter_test.go b/provider/parameter_test.go index 37a46796..32877c2b 100644 --- a/provider/parameter_test.go +++ b/provider/parameter_test.go @@ -704,6 +704,9 @@ func TestParameterValidationEnforcement(t *testing.T) { // - [NumIns/DefInv] So the default can be invalid if an input value is valid. // The value is therefore not really optional, but it is marked as such. // - [NumInsNotOptsVal | NumsInsNotOpts] values do not need to be in the option set? + // - [NumInsNotNum] number params do not require the value to be a number + // - [LStrInsNotList] list(string) do not require the value to be a list(string) + // - Same with [MulInsNotListOpts] table, err := os.ReadFile("testdata/parameter_table.md") require.NoError(t, err) diff --git a/provider/testdata/parameter_table.md b/provider/testdata/parameter_table.md index 4c9ee458..f7645efa 100644 --- a/provider/testdata/parameter_table.md +++ b/provider/testdata/parameter_table.md @@ -2,7 +2,9 @@ |----------------------|---------------|-----------|---------|-------------------|------------|----|--------|----------|--------------| | | Empty Vals | | | | | | | | | | Empty | string,number | | | | | | "" | false | | +| EmptyDupeOps | string,number | | | 1,1,1 | | | | | unique | | EmptyList | list(string) | | | | | | "" | false | | +| EmptyListDupeOpts | list(string) | | | ["a"],["a"] | | | | | unique | | EmptyMulti | tag-select | | | | | | "" | false | | | EmptyOpts | string,number | | | 1,2,3 | | | "" | false | | | EmptyRegex | string | | | | world | | | | regex error | @@ -18,6 +20,8 @@ | NumDefOpts | number | | 5 | 1,3,5,7 | 2-6 | | 5 | true | | | NumDefNotOpts | number | | 5 | 1,3,7,9 | 2-6 | | | | valid option | | NumDefInvOpt | number | | 5 | 1,3,5,7 | 6-10 | | | | 6 < 5 < 10 | +| NumDefNotNum | number | | a | | | | | | a number | +| NumDefOptsNotNum | number | | 1 | 1,a,2 | | | | | a number | | | | | | | | | | | | | StrDef | string | | hello | | | | hello | true | | | StrDefInv | string | | hello | | world | | | | regex error | @@ -36,6 +40,8 @@ | | | | | | | | | | | | | Input Vals | | | | | | | | | | NumIns | number | 3 | | | | | 3 | false | | +| NumInsNotNum | number | a | | | | | a | false | | +| NumInsNotNumInv | number | a | | | 1-3 | | | | 1 < a < 3 | | NumInsDef | number | 3 | 5 | | | | 3 | true | | | NumIns/DefInv | number | 3 | 5 | | 1-3 | | 3 | true | | | NumIns=DefInv | number | 5 | 5 | | 1-3 | | | | 1 < 5 < 3 | @@ -46,6 +52,7 @@ | NumInsNotOpts/NoDef | number | 3 | | 1,2,4,5 | | | 3 | false | | | | | | | | | | | | | | StrIns | string | c | | | | | c | false | | +| StrInsDupeOpts | string | c | | a,b,c,c | | | | | unique | | StrInsDef | string | c | e | | | | c | true | | | StrIns/DefInv | string | c | e | | [a-c] | | c | true | | | StrIns=DefInv | string | e | e | | [a-c] | | | | regex error | @@ -58,6 +65,7 @@ | | | | | | | | | | | | | list(string) | | | | | | | | | | LStrIns | list(string) | ["c"] | | | | | ["c"] | false | | +| LStrInsNotList | list(string) | c | | | | | c | false | | | LStrInsDef | list(string) | ["c"] | ["e"] | | | | ["c"] | true | | | LStrIns/DefInv | list(string) | ["c"] | ["e"] | | [a-c] | | | | regex cannot | | LStrInsOpts | list(string) | ["c"] | ["e"] | ["c"],["d"],["e"] | | | ["c"] | true | | @@ -65,6 +73,7 @@ | LStrInsNotOpts/NoDef | list(string) | ["c"] | | ["d"],["e"] | | | ["c"] | false | | | | | | | | | | | | | | MulInsOpts | multi-select | ["c"] | ["e"] | c,d,e | | | ["c"] | true | | +| MulInsNotListOpts | multi-select | c | ["e"] | c,d,e | | | c | true | | | MulInsNotOpts | multi-select | ["c"] | ["e"] | d,e | | | ["c"] | true | | | MulInsNotOpts/NoDef | multi-select | ["c"] | | d,e | | | ["c"] | false | | | MulInsInvOpts | multi-select | ["c"] | ["e"] | c,d,e | [a-c] | | | | regex cannot | \ No newline at end of file From 5648efbf6db0e02bd7cf79386650ade6cf923681 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 2 May 2025 08:57:05 +0200 Subject: [PATCH 40/63] feat: reuse agent tokens when a prebuilt agent reinitializes (#374) * feat: allow presets to define prebuilds * document prebuild parameters * remove todo * make gen * feat: reuse agent tokens when a prebuilt agent reinitializes * WIP: get agent.go ready to be merged with support for prebuilds * fix: ensure the agent token is reused for prebuilds * lint and make gen * simplify function * test: rbac role test assertion to handle site wide roles Site wide roles have empty string org_ids --------- Co-authored-by: Steven Masley --- go.mod | 2 +- provider/agent.go | 51 ++++++++++++++++++++++++++++++++++++++----- provider/workspace.go | 28 +++++++++++++++++++----- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 848423f4..2d3db5d0 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/docker/docker v26.1.5+incompatible github.com/google/uuid v1.6.0 github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 + github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.1 github.com/masterminds/semver v1.5.0 github.com/mitchellh/mapstructure v1.5.0 @@ -50,7 +51,6 @@ require ( github.com/hashicorp/terraform-exec v0.22.0 // indirect github.com/hashicorp/terraform-json v0.24.0 // indirect github.com/hashicorp/terraform-plugin-go v0.26.0 // indirect - github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.4 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect diff --git a/provider/agent.go b/provider/agent.go index 3ddae235..ad264030 100644 --- a/provider/agent.go +++ b/provider/agent.go @@ -2,6 +2,8 @@ package provider import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "path/filepath" "reflect" @@ -9,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-log/tflog" "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" @@ -22,10 +25,12 @@ func agentResource() *schema.Resource { SchemaVersion: 1, Description: "Use this resource to associate an agent.", - CreateContext: func(_ context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { - // This should be a real authentication token! - resourceData.SetId(uuid.NewString()) - err := resourceData.Set("token", uuid.NewString()) + CreateContext: func(ctx context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { + agentID := uuid.NewString() + resourceData.SetId(agentID) + + token := agentAuthToken(ctx, "") + err := resourceData.Set("token", token) if err != nil { return diag.FromErr(err) } @@ -48,10 +53,12 @@ func agentResource() *schema.Resource { return updateInitScript(resourceData, i) }, ReadWithoutTimeout: func(ctx context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { - err := resourceData.Set("token", uuid.NewString()) + token := agentAuthToken(ctx, "") + err := resourceData.Set("token", token) if err != nil { return diag.FromErr(err) } + if _, ok := resourceData.GetOk("display_apps"); !ok { err = resourceData.Set("display_apps", []interface{}{ map[string]bool{ @@ -469,3 +476,37 @@ func updateInitScript(resourceData *schema.ResourceData, i interface{}) diag.Dia } return nil } + +func agentAuthToken(ctx context.Context, agentID string) string { + existingToken := helpers.OptionalEnv(RunningAgentTokenEnvironmentVariable(agentID)) + if existingToken == "" { + // Most of the time, we will generate a new token for the agent. + // In the case of a prebuilt workspace being claimed, we will override with + // an existing token provided below. + token := uuid.NewString() + return token + } + + // An existing token was provided for this agent. That means that this + // is a prebuilt workspace in the process of being claimed. + // We should reuse the token. + tflog.Info(ctx, "using provided agent token for prebuild", map[string]interface{}{ + "agent_id": agentID, + }) + return existingToken +} + +// RunningAgentTokenEnvironmentVariable returns the name of an environment variable +// that contains the token to use for the running agent. This is used for prebuilds, +// where we want to reuse the same token for the next iteration of a workspace agent +// before and after the workspace was claimed by a user. +// +// By reusing an existing token, we can avoid the need to change a value that may have been +// used immutably. Thus, allowing us to avoid reprovisioning resources that may take a long time +// to replace. +// +// agentID is unused for now, but will be used as soon as we support multiple agents. +func RunningAgentTokenEnvironmentVariable(agentID string) string { + sum := sha256.Sum256([]byte(agentID)) + return "CODER_RUNNING_WORKSPACE_AGENT_TOKEN_" + hex.EncodeToString(sum[:]) +} diff --git a/provider/workspace.go b/provider/workspace.go index 5ddd3ee8..c477fad6 100644 --- a/provider/workspace.go +++ b/provider/workspace.go @@ -27,13 +27,13 @@ func workspaceDataSource() *schema.Resource { } _ = rd.Set("start_count", count) - prebuild := helpers.OptionalEnv(IsPrebuildEnvironmentVariable()) - prebuildCount := 0 - if prebuild == "true" { - prebuildCount = 1 + if isPrebuiltWorkspace() { + _ = rd.Set("prebuild_count", 1) _ = rd.Set("is_prebuild", true) + } else { + _ = rd.Set("prebuild_count", 0) + _ = rd.Set("is_prebuild", false) } - _ = rd.Set("prebuild_count", prebuildCount) name := helpers.OptionalEnvOrDefault("CODER_WORKSPACE_NAME", "default") rd.Set("name", name) @@ -140,6 +140,24 @@ func workspaceDataSource() *schema.Resource { } } +// isPrebuiltWorkspace returns true if the workspace is an unclaimed prebuilt workspace. +func isPrebuiltWorkspace() bool { + return helpers.OptionalEnv(IsPrebuildEnvironmentVariable()) == "true" +} + +// IsPrebuildEnvironmentVariable returns the name of the environment variable that +// indicates whether the workspace is an unclaimed prebuilt workspace. +// +// Knowing whether the workspace is an unclaimed prebuilt workspace allows template +// authors to conditionally execute code in the template based on whether the workspace +// has been assigned to a user or not. This allows identity specific configuration to +// be applied only after the workspace is claimed, while the rest of the workspace can +// be pre-configured. +// +// The value of this environment variable should be set to "true" if the workspace is prebuilt +// and it has not yet been claimed by a user. Any other values, including "false" +// and "" will be interpreted to mean that the workspace is not prebuilt, or was +// prebuilt but has since been claimed by a user. func IsPrebuildEnvironmentVariable() string { return "CODER_WORKSPACE_IS_PREBUILD" } From 21147bf3e4f814ce804be47204501d06740fa7dd Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 2 May 2025 10:08:37 -0500 Subject: [PATCH 41/63] test: fix concurrent map write of TestValidateFormType (#390) --- provider/formtype_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/provider/formtype_test.go b/provider/formtype_test.go index 599c2e41..eaf7b587 100644 --- a/provider/formtype_test.go +++ b/provider/formtype_test.go @@ -6,6 +6,7 @@ import ( "regexp" "strconv" "strings" + "sync" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" @@ -53,7 +54,7 @@ func TestValidateFormType(t *testing.T) { // formTypesChecked keeps track of all checks run. It will be used to // ensure all combinations of form_type and option_type are tested. // All untested options are assumed to throw an error. - formTypesChecked := make(map[string]struct{}) + var formTypesChecked sync.Map expectType := func(expected provider.ParameterFormType, opts formTypeCheck) formTypeTestCase { ftname := opts.formType @@ -240,12 +241,12 @@ func TestValidateFormType(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() - if _, ok := formTypesChecked[c.config.String()]; ok { + if _, ok := formTypesChecked.Load(c.config.String()); ok { t.Log("Duplicated form type check, delete this extra test case") t.Fatalf("form type %q already checked", c.config.String()) } - formTypesChecked[c.config.String()] = struct{}{} + formTypesChecked.Store(c.config.String(), struct{}{}) formTypeTest(t, c) }) } @@ -282,7 +283,7 @@ func TestValidateFormType(t *testing.T) { } for _, check := range requiredChecks { - if _, alreadyChecked := formTypesChecked[check.String()]; alreadyChecked { + if _, alreadyChecked := formTypesChecked.Load(check.String()); alreadyChecked { continue } From 3c748041033c3f80effb41fc7c9120f01003d5bd Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 7 May 2025 08:01:02 -0500 Subject: [PATCH 42/63] feat: parameter validation refactored to single function (#381) Validation bugs resolved: - Number parameters require valid number strings - Input values must be in the option set --- provider/parameter.go | 298 +++++++++++++++++---------- provider/parameter_test.go | 187 +++++++++++++++-- provider/testdata/parameter_table.md | 159 +++++++------- 3 files changed, 440 insertions(+), 204 deletions(-) diff --git a/provider/parameter.go b/provider/parameter.go index 2f7dc662..fd484578 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -21,6 +21,10 @@ import ( "golang.org/x/xerrors" ) +var ( + defaultValuePath = cty.Path{cty.GetAttrStep{Name: "default"}} +) + type Option struct { Name string Description string @@ -46,14 +50,13 @@ const ( ) type Parameter struct { - Value string Name string DisplayName string `mapstructure:"display_name"` Description string Type OptionType FormType ParameterFormType Mutable bool - Default string + Default *string Icon string Option []Option Validation []Validation @@ -82,7 +85,6 @@ func parameterDataSource() *schema.Resource { var parameter Parameter err = mapstructure.Decode(struct { - Value interface{} Name interface{} DisplayName interface{} Description interface{} @@ -97,17 +99,22 @@ func parameterDataSource() *schema.Resource { Order interface{} Ephemeral interface{} }{ - Value: rd.Get("value"), Name: rd.Get("name"), DisplayName: rd.Get("display_name"), Description: rd.Get("description"), Type: rd.Get("type"), FormType: rd.Get("form_type"), Mutable: rd.Get("mutable"), - Default: rd.Get("default"), - Icon: rd.Get("icon"), - Option: rd.Get("option"), - Validation: fixedValidation, + Default: func() *string { + if rd.GetRawConfig().AsValueMap()["default"].IsNull() { + return nil + } + val, _ := rd.Get("default").(string) + return &val + }(), + Icon: rd.Get("icon"), + Option: rd.Get("option"), + Validation: fixedValidation, Optional: func() bool { // This hack allows for checking if the "default" field is present in the .tf file. // If "default" is missing or is "null", then it means that this field is required, @@ -122,19 +129,6 @@ func parameterDataSource() *schema.Resource { if err != nil { return diag.Errorf("decode parameter: %s", err) } - var value string - if parameter.Default != "" { - err := valueIsType(parameter.Type, parameter.Default, cty.Path{cty.GetAttrStep{Name: "default"}}) - if err != nil { - return err - } - value = parameter.Default - } - envValue, ok := os.LookupEnv(ParameterEnvironmentVariable(parameter.Name)) - if ok { - value = envValue - } - rd.Set("value", value) if !parameter.Mutable && parameter.Ephemeral { return diag.Errorf("parameter can't be immutable and ephemeral") @@ -144,38 +138,25 @@ func parameterDataSource() *schema.Resource { return diag.Errorf("ephemeral parameter requires the default property") } - // TODO: Should we move this into the Valid() function on - // Parameter? - if len(parameter.Validation) == 1 { - validation := ¶meter.Validation[0] - err = validation.Valid(parameter.Type, value) - if err != nil { - return diag.FromErr(err) - } - } - - // Validate options - _, parameter.FormType, err = ValidateFormType(parameter.Type, len(parameter.Option), parameter.FormType) - if err != nil { - return diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid form_type for parameter", - Detail: err.Error(), - AttributePath: cty.Path{cty.GetAttrStep{Name: "form_type"}}, - }, - } + var input *string + envValue, ok := os.LookupEnv(ParameterEnvironmentVariable(parameter.Name)) + if ok { + input = &envValue } - // Set the form_type back in case the value was changed. - // Eg via a default. If a user does not specify, a default value - // is used and saved. - rd.Set("form_type", parameter.FormType) - diags := parameter.Valid() + value, diags := parameter.ValidateInput(input) if diags.HasError() { return diags } + // Always set back the value, as it can be sourced from the default + rd.Set("value", value) + + // Set the form_type, as if it was unset, a default form_type will be updated on + // the parameter struct. Always set back the updated form_type to be more + // specific than the default empty string. + rd.Set("form_type", parameter.FormType) + return nil }, Schema: map[string]*schema.Schema{ @@ -389,37 +370,49 @@ func fixValidationResourceData(rawConfig cty.Value, validation interface{}) (int return vArr, nil } -func valueIsType(typ OptionType, value string, attrPath cty.Path) diag.Diagnostics { +func valueIsType(typ OptionType, value string) error { switch typ { case OptionTypeNumber: _, err := strconv.ParseFloat(value, 64) if err != nil { - return diag.Errorf("%q is not a number", value) + return fmt.Errorf("%q is not a number", value) } case OptionTypeBoolean: _, err := strconv.ParseBool(value) if err != nil { - return diag.Errorf("%q is not a bool", value) + return fmt.Errorf("%q is not a bool", value) } case OptionTypeListString: - _, diags := valueIsListString(value, attrPath) - if diags.HasError() { - return diags + _, err := valueIsListString(value) + if err != nil { + return err } case OptionTypeString: // Anything is a string! default: - return diag.Errorf("invalid type %q", typ) + return fmt.Errorf("invalid type %q", typ) } return nil } -func (v *Parameter) Valid() diag.Diagnostics { +func (v *Parameter) ValidateInput(input *string) (string, diag.Diagnostics) { + var err error + var optionType OptionType + + valuePath := cty.Path{} + value := input + if input == nil { + value = v.Default + if v.Default != nil { + valuePath = defaultValuePath + } + } + // optionType might differ from parameter.Type. This is ok, and parameter.Type // should be used for the value type, and optionType for options. - optionType, _, err := ValidateFormType(v.Type, len(v.Option), v.FormType) + optionType, v.FormType, err = ValidateFormType(v.Type, len(v.Option), v.FormType) if err != nil { - return diag.Diagnostics{ + return "", diag.Diagnostics{ { Severity: diag.Error, Summary: "Invalid form_type for parameter", @@ -429,53 +422,120 @@ func (v *Parameter) Valid() diag.Diagnostics { } } - optionNames := map[string]any{} - optionValues := map[string]any{} - if len(v.Option) > 0 { - for _, option := range v.Option { - _, exists := optionNames[option.Name] - if exists { - return diag.Diagnostics{{ - Severity: diag.Error, - Summary: "Option names must be unique.", - Detail: fmt.Sprintf("multiple options found with the same name %q", option.Name), - }, - } - } - _, exists = optionValues[option.Value] - if exists { - return diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Option values must be unique.", - Detail: fmt.Sprintf("multiple options found with the same value %q", option.Value), - }, - } - } - diags := valueIsType(optionType, option.Value, cty.Path{}) - if diags.HasError() { - return diags - } - optionValues[option.Value] = nil - optionNames[option.Name] = nil + optionValues, diags := v.ValidOptions(optionType) + if diags.HasError() { + return "", diags + } + + // TODO: This is a bit of a hack. The current behavior states if validation + // is given, then apply validation to unset values. + // value == nil should not be accepted in the first place. + // To fix this, value should be coerced to an empty string + // if it is nil. Then let the validation logic always apply. + if len(v.Validation) == 0 && value == nil { + return "", nil + } + + // forcedValue ensures the value is not-nil. + var forcedValue string + if value != nil { + forcedValue = *value + } + + d := v.validValue(forcedValue, optionType, optionValues, valuePath) + if d.HasError() { + return "", d + } + + err = valueIsType(v.Type, forcedValue) + if err != nil { + return "", diag.Diagnostics{ + { + Severity: diag.Error, + Summary: fmt.Sprintf("Parameter value is not of type %q", v.Type), + Detail: err.Error(), + }, } } - if v.Default != "" && len(v.Option) > 0 { + return forcedValue, nil +} + +func (v *Parameter) ValidOptions(optionType OptionType) (map[string]struct{}, diag.Diagnostics) { + optionNames := map[string]struct{}{} + optionValues := map[string]struct{}{} + + var diags diag.Diagnostics + for _, option := range v.Option { + _, exists := optionNames[option.Name] + if exists { + return nil, diag.Diagnostics{{ + Severity: diag.Error, + Summary: "Option names must be unique.", + Detail: fmt.Sprintf("multiple options found with the same name %q", option.Name), + }} + } + + _, exists = optionValues[option.Value] + if exists { + return nil, diag.Diagnostics{{ + Severity: diag.Error, + Summary: "Option values must be unique.", + Detail: fmt.Sprintf("multiple options found with the same value %q", option.Value), + }} + } + + err := valueIsType(optionType, option.Value) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("Option %q with value=%q is not of type %q", option.Name, option.Value, optionType), + Detail: err.Error(), + }) + continue + } + optionValues[option.Value] = struct{}{} + optionNames[option.Name] = struct{}{} + + // Option values are assumed to be valid. Do not call validValue on them. + } + + if diags != nil && diags.HasError() { + return nil, diags + } + return optionValues, nil +} + +func (v *Parameter) validValue(value string, optionType OptionType, optionValues map[string]struct{}, path cty.Path) diag.Diagnostics { + // name is used for constructing more precise error messages. + name := "Value" + if path.Equals(defaultValuePath) { + name = "Default value" + } + + // First validate if the value is a valid option + if len(optionValues) > 0 { if v.Type == OptionTypeListString && optionType == OptionTypeString { // If the type is list(string) and optionType is string, we have - // to ensure all elements of the default exist as options. - defaultValues, diags := valueIsListString(v.Default, cty.Path{cty.GetAttrStep{Name: "default"}}) - if diags.HasError() { - return diags + // to ensure all elements of the value exist as options. + listValues, err := valueIsListString(value) + if err != nil { + return diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "When using list(string) type, value must be a json encoded list of strings", + Detail: err.Error(), + AttributePath: path, + }, + } } // missing is used to construct a more helpful error message var missing []string - for _, defaultValue := range defaultValues { - _, defaultIsValid := optionValues[defaultValue] - if !defaultIsValid { - missing = append(missing, defaultValue) + for _, listValue := range listValues { + _, isValid := optionValues[listValue] + if !isValid { + missing = append(missing, listValue) } } @@ -483,30 +543,49 @@ func (v *Parameter) Valid() diag.Diagnostics { return diag.Diagnostics{ { Severity: diag.Error, - Summary: "Default values must be a valid option", + Summary: fmt.Sprintf("%ss must be a valid option", name), Detail: fmt.Sprintf( - "default value %q is not a valid option, values %q are missing from the options", - v.Default, strings.Join(missing, ", "), + "%s %q is not a valid option, values %q are missing from the options", + name, value, strings.Join(missing, ", "), ), - AttributePath: cty.Path{cty.GetAttrStep{Name: "default"}}, + AttributePath: path, }, } } } else { - _, defaultIsValid := optionValues[v.Default] - if !defaultIsValid { + _, isValid := optionValues[value] + if !isValid { + extra := "" + if value == "" { + extra = ". The value is empty, did you forget to set it with a default or from user input?" + } return diag.Diagnostics{ { Severity: diag.Error, - Summary: "Default value must be a valid option", - Detail: fmt.Sprintf("the value %q must be defined as one of options", v.Default), - AttributePath: cty.Path{cty.GetAttrStep{Name: "default"}}, + Summary: fmt.Sprintf("%s must be a valid option%s", name, extra), + Detail: fmt.Sprintf("the value %q must be defined as one of options", value), + AttributePath: path, }, } } } } + if len(v.Validation) == 1 { + validCheck := &v.Validation[0] + err := validCheck.Valid(v.Type, value) + if err != nil { + return diag.Diagnostics{ + { + Severity: diag.Error, + Summary: fmt.Sprintf("Invalid parameter %s according to 'validation' block", strings.ToLower(name)), + Detail: err.Error(), + AttributePath: path, + }, + } + } + } + return nil } @@ -570,18 +649,11 @@ func (v *Validation) Valid(typ OptionType, value string) error { return nil } -func valueIsListString(value string, path cty.Path) ([]string, diag.Diagnostics) { +func valueIsListString(value string) ([]string, error) { var items []string err := json.Unmarshal([]byte(value), &items) if err != nil { - return nil, diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "When using list(string) type, value must be a json encoded list of strings", - Detail: fmt.Sprintf("value %q is not a valid list of strings", value), - AttributePath: path, - }, - } + return nil, fmt.Errorf("value %q is not a valid list of strings", value) } return items, nil } diff --git a/provider/parameter_test.go b/provider/parameter_test.go index 32877c2b..21842b6a 100644 --- a/provider/parameter_test.go +++ b/provider/parameter_test.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/terraform-provider-coder/v2/provider" @@ -84,6 +85,7 @@ func TestParameter(t *testing.T) { data "coder_parameter" "region" { name = "Region" type = "number" + default = 1 option { name = "1" value = "1" @@ -101,6 +103,7 @@ func TestParameter(t *testing.T) { data "coder_parameter" "region" { name = "Region" type = "string" + default = "1" option { name = "1" value = "1" @@ -688,6 +691,168 @@ data "coder_parameter" "region" { } } +func TestParameterValidation(t *testing.T) { + t.Parallel() + opts := func(vals ...string) []provider.Option { + options := make([]provider.Option, 0, len(vals)) + for _, val := range vals { + options = append(options, provider.Option{ + Name: val, + Value: val, + }) + } + return options + } + + for _, tc := range []struct { + Name string + Parameter provider.Parameter + Value string + ExpectError *regexp.Regexp + }{ + { + Name: "ValidStringParameter", + Parameter: provider.Parameter{ + Type: "string", + }, + Value: "alpha", + }, + // Test invalid states + { + Name: "InvalidFormType", + Parameter: provider.Parameter{ + Type: "string", + Option: opts("alpha", "bravo", "charlie"), + FormType: provider.ParameterFormTypeSlider, + }, + Value: "alpha", + ExpectError: regexp.MustCompile("Invalid form_type for parameter"), + }, + { + Name: "NotInOptions", + Parameter: provider.Parameter{ + Type: "string", + Option: opts("alpha", "bravo", "charlie"), + }, + Value: "delta", // not in option set + ExpectError: regexp.MustCompile("Value must be a valid option"), + }, + { + Name: "NumberNotInOptions", + Parameter: provider.Parameter{ + Type: "number", + Option: opts("1", "2", "3"), + }, + Value: "0", // not in option set + ExpectError: regexp.MustCompile("Value must be a valid option"), + }, + { + Name: "NonUniqueOptionNames", + Parameter: provider.Parameter{ + Type: "string", + Option: opts("alpha", "alpha"), + }, + Value: "alpha", + ExpectError: regexp.MustCompile("Option names must be unique"), + }, + { + Name: "NonUniqueOptionValues", + Parameter: provider.Parameter{ + Type: "string", + Option: []provider.Option{ + {Name: "Alpha", Value: "alpha"}, + {Name: "AlphaAgain", Value: "alpha"}, + }, + }, + Value: "alpha", + ExpectError: regexp.MustCompile("Option values must be unique"), + }, + { + Name: "IncorrectValueTypeOption", + Parameter: provider.Parameter{ + Type: "number", + Option: opts("not-a-number"), + }, + Value: "5", + ExpectError: regexp.MustCompile("is not a number"), + }, + { + Name: "IncorrectValueType", + Parameter: provider.Parameter{ + Type: "number", + }, + Value: "not-a-number", + ExpectError: regexp.MustCompile("Parameter value is not of type \"number\""), + }, + { + Name: "NotListStringDefault", + Parameter: provider.Parameter{ + Type: "list(string)", + Default: ptr("not-a-list"), + }, + ExpectError: regexp.MustCompile("not a valid list of strings"), + }, + { + Name: "NotListStringDefault", + Parameter: provider.Parameter{ + Type: "list(string)", + }, + Value: "not-a-list", + ExpectError: regexp.MustCompile("not a valid list of strings"), + }, + { + Name: "DefaultListStringNotInOptions", + Parameter: provider.Parameter{ + Type: "list(string)", + Default: ptr(`["red", "yellow", "black"]`), + Option: opts("red", "blue", "green"), + FormType: provider.ParameterFormTypeMultiSelect, + }, + Value: `["red", "yellow", "black"]`, + ExpectError: regexp.MustCompile("is not a valid option, values \"yellow, black\" are missing from the options"), + }, + { + Name: "ListStringNotInOptions", + Parameter: provider.Parameter{ + Type: "list(string)", + Default: ptr(`["red"]`), + Option: opts("red", "blue", "green"), + FormType: provider.ParameterFormTypeMultiSelect, + }, + Value: `["red", "yellow", "black"]`, + ExpectError: regexp.MustCompile("is not a valid option, values \"yellow, black\" are missing from the options"), + }, + { + Name: "InvalidMiniumum", + Parameter: provider.Parameter{ + Type: "number", + Default: ptr("5"), + Validation: []provider.Validation{{ + Min: 10, + Error: "must be greater than 10", + }}, + }, + ExpectError: regexp.MustCompile("must be greater than 10"), + }, + } { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + value := &tc.Value + _, diags := tc.Parameter.ValidateInput(value) + if tc.ExpectError != nil { + require.True(t, diags.HasError()) + errMsg := fmt.Sprintf("%+v", diags[0]) // close enough + require.Truef(t, tc.ExpectError.MatchString(errMsg), "got: %s", errMsg) + } else { + if !assert.False(t, diags.HasError()) { + t.Logf("got: %+v", diags[0]) + } + } + }) + } +} + // TestParameterValidationEnforcement tests various parameter states and the // validation enforcement that should be applied to them. The table is described // by a markdown table. This is done so that the test cases can be more easily @@ -703,10 +868,6 @@ func TestParameterValidationEnforcement(t *testing.T) { // - Validation logic does not apply to the default if a value is given // - [NumIns/DefInv] So the default can be invalid if an input value is valid. // The value is therefore not really optional, but it is marked as such. - // - [NumInsNotOptsVal | NumsInsNotOpts] values do not need to be in the option set? - // - [NumInsNotNum] number params do not require the value to be a number - // - [LStrInsNotList] list(string) do not require the value to be a list(string) - // - Same with [MulInsNotListOpts] table, err := os.ReadFile("testdata/parameter_table.md") require.NoError(t, err) @@ -719,7 +880,7 @@ func TestParameterValidationEnforcement(t *testing.T) { Validation *provider.Validation OutputValue string Optional bool - Error *regexp.Regexp + CreateError *regexp.Regexp } rows := make([]row, 0) @@ -750,6 +911,7 @@ func TestParameterValidationEnforcement(t *testing.T) { t.Fatalf("failed to parse error column %q: %v", columns[9], err) } } + var options []string if columns[4] != "" { options = strings.Split(columns[4], ",") @@ -796,7 +958,7 @@ func TestParameterValidationEnforcement(t *testing.T) { Validation: validation, OutputValue: columns[7], Optional: optional, - Error: rerr, + CreateError: rerr, }) } @@ -815,10 +977,8 @@ func TestParameterValidationEnforcement(t *testing.T) { t.Setenv(provider.ParameterEnvironmentVariable("parameter"), row.InputValue) } - if row.Error != nil { - if row.OutputValue != "" { - t.Errorf("output value %q should not be set if error is set", row.OutputValue) - } + if row.CreateError != nil && row.OutputValue != "" { + t.Errorf("output value %q should not be set if both errors are set", row.OutputValue) } var cfg strings.Builder @@ -860,13 +1020,12 @@ func TestParameterValidationEnforcement(t *testing.T) { } cfg.WriteString("}\n") - resource.Test(t, resource.TestCase{ ProviderFactories: coderFactory(), IsUnitTest: true, Steps: []resource.TestStep{{ Config: cfg.String(), - ExpectError: row.Error, + ExpectError: row.CreateError, Check: func(state *terraform.State) error { require.Len(t, state.Modules, 1) require.Len(t, state.Modules[0].Resources, 1) @@ -1096,3 +1255,7 @@ func TestParameterWithManyOptions(t *testing.T) { }}, }) } + +func ptr[T any](v T) *T { + return &v +} diff --git a/provider/testdata/parameter_table.md b/provider/testdata/parameter_table.md index f7645efa..3df16f06 100644 --- a/provider/testdata/parameter_table.md +++ b/provider/testdata/parameter_table.md @@ -1,79 +1,80 @@ -| Name | Type | Input | Default | Options | Validation | -> | Output | Optional | Error | -|----------------------|---------------|-----------|---------|-------------------|------------|----|--------|----------|--------------| -| | Empty Vals | | | | | | | | | -| Empty | string,number | | | | | | "" | false | | -| EmptyDupeOps | string,number | | | 1,1,1 | | | | | unique | -| EmptyList | list(string) | | | | | | "" | false | | -| EmptyListDupeOpts | list(string) | | | ["a"],["a"] | | | | | unique | -| EmptyMulti | tag-select | | | | | | "" | false | | -| EmptyOpts | string,number | | | 1,2,3 | | | "" | false | | -| EmptyRegex | string | | | | world | | | | regex error | -| EmptyMin | number | | | | 1-10 | | | | 1 < < 10 | -| EmptyMinOpt | number | | | 1,2,3 | 2-5 | | | | 2 < < 5 | -| EmptyRegexOpt | string | | | "hello","goodbye" | goodbye | | | | regex error | -| EmptyRegexOk | string | | | | .* | | "" | false | | -| | | | | | | | | | | -| | Default Set | No inputs | | | | | | | | -| NumDef | number | | 5 | | | | 5 | true | | -| NumDefVal | number | | 5 | | 3-7 | | 5 | true | | -| NumDefInv | number | | 5 | | 10- | | | | 10 < 5 < 0 | -| NumDefOpts | number | | 5 | 1,3,5,7 | 2-6 | | 5 | true | | -| NumDefNotOpts | number | | 5 | 1,3,7,9 | 2-6 | | | | valid option | -| NumDefInvOpt | number | | 5 | 1,3,5,7 | 6-10 | | | | 6 < 5 < 10 | -| NumDefNotNum | number | | a | | | | | | a number | -| NumDefOptsNotNum | number | | 1 | 1,a,2 | | | | | a number | -| | | | | | | | | | | -| StrDef | string | | hello | | | | hello | true | | -| StrDefInv | string | | hello | | world | | | | regex error | -| StrDefOpts | string | | a | a,b,c | | | a | true | | -| StrDefNotOpts | string | | a | b,c,d | | | | | valid option | -| StrDefValOpts | string | | a | a,b,c,d,e,f | [a-c] | | a | true | | -| StrDefInvOpt | string | | d | a,b,c,d,e,f | [a-c] | | | | regex error | -| | | | | | | | | | | -| LStrDef | list(string) | | ["a"] | | | | ["a"] | true | | -| LStrDefOpts | list(string) | | ["a"] | ["a"], ["b"] | | | ["a"] | true | | -| LStrDefNotOpts | list(string) | | ["a"] | ["b"], ["c"] | | | | | valid option | -| | | | | | | | | | | -| MulDef | tag-select | | ["a"] | | | | ["a"] | true | | -| MulDefOpts | multi-select | | ["a"] | a,b | | | ["a"] | true | | -| MulDefNotOpts | multi-select | | ["a"] | b,c | | | | | valid option | -| | | | | | | | | | | -| | Input Vals | | | | | | | | | -| NumIns | number | 3 | | | | | 3 | false | | -| NumInsNotNum | number | a | | | | | a | false | | -| NumInsNotNumInv | number | a | | | 1-3 | | | | 1 < a < 3 | -| NumInsDef | number | 3 | 5 | | | | 3 | true | | -| NumIns/DefInv | number | 3 | 5 | | 1-3 | | 3 | true | | -| NumIns=DefInv | number | 5 | 5 | | 1-3 | | | | 1 < 5 < 3 | -| NumInsOpts | number | 3 | 5 | 1,2,3,4,5 | 1-3 | | 3 | true | | -| NumInsNotOptsVal | number | 3 | 5 | 1,2,4,5 | 1-3 | | 3 | true | | -| NumInsNotOptsInv | number | 3 | 5 | 1,2,4,5 | 1-2 | | | true | 1 < 3 < 2 | -| NumInsNotOpts | number | 3 | 5 | 1,2,4,5 | | | 3 | true | | -| NumInsNotOpts/NoDef | number | 3 | | 1,2,4,5 | | | 3 | false | | -| | | | | | | | | | | -| StrIns | string | c | | | | | c | false | | -| StrInsDupeOpts | string | c | | a,b,c,c | | | | | unique | -| StrInsDef | string | c | e | | | | c | true | | -| StrIns/DefInv | string | c | e | | [a-c] | | c | true | | -| StrIns=DefInv | string | e | e | | [a-c] | | | | regex error | -| StrInsOpts | string | c | e | a,b,c,d,e | [a-c] | | c | true | | -| StrInsNotOptsVal | string | c | e | a,b,d,e | [a-c] | | c | true | | -| StrInsNotOptsInv | string | c | e | a,b,d,e | [a-b] | | | | regex error | -| StrInsNotOpts | string | c | e | a,b,d,e | | | c | true | | -| StrInsNotOpts/NoDef | string | c | | a,b,d,e | | | c | false | | -| StrInsBadVal | string | c | | a,b,c,d,e | 1-10 | | | | min cannot | -| | | | | | | | | | | -| | list(string) | | | | | | | | | -| LStrIns | list(string) | ["c"] | | | | | ["c"] | false | | -| LStrInsNotList | list(string) | c | | | | | c | false | | -| LStrInsDef | list(string) | ["c"] | ["e"] | | | | ["c"] | true | | -| LStrIns/DefInv | list(string) | ["c"] | ["e"] | | [a-c] | | | | regex cannot | -| LStrInsOpts | list(string) | ["c"] | ["e"] | ["c"],["d"],["e"] | | | ["c"] | true | | -| LStrInsNotOpts | list(string) | ["c"] | ["e"] | ["d"],["e"] | | | ["c"] | true | | -| LStrInsNotOpts/NoDef | list(string) | ["c"] | | ["d"],["e"] | | | ["c"] | false | | -| | | | | | | | | | | -| MulInsOpts | multi-select | ["c"] | ["e"] | c,d,e | | | ["c"] | true | | -| MulInsNotListOpts | multi-select | c | ["e"] | c,d,e | | | c | true | | -| MulInsNotOpts | multi-select | ["c"] | ["e"] | d,e | | | ["c"] | true | | -| MulInsNotOpts/NoDef | multi-select | ["c"] | | d,e | | | ["c"] | false | | -| MulInsInvOpts | multi-select | ["c"] | ["e"] | c,d,e | [a-c] | | | | regex cannot | \ No newline at end of file +| Name | Type | Input | Default | Options | Validation | -> | Output | Optional | ErrorCreate | +|----------------------|---------------|-----------|---------|-------------------|------------|----|--------|----------|-----------------| +| | Empty Vals | | | | | | | | | +| Empty | string,number | | | | | | "" | false | | +| EmptyDupeOps | string,number | | | 1,1,1 | | | | | unique | +| EmptyList | list(string) | | | | | | "" | false | | +| EmptyListDupeOpts | list(string) | | | ["a"],["a"] | | | | | unique | +| EmptyMulti | tag-select | | | | | | "" | false | | +| EmptyOpts | string,number | | | 1,2,3 | | | "" | false | | +| EmptyRegex | string | | | | world | | | | regex error | +| EmptyMin | number | | | | 1-10 | | | | 1 < < 10 | +| EmptyMinOpt | number | | | 1,2,3 | 2-5 | | | | valid option | +| EmptyRegexOpt | string | | | "hello","goodbye" | goodbye | | | | valid option | +| EmptyRegexOk | string | | | | .* | | "" | false | | +| | | | | | | | | | | +| | Default Set | No inputs | | | | | | | | +| NumDef | number | | 5 | | | | 5 | true | | +| NumDefVal | number | | 5 | | 3-7 | | 5 | true | | +| NumDefInv | number | | 5 | | 10- | | | | 10 < 5 < 0 | +| NumDefOpts | number | | 5 | 1,3,5,7 | 2-6 | | 5 | true | | +| NumDefNotOpts | number | | 5 | 1,3,7,9 | 2-6 | | | | valid option | +| NumDefInvOpt | number | | 5 | 1,3,5,7 | 6-10 | | | | 6 < 5 < 10 | +| NumDefNotNum | number | | a | | | | | | type "number" | +| NumDefOptsNotNum | number | | 1 | 1,a,2 | | | | | type "number" | +| | | | | | | | | | | +| StrDef | string | | hello | | | | hello | true | | +| StrDefInv | string | | hello | | world | | | | regex error | +| StrDefOpts | string | | a | a,b,c | | | a | true | | +| StrDefNotOpts | string | | a | b,c,d | | | | | valid option | +| StrDefValOpts | string | | a | a,b,c,d,e,f | [a-c] | | a | true | | +| StrDefInvOpt | string | | d | a,b,c,d,e,f | [a-c] | | | | regex error | +| | | | | | | | | | | +| LStrDef | list(string) | | ["a"] | | | | ["a"] | true | | +| LStrDefOpts | list(string) | | ["a"] | ["a"], ["b"] | | | ["a"] | true | | +| LStrDefNotOpts | list(string) | | ["a"] | ["b"], ["c"] | | | | | valid option | +| | | | | | | | | | | +| MulDef | tag-select | | ["a"] | | | | ["a"] | true | | +| MulDefOpts | multi-select | | ["a"] | a,b | | | ["a"] | true | | +| MulDefNotOpts | multi-select | | ["a"] | b,c | | | | | valid option | +| | | | | | | | | | | +| | Input Vals | | | | | | | | | +| NumIns | number | 3 | | | | | 3 | false | | +| NumInsOptsNaN | number | 3 | 5 | a,1,2,3,4,5 | 1-3 | | | | type "number" | +| NumInsNotNum | number | a | | | | | | | type "number" | +| NumInsNotNumInv | number | a | | | 1-3 | | | | 1 < a < 3 | +| NumInsDef | number | 3 | 5 | | | | 3 | true | | +| NumIns/DefInv | number | 3 | 5 | | 1-3 | | 3 | true | | +| NumIns=DefInv | number | 5 | 5 | | 1-3 | | | | 1 < 5 < 3 | +| NumInsOpts | number | 3 | 5 | 1,2,3,4,5 | 1-3 | | 3 | true | | +| NumInsNotOptsVal | number | 3 | 5 | 1,2,4,5 | 1-3 | | | | valid option | +| NumInsNotOptsInv | number | 3 | 5 | 1,2,4,5 | 1-2 | | | true | valid option | +| NumInsNotOpts | number | 3 | 5 | 1,2,4,5 | | | | | valid option | +| NumInsNotOpts/NoDef | number | 3 | | 1,2,4,5 | | | | | valid option | +| | | | | | | | | | | +| StrIns | string | c | | | | | c | false | | +| StrInsDupeOpts | string | c | | a,b,c,c | | | | | unique | +| StrInsDef | string | c | e | | | | c | true | | +| StrIns/DefInv | string | c | e | | [a-c] | | c | true | | +| StrIns=DefInv | string | e | e | | [a-c] | | | | regex error | +| StrInsOpts | string | c | e | a,b,c,d,e | [a-c] | | c | true | | +| StrInsNotOptsVal | string | c | e | a,b,d,e | [a-c] | | | | valid option | +| StrInsNotOptsInv | string | c | e | a,b,d,e | [a-b] | | | | valid option | +| StrInsNotOpts | string | c | e | a,b,d,e | | | | | valid option | +| StrInsNotOpts/NoDef | string | c | | a,b,d,e | | | | | valid option | +| StrInsBadVal | string | c | | a,b,c,d,e | 1-10 | | | | min cannot | +| | | | | | | | | | | +| | list(string) | | | | | | | | | +| LStrIns | list(string) | ["c"] | | | | | ["c"] | false | | +| LStrInsNotList | list(string) | c | | | | | | | list of strings | +| LStrInsDef | list(string) | ["c"] | ["e"] | | | | ["c"] | true | | +| LStrIns/DefInv | list(string) | ["c"] | ["e"] | | [a-c] | | | | regex cannot | +| LStrInsOpts | list(string) | ["c"] | ["e"] | ["c"],["d"],["e"] | | | ["c"] | true | | +| LStrInsNotOpts | list(string) | ["c"] | ["e"] | ["d"],["e"] | | | | | valid option | +| LStrInsNotOpts/NoDef | list(string) | ["c"] | | ["d"],["e"] | | | | | valid option | +| | | | | | | | | | | +| MulInsOpts | multi-select | ["c"] | ["e"] | c,d,e | | | ["c"] | true | | +| MulInsNotListOpts | multi-select | c | ["e"] | c,d,e | | | | | json encoded | +| MulInsNotOpts | multi-select | ["c"] | ["e"] | d,e | | | | | valid option | +| MulInsNotOpts/NoDef | multi-select | ["c"] | | d,e | | | | | valid option | +| MulInsInvOpts | multi-select | ["c"] | ["e"] | c,d,e | [a-c] | | | | regex cannot | \ No newline at end of file From 8692a56f55acb7a10f74c16f7f00b896c33ce02b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 8 May 2025 09:36:57 -0500 Subject: [PATCH 43/63] feat: enforce monotonicity in terraform provider (#392) Previous value must come from env var. --- provider/parameter.go | 55 ++++++++- provider/parameter_test.go | 120 ++++++++++++++---- provider/testdata/parameter_table.md | 175 +++++++++++++++------------ 3 files changed, 241 insertions(+), 109 deletions(-) diff --git a/provider/parameter.go b/provider/parameter.go index fd484578..f0a26c99 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -144,7 +144,13 @@ func parameterDataSource() *schema.Resource { input = &envValue } - value, diags := parameter.ValidateInput(input) + var previous *string + envPreviousValue, ok := os.LookupEnv(ParameterEnvironmentVariablePrevious(parameter.Name)) + if ok { + previous = &envPreviousValue + } + + value, diags := parameter.ValidateInput(input, previous) if diags.HasError() { return diags } @@ -395,7 +401,7 @@ func valueIsType(typ OptionType, value string) error { return nil } -func (v *Parameter) ValidateInput(input *string) (string, diag.Diagnostics) { +func (v *Parameter) ValidateInput(input *string, previous *string) (string, diag.Diagnostics) { var err error var optionType OptionType @@ -442,7 +448,7 @@ func (v *Parameter) ValidateInput(input *string) (string, diag.Diagnostics) { forcedValue = *value } - d := v.validValue(forcedValue, optionType, optionValues, valuePath) + d := v.validValue(forcedValue, previous, optionType, optionValues, valuePath) if d.HasError() { return "", d } @@ -506,7 +512,7 @@ func (v *Parameter) ValidOptions(optionType OptionType) (map[string]struct{}, di return optionValues, nil } -func (v *Parameter) validValue(value string, optionType OptionType, optionValues map[string]struct{}, path cty.Path) diag.Diagnostics { +func (v *Parameter) validValue(value string, previous *string, optionType OptionType, optionValues map[string]struct{}, path cty.Path) diag.Diagnostics { // name is used for constructing more precise error messages. name := "Value" if path.Equals(defaultValuePath) { @@ -573,7 +579,7 @@ func (v *Parameter) validValue(value string, optionType OptionType, optionValues if len(v.Validation) == 1 { validCheck := &v.Validation[0] - err := validCheck.Valid(v.Type, value) + err := validCheck.Valid(v.Type, value, previous) if err != nil { return diag.Diagnostics{ { @@ -589,7 +595,7 @@ func (v *Parameter) validValue(value string, optionType OptionType, optionValues return nil } -func (v *Validation) Valid(typ OptionType, value string) error { +func (v *Validation) Valid(typ OptionType, value string, previous *string) error { if typ != OptionTypeNumber { if !v.MinDisabled { return fmt.Errorf("a min cannot be specified for a %s type", typ) @@ -639,6 +645,34 @@ func (v *Validation) Valid(typ OptionType, value string) error { if v.Monotonic != "" && v.Monotonic != ValidationMonotonicIncreasing && v.Monotonic != ValidationMonotonicDecreasing { return fmt.Errorf("number monotonicity can be either %q or %q", ValidationMonotonicIncreasing, ValidationMonotonicDecreasing) } + + switch v.Monotonic { + case "": + // No monotonicity check + case ValidationMonotonicIncreasing, ValidationMonotonicDecreasing: + if previous != nil { // Only check if previous value exists + previousNum, err := strconv.Atoi(*previous) + if err != nil { + // Do not throw an error for the previous value not being a number. Throwing an + // error here would cause an unrepairable state for the user. This is + // unfortunate, but there is not much we can do at this point. + // TODO: Maybe we should enforce this, and have the calling coderd + // do something to resolve it. Such as doing this check before calling + // terraform apply. + break + } + + if v.Monotonic == ValidationMonotonicIncreasing && !(num >= previousNum) { + return fmt.Errorf("parameter value '%d' must be equal or greater than previous value: %d", num, previousNum) + } + + if v.Monotonic == ValidationMonotonicDecreasing && !(num <= previousNum) { + return fmt.Errorf("parameter value '%d' must be equal or lower than previous value: %d", num, previousNum) + } + } + default: + return fmt.Errorf("number monotonicity can be either %q or %q", ValidationMonotonicIncreasing, ValidationMonotonicDecreasing) + } case OptionTypeListString: var listOfStrings []string err := json.Unmarshal([]byte(value), &listOfStrings) @@ -666,6 +700,15 @@ func ParameterEnvironmentVariable(name string) string { return "CODER_PARAMETER_" + hex.EncodeToString(sum[:]) } +// ParameterEnvironmentVariablePrevious returns the environment variable to +// specify for a parameter's previous value. This is used for workspace +// subsequent builds after the first. Primarily to validate monotonicity in the +// `validation` block. +func ParameterEnvironmentVariablePrevious(name string) string { + sum := sha256.Sum256([]byte(name)) + return "CODER_PARAMETER_PREVIOUS_" + hex.EncodeToString(sum[:]) +} + func takeFirstError(errs ...error) error { for _, err := range errs { if err != nil { diff --git a/provider/parameter_test.go b/provider/parameter_test.go index 21842b6a..9b5e76f1 100644 --- a/provider/parameter_test.go +++ b/provider/parameter_test.go @@ -839,7 +839,7 @@ func TestParameterValidation(t *testing.T) { t.Run(tc.Name, func(t *testing.T) { t.Parallel() value := &tc.Value - _, diags := tc.Parameter.ValidateInput(value) + _, diags := tc.Parameter.ValidateInput(value, nil) if tc.ExpectError != nil { require.True(t, diags.HasError()) errMsg := fmt.Sprintf("%+v", diags[0]) // close enough @@ -881,6 +881,7 @@ func TestParameterValidationEnforcement(t *testing.T) { OutputValue string Optional bool CreateError *regexp.Regexp + Previous *string } rows := make([]row, 0) @@ -898,33 +899,44 @@ func TestParameterValidationEnforcement(t *testing.T) { continue // Skip rows with empty names } - optional, err := strconv.ParseBool(columns[8]) - if columns[8] != "" { + cname, ctype, cprev, cinput, cdefault, coptions, cvalidation, _, coutput, coptional, cerr := + columns[0], columns[1], columns[2], columns[3], columns[4], columns[5], columns[6], columns[7], columns[8], columns[9], columns[10] + + optional, err := strconv.ParseBool(coptional) + if coptional != "" { // Value does not matter if not specified require.NoError(t, err) } var rerr *regexp.Regexp - if columns[9] != "" { - rerr, err = regexp.Compile(columns[9]) + if cerr != "" { + rerr, err = regexp.Compile(cerr) if err != nil { - t.Fatalf("failed to parse error column %q: %v", columns[9], err) + t.Fatalf("failed to parse error column %q: %v", cerr, err) } } var options []string - if columns[4] != "" { - options = strings.Split(columns[4], ",") + if coptions != "" { + options = strings.Split(coptions, ",") } var validation *provider.Validation - if columns[5] != "" { - // Min-Max validation should look like: - // 1-10 :: min=1, max=10 - // -10 :: max=10 - // 1- :: min=1 - if validMinMax.MatchString(columns[5]) { - parts := strings.Split(columns[5], "-") + if cvalidation != "" { + switch { + case cvalidation == provider.ValidationMonotonicIncreasing || cvalidation == provider.ValidationMonotonicDecreasing: + validation = &provider.Validation{ + MinDisabled: true, + MaxDisabled: true, + Monotonic: cvalidation, + Error: "monotonicity", + } + case validMinMax.MatchString(cvalidation): + // Min-Max validation should look like: + // 1-10 :: min=1, max=10 + // -10 :: max=10 + // 1- :: min=1 + parts := strings.Split(cvalidation, "-") min, _ := strconv.ParseInt(parts[0], 10, 64) max, _ := strconv.ParseInt(parts[1], 10, 64) validation = &provider.Validation{ @@ -936,29 +948,37 @@ func TestParameterValidationEnforcement(t *testing.T) { Regex: "", Error: "{min} < {value} < {max}", } - } else { + default: validation = &provider.Validation{ Min: 0, MinDisabled: true, Max: 0, MaxDisabled: true, Monotonic: "", - Regex: columns[5], + Regex: cvalidation, Error: "regex error", } } } + var prev *string + if cprev != "" { + prev = ptr(cprev) + if cprev == `""` { + prev = ptr("") + } + } rows = append(rows, row{ - Name: columns[0], - Types: strings.Split(columns[1], ","), - InputValue: columns[2], - Default: columns[3], + Name: cname, + Types: strings.Split(ctype, ","), + InputValue: cinput, + Default: cdefault, Options: options, Validation: validation, - OutputValue: columns[7], + OutputValue: coutput, Optional: optional, CreateError: rerr, + Previous: prev, }) } @@ -976,6 +996,9 @@ func TestParameterValidationEnforcement(t *testing.T) { if row.InputValue != "" { t.Setenv(provider.ParameterEnvironmentVariable("parameter"), row.InputValue) } + if row.Previous != nil { + t.Setenv(provider.ParameterEnvironmentVariablePrevious("parameter"), *row.Previous) + } if row.CreateError != nil && row.OutputValue != "" { t.Errorf("output value %q should not be set if both errors are set", row.OutputValue) @@ -1067,6 +1090,7 @@ func TestValueValidatesType(t *testing.T) { Name string Type provider.OptionType Value string + Previous *string Regex string RegexError string Min int @@ -1154,6 +1178,56 @@ func TestValueValidatesType(t *testing.T) { Min: 0, Max: 2, Monotonic: "decreasing", + }, { + Name: "IncreasingMonotonicityEqual", + Type: "number", + Previous: ptr("1"), + Value: "1", + Monotonic: "increasing", + MinDisabled: true, + MaxDisabled: true, + }, { + Name: "DecreasingMonotonicityEqual", + Type: "number", + Value: "1", + Previous: ptr("1"), + Monotonic: "decreasing", + MinDisabled: true, + MaxDisabled: true, + }, { + Name: "IncreasingMonotonicityGreater", + Type: "number", + Previous: ptr("0"), + Value: "1", + Monotonic: "increasing", + MinDisabled: true, + MaxDisabled: true, + }, { + Name: "DecreasingMonotonicityGreater", + Type: "number", + Value: "1", + Previous: ptr("0"), + Monotonic: "decreasing", + MinDisabled: true, + MaxDisabled: true, + Error: regexp.MustCompile("must be equal or"), + }, { + Name: "IncreasingMonotonicityLesser", + Type: "number", + Previous: ptr("2"), + Value: "1", + Monotonic: "increasing", + MinDisabled: true, + MaxDisabled: true, + Error: regexp.MustCompile("must be equal or"), + }, { + Name: "DecreasingMonotonicityLesser", + Type: "number", + Value: "1", + Previous: ptr("2"), + Monotonic: "decreasing", + MinDisabled: true, + MaxDisabled: true, }, { Name: "ValidListOfStrings", Type: "list(string)", @@ -1205,7 +1279,7 @@ func TestValueValidatesType(t *testing.T) { Regex: tc.Regex, Error: tc.RegexError, } - err := v.Valid(tc.Type, tc.Value) + err := v.Valid(tc.Type, tc.Value, tc.Previous) if tc.Error != nil { require.Error(t, err) require.True(t, tc.Error.MatchString(err.Error()), "got: %s", err.Error()) diff --git a/provider/testdata/parameter_table.md b/provider/testdata/parameter_table.md index 3df16f06..6087460f 100644 --- a/provider/testdata/parameter_table.md +++ b/provider/testdata/parameter_table.md @@ -1,80 +1,95 @@ -| Name | Type | Input | Default | Options | Validation | -> | Output | Optional | ErrorCreate | -|----------------------|---------------|-----------|---------|-------------------|------------|----|--------|----------|-----------------| -| | Empty Vals | | | | | | | | | -| Empty | string,number | | | | | | "" | false | | -| EmptyDupeOps | string,number | | | 1,1,1 | | | | | unique | -| EmptyList | list(string) | | | | | | "" | false | | -| EmptyListDupeOpts | list(string) | | | ["a"],["a"] | | | | | unique | -| EmptyMulti | tag-select | | | | | | "" | false | | -| EmptyOpts | string,number | | | 1,2,3 | | | "" | false | | -| EmptyRegex | string | | | | world | | | | regex error | -| EmptyMin | number | | | | 1-10 | | | | 1 < < 10 | -| EmptyMinOpt | number | | | 1,2,3 | 2-5 | | | | valid option | -| EmptyRegexOpt | string | | | "hello","goodbye" | goodbye | | | | valid option | -| EmptyRegexOk | string | | | | .* | | "" | false | | -| | | | | | | | | | | -| | Default Set | No inputs | | | | | | | | -| NumDef | number | | 5 | | | | 5 | true | | -| NumDefVal | number | | 5 | | 3-7 | | 5 | true | | -| NumDefInv | number | | 5 | | 10- | | | | 10 < 5 < 0 | -| NumDefOpts | number | | 5 | 1,3,5,7 | 2-6 | | 5 | true | | -| NumDefNotOpts | number | | 5 | 1,3,7,9 | 2-6 | | | | valid option | -| NumDefInvOpt | number | | 5 | 1,3,5,7 | 6-10 | | | | 6 < 5 < 10 | -| NumDefNotNum | number | | a | | | | | | type "number" | -| NumDefOptsNotNum | number | | 1 | 1,a,2 | | | | | type "number" | -| | | | | | | | | | | -| StrDef | string | | hello | | | | hello | true | | -| StrDefInv | string | | hello | | world | | | | regex error | -| StrDefOpts | string | | a | a,b,c | | | a | true | | -| StrDefNotOpts | string | | a | b,c,d | | | | | valid option | -| StrDefValOpts | string | | a | a,b,c,d,e,f | [a-c] | | a | true | | -| StrDefInvOpt | string | | d | a,b,c,d,e,f | [a-c] | | | | regex error | -| | | | | | | | | | | -| LStrDef | list(string) | | ["a"] | | | | ["a"] | true | | -| LStrDefOpts | list(string) | | ["a"] | ["a"], ["b"] | | | ["a"] | true | | -| LStrDefNotOpts | list(string) | | ["a"] | ["b"], ["c"] | | | | | valid option | -| | | | | | | | | | | -| MulDef | tag-select | | ["a"] | | | | ["a"] | true | | -| MulDefOpts | multi-select | | ["a"] | a,b | | | ["a"] | true | | -| MulDefNotOpts | multi-select | | ["a"] | b,c | | | | | valid option | -| | | | | | | | | | | -| | Input Vals | | | | | | | | | -| NumIns | number | 3 | | | | | 3 | false | | -| NumInsOptsNaN | number | 3 | 5 | a,1,2,3,4,5 | 1-3 | | | | type "number" | -| NumInsNotNum | number | a | | | | | | | type "number" | -| NumInsNotNumInv | number | a | | | 1-3 | | | | 1 < a < 3 | -| NumInsDef | number | 3 | 5 | | | | 3 | true | | -| NumIns/DefInv | number | 3 | 5 | | 1-3 | | 3 | true | | -| NumIns=DefInv | number | 5 | 5 | | 1-3 | | | | 1 < 5 < 3 | -| NumInsOpts | number | 3 | 5 | 1,2,3,4,5 | 1-3 | | 3 | true | | -| NumInsNotOptsVal | number | 3 | 5 | 1,2,4,5 | 1-3 | | | | valid option | -| NumInsNotOptsInv | number | 3 | 5 | 1,2,4,5 | 1-2 | | | true | valid option | -| NumInsNotOpts | number | 3 | 5 | 1,2,4,5 | | | | | valid option | -| NumInsNotOpts/NoDef | number | 3 | | 1,2,4,5 | | | | | valid option | -| | | | | | | | | | | -| StrIns | string | c | | | | | c | false | | -| StrInsDupeOpts | string | c | | a,b,c,c | | | | | unique | -| StrInsDef | string | c | e | | | | c | true | | -| StrIns/DefInv | string | c | e | | [a-c] | | c | true | | -| StrIns=DefInv | string | e | e | | [a-c] | | | | regex error | -| StrInsOpts | string | c | e | a,b,c,d,e | [a-c] | | c | true | | -| StrInsNotOptsVal | string | c | e | a,b,d,e | [a-c] | | | | valid option | -| StrInsNotOptsInv | string | c | e | a,b,d,e | [a-b] | | | | valid option | -| StrInsNotOpts | string | c | e | a,b,d,e | | | | | valid option | -| StrInsNotOpts/NoDef | string | c | | a,b,d,e | | | | | valid option | -| StrInsBadVal | string | c | | a,b,c,d,e | 1-10 | | | | min cannot | -| | | | | | | | | | | -| | list(string) | | | | | | | | | -| LStrIns | list(string) | ["c"] | | | | | ["c"] | false | | -| LStrInsNotList | list(string) | c | | | | | | | list of strings | -| LStrInsDef | list(string) | ["c"] | ["e"] | | | | ["c"] | true | | -| LStrIns/DefInv | list(string) | ["c"] | ["e"] | | [a-c] | | | | regex cannot | -| LStrInsOpts | list(string) | ["c"] | ["e"] | ["c"],["d"],["e"] | | | ["c"] | true | | -| LStrInsNotOpts | list(string) | ["c"] | ["e"] | ["d"],["e"] | | | | | valid option | -| LStrInsNotOpts/NoDef | list(string) | ["c"] | | ["d"],["e"] | | | | | valid option | -| | | | | | | | | | | -| MulInsOpts | multi-select | ["c"] | ["e"] | c,d,e | | | ["c"] | true | | -| MulInsNotListOpts | multi-select | c | ["e"] | c,d,e | | | | | json encoded | -| MulInsNotOpts | multi-select | ["c"] | ["e"] | d,e | | | | | valid option | -| MulInsNotOpts/NoDef | multi-select | ["c"] | | d,e | | | | | valid option | -| MulInsInvOpts | multi-select | ["c"] | ["e"] | c,d,e | [a-c] | | | | regex cannot | \ No newline at end of file +| Name | Type | Previous | Input | Default | Options | Validation | -> | Output | Optional | ErrorCreate | +|----------------------|---------------|----------|-----------|---------|-------------------|------------|----|--------|----------|-----------------| +| | Empty Vals | | | | | | | | | | +| Empty | string,number | | | | | | | "" | false | | +| EmptyDupeOps | string,number | | | | 1,1,1 | | | | | unique | +| EmptyList | list(string) | | | | | | | "" | false | | +| EmptyListDupeOpts | list(string) | | | | ["a"],["a"] | | | | | unique | +| EmptyMulti | tag-select | | | | | | | "" | false | | +| EmptyOpts | string,number | | | | 1,2,3 | | | "" | false | | +| EmptyRegex | string | | | | | world | | | | regex error | +| EmptyMin | number | | | | | 1-10 | | | | 1 < < 10 | +| EmptyMinOpt | number | | | | 1,2,3 | 2-5 | | | | valid option | +| EmptyRegexOpt | string | | | | "hello","goodbye" | goodbye | | | | valid option | +| EmptyRegexOk | string | | | | | .* | | "" | false | | +| | | | | | | | | | | | +| | Default Set | | No inputs | | | | | | | | +| NumDef | number | | | 5 | | | | 5 | true | | +| NumDefVal | number | | | 5 | | 3-7 | | 5 | true | | +| NumDefInv | number | | | 5 | | 10- | | | | 10 < 5 < 0 | +| NumDefOpts | number | | | 5 | 1,3,5,7 | 2-6 | | 5 | true | | +| NumDefNotOpts | number | | | 5 | 1,3,7,9 | 2-6 | | | | valid option | +| NumDefInvOpt | number | | | 5 | 1,3,5,7 | 6-10 | | | | 6 < 5 < 10 | +| NumDefNotNum | number | | | a | | | | | | type "number" | +| NumDefOptsNotNum | number | | | 1 | 1,a,2 | | | | | type "number" | +| NumDefInc | number | 4 | | 5 | | increasing | | 5 | true | | +| NumDefIncBad | number | 6 | | 5 | | increasing | | | | greater | +| NumDefDec | number | 6 | | 5 | | decreasing | | 5 | true | | +| NumDefDecBad | number | 4 | | 5 | | decreasing | | | | lower | +| NumDefDecEq | number | 5 | | 5 | | decreasing | | 5 | true | | +| NumDefIncEq | number | 5 | | 5 | | increasing | | 5 | true | | +| NumDefIncNaN | number | a | | 5 | | increasing | | 5 | true | | +| NumDefDecNaN | number | b | | 5 | | decreasing | | 5 | true | | +| | | | | | | | | | | | +| StrDef | string | | | hello | | | | hello | true | | +| StrMonotonicity | string | | | hello | | increasing | | | | monotonic | +| StrDefInv | string | | | hello | | world | | | | regex error | +| StrDefOpts | string | | | a | a,b,c | | | a | true | | +| StrDefNotOpts | string | | | a | b,c,d | | | | | valid option | +| StrDefValOpts | string | | | a | a,b,c,d,e,f | [a-c] | | a | true | | +| StrDefInvOpt | string | | | d | a,b,c,d,e,f | [a-c] | | | | regex error | +| | | | | | | | | | | | +| LStrDef | list(string) | | | ["a"] | | | | ["a"] | true | | +| LStrDefOpts | list(string) | | | ["a"] | ["a"], ["b"] | | | ["a"] | true | | +| LStrDefNotOpts | list(string) | | | ["a"] | ["b"], ["c"] | | | | | valid option | +| | | | | | | | | | | | +| MulDef | tag-select | | | ["a"] | | | | ["a"] | true | | +| MulDefOpts | multi-select | | | ["a"] | a,b | | | ["a"] | true | | +| MulDefNotOpts | multi-select | | | ["a"] | b,c | | | | | valid option | +| | | | | | | | | | | | +| | Input Vals | | | | | | | | | | +| NumIns | number | | 3 | | | | | 3 | false | | +| NumInsOptsNaN | number | | 3 | 5 | a,1,2,3,4,5 | 1-3 | | | | type "number" | +| NumInsNotNum | number | | a | | | | | | | type "number" | +| NumInsNotNumInv | number | | a | | | 1-3 | | | | 1 < a < 3 | +| NumInsDef | number | | 3 | 5 | | | | 3 | true | | +| NumIns/DefInv | number | | 3 | 5 | | 1-3 | | 3 | true | | +| NumIns=DefInv | number | | 5 | 5 | | 1-3 | | | | 1 < 5 < 3 | +| NumInsOpts | number | | 3 | 5 | 1,2,3,4,5 | 1-3 | | 3 | true | | +| NumInsNotOptsVal | number | | 3 | 5 | 1,2,4,5 | 1-3 | | | | valid option | +| NumInsNotOptsInv | number | | 3 | 5 | 1,2,4,5 | 1-2 | | | true | valid option | +| NumInsNotOpts | number | | 3 | 5 | 1,2,4,5 | | | | | valid option | +| NumInsNotOpts/NoDef | number | | 3 | | 1,2,4,5 | | | | | valid option | +| NumInsInc | number | 4 | 5 | 3 | | increasing | | 5 | true | | +| NumInsIncBad | number | 6 | 5 | 7 | | increasing | | | | greater | +| NumInsDec | number | 6 | 5 | 7 | | decreasing | | 5 | true | | +| NumInsDecBad | number | 4 | 5 | 3 | | decreasing | | | | lower | +| NumInsDecEq | number | 5 | 5 | 5 | | decreasing | | 5 | true | | +| NumInsIncEq | number | 5 | 5 | 5 | | increasing | | 5 | true | | +| | | | | | | | | | | | +| StrIns | string | | c | | | | | c | false | | +| StrInsDupeOpts | string | | c | | a,b,c,c | | | | | unique | +| StrInsDef | string | | c | e | | | | c | true | | +| StrIns/DefInv | string | | c | e | | [a-c] | | c | true | | +| StrIns=DefInv | string | | e | e | | [a-c] | | | | regex error | +| StrInsOpts | string | | c | e | a,b,c,d,e | [a-c] | | c | true | | +| StrInsNotOptsVal | string | | c | e | a,b,d,e | [a-c] | | | | valid option | +| StrInsNotOptsInv | string | | c | e | a,b,d,e | [a-b] | | | | valid option | +| StrInsNotOpts | string | | c | e | a,b,d,e | | | | | valid option | +| StrInsNotOpts/NoDef | string | | c | | a,b,d,e | | | | | valid option | +| StrInsBadVal | string | | c | | a,b,c,d,e | 1-10 | | | | min cannot | +| | | | | | | | | | | | +| | list(string) | | | | | | | | | | +| LStrIns | list(string) | | ["c"] | | | | | ["c"] | false | | +| LStrInsNotList | list(string) | | c | | | | | | | list of strings | +| LStrInsDef | list(string) | | ["c"] | ["e"] | | | | ["c"] | true | | +| LStrIns/DefInv | list(string) | | ["c"] | ["e"] | | [a-c] | | | | regex cannot | +| LStrInsOpts | list(string) | | ["c"] | ["e"] | ["c"],["d"],["e"] | | | ["c"] | true | | +| LStrInsNotOpts | list(string) | | ["c"] | ["e"] | ["d"],["e"] | | | | | valid option | +| LStrInsNotOpts/NoDef | list(string) | | ["c"] | | ["d"],["e"] | | | | | valid option | +| | | | | | | | | | | | +| MulInsOpts | multi-select | | ["c"] | ["e"] | c,d,e | | | ["c"] | true | | +| MulInsNotListOpts | multi-select | | c | ["e"] | c,d,e | | | | | json encoded | +| MulInsNotOpts | multi-select | | ["c"] | ["e"] | d,e | | | | | valid option | +| MulInsNotOpts/NoDef | multi-select | | ["c"] | | d,e | | | | | valid option | +| MulInsInvOpts | multi-select | | ["c"] | ["e"] | c,d,e | [a-c] | | | | regex cannot | \ No newline at end of file From 4126ff41a540cd3c34dbf034fa1e4b269f38da70 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 9 May 2025 09:41:58 +0200 Subject: [PATCH 44/63] docs: link to additional documentation for prebuilt workspaces (#395) * docs: link to additional documentation for prebuilt workspaces * improve wording --- docs/data-sources/workspace_preset.md | 6 +++--- provider/workspace_preset.go | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index edd61f18..14e235f9 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -3,12 +3,12 @@ page_title: "coder_workspace_preset Data Source - terraform-provider-coder" subcategory: "" description: |- - Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are not defined by the preset will still be configurable when creating a workspace. + Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are defined by the template but not defined by the preset will still be configurable when creating a workspace. --- # coder_workspace_preset (Data Source) -Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are not defined by the preset will still be configurable when creating a workspace. +Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are defined by the template but not defined by the preset will still be configurable when creating a workspace. ## Example Usage @@ -39,7 +39,7 @@ data "coder_workspace_preset" "example" { ### Optional - `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) Prebuilt workspace configuration related to this workspace preset. Coder will build and maintain workspaces in reserve based on this configuration. When a user creates a new workspace using a preset, they will be assigned a prebuilt workspace, instead of waiting for a new workspace to build. (see [below for nested schema](#nestedblock--prebuilds)) +- `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)) ### Read-Only diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index 2fab0b8a..78466c10 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -28,7 +28,8 @@ func workspacePresetDataSource() *schema.Resource { return &schema.Resource{ SchemaVersion: 1, - Description: "Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are not defined by the preset will still be configurable when creating a workspace.", + Description: "Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are defined by the template but not defined by the preset will still be configurable when creating a workspace.", + ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics { var preset WorkspacePreset err := mapstructure.Decode(struct { @@ -68,7 +69,7 @@ func workspacePresetDataSource() *schema.Resource { }, "prebuilds": { Type: schema.TypeSet, - Description: "Prebuilt workspace configuration related to this workspace preset. Coder will build and maintain workspaces in reserve based on this configuration. When a user creates a new workspace using a preset, they will be assigned a prebuilt workspace, instead of waiting for a new workspace to build.", + Description: "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)", Optional: true, MaxItems: 1, Elem: &schema.Resource{ From 50ed1c120bb89a31c90881522fde10929f8a0004 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 9 May 2025 18:44:08 +0200 Subject: [PATCH 45/63] feat: expose `is_prebuild_claim` attribute (#396) * feat: expose is_prebuild_claim attribute Signed-off-by: Danny Kopping * fix: string comparison hardening Signed-off-by: Danny Kopping --------- Signed-off-by: Danny Kopping --- docs/data-sources/workspace.md | 1 + provider/workspace.go | 44 +++++++++++- provider/workspace_test.go | 120 +++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/docs/data-sources/workspace.md b/docs/data-sources/workspace.md index 29fd9179..4dacdfc3 100644 --- a/docs/data-sources/workspace.md +++ b/docs/data-sources/workspace.md @@ -70,6 +70,7 @@ resource "docker_container" "workspace" { - `access_url` (String) The access URL of the Coder deployment provisioning this workspace. - `id` (String) UUID of the workspace. - `is_prebuild` (Boolean) Similar to `prebuild_count`, but a boolean value instead of a count. This is set to true if the workspace is a currently unassigned prebuild. Once the workspace is assigned, this value will be false. +- `is_prebuild_claim` (Boolean) Indicates whether a prebuilt workspace has just been claimed and this is the first `apply` after that occurrence. - `name` (String) Name of the workspace. - `prebuild_count` (Number) A computed count, equal to 1 if the workspace is a currently unassigned prebuild. Use this to conditionally act on the status of a prebuild. Actions that do not require user identity can be taken when this value is set to 1. Actions that should only be taken once the workspace has been assigned to a user may be taken when this value is set to 0. - `start_count` (Number) A computed count based on `transition` state. If `start`, count will equal 1. diff --git a/provider/workspace.go b/provider/workspace.go index c477fad6..58100a88 100644 --- a/provider/workspace.go +++ b/provider/workspace.go @@ -4,6 +4,7 @@ import ( "context" "reflect" "strconv" + "strings" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -30,10 +31,23 @@ func workspaceDataSource() *schema.Resource { if isPrebuiltWorkspace() { _ = rd.Set("prebuild_count", 1) _ = rd.Set("is_prebuild", true) + + // A claim can only take place AFTER a prebuild, so it's not logically consistent to have this set to any other value. + _ = rd.Set("is_prebuild_claim", false) } else { _ = rd.Set("prebuild_count", 0) _ = rd.Set("is_prebuild", false) } + if isPrebuiltWorkspaceClaim() { + // Indicate that a prebuild claim has taken place. + _ = rd.Set("is_prebuild_claim", true) + + // A claim can only take place AFTER a prebuild, so it's not logically consistent to have these set to any other values. + _ = rd.Set("prebuild_count", 0) + _ = rd.Set("is_prebuild", false) + } else { + _ = rd.Set("is_prebuild_claim", false) + } name := helpers.OptionalEnvOrDefault("CODER_WORKSPACE_NAME", "default") rd.Set("name", name) @@ -116,6 +130,11 @@ func workspaceDataSource() *schema.Resource { Computed: true, Description: "Similar to `prebuild_count`, but a boolean value instead of a count. This is set to true if the workspace is a currently unassigned prebuild. Once the workspace is assigned, this value will be false.", }, + "is_prebuild_claim": { + Type: schema.TypeBool, + Computed: true, + Description: "Indicates whether a prebuilt workspace has just been claimed and this is the first `apply` after that occurrence.", + }, "name": { Type: schema.TypeString, Computed: true, @@ -142,7 +161,12 @@ func workspaceDataSource() *schema.Resource { // isPrebuiltWorkspace returns true if the workspace is an unclaimed prebuilt workspace. func isPrebuiltWorkspace() bool { - return helpers.OptionalEnv(IsPrebuildEnvironmentVariable()) == "true" + return strings.EqualFold(helpers.OptionalEnv(IsPrebuildEnvironmentVariable()), "true") +} + +// isPrebuiltWorkspaceClaim returns true if the workspace is a prebuilt workspace which has just been claimed. +func isPrebuiltWorkspaceClaim() bool { + return strings.EqualFold(helpers.OptionalEnv(IsPrebuildClaimEnvironmentVariable()), "true") } // IsPrebuildEnvironmentVariable returns the name of the environment variable that @@ -161,3 +185,21 @@ func isPrebuiltWorkspace() bool { func IsPrebuildEnvironmentVariable() string { return "CODER_WORKSPACE_IS_PREBUILD" } + +// IsPrebuildClaimEnvironmentVariable returns the name of the environment variable that +// indicates whether the workspace is a prebuilt workspace which has just been claimed, and this is the first Terraform +// apply after that occurrence. +// +// Knowing whether the workspace is a claimed prebuilt workspace allows template +// authors to conditionally execute code in the template based on whether the workspace +// has been assigned to a user or not. This allows identity specific configuration to +// be applied only after the workspace is claimed, while the rest of the workspace can +// be pre-configured. +// +// The value of this environment variable should be set to "true" if the workspace is prebuilt +// and it has just been claimed by a user. Any other values, including "false" +// and "" will be interpreted to mean that the workspace is not prebuilt, or was +// prebuilt but has not been claimed by a user. +func IsPrebuildClaimEnvironmentVariable() string { + return "CODER_WORKSPACE_IS_PREBUILD_CLAIM" +} diff --git a/provider/workspace_test.go b/provider/workspace_test.go index e82a1005..17dfabd2 100644 --- a/provider/workspace_test.go +++ b/provider/workspace_test.go @@ -4,6 +4,7 @@ import ( "regexp" "testing" + "github.com/coder/terraform-provider-coder/v2/provider" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/stretchr/testify/assert" @@ -102,3 +103,122 @@ func TestWorkspace_MissingTemplateName(t *testing.T) { }}, }) } + +// TestWorkspace_PrebuildEnv validates that our handling of input environment variables is correct. +func TestWorkspace_PrebuildEnv(t *testing.T) { + cases := []struct { + name string + envs map[string]string + check func(state *terraform.State, resource *terraform.ResourceState) error + }{ + { + name: "unused", + envs: map[string]string{}, + check: func(state *terraform.State, resource *terraform.ResourceState) error { + attribs := resource.Primary.Attributes + assert.Equal(t, "false", attribs["is_prebuild"]) + assert.Equal(t, "0", attribs["prebuild_count"]) + assert.Equal(t, "false", attribs["is_prebuild_claim"]) + return nil + }, + }, + { + name: "prebuild=true", + envs: map[string]string{ + provider.IsPrebuildEnvironmentVariable(): "true", + }, + check: func(state *terraform.State, resource *terraform.ResourceState) error { + attribs := resource.Primary.Attributes + assert.Equal(t, "true", attribs["is_prebuild"]) + assert.Equal(t, "1", attribs["prebuild_count"]) + assert.Equal(t, "false", attribs["is_prebuild_claim"]) + return nil + }, + }, + { + name: "prebuild=false", + envs: map[string]string{ + provider.IsPrebuildEnvironmentVariable(): "false", + }, + check: func(state *terraform.State, resource *terraform.ResourceState) error { + attribs := resource.Primary.Attributes + assert.Equal(t, "false", attribs["is_prebuild"]) + assert.Equal(t, "0", attribs["prebuild_count"]) + assert.Equal(t, "false", attribs["is_prebuild_claim"]) + return nil + }, + }, + { + name: "prebuild_claim=true", + envs: map[string]string{ + provider.IsPrebuildClaimEnvironmentVariable(): "true", + }, + check: func(state *terraform.State, resource *terraform.ResourceState) error { + attribs := resource.Primary.Attributes + assert.Equal(t, "false", attribs["is_prebuild"]) + assert.Equal(t, "0", attribs["prebuild_count"]) + assert.Equal(t, "true", attribs["is_prebuild_claim"]) + return nil + }, + }, + { + name: "prebuild_claim=false", + envs: map[string]string{ + provider.IsPrebuildClaimEnvironmentVariable(): "false", + }, + check: func(state *terraform.State, resource *terraform.ResourceState) error { + attribs := resource.Primary.Attributes + assert.Equal(t, "false", attribs["is_prebuild"]) + assert.Equal(t, "0", attribs["prebuild_count"]) + assert.Equal(t, "false", attribs["is_prebuild_claim"]) + return nil + }, + }, + { + // Should not ever happen, but let's ensure our defensive check is activated. We can't ever have both flags + // being true. + name: "prebuild=true,prebuild_claim=true", + envs: map[string]string{ + provider.IsPrebuildEnvironmentVariable(): "true", + provider.IsPrebuildClaimEnvironmentVariable(): "true", + }, + check: func(state *terraform.State, resource *terraform.ResourceState) error { + attribs := resource.Primary.Attributes + assert.Equal(t, "false", attribs["is_prebuild"]) + assert.Equal(t, "0", attribs["prebuild_count"]) + assert.Equal(t, "true", attribs["is_prebuild_claim"]) + return nil + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.envs { + t.Setenv(k, v) + } + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` +provider "coder" { + url = "https://example.com:8080" +} +data "coder_workspace" "me" { +}`, + Check: func(state *terraform.State) error { + // Baseline checks + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + resource := state.Modules[0].Resources["data.coder_workspace.me"] + require.NotNil(t, resource) + + return tc.check(state, resource) + }, + }}, + }) + }) + } +} From 760b9fffa39ded6b1f2660fbf6ecb2746c801426 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 12 May 2025 09:04:03 +0200 Subject: [PATCH 46/63] chore: add tag_sort option to goreleaser (#398) Choose more recently-created tag if multiple tags point to same ref Signed-off-by: Danny Kopping --- .goreleaser.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index 658a715c..8b2c0012 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -60,3 +60,5 @@ changelog: use: github-native sort: asc abbrev: 0 +git: + tag_sort: -version:creatordate # if two tags reference the same commit, pick the latest one; see https://github.com/goreleaser/goreleaser/issues/4209 \ No newline at end of file From ff49ef5840f013950b47b20090ae7acdc35fc1a5 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 12 May 2025 07:53:13 -0500 Subject: [PATCH 47/63] test: add unit test to document behavior when no new value exists (#394) --- provider/testdata/parameter_table.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/provider/testdata/parameter_table.md b/provider/testdata/parameter_table.md index 6087460f..980a6d19 100644 --- a/provider/testdata/parameter_table.md +++ b/provider/testdata/parameter_table.md @@ -12,6 +12,8 @@ | EmptyMinOpt | number | | | | 1,2,3 | 2-5 | | | | valid option | | EmptyRegexOpt | string | | | | "hello","goodbye" | goodbye | | | | valid option | | EmptyRegexOk | string | | | | | .* | | "" | false | | +| EmptyInc | number | 4 | | | | increasing | | | | monotonicity | +| EmptyDec | number | 4 | | | | decreasing | | | | monotonicity | | | | | | | | | | | | | | | Default Set | | No inputs | | | | | | | | | NumDef | number | | | 5 | | | | 5 | true | | From 01334b626acc918967f0519759618dc86b6f0fb9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 15 May 2025 15:33:50 +0100 Subject: [PATCH 48/63] feat(agent): add api_key_scope to control agent token permissions (#391) --- .gitignore | 3 + docs/resources/agent.md | 8 +- examples/resources/coder_agent/resource.tf | 7 +- flake.lock | 8 +- flake.nix | 6 +- provider/agent.go | 11 +++ provider/agent_test.go | 91 ++++++++++++++++++++++ 7 files changed, 121 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 4d5d5ad6..ff9f6a53 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ website/vendor # Binary terraform-provider-coder + +# direnv +.direnv diff --git a/docs/resources/agent.md b/docs/resources/agent.md index 7c28b1f4..7df11e34 100644 --- a/docs/resources/agent.md +++ b/docs/resources/agent.md @@ -17,9 +17,10 @@ data "coder_workspace" "me" { } resource "coder_agent" "dev" { - os = "linux" - arch = "amd64" - dir = "/workspace" + os = "linux" + arch = "amd64" + dir = "/workspace" + api_key_scope = "all" display_apps { vscode = true vscode_insiders = false @@ -71,6 +72,7 @@ resource "kubernetes_pod" "dev" { ### Optional +- `api_key_scope` (String) Controls what API routes the agent token can access. Options: 'all' (full access) or 'no_user_data' (blocks /external-auth, /gitsshkey, and /gitauth routes) - `auth` (String) The authentication type the agent will use. Must be one of: `"token"`, `"google-instance-identity"`, `"aws-instance-identity"`, `"azure-instance-identity"`. - `connection_timeout` (Number) Time in seconds until the agent is marked as timed out when a connection with the server cannot be established. A value of zero never marks the agent as timed out. - `dir` (String) The starting directory when a user creates a shell session. Defaults to `"$HOME"`. diff --git a/examples/resources/coder_agent/resource.tf b/examples/resources/coder_agent/resource.tf index 6ccb07bf..7c219604 100644 --- a/examples/resources/coder_agent/resource.tf +++ b/examples/resources/coder_agent/resource.tf @@ -2,9 +2,10 @@ data "coder_workspace" "me" { } resource "coder_agent" "dev" { - os = "linux" - arch = "amd64" - dir = "/workspace" + os = "linux" + arch = "amd64" + dir = "/workspace" + api_key_scope = "all" display_apps { vscode = true vscode_insiders = false diff --git a/flake.lock b/flake.lock index d8033e14..ecf99d98 100644 --- a/flake.lock +++ b/flake.lock @@ -20,16 +20,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1714272655, - "narHash": "sha256-3/ghIWCve93ngkx5eNPdHIKJP/pMzSr5Wc4rNKE1wOc=", + "lastModified": 1746422338, + "narHash": "sha256-NTtKOTLQv6dPfRe00OGSywg37A1FYqldS6xiNmqBUYc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "12430e43bd9b81a6b4e79e64f87c624ade701eaf", + "rev": "5b35d248e9206c1f3baf8de6a7683fee126364aa", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-23.11", + "ref": "nixos-24.11", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index 87719bf4..40ceb861 100644 --- a/flake.nix +++ b/flake.nix @@ -2,11 +2,11 @@ description = "Terraform provider for Coder"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils, ... }: + outputs = { nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { @@ -21,7 +21,7 @@ name = "devShell"; buildInputs = with pkgs; [ terraform - go_1_20 + go_1_24 ]; }; } diff --git a/provider/agent.go b/provider/agent.go index ad264030..ebd8fff5 100644 --- a/provider/agent.go +++ b/provider/agent.go @@ -80,6 +80,17 @@ func agentResource() *schema.Resource { return nil }, Schema: map[string]*schema.Schema{ + "api_key_scope": { + Type: schema.TypeString, + Optional: true, + Default: "all", + ForceNew: true, + Description: "Controls what API routes the agent token can access. Options: 'all' (full access) or 'no_user_data' (blocks /external-auth, /gitsshkey, and /gitauth routes)", + ValidateFunc: validation.StringInSlice([]string{ + "all", + "no_user_data", + }, false), + }, "init_script": { Type: schema.TypeString, Computed: true, diff --git a/provider/agent_test.go b/provider/agent_test.go index a45ac86a..82e8691f 100644 --- a/provider/agent_test.go +++ b/provider/agent_test.go @@ -709,5 +709,96 @@ func TestAgent_DisplayApps(t *testing.T) { }}, }) }) +} + +// TestAgent_APIKeyScope tests valid states/transitions and invalid values for api_key_scope. +func TestAgent_APIKeyScope(t *testing.T) { + t.Parallel() + + t.Run("ValidTransitions", func(t *testing.T) { + t.Parallel() + + resourceName := "coder_agent.test_scope_valid" + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{ + // Step 1: Default value + { + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "test_scope_valid" { + os = "linux" + arch = "amd64" + # api_key_scope is omitted, should default to "default" + } + `, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "api_key_scope", "all"), + ), + }, + // Step 2: Explicit "default" + { + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "test_scope_valid" { + os = "linux" + arch = "amd64" + api_key_scope = "all" + } + `, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "api_key_scope", "all"), + ), + }, + // Step 3: Explicit "no_user_data" + { + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "test_scope_valid" { + os = "linux" + arch = "amd64" + api_key_scope = "no_user_data" + } + `, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "api_key_scope", "no_user_data"), + ), + }, + }, + }) + }) + + t.Run("InvalidValue", func(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{ + // Step 1: Invalid value check + { + Config: ` + provider "coder" { + url = "https://example.com" + } + resource "coder_agent" "test_scope_invalid" { // Use unique name + os = "linux" + arch = "amd64" + api_key_scope = "invalid-scope" + } + `, + ExpectError: regexp.MustCompile(`expected api_key_scope to be one of \["all" "no_user_data"\], got invalid-scope`), + PlanOnly: true, + }, + }, + }) + }) } From ace4462675ffbab17e098dff330ce50ece20b7fe Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Sun, 18 May 2025 09:15:30 -0500 Subject: [PATCH 49/63] fix: allow dropdown form_type for boolean types (#397) * fix: allow dropdown form_type for boolean types * add dropdown boolean test --- provider/formtype.go | 2 +- provider/formtype_test.go | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/provider/formtype.go b/provider/formtype.go index 75d32c46..931179da 100644 --- a/provider/formtype.go +++ b/provider/formtype.go @@ -99,7 +99,7 @@ var formTypeTruthTable = map[OptionType]map[bool][]ParameterFormType{ false: {ParameterFormTypeInput, ParameterFormTypeSlider}, }, OptionTypeBoolean: { - true: {ParameterFormTypeRadio}, + true: {ParameterFormTypeRadio, ParameterFormTypeDropdown}, false: {ParameterFormTypeCheckbox, ParameterFormTypeSwitch}, }, OptionTypeListString: { diff --git a/provider/formtype_test.go b/provider/formtype_test.go index eaf7b587..2f3dff53 100644 --- a/provider/formtype_test.go +++ b/provider/formtype_test.go @@ -193,6 +193,11 @@ func TestValidateFormType(t *testing.T) { optionType: provider.OptionTypeBoolean, formType: provider.ParameterFormTypeRadio, }), + expectSameFormType(formTypeCheck{ + options: true, + optionType: provider.OptionTypeBoolean, + formType: provider.ParameterFormTypeDropdown, + }), expectSameFormType(formTypeCheck{ options: false, optionType: provider.OptionTypeBoolean, From 77de38e09cca9ca228b63bb53f32ba0ae75a1f2d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 09:15:33 +0100 Subject: [PATCH 50/63] build(deps): Bump github.com/hashicorp/terraform-plugin-sdk/v2 (#400) Bumps [github.com/hashicorp/terraform-plugin-sdk/v2](https://github.com/hashicorp/terraform-plugin-sdk) from 2.36.1 to 2.37.0. - [Release notes](https://github.com/hashicorp/terraform-plugin-sdk/releases) - [Changelog](https://github.com/hashicorp/terraform-plugin-sdk/blob/main/CHANGELOG.md) - [Commits](https://github.com/hashicorp/terraform-plugin-sdk/compare/v2.36.1...v2.37.0) --- updated-dependencies: - dependency-name: github.com/hashicorp/terraform-plugin-sdk/v2 dependency-version: 2.37.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 49 ++++++++++---------- go.sum | 142 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 94 insertions(+), 97 deletions(-) diff --git a/go.mod b/go.mod index 2d3db5d0..7b0b437c 100644 --- a/go.mod +++ b/go.mod @@ -5,25 +5,25 @@ go 1.24.2 require ( github.com/docker/docker v26.1.5+incompatible github.com/google/uuid v1.6.0 - github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 + github.com/hashicorp/go-cty v1.5.0 github.com/hashicorp/terraform-plugin-log v0.9.0 - github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.1 + github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 github.com/masterminds/semver v1.5.0 github.com/mitchellh/mapstructure v1.5.0 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.23.0 + golang.org/x/mod v0.24.0 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 ) require ( github.com/Masterminds/semver v1.5.0 // indirect - github.com/Microsoft/go-winio v0.6.1 // indirect - github.com/ProtonMail/go-crypto v1.1.3 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + 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.3.7 // indirect + github.com/cloudflare/circl v1.6.0 // 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 @@ -35,26 +35,25 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-checkpoint v0.5.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-plugin v1.6.2 // indirect + github.com/hashicorp/go-plugin v1.6.3 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-version v1.7.0 // indirect - github.com/hashicorp/hc-install v0.9.1 // indirect + github.com/hashicorp/hc-install v0.9.2 // indirect github.com/hashicorp/hcl/v2 v2.23.0 // indirect github.com/hashicorp/logutils v1.0.0 // indirect - github.com/hashicorp/terraform-exec v0.22.0 // indirect - github.com/hashicorp/terraform-json v0.24.0 // indirect - github.com/hashicorp/terraform-plugin-go v0.26.0 // indirect - github.com/hashicorp/terraform-registry-address v0.2.4 // indirect + github.com/hashicorp/terraform-exec v0.23.0 // indirect + github.com/hashicorp/terraform-json v0.25.0 // indirect + github.com/hashicorp/terraform-plugin-go v0.27.0 // indirect + github.com/hashicorp/terraform-registry-address v0.2.5 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect - github.com/kr/pretty v0.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect @@ -69,27 +68,27 @@ require ( github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.8.0 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/zclconf/go-cty v1.16.2 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect - go.opentelemetry.io/otel v1.31.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect - go.opentelemetry.io/otel/metric v1.31.0 // indirect - go.opentelemetry.io/otel/trace v1.31.0 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.22.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect - google.golang.org/grpc v1.69.4 // indirect - google.golang.org/protobuf v1.36.3 // indirect + google.golang.org/grpc v1.72.1 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.1 // indirect ) diff --git a/go.sum b/go.sum index 77c61771..0531c5e1 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,10 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= -github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= -github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= -github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= @@ -17,13 +17,12 @@ 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.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= -github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= +github.com/cloudflare/circl v1.6.0/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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo= -github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -44,10 +43,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8= -github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= -github.com/go-git/go-git/v5 v5.13.0 h1:vLn5wlGIh/X78El6r3Jr+30W16Blk0CTcxTYcYPWi5E= -github.com/go-git/go-git/v5 v5.13.0/go.mod h1:Wjo7/JyVKtQgUNdXYXIepzWfJQkUEIGvkvVkiXRR/zw= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= +github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -57,8 +56,8 @@ github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= @@ -66,8 +65,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= @@ -80,14 +79,14 @@ github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuD github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUKaCaGKZ/dR2roBXv0vKbSCnssIldfQdI= -github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs= +github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0= +github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog= -github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -95,24 +94,24 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/hc-install v0.9.1 h1:gkqTfE3vVbafGQo6VZXcy2v5yoz2bE0+nhZXruCuODQ= -github.com/hashicorp/hc-install v0.9.1/go.mod h1:pWWvN/IrfeBK4XPeXXYkL6EjMufHkCK5DvwxeLKuBf0= +github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24= +github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I= github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/terraform-exec v0.22.0 h1:G5+4Sz6jYZfRYUCg6eQgDsqTzkNXV+fP8l+uRmZHj64= -github.com/hashicorp/terraform-exec v0.22.0/go.mod h1:bjVbsncaeh8jVdhttWYZuBGj21FcYw6Ia/XfHcNO7lQ= -github.com/hashicorp/terraform-json v0.24.0 h1:rUiyF+x1kYawXeRth6fKFm/MdfBS6+lW4NbeATsYz8Q= -github.com/hashicorp/terraform-json v0.24.0/go.mod h1:Nfj5ubo9xbu9uiAoZVBsNOjvNKB66Oyrvtit74kC7ow= -github.com/hashicorp/terraform-plugin-go v0.26.0 h1:cuIzCv4qwigug3OS7iKhpGAbZTiypAfFQmw8aE65O2M= -github.com/hashicorp/terraform-plugin-go v0.26.0/go.mod h1:+CXjuLDiFgqR+GcrM5a2E2Kal5t5q2jb0E3D57tTdNY= +github.com/hashicorp/terraform-exec v0.23.0 h1:MUiBM1s0CNlRFsCLJuM5wXZrzA3MnPYEsiXmzATMW/I= +github.com/hashicorp/terraform-exec v0.23.0/go.mod h1:mA+qnx1R8eePycfwKkCRk3Wy65mwInvlpAeOwmA7vlY= +github.com/hashicorp/terraform-json v0.25.0 h1:rmNqc/CIfcWawGiwXmRuiXJKEiJu1ntGoxseG1hLhoQ= +github.com/hashicorp/terraform-json v0.25.0/go.mod h1:sMKS8fiRDX4rVlR6EJUMudg1WcanxCMoWwTLkgZP/vc= +github.com/hashicorp/terraform-plugin-go v0.27.0 h1:ujykws/fWIdsi6oTUT5Or4ukvEan4aN9lY+LOxVP8EE= +github.com/hashicorp/terraform-plugin-go v0.27.0/go.mod h1:FDa2Bb3uumkTGSkTFpWSOwWJDwA7bf3vdP3ltLDTH6o= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= -github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.1 h1:WNMsTLkZf/3ydlgsuXePa3jvZFwAJhruxTxP/c1Viuw= -github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.1/go.mod h1:P6o64QS97plG44iFzSM6rAn6VJIC/Sy9a9IkEtl79K4= -github.com/hashicorp/terraform-registry-address v0.2.4 h1:JXu/zHB2Ymg/TGVCRu10XqNa4Sh2bWcqCNyKWjnCPJA= -github.com/hashicorp/terraform-registry-address v0.2.4/go.mod h1:tUNYTVyCtU4OIGXXMDp7WNcJ+0W1B4nmstVDgHMjfAU= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 h1:NFPMacTrY/IdcIcnUB+7hsore1ZaRWU9cnB6jFoBnIM= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0/go.mod h1:QYmYnLfsosrxjCnGY1p9c7Zj6n9thnEE+7RObeYs3fA= +github.com/hashicorp/terraform-registry-address v0.2.5 h1:2GTftHqmUhVOeuu9CW3kwDkRe4pcBDq0uuK5VJngU1M= +github.com/hashicorp/terraform-registry-address v0.2.5/go.mod h1:PpzXWINwB5kuVS5CA7m1+eO2f1jKb5ZDIxrOPfpnGkg= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= @@ -126,8 +125,8 @@ github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -165,24 +164,22 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= -github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= -github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= -github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -203,52 +200,54 @@ github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70 github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0= -go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= -go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY= -go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= -go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= -go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= -go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= -go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= -go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= -go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= -go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 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.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= -golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +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/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= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -263,17 +262,16 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -293,16 +291,16 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= -google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= -google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= +google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= -google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From a4f40659a9be991eaaa6d833781fbdb942c2e308 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 19 May 2025 11:27:50 -0500 Subject: [PATCH 51/63] fix: map_structure of form_type to match argument name (#401) * fix: map_structure of form_type to match argument name * chore: required on anonymouse struct too --- provider/parameter.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/provider/parameter.go b/provider/parameter.go index f0a26c99..de6e60f0 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -54,7 +54,7 @@ type Parameter struct { DisplayName string `mapstructure:"display_name"` Description string Type OptionType - FormType ParameterFormType + FormType ParameterFormType `mapstructure:"form_type"` Mutable bool Default *string Icon string @@ -86,10 +86,10 @@ func parameterDataSource() *schema.Resource { var parameter Parameter err = mapstructure.Decode(struct { Name interface{} - DisplayName interface{} + DisplayName interface{} `mapstructure:"display_name"` Description interface{} Type interface{} - FormType interface{} + FormType interface{} `mapstructure:"form_type"` Mutable interface{} Default interface{} Icon interface{} From 0c7fd6a3950b2195043df792087d6a33b4e14369 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Wed, 21 May 2025 12:12:26 +0100 Subject: [PATCH 52/63] docs: update README note for local provider testing with correct module path (#403) * docs: update README note for local provider testing with correct module path * docs: improve sentence structure * docs: remove extra backtick --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f055961e..4ae9be15 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,13 @@ to setup your local Terraform to use your local version rather than the registry 2. Run `terraform init` and observe a warning like `Warning: Provider development overrides are in effect` 4. Run `make build` to build the provider binary, which Terraform will try locate and execute 5. All local Terraform runs will now use your local provider! -6. _**NOTE**: we vendor in this provider into `github.com/coder/coder`, so if you're testing with a local clone then you should also run `go mod edit -replace github.com/coder/terraform-provider-coder=/path/to/terraform-provider-coder` in your clone._ +6. **NOTE**: We vendor this provider into `github.com/coder/coder`, so if you're testing with a local clone, make sure to run the following in your local clone of `coder`: + ```console + go mod edit -replace github.com/coder/terraform-provider-coder/v2=/path/to/terraform-provider-coder + go mod tidy + ``` + ⚠️ Be sure to include `/v2` in the module path as it needs to match the version declared in the provider’s `go.mod`. + #### Terraform Acceptance Tests From 74899537ddc92f178bb9129645649889f8e0af32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B1=E3=82=A4=E3=83=A9?= Date: Wed, 21 May 2025 10:00:16 -0600 Subject: [PATCH 53/63] feat: add `group` attribute to `coder_app` resource (#402) --- docs/resources/app.md | 1 + provider/app.go | 6 ++++++ provider/app_test.go | 3 ++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/resources/app.md b/docs/resources/app.md index b3ac728f..338b1837 100644 --- a/docs/resources/app.md +++ b/docs/resources/app.md @@ -63,6 +63,7 @@ resource "coder_app" "vim" { - `command` (String) A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either `command` or `url` may be specified, but not both. - `display_name` (String) A display name to identify the app. Defaults to the slug. - `external` (Boolean) Specifies whether `url` is opened on the client machine instead of proxied through the workspace. +- `group` (String) The name of a group that this app belongs to. - `healthcheck` (Block Set, Max: 1) HTTP health checking to determine the application readiness. (see [below for nested schema](#nestedblock--healthcheck)) - `hidden` (Boolean) Determines if the app is visible in the UI (minimum Coder version: v2.16). - `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/"`. diff --git a/provider/app.go b/provider/app.go index 2d0d6b09..213372b4 100644 --- a/provider/app.go +++ b/provider/app.go @@ -223,6 +223,12 @@ func appResource() *schema.Resource { }, }, }, + "group": { + Type: schema.TypeString, + Description: "The name of a group that this app belongs to.", + ForceNew: true, + Optional: true, + }, "order": { Type: schema.TypeInt, Description: "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order).", diff --git a/provider/app_test.go b/provider/app_test.go index 444b6b0d..aeb42d08 100644 --- a/provider/app_test.go +++ b/provider/app_test.go @@ -40,6 +40,7 @@ func TestApp(t *testing.T) { interval = 5 threshold = 6 } + group = "Apps" order = 4 hidden = false open_in = "slim-window" @@ -63,6 +64,7 @@ func TestApp(t *testing.T) { "healthcheck.0.url", "healthcheck.0.interval", "healthcheck.0.threshold", + "group", "order", "hidden", "open_in", @@ -476,5 +478,4 @@ func TestApp(t *testing.T) { }) } }) - } From 28dae7fbc263efcaaa33f5ec448004fa47109858 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Fri, 23 May 2025 19:45:31 +0100 Subject: [PATCH 54/63] feat: add expiration_policy parameter to prebuild resource (#404) * feat: add cache_invalidation parameter to prebuild resource * fix: formatting * fix: remove TODO comment * fix: cache_invalidation block in integration tests * fix: main.tf integration test * fix: test Prebuilds_is_set_with_a_cache_invalidation_field_without_its_required_fields * fix: remove printf * chore: add comment about partial match in preset test * chore: add comment about cache_invalidation struct as slice * chore: add max validation of 604800 seconds (7 days) for invalidate_after_secs to prevent stale prebuilds * chores: update terraform schema to expiration_policy.ttl & update max validation ttl value to 1 year * fix: comment --- docs/data-sources/workspace_preset.md | 11 ++++ integration/integration_test.go | 11 ++-- integration/test-data-source/main.tf | 4 ++ provider/workspace_preset.go | 28 ++++++++++ provider/workspace_preset_test.go | 77 +++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 5 deletions(-) diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index 14e235f9..cd4908c2 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -51,3 +51,14 @@ data "coder_workspace_preset" "example" { Required: - `instances` (Number) The number of workspaces to keep in reserve for this preset. + +Optional: + +- `expiration_policy` (Block Set, Max: 1) Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date. (see [below for nested schema](#nestedblock--prebuilds--expiration_policy)) + + +### Nested Schema for `prebuilds.expiration_policy` + +Required: + +- `ttl` (Number) Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup. diff --git a/integration/integration_test.go b/integration/integration_test.go index a5019635..36612904 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -90,11 +90,12 @@ 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_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`, }, }, { diff --git a/integration/test-data-source/main.tf b/integration/test-data-source/main.tf index f18fa347..50274fff 100644 --- a/integration/test-data-source/main.tf +++ b/integration/test-data-source/main.tf @@ -27,6 +27,9 @@ data "coder_workspace_preset" "preset" { prebuilds { instances = 1 + expiration_policy { + ttl = 86400 + } } } @@ -52,6 +55,7 @@ locals { "workspace_preset.name" : data.coder_workspace_preset.preset.name, "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 78466c10..e9c697c7 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -22,6 +22,16 @@ type WorkspacePreset struct { type WorkspacePrebuild struct { Instances int `mapstructure:"instances"` + // There should always be only one expiration_policy block, but Terraform's type system + // still parses them as a slice, so we need to handle it as such. We could use + // an anonymous type and rd.Get to avoid a slice here, but that would not be possible + // for utilities that parse our terraform output using this type. To remain compatible + // with those cases, we use a slice here. + ExpirationPolicy []ExpirationPolicy `mapstructure:"expiration_policy"` +} + +type ExpirationPolicy struct { + TTL int `mapstructure:"ttl"` } func workspacePresetDataSource() *schema.Resource { @@ -81,6 +91,24 @@ func workspacePresetDataSource() *schema.Resource { ForceNew: true, ValidateFunc: validation.IntAtLeast(0), }, + "expiration_policy": { + Type: schema.TypeSet, + Description: "Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date.", + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "ttl": { + Type: schema.TypeInt, + Description: "Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.", + Required: true, + ForceNew: true, + // Ensure TTL is between 0 and 31536000 seconds (1 year) to prevent stale prebuilds + ValidateFunc: validation.IntBetween(0, 31536000), + }, + }, + }, + }, }, }, }, diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go index aa1ca0ce..d9f7da46 100644 --- a/provider/workspace_preset_test.go +++ b/provider/workspace_preset_test.go @@ -144,6 +144,83 @@ func TestWorkspacePreset(t *testing.T) { return nil }, }, + { + Name: "Prebuilds is set with a expiration_policy field without its required fields", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + instances = 1 + expiration_policy {} + } + }`, + ExpectError: regexp.MustCompile("The argument \"ttl\" is required, but no definition was found."), + }, + { + Name: "Prebuilds is set with a expiration_policy field with its required fields", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + instances = 1 + expiration_policy { + ttl = 86400 + } + } + }`, + ExpectError: nil, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"] + require.NotNil(t, resource) + attrs := resource.Primary.Attributes + require.Equal(t, attrs["name"], "preset_1") + require.Equal(t, attrs["prebuilds.0.expiration_policy.0.ttl"], "86400") + return nil + }, + }, + { + Name: "Prebuilds block with expiration_policy.ttl set to 2 years (exceeds 1 year limit)", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + instances = 1 + expiration_policy { + ttl = 63072000 + } + } + }`, + ExpectError: regexp.MustCompile(`expected prebuilds.0.expiration_policy.0.ttl to be in the range \(0 - 31536000\), got 63072000`), + }, + { + Name: "Prebuilds is set with a expiration_policy field with its required fields and an unexpected argument", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + instances = 1 + expiration_policy { + ttl = 86400 + invalid_argument = "test" + } + } + }`, + ExpectError: regexp.MustCompile("An argument named \"invalid_argument\" is not expected here."), + }, } for _, testcase := range testcases { From 18bfe55780d3f0cd5f9c86c37e3941c80383ac92 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Tue, 27 May 2025 02:57:38 -0700 Subject: [PATCH 55/63] Refactor documentation for parameter types and icons (#405) --- docs/data-sources/parameter.md | 14 +++++++------- docs/resources/agent.md | 2 +- docs/resources/app.md | 2 +- docs/resources/devcontainer.md | 7 +++++-- provider/agent.go | 2 +- provider/app.go | 2 +- provider/devcontainer.go | 2 +- provider/parameter.go | 14 +++++++------- 8 files changed, 24 insertions(+), 21 deletions(-) diff --git a/docs/data-sources/parameter.md b/docs/data-sources/parameter.md index 934ae77a..ecba3929 100644 --- a/docs/data-sources/parameter.md +++ b/docs/data-sources/parameter.md @@ -145,13 +145,13 @@ data "coder_parameter" "home_volume_size" { - `description` (String) Describe what this parameter does. - `display_name` (String) The displayed name of the parameter as it will appear in the interface. - `ephemeral` (Boolean) The value of an ephemeral parameter will not be preserved between consecutive workspace builds. -- `form_type` (String) The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error]. +- `form_type` (String) The type of this parameter. Must be one of: `"radio"`, `"slider"`, `"input"`, `"dropdown"`, `"checkbox"`, `"switch"`, `"multi-select"`, `"tag-select"`, `"textarea"`, `"error"`. - `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/"`. - `mutable` (Boolean) Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution! - `option` (Block List) Each `option` block defines a value for a user to select from. (see [below for nested schema](#nestedblock--option)) - `order` (Number) The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order). - `styling` (String) JSON encoded string containing the metadata for controlling the appearance of this parameter in the UI. This option is purely cosmetic and does not affect the function of the parameter in terraform. -- `type` (String) The type of this parameter. Must be one of: `"number"`, `"string"`, `"bool"`, or `"list(string)"`. +- `type` (String) The type of this parameter. Must be one of: `"string"`, `"number"`, `"bool"`, `"list(string)"`. - `validation` (Block List, Max: 1) Validate the input of a parameter. (see [below for nested schema](#nestedblock--validation)) ### Read-Only @@ -179,13 +179,13 @@ Optional: Optional: -- `error` (String) An error message to display if the value breaks the validation rules. The following placeholders are supported: {max}, {min}, and {value}. -- `max` (Number) The maximum of a number parameter. -- `min` (Number) The minimum of a number parameter. +- `error` (String) An error message to display if the value breaks the validation rules. The following placeholders are supported: `{max}`, `{min}`, and `{value}`. +- `max` (Number) The maximum value of a number parameter. +- `min` (Number) The minimum value of a number parameter. - `monotonic` (String) Number monotonicity, either increasing or decreasing. - `regex` (String) A regex for the input parameter to match against. Read-Only: -- `max_disabled` (Boolean) Helper field to check if max is present -- `min_disabled` (Boolean) Helper field to check if min is present +- `max_disabled` (Boolean) Helper field to check if `max` is present +- `min_disabled` (Boolean) Helper field to check if `min` is present diff --git a/docs/resources/agent.md b/docs/resources/agent.md index 7df11e34..87ab4239 100644 --- a/docs/resources/agent.md +++ b/docs/resources/agent.md @@ -72,7 +72,7 @@ resource "kubernetes_pod" "dev" { ### Optional -- `api_key_scope` (String) Controls what API routes the agent token can access. Options: 'all' (full access) or 'no_user_data' (blocks /external-auth, /gitsshkey, and /gitauth routes) +- `api_key_scope` (String) Controls what API routes the agent token can access. Options: `all` (full access) or `no_user_data` (blocks `/external-auth`, `/gitsshkey`, and `/gitauth` routes) - `auth` (String) The authentication type the agent will use. Must be one of: `"token"`, `"google-instance-identity"`, `"aws-instance-identity"`, `"azure-instance-identity"`. - `connection_timeout` (Number) Time in seconds until the agent is marked as timed out when a connection with the server cannot be established. A value of zero never marks the agent as timed out. - `dir` (String) The starting directory when a user creates a shell session. Defaults to `"$HOME"`. diff --git a/docs/resources/app.md b/docs/resources/app.md index 338b1837..6be99cf3 100644 --- a/docs/resources/app.md +++ b/docs/resources/app.md @@ -66,7 +66,7 @@ resource "coder_app" "vim" { - `group` (String) The name of a group that this app belongs to. - `healthcheck` (Block Set, Max: 1) HTTP health checking to determine the application readiness. (see [below for nested schema](#nestedblock--healthcheck)) - `hidden` (Boolean) Determines if the app is visible in the UI (minimum Coder version: v2.16). -- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/"`. +- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/"`. - `open_in` (String) Determines where the app will be opened. Valid values are `"tab"` and `"slim-window" (default)`. `"tab"` opens in a new tab in the same browser window. `"slim-window"` opens a new browser window without navigation controls. - `order` (Number) The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order). - `share` (String) Determines the level which the application is shared at. Valid levels are `"owner"` (default), `"authenticated"` and `"public"`. Level `"owner"` disables sharing on the app, so only the workspace owner can access it. Level `"authenticated"` shares the app with all authenticated users. Level `"public"` shares it with any user, including unauthenticated users. Permitted application sharing levels can be configured site-wide via a flag on `coder server` (Enterprise only). diff --git a/docs/resources/devcontainer.md b/docs/resources/devcontainer.md index 93d5724b..06d7f6f3 100644 --- a/docs/resources/devcontainer.md +++ b/docs/resources/devcontainer.md @@ -3,12 +3,15 @@ page_title: "coder_devcontainer Resource - terraform-provider-coder" subcategory: "" description: |- - Define a Dev Container the agent should know of and attempt to autostart (minimum Coder version: v2.21). + Define a Dev Container the agent should know of and attempt to autostart. + -> This resource is only available in Coder v2.21 and later. --- # coder_devcontainer (Resource) -Define a Dev Container the agent should know of and attempt to autostart (minimum Coder version: v2.21). +Define a Dev Container the agent should know of and attempt to autostart. + +-> This resource is only available in Coder v2.21 and later. diff --git a/provider/agent.go b/provider/agent.go index ebd8fff5..32da2e58 100644 --- a/provider/agent.go +++ b/provider/agent.go @@ -85,7 +85,7 @@ func agentResource() *schema.Resource { Optional: true, Default: "all", ForceNew: true, - Description: "Controls what API routes the agent token can access. Options: 'all' (full access) or 'no_user_data' (blocks /external-auth, /gitsshkey, and /gitauth routes)", + Description: "Controls what API routes the agent token can access. Options: `all` (full access) or `no_user_data` (blocks `/external-auth`, `/gitsshkey`, and `/gitauth` routes)", ValidateFunc: validation.StringInSlice([]string{ "all", "no_user_data", diff --git a/provider/app.go b/provider/app.go index 213372b4..fb686794 100644 --- a/provider/app.go +++ b/provider/app.go @@ -88,7 +88,7 @@ func appResource() *schema.Resource { "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 " + + "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, diff --git a/provider/devcontainer.go b/provider/devcontainer.go index 7d1fe0a4..81a31194 100644 --- a/provider/devcontainer.go +++ b/provider/devcontainer.go @@ -13,7 +13,7 @@ func devcontainerResource() *schema.Resource { return &schema.Resource{ SchemaVersion: 1, - Description: "Define a Dev Container the agent should know of and attempt to autostart (minimum Coder version: v2.21).", + Description: "Define a Dev Container the agent should know of and attempt to autostart.\n\n-> This resource is only available in Coder v2.21 and later.", CreateContext: func(_ context.Context, rd *schema.ResourceData, _ interface{}) diag.Diagnostics { rd.SetId(uuid.NewString()) diff --git a/provider/parameter.go b/provider/parameter.go index de6e60f0..c8284da1 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -191,14 +191,14 @@ func parameterDataSource() *schema.Resource { Default: "string", Optional: true, ValidateFunc: validation.StringInSlice(toStrings(OptionTypes()), false), - Description: "The type of this parameter. Must be one of: `\"number\"`, `\"string\"`, `\"bool\"`, or `\"list(string)\"`.", + Description: fmt.Sprintf("The type of this parameter. Must be one of: `\"%s\"`.", strings.Join(toStrings(OptionTypes()), "\"`, `\"")), }, "form_type": { Type: schema.TypeString, Default: ParameterFormTypeDefault, Optional: true, ValidateFunc: validation.StringInSlice(toStrings(ParameterFormTypes()), false), - Description: fmt.Sprintf("The type of this parameter. Must be one of: [%s].", strings.Join(toStrings(ParameterFormTypes()), ", ")), + Description: fmt.Sprintf("The type of this parameter. Must be one of: `\"%s\"`.", strings.Join(toStrings(ParameterFormTypes()), "\"`, `\"")), }, "styling": { Type: schema.TypeString, @@ -286,22 +286,22 @@ func parameterDataSource() *schema.Resource { "min": { Type: schema.TypeInt, Optional: true, - Description: "The minimum of a number parameter.", + Description: "The minimum value of a number parameter.", }, "min_disabled": { Type: schema.TypeBool, Computed: true, - Description: "Helper field to check if min is present", + Description: "Helper field to check if `min` is present", }, "max": { Type: schema.TypeInt, Optional: true, - Description: "The maximum of a number parameter.", + Description: "The maximum value of a number parameter.", }, "max_disabled": { Type: schema.TypeBool, Computed: true, - Description: "Helper field to check if max is present", + Description: "Helper field to check if `max` is present", }, "monotonic": { Type: schema.TypeString, @@ -317,7 +317,7 @@ func parameterDataSource() *schema.Resource { "error": { Type: schema.TypeString, Optional: true, - Description: "An error message to display if the value breaks the validation rules. The following placeholders are supported: {max}, {min}, and {value}.", + Description: "An error message to display if the value breaks the validation rules. The following placeholders are supported: `{max}`, `{min}`, and `{value}`.", }, }, }, From 01193dc4750e3f2a022b4bc7de4e59bddc371a54 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 15:00:36 +0500 Subject: [PATCH 56/63] build(deps): Bump goreleaser/goreleaser-action from 6.2.1 to 6.3.0 (#372) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33f17b8d..82eb9562 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,7 +91,7 @@ jobs: passphrase: ${{ secrets.PASSPHRASE }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6.2.1 + uses: goreleaser/goreleaser-action@v6.3.0 with: version: '~> v2' args: release --clean From d0457a71ce7a7b47393ee34cb723a9fae18a0f90 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 15:00:40 +0500 Subject: [PATCH 57/63] build(deps): Bump crazy-max/ghaction-import-gpg from 6.2.0 to 6.3.0 (#371) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82eb9562..f731b412 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,7 +84,7 @@ jobs: - name: Import GPG key id: import_gpg - uses: crazy-max/ghaction-import-gpg@v6.2.0 + uses: crazy-max/ghaction-import-gpg@v6.3.0 with: # These secrets will need to be configured for the repository: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} From 442ff2abd9eb47cf7deb65a8d63c58de24b986de Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Tue, 27 May 2025 11:04:35 +0100 Subject: [PATCH 58/63] chore: add minimum TTL value for expiration.policy.ttl (#406) * chore: add minimum TTL value for expiration.policy.ttl * chore: support 0 as a valid ttl value in expiration_policy to disable expiration --- provider/workspace_preset.go | 14 +++++++-- provider/workspace_preset_test.go | 48 +++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index e9c697c7..e0f2276c 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -2,6 +2,7 @@ package provider import ( "context" + "fmt" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -103,8 +104,17 @@ func workspacePresetDataSource() *schema.Resource { Description: "Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.", Required: true, ForceNew: true, - // Ensure TTL is between 0 and 31536000 seconds (1 year) to prevent stale prebuilds - ValidateFunc: validation.IntBetween(0, 31536000), + // Ensure TTL is either 0 (to disable expiration) or between 3600 seconds (1 hour) and 31536000 seconds (1 year) + ValidateFunc: func(val interface{}, key string) ([]string, []error) { + v := val.(int) + if v == 0 { + return nil, nil + } + if v < 3600 || v > 31536000 { + return nil, []error{fmt.Errorf("%q must be 0 or between 3600 and 31536000, got %d", key, v)} + } + return nil, nil + }, }, }, }, diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go index d9f7da46..b8e752ae 100644 --- a/provider/workspace_preset_test.go +++ b/provider/workspace_preset_test.go @@ -157,7 +157,7 @@ func TestWorkspacePreset(t *testing.T) { expiration_policy {} } }`, - ExpectError: regexp.MustCompile("The argument \"ttl\" is required, but no definition was found."), + ExpectError: regexp.MustCompile(`The argument "ttl" is required, but no definition was found.`), }, { Name: "Prebuilds is set with a expiration_policy field with its required fields", @@ -186,6 +186,50 @@ func TestWorkspacePreset(t *testing.T) { return nil }, }, + { + Name: "Prebuilds block with expiration_policy.ttl set to 0 seconds (disables expiration)", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + instances = 1 + expiration_policy { + ttl = 0 + } + } + }`, + ExpectError: nil, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"] + require.NotNil(t, resource) + attrs := resource.Primary.Attributes + require.Equal(t, attrs["name"], "preset_1") + require.Equal(t, attrs["prebuilds.0.expiration_policy.0.ttl"], "0") + return nil + }, + }, + { + Name: "Prebuilds block with expiration_policy.ttl set to 30 minutes (below 1 hour limit)", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + instances = 1 + expiration_policy { + ttl = 1800 + } + } + }`, + ExpectError: regexp.MustCompile(`"prebuilds.0.expiration_policy.0.ttl" must be 0 or between 3600 and 31536000, got 1800`), + }, { Name: "Prebuilds block with expiration_policy.ttl set to 2 years (exceeds 1 year limit)", Config: ` @@ -201,7 +245,7 @@ func TestWorkspacePreset(t *testing.T) { } } }`, - ExpectError: regexp.MustCompile(`expected prebuilds.0.expiration_policy.0.ttl to be in the range \(0 - 31536000\), got 63072000`), + ExpectError: regexp.MustCompile(`"prebuilds.0.expiration_policy.0.ttl" must be 0 or between 3600 and 31536000, got 63072000`), }, { Name: "Prebuilds is set with a expiration_policy field with its required fields and an unexpected argument", 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 59/63] 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 60/63] 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 61/63] 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 62/63] 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 63/63] 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(),