diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2755e44..4d5635f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,37 +1,36 @@ name: release on: - push: - tags: - - 'v*' + push: + tags: + - "v*" permissions: - contents: write + contents: write jobs: - goreleaser: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Unshallow - run: git fetch --prune --unshallow - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: 1.22.4 - - name: Import GPG Key - id: import_gpg - uses: crazy-max/ghaction-import-gpg@v6.2.0 - with: - gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6.0.0 - with: - version: latest - args: release --clean - env: - GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} - # GitHub sets this automatically - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Unshallow + run: git fetch --prune --unshallow + - name: Setup Go + uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + with: + go-version: "1.22" + - name: Import GPG Key + id: import_gpg + uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0 + with: + version: latest + args: release --clean + env: + GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} + # GitHub sets this automatically + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b348a86..e6885d3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,31 +24,40 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: "go.mod" cache: true + - run: go mod download + - run: go build -v . + - name: Run linters - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1 with: version: latest generate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: "go.mod" cache: true + - run: go generate ./... + - name: git diff run: | - git diff --compact-summary --exit-code || \ - (echo; echo "Unexpected difference in directories after code generation. Run 'go generate ./...' command and commit."; exit 1) + if [[ -n $(git ls-files --other --modified --exclude-standard) ]]; then + echo "Unexpected difference in directories after code generation. Run 'make gen' and include the output in the commit." + exit 1 + fi # Run acceptance tests in a matrix with Terraform CLI versions test: @@ -72,20 +81,24 @@ jobs: - "1.8.*" - "1.9.*" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: "go.mod" cache: true - - uses: hashicorp/setup-terraform@v3 + + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 with: terraform_version: ${{ matrix.terraform }} terraform_wrapper: false + - run: go mod download - - env: + + - run: go test -v -cover ./internal/provider/ + env: TF_ACC: "1" CODER_ENTERPRISE_LICENSE: ${{ secrets.CODER_ENTERPRISE_LICENSE }} - run: go test -v -cover ./internal/provider/ timeout-minutes: 10 lint: @@ -93,20 +106,18 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Set up Go - uses: actions/setup-go@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version: "1.22" id: go - - uses: hashicorp/setup-terraform@v3 + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 with: terraform_version: "1.9.*" terraform_wrapper: false - - name: Check out code into the Go module directory - uses: actions/checkout@v4 - - name: Get dependencies run: | go mod download diff --git a/.golangci.yml b/.golangci.yml index 223cf95..679a35a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,5 @@ -# Visit https://golangci-lint.run/ for usage documentation -# and information on other useful linters +# Visit https://golangci-lint.run/ for usage documentation and information on +# other useful linters issues: max-per-linter: 0 max-same-issues: 0 @@ -24,4 +24,4 @@ linters: - unconvert - unparam - unused - - vet \ No newline at end of file + - vet diff --git a/Makefile b/Makefile index 54a7a12..b1f903b 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,12 @@ default: testacc fmt: + go fmt ./... terraform fmt -recursive +vet: + go vet ./... + gen: go generate ./... diff --git a/docs/resources/license.md b/docs/resources/license.md index 58c773c..ec29706 100644 --- a/docs/resources/license.md +++ b/docs/resources/license.md @@ -4,7 +4,7 @@ page_title: "coderd_license Resource - terraform-provider-coderd" subcategory: "" description: |- A license for a Coder deployment. - It's recommended to create multiple instances of this resource when updating a license. Modifying an existing license will cause the resource to be replaced, which may result in a brief unlicensed period. + It's recommended to set create_before_destroy https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle#create_before_destroy on license resources. Without setting this, Terraform will remove the old license before adding the updated license. This will result in a temporary disruption to your users; during which they may be unable to use features that require a license. Terraform does not guarantee this resource will be created before other resources or attributes that require a licensed deployment. The depends_on meta-argument is instead recommended. --- @@ -12,11 +12,21 @@ description: |- A license for a Coder deployment. -It's recommended to create multiple instances of this resource when updating a license. Modifying an existing license will cause the resource to be replaced, which may result in a brief unlicensed period. +It's recommended to set [`create_before_destroy`](https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle#create_before_destroy) on license resources. Without setting this, Terraform will remove the old license before adding the updated license. This will result in a temporary disruption to your users; during which they may be unable to use features that require a license. Terraform does not guarantee this resource will be created before other resources or attributes that require a licensed deployment. The `depends_on` meta-argument is instead recommended. +## Example Usage +```terraform +resource "coderd_license" "license" { + license = "<…>" + + lifecycle { + create_before_destroy = true + } +} +``` ## Schema diff --git a/docs/resources/organization.md b/docs/resources/organization.md new file mode 100644 index 0000000..9556be4 --- /dev/null +++ b/docs/resources/organization.md @@ -0,0 +1,60 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_organization Resource - terraform-provider-coderd" +subcategory: "" +description: |- + An organization on the Coder deployment +--- + +# coderd_organization (Resource) + +An organization on the Coder deployment + + + + +## Schema + +### Required + +- `name` (String) Name of the organization. + +### Optional + +- `description` (String) +- `display_name` (String) Display name of the organization. Defaults to name. +- `group_sync` (Block, Optional) (see [below for nested schema](#nestedblock--group_sync)) +- `icon` (String) +- `role_sync` (Block, Optional) (see [below for nested schema](#nestedblock--role_sync)) + +### Read-Only + +- `id` (String) Organization ID + + +### Nested Schema for `group_sync` + +Optional: + +- `auto_create_missing` (Boolean) Controls whether groups will be created if they are missing. +- `field` (String) The claim field that specifies what groups a user should be in. +- `mapping` (Map of List of String) A map from OIDC group name to Coder group ID. +- `regex_filter` (String) A regular expression that will be used to filter the groups returned by the OIDC provider. Any group not matched will be ignored. + + + +### Nested Schema for `role_sync` + +Optional: + +- `field` (String) The claim field that specifies what organization roles a user should be given. +- `mapping` (Map of List of String) A map from OIDC group name to Coder organization role. + +## Import + +Import is supported using the following syntax: + +```shell +# Organizations can be imported by their name +terraform import coderd_organization.our_org our_org +``` diff --git a/docs/resources/provisioner_key.md b/docs/resources/provisioner_key.md new file mode 100644 index 0000000..d64b724 --- /dev/null +++ b/docs/resources/provisioner_key.md @@ -0,0 +1,29 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_provisioner_key Resource - terraform-provider-coderd" +subcategory: "" +description: |- + A provisioner key for a Coder deployment. +--- + +# coderd_provisioner_key (Resource) + +A provisioner key for a Coder deployment. + + + + +## Schema + +### Required + +- `name` (String) The name of the key. +- `organization_id` (String) The organization that provisioners connected with this key will be connected to. + +### Optional + +- `tags` (Map of String) The tags that provisioners connected with this key will accept jobs for. + +### Read-Only + +- `key` (String, Sensitive) The acquired provisioner key diff --git a/examples/resources/coderd_license/resource.tf b/examples/resources/coderd_license/resource.tf new file mode 100644 index 0000000..3a25e9c --- /dev/null +++ b/examples/resources/coderd_license/resource.tf @@ -0,0 +1,7 @@ +resource "coderd_license" "license" { + license = "<…>" + + lifecycle { + create_before_destroy = true + } +} diff --git a/examples/resources/coderd_organization/import.sh b/examples/resources/coderd_organization/import.sh new file mode 100644 index 0000000..882dce6 --- /dev/null +++ b/examples/resources/coderd_organization/import.sh @@ -0,0 +1,2 @@ +# Organizations can be imported by their name +terraform import coderd_organization.our_org our_org diff --git a/go.mod b/go.mod index c62b2b8..4c43f7c 100644 --- a/go.mod +++ b/go.mod @@ -8,14 +8,14 @@ require ( github.com/docker/docker v27.2.1+incompatible github.com/docker/go-connections v0.5.0 github.com/google/uuid v1.6.0 - github.com/hashicorp/terraform-plugin-docs v0.19.4 + github.com/hashicorp/terraform-plugin-docs v0.20.1 github.com/hashicorp/terraform-plugin-framework v1.13.0 github.com/hashicorp/terraform-plugin-framework-validators v0.15.0 github.com/hashicorp/terraform-plugin-go v0.25.0 github.com/hashicorp/terraform-plugin-log v0.9.0 - github.com/hashicorp/terraform-plugin-testing v1.10.0 + github.com/hashicorp/terraform-plugin-testing v1.11.0 github.com/otiai10/copy v1.14.0 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 ) require ( @@ -40,7 +40,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bgentry/speakeasy v0.2.0 // indirect - github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.3.7 // indirect @@ -80,11 +80,11 @@ require ( 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/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.22.1 // indirect - github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0 // indirect + github.com/hashicorp/terraform-json v0.23.0 // indirect + github.com/hashicorp/terraform-plugin-sdk/v2 v2.35.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.3 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect @@ -133,7 +133,7 @@ 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/yuin/goldmark v1.7.4 // indirect + github.com/yuin/goldmark v1.7.7 // indirect github.com/yuin/goldmark-meta v1.1.0 // indirect github.com/zclconf/go-cty v1.15.0 // indirect github.com/zeebo/errs v1.3.0 // indirect @@ -148,15 +148,15 @@ require ( go.opentelemetry.io/otel/trace v1.30.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.28.0 // indirect + golang.org/x/crypto v0.29.0 // indirect golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect golang.org/x/mod v0.21.0 // indirect golang.org/x/net v0.30.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/term v0.25.0 // indirect - golang.org/x/text v0.19.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/term v0.26.0 // indirect + golang.org/x/text v0.20.0 // indirect golang.org/x/time v0.7.0 // indirect golang.org/x/tools v0.26.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect diff --git a/go.sum b/go.sum index 55c9312..d9a08c6 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE51E= github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= -github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q= +github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= @@ -243,16 +243,16 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe 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/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.22.1 h1:xft84GZR0QzjPVWs4lRUwvTcPnegqlyS7orfb5Ltvec= -github.com/hashicorp/terraform-json v0.22.1/go.mod h1:JbWSQCLFSXFFhg42T7l9iJwdGXBYV8fmmD6o/ML4p3A= -github.com/hashicorp/terraform-plugin-docs v0.19.4 h1:G3Bgo7J22OMtegIgn8Cd/CaSeyEljqjH3G39w28JK4c= -github.com/hashicorp/terraform-plugin-docs v0.19.4/go.mod h1:4pLASsatTmRynVzsjEhbXZ6s7xBlUw/2Kt0zfrq8HxA= +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-docs v0.20.1 h1:Fq7E/HrU8kuZu3hNliZGwloFWSYfWEOWnylFhYQIoys= +github.com/hashicorp/terraform-plugin-docs v0.20.1/go.mod h1:Yz6HoK7/EgzSrHPB9J/lWFzwl9/xep2OPnc5jaJDV90= github.com/hashicorp/terraform-plugin-framework v1.13.0 h1:8OTG4+oZUfKgnfTdPTJwZ532Bh2BobF4H+yBiYJ/scw= github.com/hashicorp/terraform-plugin-framework v1.13.0/go.mod h1:j64rwMGpgM3NYXTKuxrCnyubQb/4VKldEKlcG8cvmjU= github.com/hashicorp/terraform-plugin-framework-validators v0.15.0 h1:RXMmu7JgpFjnI1a5QjMCBb11usrW2OtAG+iOTIj5c9Y= @@ -261,10 +261,10 @@ github.com/hashicorp/terraform-plugin-go v0.25.0 h1:oi13cx7xXA6QciMcpcFi/rwA974r github.com/hashicorp/terraform-plugin-go v0.25.0/go.mod h1:+SYagMYadJP86Kvn+TGeV+ofr/R3g4/If0O5sO96MVw= 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.34.0 h1:kJiWGx2kiQVo97Y5IOGR4EMcZ8DtMswHhUuFibsCQQE= -github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0/go.mod h1:sl/UoabMc37HA6ICVMmGO+/0wofkVIRxf+BMb/dnoIg= -github.com/hashicorp/terraform-plugin-testing v1.10.0 h1:2+tmRNhvnfE4Bs8rB6v58S/VpqzGC6RCh9Y8ujdn+aw= -github.com/hashicorp/terraform-plugin-testing v1.10.0/go.mod h1:iWRW3+loP33WMch2P/TEyCxxct/ZEcCGMquSLSCVsrc= +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-plugin-testing v1.11.0 h1:MeDT5W3YHbONJt2aPQyaBsgQeAIckwPX41EUHXEn29A= +github.com/hashicorp/terraform-plugin-testing v1.11.0/go.mod h1:WNAHQ3DcgV/0J+B15WTE6hDvxcUdkPPpnB1FR3M910U= 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-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= @@ -413,8 +413,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +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/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= @@ -454,8 +454,8 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= github.com/tinylib/msgp v1.2.1 h1:6ypy2qcCznxpP4hpORzhtXyTqrBs7cfM9MCCWY8zsmU= @@ -489,8 +489,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= -github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.7 h1:5m9rrB1sW3JUMToKFQfb+FGt1U7r57IHu5GrYrG2nqU= +github.com/yuin/goldmark v1.7.7/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ= @@ -542,8 +542,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -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.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -576,8 +576,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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.1.0/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.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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= @@ -603,8 +603,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.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.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.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/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -612,8 +612,8 @@ golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= -golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= +golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= 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= @@ -623,8 +623,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/internal/codersdkvalidator/display_name.go b/internal/codersdkvalidator/display_name.go new file mode 100644 index 0000000..1000c32 --- /dev/null +++ b/internal/codersdkvalidator/display_name.go @@ -0,0 +1,10 @@ +package codersdkvalidator + +import ( + "github.com/coder/coder/v2/codersdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func DisplayName() validator.String { + return validatorFromFunc(codersdk.DisplayNameValid, "value must be a valid display name") +} diff --git a/internal/codersdkvalidator/group_name.go b/internal/codersdkvalidator/group_name.go new file mode 100644 index 0000000..20313db --- /dev/null +++ b/internal/codersdkvalidator/group_name.go @@ -0,0 +1,10 @@ +package codersdkvalidator + +import ( + "github.com/coder/coder/v2/codersdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func GroupName() validator.String { + return validatorFromFunc(codersdk.GroupNameValid, "value must be a valid group name") +} diff --git a/internal/codersdkvalidator/name.go b/internal/codersdkvalidator/name.go new file mode 100644 index 0000000..14adb25 --- /dev/null +++ b/internal/codersdkvalidator/name.go @@ -0,0 +1,10 @@ +package codersdkvalidator + +import ( + "github.com/coder/coder/v2/codersdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func Name() validator.String { + return validatorFromFunc(codersdk.NameValid, "value must be a valid name") +} diff --git a/internal/codersdkvalidator/regex.go b/internal/codersdkvalidator/regex.go new file mode 100644 index 0000000..0077616 --- /dev/null +++ b/internal/codersdkvalidator/regex.go @@ -0,0 +1,16 @@ +package codersdkvalidator + +import ( + "regexp" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func checkRegexp(it string) error { + _, err := regexp.Compile("") + return err +} + +func Regexp() validator.String { + return validatorFromFunc(checkRegexp, "value must be a valid regexp") +} diff --git a/internal/codersdkvalidator/template_version_name.go b/internal/codersdkvalidator/template_version_name.go new file mode 100644 index 0000000..32c69d6 --- /dev/null +++ b/internal/codersdkvalidator/template_version_name.go @@ -0,0 +1,10 @@ +package codersdkvalidator + +import ( + "github.com/coder/coder/v2/codersdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func TemplateVersionName() validator.String { + return validatorFromFunc(codersdk.TemplateVersionNameValid, "value must be a valid template version name") +} diff --git a/internal/codersdkvalidator/user_real_name.go b/internal/codersdkvalidator/user_real_name.go new file mode 100644 index 0000000..5bf9686 --- /dev/null +++ b/internal/codersdkvalidator/user_real_name.go @@ -0,0 +1,10 @@ +package codersdkvalidator + +import ( + "github.com/coder/coder/v2/codersdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func UserRealName() validator.String { + return validatorFromFunc(codersdk.UserRealNameValid, "value must be a valid name for a user") +} diff --git a/internal/codersdkvalidator/validator_from_func.go b/internal/codersdkvalidator/validator_from_func.go new file mode 100644 index 0000000..9d5e631 --- /dev/null +++ b/internal/codersdkvalidator/validator_from_func.go @@ -0,0 +1,51 @@ +package codersdkvalidator + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +type functionValidator struct { + check func(string) error + defaultMessage string + err error +} + +func validatorFromFunc(check func(string) error, defaultMessage string) functionValidator { + return functionValidator{ + check: check, + defaultMessage: defaultMessage, + } +} + +var _ validator.String = functionValidator{} + +func (v functionValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + name := req.ConfigValue.ValueString() + if v.err = v.check(name); v.err != nil { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + v.Description(ctx), + name, + )) + } +} + +var _ validator.Describer = functionValidator{} + +func (v functionValidator) Description(_ context.Context) string { + if v.err != nil { + return v.err.Error() + } + return v.defaultMessage +} + +func (v functionValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} diff --git a/internal/provider/group_data_source_test.go b/internal/provider/group_data_source_test.go index 349e855..5a081af 100644 --- a/internal/provider/group_data_source_test.go +++ b/internal/provider/group_data_source_test.go @@ -8,6 +8,7 @@ import ( "testing" "text/template" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -74,7 +75,7 @@ func TestAccGroupDataSource(t *testing.T) { cfg := testAccGroupDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - ID: PtrTo(group.ID.String()), + ID: ptr.Ref(group.ID.String()), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -92,8 +93,8 @@ func TestAccGroupDataSource(t *testing.T) { cfg := testAccGroupDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - OrganizationID: PtrTo(firstUser.OrganizationIDs[0].String()), - Name: PtrTo("example-group"), + OrganizationID: ptr.Ref(firstUser.OrganizationIDs[0].String()), + Name: ptr.Ref("example-group"), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -111,7 +112,7 @@ func TestAccGroupDataSource(t *testing.T) { cfg := testAccGroupDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: PtrTo("example-group"), + Name: ptr.Ref("example-group"), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -129,7 +130,7 @@ func TestAccGroupDataSource(t *testing.T) { cfg := testAccGroupDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - OrganizationID: PtrTo(firstUser.OrganizationIDs[0].String()), + OrganizationID: ptr.Ref(firstUser.OrganizationIDs[0].String()), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, diff --git a/internal/provider/group_resource.go b/internal/provider/group_resource.go index a7dcd6a..fa370a0 100644 --- a/internal/provider/group_resource.go +++ b/internal/provider/group_resource.go @@ -6,8 +6,8 @@ import ( "strings" "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" "github.com/google/uuid" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" @@ -77,8 +77,7 @@ func (r *GroupResource) Schema(ctx context.Context, req resource.SchemaRequest, MarkdownDescription: "The unique name of the group.", Required: true, Validators: []validator.String{ - stringvalidator.LengthBetween(1, 36), - stringvalidator.RegexMatches(nameValidRegex, "Group names must be alpahnumeric with hyphens."), + codersdkvalidator.GroupName(), }, }, "display_name": schema.StringAttribute{ @@ -86,8 +85,7 @@ func (r *GroupResource) Schema(ctx context.Context, req resource.SchemaRequest, Computed: true, Optional: true, Validators: []validator.String{ - stringvalidator.LengthBetween(1, 64), - stringvalidator.RegexMatches(displayNameRegex, "Group display names must be alphanumeric with spaces"), + codersdkvalidator.DisplayName(), }, Default: stringdefault.StaticString(""), }, diff --git a/internal/provider/group_resource_test.go b/internal/provider/group_resource_test.go index 159856f..865851a 100644 --- a/internal/provider/group_resource_test.go +++ b/internal/provider/group_resource_test.go @@ -8,6 +8,7 @@ import ( "testing" "text/template" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -44,17 +45,17 @@ func TestAccGroupResource(t *testing.T) { cfg1 := testAccGroupResourceconfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: PtrTo("example-group"), - DisplayName: PtrTo("Example Group"), - AvatarUrl: PtrTo("https://google.com"), - QuotaAllowance: PtrTo(int32(100)), - Members: PtrTo([]string{user1.ID.String()}), + Name: ptr.Ref("example-group"), + DisplayName: ptr.Ref("Example Group"), + AvatarUrl: ptr.Ref("https://google.com"), + QuotaAllowance: ptr.Ref(int32(100)), + Members: ptr.Ref([]string{user1.ID.String()}), } cfg2 := cfg1 - cfg2.Name = PtrTo("example-group-new") - cfg2.DisplayName = PtrTo("Example Group New") - cfg2.Members = PtrTo([]string{user2.ID.String()}) + cfg2.Name = ptr.Ref("example-group-new") + cfg2.DisplayName = ptr.Ref("Example Group New") + cfg2.Members = ptr.Ref([]string{user2.ID.String()}) cfg3 := cfg2 cfg3.Members = nil @@ -143,11 +144,11 @@ func TestAccGroupResourceAGPL(t *testing.T) { cfg1 := testAccGroupResourceconfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: PtrTo("example-group"), - DisplayName: PtrTo("Example Group"), - AvatarUrl: PtrTo("https://google.com"), - QuotaAllowance: PtrTo(int32(100)), - Members: PtrTo([]string{firstUser.ID.String()}), + Name: ptr.Ref("example-group"), + DisplayName: ptr.Ref("Example Group"), + AvatarUrl: ptr.Ref("https://google.com"), + QuotaAllowance: ptr.Ref(int32(100)), + Members: ptr.Ref([]string{firstUser.ID.String()}), } resource.Test(t, resource.TestCase{ diff --git a/internal/provider/license_resource.go b/internal/provider/license_resource.go index 2dec235..5cb4778 100644 --- a/internal/provider/license_resource.go +++ b/internal/provider/license_resource.go @@ -39,9 +39,12 @@ func (r *LicenseResource) Metadata(ctx context.Context, req resource.MetadataReq func (r *LicenseResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ - MarkdownDescription: "A license for a Coder deployment.\n\nIt's recommended to create multiple instances of this " + - "resource when updating a license. Modifying an existing license will cause the resource to be replaced, " + - "which may result in a brief unlicensed period.\n\n" + + MarkdownDescription: "A license for a Coder deployment.\n\nIt's recommended to set " + + "[`create_before_destroy`](https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle#create_before_destroy) " + + "on license resources. Without setting this, Terraform will remove the old " + + "license before adding the updated license. This will result in a temporary " + + "disruption to your users; during which they may be unable to use features " + + "that require a license.\n\n" + "Terraform does not guarantee this resource " + "will be created before other resources or attributes that require a licensed deployment. " + "The `depends_on` meta-argument is instead recommended.", diff --git a/internal/provider/license_resource_test.go b/internal/provider/license_resource_test.go index e2d13d6..9a4abc7 100644 --- a/internal/provider/license_resource_test.go +++ b/internal/provider/license_resource_test.go @@ -24,7 +24,7 @@ func TestAccLicenseResource(t *testing.T) { t.Skip("No license found for license resource tests, skipping") } - cfg1 := testAccLicenseResourceconfig{ + cfg1 := testAccLicenseResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), License: license, @@ -42,13 +42,13 @@ func TestAccLicenseResource(t *testing.T) { }) } -type testAccLicenseResourceconfig struct { +type testAccLicenseResourceConfig struct { URL string Token string License string } -func (c testAccLicenseResourceconfig) String(t *testing.T) string { +func (c testAccLicenseResourceConfig) String(t *testing.T) string { t.Helper() tpl := ` provider coderd { diff --git a/internal/provider/organization_data_source_test.go b/internal/provider/organization_data_source_test.go index 7f9bef3..c7bb982 100644 --- a/internal/provider/organization_data_source_test.go +++ b/internal/provider/organization_data_source_test.go @@ -8,6 +8,7 @@ import ( "testing" "text/template" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -37,7 +38,7 @@ func TestAccOrganizationDataSource(t *testing.T) { cfg := testAccOrganizationDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - ID: PtrTo(firstUser.OrganizationIDs[0].String()), + ID: ptr.Ref(firstUser.OrganizationIDs[0].String()), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -55,7 +56,7 @@ func TestAccOrganizationDataSource(t *testing.T) { cfg := testAccOrganizationDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: PtrTo("coder"), + Name: ptr.Ref("coder"), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -73,7 +74,7 @@ func TestAccOrganizationDataSource(t *testing.T) { cfg := testAccOrganizationDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - IsDefault: PtrTo(true), + IsDefault: ptr.Ref(true), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -91,8 +92,8 @@ func TestAccOrganizationDataSource(t *testing.T) { cfg := testAccOrganizationDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - IsDefault: PtrTo(true), - Name: PtrTo("coder"), + IsDefault: ptr.Ref(true), + Name: ptr.Ref("coder"), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go new file mode 100644 index 0000000..397f92e --- /dev/null +++ b/internal/provider/organization_resource.go @@ -0,0 +1,551 @@ +package provider + +import ( + "context" + "fmt" + "regexp" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &OrganizationResource{} +var _ resource.ResourceWithImportState = &OrganizationResource{} + +type OrganizationResource struct { + *CoderdProviderData +} + +// OrganizationResourceModel describes the resource data model. +type OrganizationResourceModel struct { + ID UUID `tfsdk:"id"` + + Name types.String `tfsdk:"name"` + DisplayName types.String `tfsdk:"display_name"` + Description types.String `tfsdk:"description"` + Icon types.String `tfsdk:"icon"` + + GroupSync types.Object `tfsdk:"group_sync"` + RoleSync types.Object `tfsdk:"role_sync"` +} + +type GroupSyncModel struct { + Field types.String `tfsdk:"field"` + RegexFilter types.String `tfsdk:"regex_filter"` + AutoCreateMissing types.Bool `tfsdk:"auto_create_missing"` + Mapping types.Map `tfsdk:"mapping"` +} + +var groupSyncAttrTypes = map[string]attr.Type{ + "field": types.StringType, + "regex_filter": types.StringType, + "auto_create_missing": types.BoolType, + "mapping": types.MapType{ElemType: types.ListType{ElemType: UUIDType}}, +} + +func (m GroupSyncModel) ValueObject() types.Object { + return types.ObjectValueMust(groupSyncAttrTypes, map[string]attr.Value{ + "field": m.Field, + "regex_filter": m.RegexFilter, + "auto_create_missing": m.AutoCreateMissing, + "mapping": m.Mapping, + }) +} + +type RoleSyncModel struct { + Field types.String `tfsdk:"field"` + Mapping types.Map `tfsdk:"mapping"` +} + +var roleSyncAttrTypes = map[string]attr.Type{ + "field": types.StringType, + "mapping": types.MapType{ElemType: types.ListType{ElemType: types.StringType}}, +} + +func (m RoleSyncModel) ValueObject() types.Object { + return types.ObjectValueMust(roleSyncAttrTypes, map[string]attr.Value{ + "field": m.Field, + "mapping": m.Mapping, + }) +} + +func NewOrganizationResource() resource.Resource { + return &OrganizationResource{} +} + +func (r *OrganizationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_organization" +} + +func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "An organization on the Coder deployment", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + CustomType: UUIDType, + Computed: true, + MarkdownDescription: "Organization ID", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "Name of the organization.", + Required: true, + Validators: []validator.String{ + codersdkvalidator.Name(), + }, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Display name of the organization. Defaults to name.", + Computed: true, + Optional: true, + Default: stringdefault.StaticString(""), + Validators: []validator.String{ + codersdkvalidator.DisplayName(), + }, + }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "icon": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + }, + + Blocks: map[string]schema.Block{ + "group_sync": schema.SingleNestedBlock{ + Attributes: map[string]schema.Attribute{ + "field": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "The claim field that specifies what groups " + + "a user should be in.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "regex_filter": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "A regular expression that will be used to " + + "filter the groups returned by the OIDC provider. Any group " + + "not matched will be ignored.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + codersdkvalidator.Regexp(), + }, + }, + "auto_create_missing": schema.BoolAttribute{ + Optional: true, + MarkdownDescription: "Controls whether groups will be created if " + + "they are missing.", + }, + "mapping": schema.MapAttribute{ + ElementType: types.ListType{ElemType: UUIDType}, + Optional: true, + MarkdownDescription: "A map from OIDC group name to Coder group ID.", + }, + }, + }, + "role_sync": schema.SingleNestedBlock{ + Attributes: map[string]schema.Attribute{ + "field": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "The claim field that specifies what " + + "organization roles a user should be given.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "mapping": schema.MapAttribute{ + ElementType: types.ListType{ElemType: types.StringType}, + Optional: true, + MarkdownDescription: "A map from OIDC group name to Coder " + + "organization role.", + }, + }, + }, + }, + } +} + +func (r *OrganizationResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + data, ok := req.ProviderData.(*CoderdProviderData) + + if !ok { + resp.Diagnostics.AddError( + "Unable to configure provider data", + fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.CoderdProviderData = data +} + +func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Read Terraform prior state data into the model + var data OrganizationResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + var org codersdk.Organization + var err error + if data.ID.IsNull() { + orgName := data.Name.ValueString() + org, err = r.Client.OrganizationByName(ctx, orgName) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization by name, got error: %s", err)) + return + } + data.ID = UUIDValue(org.ID) + } else { + orgID := data.ID.ValueUUID() + org, err = r.Client.Organization(ctx, orgID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization by ID, got error: %s", err)) + return + } + } + + if !data.GroupSync.IsNull() { + groupSync, err := r.Client.GroupIDPSyncSettings(ctx, data.ID.ValueUUID().String()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("unable to get organization group sync settings, got error: %s", err)) + return + } + + // Read values from Terraform + var groupSyncData GroupSyncModel + resp.Diagnostics.Append(data.GroupSync.As(ctx, &groupSyncData, basetypes.ObjectAsOptions{})...) + if resp.Diagnostics.HasError() { + return + } + + if !groupSyncData.Field.IsNull() { + groupSyncData.Field = types.StringValue(groupSync.Field) + } + if !groupSyncData.RegexFilter.IsNull() { + groupSyncData.RegexFilter = types.StringValue(groupSync.RegexFilter.String()) + } + if !groupSyncData.AutoCreateMissing.IsNull() { + groupSyncData.AutoCreateMissing = types.BoolValue(groupSync.AutoCreateMissing) + } + if !groupSyncData.Mapping.IsNull() { + elements := make(map[string][]string) + for key, ids := range groupSync.Mapping { + for _, id := range ids { + elements[key] = append(elements[key], id.String()) + } + } + + mapping, diags := types.MapValueFrom(ctx, types.ListType{ElemType: UUIDType}, elements) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + groupSyncData.Mapping = mapping + } + + data.GroupSync = groupSyncData.ValueObject() + } + + if !data.RoleSync.IsNull() { + roleSync, err := r.Client.RoleIDPSyncSettings(ctx, data.ID.ValueUUID().String()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("unable to get organization role sync settings, got error: %s", err)) + return + } + + // Read values from Terraform + var roleSyncData RoleSyncModel + resp.Diagnostics.Append(data.RoleSync.As(ctx, &roleSyncData, basetypes.ObjectAsOptions{})...) + if resp.Diagnostics.HasError() { + return + } + + if !roleSyncData.Field.IsNull() { + roleSyncData.Field = types.StringValue(roleSync.Field) + } + if !roleSyncData.Mapping.IsNull() { + mapping, diags := types.MapValueFrom(ctx, types.ListType{ElemType: types.StringType}, roleSync.Mapping) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + roleSyncData.Mapping = mapping + } + + data.RoleSync = roleSyncData.ValueObject() + } + + // We've fetched the organization ID from state, and the latest values for + // everything else from the backend. Ensure that any mutable data is synced + // with the backend. + data.Name = types.StringValue(org.Name) + data.DisplayName = types.StringValue(org.DisplayName) + data.Description = types.StringValue(org.Description) + data.Icon = types.StringValue(org.Icon) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Read Terraform plan data into the model + var data OrganizationResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Trace(ctx, "creating organization", map[string]any{ + "id": data.ID.ValueUUID(), + "name": data.Name.ValueString(), + "display_name": data.DisplayName.ValueString(), + "description": data.Description.ValueString(), + "icon": data.Icon.ValueString(), + }) + org, err := r.Client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: data.Name.ValueString(), + DisplayName: data.DisplayName.ValueString(), + Description: data.Description.ValueString(), + Icon: data.Icon.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Failed to create organization", err.Error()) + return + } + tflog.Trace(ctx, "successfully created organization", map[string]any{ + "id": org.ID, + "name": org.Name, + "display_name": org.DisplayName, + "description": org.Description, + "icon": org.Icon, + }) + // Fill in `ID` since it must be "computed". + data.ID = UUIDValue(org.ID) + // We also fill in `DisplayName`, since it's optional but the backend will + // default it. + data.DisplayName = types.StringValue(org.DisplayName) + + // Now apply group and role sync settings, if specified + orgID := data.ID.ValueUUID() + tflog.Trace(ctx, "updating group sync", map[string]any{ + "orgID": orgID, + }) + if !data.GroupSync.IsNull() { + resp.Diagnostics.Append(r.patchGroupSync(ctx, orgID, data.GroupSync)...) + if resp.Diagnostics.HasError() { + return + } + } + tflog.Trace(ctx, "updating role sync", map[string]any{ + "orgID": orgID, + }) + if !data.RoleSync.IsNull() { + resp.Diagnostics.Append(r.patchRoleSync(ctx, orgID, data.RoleSync)...) + if resp.Diagnostics.HasError() { + return + } + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Read Terraform plan data into the model + var data OrganizationResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + orgID := data.ID.ValueUUID() + + // Update the organization metadata + tflog.Trace(ctx, "updating organization", map[string]any{ + "id": orgID, + "new_name": data.Name.ValueString(), + "new_display_name": data.DisplayName.ValueString(), + "new_description": data.Description.ValueString(), + "new_icon": data.Icon.ValueString(), + }) + org, err := r.Client.UpdateOrganization(ctx, orgID.String(), codersdk.UpdateOrganizationRequest{ + Name: data.Name.ValueString(), + DisplayName: data.DisplayName.ValueString(), + Description: data.Description.ValueStringPointer(), + Icon: data.Icon.ValueStringPointer(), + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update organization %s, got error: %s", orgID, err)) + return + } + + tflog.Trace(ctx, "successfully updated organization", map[string]any{ + "id": orgID, + "name": org.Name, + "display_name": org.DisplayName, + "description": org.Description, + "icon": org.Icon, + }) + + tflog.Trace(ctx, "updating group sync", map[string]any{ + "orgID": orgID, + }) + if !data.GroupSync.IsNull() { + resp.Diagnostics.Append(r.patchGroupSync(ctx, orgID, data.GroupSync)...) + if resp.Diagnostics.HasError() { + return + } + } + tflog.Trace(ctx, "updating role sync", map[string]any{ + "orgID": orgID, + }) + if !data.RoleSync.IsNull() { + resp.Diagnostics.Append(r.patchRoleSync(ctx, orgID, data.RoleSync)...) + if resp.Diagnostics.HasError() { + return + } + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Read Terraform prior state data into the model + var data OrganizationResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + orgID := data.ID.ValueUUID() + + tflog.Trace(ctx, "deleting organization", map[string]any{ + "id": orgID, + "name": data.Name.ValueString(), + }) + err := r.Client.DeleteOrganization(ctx, orgID.String()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete organization %s, got error: %s", orgID, err)) + return + } + tflog.Trace(ctx, "successfully deleted organization", map[string]any{ + "id": orgID, + "name": data.Name.ValueString(), + }) + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) +} + +func (r *OrganizationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Terraform will eventually `Read` in the rest of the fields after we have + // set the `name` attribute. + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} + +func (r *OrganizationResource) patchGroupSync( + ctx context.Context, + orgID uuid.UUID, + groupSyncObject types.Object, +) diag.Diagnostics { + var diags diag.Diagnostics + + // Read values from Terraform + var groupSyncData GroupSyncModel + diags.Append(groupSyncObject.As(ctx, &groupSyncData, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return diags + } + + // Convert that into the type used to send the PATCH to the backend + var groupSync codersdk.GroupSyncSettings + groupSync.Field = groupSyncData.Field.ValueString() + groupSync.RegexFilter = regexp.MustCompile(groupSyncData.RegexFilter.ValueString()) + groupSync.AutoCreateMissing = groupSyncData.AutoCreateMissing.ValueBool() + groupSync.Mapping = make(map[string][]uuid.UUID) + // Terraform doesn't know how to turn one our `UUID` Terraform values into a + // `uuid.UUID`, so we have to do the unwrapping manually here. + var mapping map[string][]UUID + diags.Append(groupSyncData.Mapping.ElementsAs(ctx, &mapping, false)...) + if diags.HasError() { + return diags + } + for key, ids := range mapping { + for _, id := range ids { + groupSync.Mapping[key] = append(groupSync.Mapping[key], id.ValueUUID()) + } + } + + // Perform the PATCH + _, err := r.Client.PatchGroupIDPSyncSettings(ctx, orgID.String(), groupSync) + if err != nil { + diags.AddError("Group Sync Update error", err.Error()) + return diags + } + + return diags +} + +func (r *OrganizationResource) patchRoleSync( + ctx context.Context, + orgID uuid.UUID, + roleSyncObject types.Object, +) diag.Diagnostics { + var diags diag.Diagnostics + + // Read values from Terraform + var roleSyncData RoleSyncModel + diags.Append(roleSyncObject.As(ctx, &roleSyncData, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return diags + } + + // Convert that into the type used to send the PATCH to the backend + var roleSync codersdk.RoleSyncSettings + roleSync.Field = roleSyncData.Field.ValueString() + diags.Append(roleSyncData.Mapping.ElementsAs(ctx, &roleSync.Mapping, false)...) + if diags.HasError() { + return diags + } + + // Perform the PATCH + _, err := r.Client.PatchRoleIDPSyncSettings(ctx, orgID.String(), roleSync) + if err != nil { + diags.AddError("Role Sync Update error", err.Error()) + return diags + } + + return diags +} diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go new file mode 100644 index 0000000..0a755c4 --- /dev/null +++ b/internal/provider/organization_resource_test.go @@ -0,0 +1,165 @@ +package provider + +import ( + "context" + "os" + "strings" + "testing" + "text/template" + + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/integration" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/stretchr/testify/require" +) + +func TestAccOrganizationResource(t *testing.T) { + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + + ctx := context.Background() + client := integration.StartCoder(ctx, t, "organization_acc", true) + _, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + + cfg1 := testAccOrganizationResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + Name: ptr.Ref("example-org"), + DisplayName: ptr.Ref("Example Organization"), + Description: ptr.Ref("This is an example organization"), + Icon: ptr.Ref("/icon/coder.svg"), + } + + cfg2 := cfg1 + cfg2.Name = ptr.Ref("example-org-new") + cfg2.DisplayName = ptr.Ref("Example Organization New") + + cfg3 := cfg2 + cfg3.GroupSync = ptr.Ref(codersdk.GroupSyncSettings{ + Field: "wibble", + Mapping: map[string][]uuid.UUID{ + "wibble": {uuid.MustParse("6e57187f-6543-46ab-a62c-a10065dd4314")}, + }, + }) + cfg3.RoleSync = ptr.Ref(codersdk.RoleSyncSettings{ + Field: "wobble", + Mapping: map[string][]string{ + "wobble": {"wobbly"}, + }, + }) + + t.Run("CreateImportUpdateReadOk", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read + { + Config: cfg1.String(t), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("name"), knownvalue.StringExact("example-org")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("display_name"), knownvalue.StringExact("Example Organization")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("icon"), knownvalue.StringExact("/icon/coder.svg")), + }, + }, + // Import + { + Config: cfg1.String(t), + ResourceName: "coderd_organization.test", + ImportState: true, + ImportStateVerify: true, + ImportStateId: *cfg1.Name, + }, + // Update and Read + { + Config: cfg2.String(t), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("name"), knownvalue.StringExact("example-org-new")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("display_name"), knownvalue.StringExact("Example Organization New")), + }, + }, + // Add group and role sync + { + Config: cfg3.String(t), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("group_sync").AtMapKey("field"), knownvalue.StringExact("wibble")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("group_sync").AtMapKey("mapping").AtMapKey("wibble").AtSliceIndex(0), knownvalue.StringExact("6e57187f-6543-46ab-a62c-a10065dd4314")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("role_sync").AtMapKey("field"), knownvalue.StringExact("wobble")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("role_sync").AtMapKey("mapping").AtMapKey("wobble").AtSliceIndex(0), knownvalue.StringExact("wobbly")), + }, + }, + }, + }) + }) +} + +type testAccOrganizationResourceConfig struct { + URL string + Token string + + Name *string + DisplayName *string + Description *string + Icon *string + + GroupSync *codersdk.GroupSyncSettings + RoleSync *codersdk.RoleSyncSettings +} + +func (c testAccOrganizationResourceConfig) String(t *testing.T) string { + t.Helper() + tpl := ` +provider coderd { + url = "{{.URL}}" + token = "{{.Token}}" +} + +resource "coderd_organization" "test" { + name = {{orNull .Name}} + display_name = {{orNull .DisplayName}} + description = {{orNull .Description}} + icon = {{orNull .Icon}} + + {{- if .GroupSync}} + group_sync { + field = "{{.GroupSync.Field}}" + mapping = { + {{- range $key, $value := .GroupSync.Mapping}} + {{$key}} = {{printf "%q" $value}} + {{- end}} + } + } + {{- end}} + + {{- if .RoleSync}} + role_sync { + field = "{{.RoleSync.Field}}" + mapping = { + {{- range $key, $value := .RoleSync.Mapping}} + {{$key}} = {{printf "%q" $value}} + {{- end}} + } + } + {{- end}} +} +` + funcMap := template.FuncMap{ + "orNull": PrintOrNull, + } + + buf := strings.Builder{} + tmpl, err := template.New("organizationResource").Funcs(funcMap).Parse(tpl) + require.NoError(t, err) + + err = tmpl.Execute(&buf, c) + require.NoError(t, err) + return buf.String() +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index bfeea5e..b8b9fa7 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -78,7 +78,6 @@ This provider is only compatible with Coder version [2.10.1](https://github.com/ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { var data CoderdProviderModel - resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) if resp.Diagnostics.HasError() { @@ -139,6 +138,8 @@ func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resour NewTemplateResource, NewWorkspaceProxyResource, NewLicenseResource, + NewOrganizationResource, + NewProvisionerKeyResource, } } diff --git a/internal/provider/provisioner_key_resource.go b/internal/provider/provisioner_key_resource.go new file mode 100644 index 0000000..5904df0 --- /dev/null +++ b/internal/provider/provisioner_key_resource.go @@ -0,0 +1,154 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/coder/coder/v2/codersdk" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &ProvisionerKeyResource{} + +func NewProvisionerKeyResource() resource.Resource { + return &ProvisionerKeyResource{} +} + +// ProvisionerKeyResource defines the resource implementation. +type ProvisionerKeyResource struct { + *CoderdProviderData +} + +// ProvisionerKeyResourceModel describes the resource data model. +type ProvisionerKeyResourceModel struct { + OrganizationID UUID `tfsdk:"organization_id"` + Name types.String `tfsdk:"name"` + Tags types.Map `tfsdk:"tags"` + Key types.String `tfsdk:"key"` +} + +func (r *ProvisionerKeyResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_provisioner_key" +} + +func (r *ProvisionerKeyResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "A provisioner key for a Coder deployment.", + + Attributes: map[string]schema.Attribute{ + "organization_id": schema.StringAttribute{ + CustomType: UUIDType, + MarkdownDescription: "The organization that provisioners connected with this key will be connected to.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the key.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "tags": schema.MapAttribute{ + MarkdownDescription: "The tags that provisioners connected with this key will accept jobs for.", + ElementType: types.StringType, + Optional: true, + PlanModifiers: []planmodifier.Map{ + mapplanmodifier.RequiresReplace(), + }, + }, + "key": schema.StringAttribute{ + MarkdownDescription: "The acquired provisioner key", + Computed: true, + Sensitive: true, + }, + }, + } +} + +func (r *ProvisionerKeyResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + data, ok := req.ProviderData.(*CoderdProviderData) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.CoderdProviderData = data +} + +func (r *ProvisionerKeyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Read Terraform plan data into the model + var data ProvisionerKeyResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + var tags map[string]string + resp.Diagnostics.Append(data.Tags.ElementsAs(ctx, &tags, false)...) + createKeyResult, err := r.Client.CreateProvisionerKey(ctx, data.OrganizationID.ValueUUID(), codersdk.CreateProvisionerKeyRequest{ + Name: data.Name.ValueString(), + Tags: tags, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create provisioner_key, got error: %s", err)) + return + } + + data.Key = types.StringValue(createKeyResult.Key) + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ProvisionerKeyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Read Terraform prior state data into the model + var data ProvisionerKeyResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Provisioner keys are immutable, no reading necessary. + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ProvisionerKeyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Provisioner keys are immutable, updating is always invalid. + resp.Diagnostics.AddError("Invalid Update", "Terraform is attempting to update a resource which must be replaced") +} + +func (r *ProvisionerKeyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Read Terraform prior state data into the model + var data ProvisionerKeyResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.Client.DeleteProvisionerKey(ctx, data.OrganizationID.ValueUUID(), data.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete provisionerkey, got error: %s", err)) + return + } +} diff --git a/internal/provider/provisioner_key_resource_test.go b/internal/provider/provisioner_key_resource_test.go new file mode 100644 index 0000000..9c89647 --- /dev/null +++ b/internal/provider/provisioner_key_resource_test.go @@ -0,0 +1,114 @@ +package provider + +import ( + "context" + "os" + "strings" + "testing" + "text/template" + + "github.com/coder/terraform-provider-coderd/integration" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/stretchr/testify/require" +) + +func TestAccProvisionerKeyResource(t *testing.T) { + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := context.Background() + client := integration.StartCoder(ctx, t, "provisioner_key_acc", true) + orgs, err := client.Organizations(ctx) + require.NoError(t, err) + firstOrg := orgs[0].ID + + cfg1 := testAccProvisionerKeyResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + + OrganizationID: firstOrg, + Name: "example-provisioner-key", + } + + cfg2 := cfg1 + cfg2.Tags = map[string]string{ + "wibble": "wobble", + } + + cfg3 := cfg2 + cfg3.Name = "different-provisioner-key" + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg1.String(t), + }, + { + Config: cfg2.String(t), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("coderd_provisioner_key.test", plancheck.ResourceActionReplace), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_provisioner_key.test", tfjsonpath.New("tags").AtMapKey("wibble"), knownvalue.StringExact("wobble")), + }, + }, + { + Config: cfg3.String(t), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("coderd_provisioner_key.test", plancheck.ResourceActionReplace), + }, + }, + }, + }, + }) +} + +type testAccProvisionerKeyResourceConfig struct { + URL string + Token string + + OrganizationID uuid.UUID + Name string + Tags map[string]string +} + +func (c testAccProvisionerKeyResourceConfig) String(t *testing.T) string { + t.Helper() + + tpl := ` +provider coderd { + url = "{{.URL}}" + token = "{{.Token}}" +} + +resource "coderd_provisioner_key" "test" { + organization_id = "{{.OrganizationID}}" + name = "{{.Name}}" + + tags = { + {{- range $key, $value := .Tags}} + {{$key}} = "{{$value}}" + {{- end}} + } +} +` + + buf := strings.Builder{} + tmpl, err := template.New("provisionerKeyResource").Parse(tpl) + require.NoError(t, err) + + err = tmpl.Execute(&buf, c) + require.NoError(t, err) + return buf.String() +} diff --git a/internal/provider/template_data_source_test.go b/internal/provider/template_data_source_test.go index cebdba7..f6759b3 100644 --- a/internal/provider/template_data_source_test.go +++ b/internal/provider/template_data_source_test.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" ) @@ -49,8 +50,8 @@ func TestAccTemplateDataSource(t *testing.T) { Description: "An example template", Icon: "/path/to/icon.png", VersionID: version.ID, - DefaultTTLMillis: PtrTo((10 * time.Hour).Milliseconds()), - ActivityBumpMillis: PtrTo((4 * time.Hour).Milliseconds()), + DefaultTTLMillis: ptr.Ref((10 * time.Hour).Milliseconds()), + ActivityBumpMillis: ptr.Ref((4 * time.Hour).Milliseconds()), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ DaysOfWeek: []string{"sunday"}, Weeks: 1, @@ -58,12 +59,12 @@ func TestAccTemplateDataSource(t *testing.T) { AutostartRequirement: &codersdk.TemplateAutostartRequirement{ DaysOfWeek: []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}, }, - AllowUserCancelWorkspaceJobs: PtrTo(true), - AllowUserAutostart: PtrTo(true), - AllowUserAutostop: PtrTo(true), - FailureTTLMillis: PtrTo((1 * time.Hour).Milliseconds()), - TimeTilDormantMillis: PtrTo((7 * 24 * time.Hour).Milliseconds()), - TimeTilDormantAutoDeleteMillis: PtrTo((30 * 24 * time.Hour).Milliseconds()), + AllowUserCancelWorkspaceJobs: ptr.Ref(true), + AllowUserAutostart: ptr.Ref(true), + AllowUserAutostop: ptr.Ref(true), + FailureTTLMillis: ptr.Ref((1 * time.Hour).Milliseconds()), + TimeTilDormantMillis: ptr.Ref((7 * 24 * time.Hour).Milliseconds()), + TimeTilDormantAutoDeleteMillis: ptr.Ref((30 * 24 * time.Hour).Milliseconds()), DisableEveryoneGroupAccess: true, RequireActiveVersion: true, }) @@ -93,9 +94,9 @@ func TestAccTemplateDataSource(t *testing.T) { UpdateWorkspaceLastUsedAt: false, UpdateWorkspaceDormantAt: false, RequireActiveVersion: tpl.RequireActiveVersion, - DeprecationMessage: PtrTo("This template is deprecated"), + DeprecationMessage: ptr.Ref("This template is deprecated"), DisableEveryoneGroupAccess: true, - MaxPortShareLevel: PtrTo(codersdk.WorkspaceAgentPortShareLevelOwner), + MaxPortShareLevel: ptr.Ref(codersdk.WorkspaceAgentPortShareLevelOwner), }) require.NoError(t, err) @@ -153,8 +154,8 @@ func TestAccTemplateDataSource(t *testing.T) { cfg := testAccTemplateDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - OrganizationID: PtrTo(orgID.String()), - Name: PtrTo(tpl.Name), + OrganizationID: ptr.Ref(orgID.String()), + Name: ptr.Ref(tpl.Name), } resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -173,7 +174,7 @@ func TestAccTemplateDataSource(t *testing.T) { cfg := testAccTemplateDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - ID: PtrTo(tpl.ID.String()), + ID: ptr.Ref(tpl.ID.String()), } resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -210,7 +211,7 @@ func TestAccTemplateDataSource(t *testing.T) { cfg := testAccTemplateDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: PtrTo(tpl.Name), + Name: ptr.Ref(tpl.Name), } resource.Test(t, resource.TestCase{ IsUnitTest: true, diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index f1d7adc..a5834db 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -10,8 +10,10 @@ import ( "strings" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk" + "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" @@ -257,8 +259,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques MarkdownDescription: "The name of the template.", Required: true, Validators: []validator.String{ - stringvalidator.LengthBetween(1, 32), - stringvalidator.RegexMatches(nameValidRegex, "Template names must be alphanumeric with hyphens."), + codersdkvalidator.Name(), }, }, "display_name": schema.StringAttribute{ @@ -266,8 +267,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques Optional: true, Computed: true, Validators: []validator.String{ - stringvalidator.LengthBetween(1, 64), - stringvalidator.RegexMatches(displayNameRegex, "Template display names must be alphanumeric with spaces."), + codersdkvalidator.DisplayName(), }, }, "description": schema.StringAttribute{ @@ -417,8 +417,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques Optional: true, Computed: true, Validators: []validator.String{ - stringvalidator.LengthBetween(1, 64), - stringvalidator.RegexMatches(templateVersionNameRegex, "Template version names must be alphanumeric with underscores and dots."), + codersdkvalidator.TemplateVersionName(), }, }, "message": schema.StringAttribute{ @@ -1253,7 +1252,7 @@ func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, diag *diag. TimeTilDormantAutoDeleteMillis: r.TimeTilDormantAutoDeleteMillis.ValueInt64(), RequireActiveVersion: r.RequireActiveVersion.ValueBool(), DeprecationMessage: r.DeprecationMessage.ValueStringPointer(), - MaxPortShareLevel: PtrTo(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())), + MaxPortShareLevel: ptr.Ref(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())), // If we're managing ACL, we want to delete the everyone group DisableEveryoneGroupAccess: !r.ACL.IsNull(), } diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index acb07f2..c844da0 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -17,6 +17,7 @@ import ( cp "github.com/otiai10/copy" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" ) @@ -42,12 +43,12 @@ func TestAccTemplateResource(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: PtrTo("example-template"), + Name: ptr.Ref("example-template"), Versions: []testAccTemplateVersionConfig{ { // Auto-generated version name Directory: &exTemplateOne, - Active: PtrTo(true), + Active: ptr.Ref(true), }, }, ACL: testAccTemplateACLConfig{ @@ -57,28 +58,28 @@ func TestAccTemplateResource(t *testing.T) { cfg2 := cfg1 cfg2.Versions = slices.Clone(cfg2.Versions) - cfg2.Name = PtrTo("example-template-new") + cfg2.Name = ptr.Ref("example-template-new") cfg2.Versions[0].Directory = &exTemplateTwo - cfg2.Versions[0].Name = PtrTo("new") + cfg2.Versions[0].Name = ptr.Ref("new") cfg3 := cfg2 cfg3.Versions = slices.Clone(cfg3.Versions) cfg3.Versions = append(cfg3.Versions, testAccTemplateVersionConfig{ - Name: PtrTo("legacy-template"), + Name: ptr.Ref("legacy-template"), Directory: &exTemplateOne, - Active: PtrTo(false), + Active: ptr.Ref(false), TerraformVariables: []testAccTemplateKeyValueConfig{ { - Key: PtrTo("name"), - Value: PtrTo("world"), + Key: ptr.Ref("name"), + Value: ptr.Ref("world"), }, }, }) cfg4 := cfg3 cfg4.Versions = slices.Clone(cfg4.Versions) - cfg4.Versions[0].Active = PtrTo(false) - cfg4.Versions[1].Active = PtrTo(true) + cfg4.Versions[0].Active = ptr.Ref(false) + cfg4.Versions[1].Active = ptr.Ref(true) cfg5 := cfg4 cfg5.Versions = slices.Clone(cfg5.Versions) @@ -240,26 +241,26 @@ func TestAccTemplateResource(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: PtrTo("example-template2"), + Name: ptr.Ref("example-template2"), Versions: []testAccTemplateVersionConfig{ { // Auto-generated version name - Directory: PtrTo("../../integration/template-test/example-template-2/"), + Directory: ptr.Ref("../../integration/template-test/example-template-2/"), TerraformVariables: []testAccTemplateKeyValueConfig{ { - Key: PtrTo("name"), - Value: PtrTo("world"), + Key: ptr.Ref("name"), + Value: ptr.Ref("world"), }, }, - Active: PtrTo(true), + Active: ptr.Ref(true), }, { // Auto-generated version name - Directory: PtrTo("../../integration/template-test/example-template-2/"), + Directory: ptr.Ref("../../integration/template-test/example-template-2/"), TerraformVariables: []testAccTemplateKeyValueConfig{ { - Key: PtrTo("name"), - Value: PtrTo("world"), + Key: ptr.Ref("name"), + Value: ptr.Ref("world"), }, }, }, @@ -271,28 +272,28 @@ func TestAccTemplateResource(t *testing.T) { cfg2 := cfg1 cfg2.Versions = slices.Clone(cfg2.Versions) - cfg2.Versions[1].Name = PtrTo("new-name") + cfg2.Versions[1].Name = ptr.Ref("new-name") cfg3 := cfg2 cfg3.Versions = slices.Clone(cfg3.Versions) - cfg3.Versions[0].Name = PtrTo("new-name-one") - cfg3.Versions[1].Name = PtrTo("new-name-two") + cfg3.Versions[0].Name = ptr.Ref("new-name-one") + cfg3.Versions[1].Name = ptr.Ref("new-name-two") cfg3.Versions[0], cfg3.Versions[1] = cfg3.Versions[1], cfg3.Versions[0] cfg4 := cfg1 cfg4.Versions = slices.Clone(cfg4.Versions) - cfg4.Versions[0].Directory = PtrTo("../../integration/template-test/example-template/") + cfg4.Versions[0].Directory = ptr.Ref("../../integration/template-test/example-template/") cfg5 := cfg4 cfg5.Versions = slices.Clone(cfg5.Versions) - cfg5.Versions[1].Directory = PtrTo("../../integration/template-test/example-template/") + cfg5.Versions[1].Directory = ptr.Ref("../../integration/template-test/example-template/") cfg6 := cfg5 cfg6.Versions = slices.Clone(cfg6.Versions) cfg6.Versions[0].TerraformVariables = []testAccTemplateKeyValueConfig{ { - Key: PtrTo("name"), - Value: PtrTo("world2"), + Key: ptr.Ref("name"), + Value: ptr.Ref("world2"), }, } @@ -368,18 +369,18 @@ func TestAccTemplateResource(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: PtrTo("example-template3"), + Name: ptr.Ref("example-template3"), Versions: []testAccTemplateVersionConfig{ { // Auto-generated version name - Directory: PtrTo("../../integration/template-test/example-template-2/"), + Directory: ptr.Ref("../../integration/template-test/example-template-2/"), TerraformVariables: []testAccTemplateKeyValueConfig{ { - Key: PtrTo("name"), - Value: PtrTo("world"), + Key: ptr.Ref("name"), + Value: ptr.Ref("world"), }, }, - Active: PtrTo(true), + Active: ptr.Ref(true), }, }, ACL: testAccTemplateACLConfig{ @@ -391,8 +392,8 @@ func TestAccTemplateResource(t *testing.T) { cfg2.Versions = slices.Clone(cfg2.Versions) cfg2.Versions[0].TerraformVariables = []testAccTemplateKeyValueConfig{ { - Key: PtrTo("name"), - Value: PtrTo("world2"), + Key: ptr.Ref("name"), + Value: ptr.Ref("world2"), }, } @@ -423,7 +424,7 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { t.Skip("Acceptance tests are disabled.") } ctx := context.Background() - client := integration.StartCoder(ctx, t, "template_acc", true) + client := integration.StartCoder(ctx, t, "template_resource_acc", true) firstUser, err := client.User(ctx, codersdk.Me) require.NoError(t, err) @@ -436,29 +437,29 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: PtrTo("example-template"), + Name: ptr.Ref("example-template"), Versions: []testAccTemplateVersionConfig{ { // Auto-generated version name - Directory: PtrTo("../../integration/template-test/example-template"), - Active: PtrTo(true), + Directory: ptr.Ref("../../integration/template-test/example-template"), + Active: ptr.Ref(true), }, }, ACL: testAccTemplateACLConfig{ GroupACL: []testAccTemplateKeyValueConfig{ { - Key: PtrTo(firstUser.OrganizationIDs[0].String()), - Value: PtrTo("use"), + Key: ptr.Ref(firstUser.OrganizationIDs[0].String()), + Value: ptr.Ref("use"), }, { - Key: PtrTo(group.ID.String()), - Value: PtrTo("admin"), + Key: ptr.Ref(group.ID.String()), + Value: ptr.Ref("admin"), }, }, UserACL: []testAccTemplateKeyValueConfig{ { - Key: PtrTo(firstUser.ID.String()), - Value: PtrTo("admin"), + Key: ptr.Ref(firstUser.ID.String()), + Value: ptr.Ref("admin"), }, }, }, @@ -466,17 +467,17 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { cfg2 := cfg1 cfg2.ACL.GroupACL = slices.Clone(cfg2.ACL.GroupACL[1:]) - cfg2.MaxPortShareLevel = PtrTo("owner") + cfg2.MaxPortShareLevel = ptr.Ref("owner") cfg3 := cfg2 cfg3.ACL.null = true - cfg3.MaxPortShareLevel = PtrTo("public") + cfg3.MaxPortShareLevel = ptr.Ref("public") cfg4 := cfg3 - cfg4.AllowUserAutostart = PtrTo(false) + cfg4.AllowUserAutostart = ptr.Ref(false) cfg4.AutostopRequirement = testAccAutostopRequirementConfig{ - DaysOfWeek: PtrTo([]string{"monday", "tuesday"}), - Weeks: PtrTo(int64(2)), + DaysOfWeek: ptr.Ref([]string{"monday", "tuesday"}), + Weeks: ptr.Ref(int64(2)), } resource.Test(t, resource.TestCase{ @@ -564,54 +565,54 @@ func TestAccTemplateResourceAGPL(t *testing.T) { t.Skip("Acceptance tests are disabled.") } ctx := context.Background() - client := integration.StartCoder(ctx, t, "template_acc", false) + client := integration.StartCoder(ctx, t, "template_resource_agpl_acc", false) firstUser, err := client.User(ctx, codersdk.Me) require.NoError(t, err) cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: PtrTo("example-template"), + Name: ptr.Ref("example-template"), Versions: []testAccTemplateVersionConfig{ { // Auto-generated version name - Directory: PtrTo("../../integration/template-test/example-template/"), - Active: PtrTo(true), + Directory: ptr.Ref("../../integration/template-test/example-template/"), + Active: ptr.Ref(true), }, }, - AllowUserAutostart: PtrTo(false), + AllowUserAutostart: ptr.Ref(false), } cfg2 := cfg1 cfg2.AllowUserAutostart = nil - cfg2.AutostopRequirement.DaysOfWeek = PtrTo([]string{"monday", "tuesday"}) + cfg2.AutostopRequirement.DaysOfWeek = ptr.Ref([]string{"monday", "tuesday"}) cfg3 := cfg2 cfg3.AutostopRequirement.null = true - cfg3.AutostartRequirement = PtrTo([]string{}) + cfg3.AutostartRequirement = ptr.Ref([]string{}) cfg4 := cfg3 - cfg4.FailureTTL = PtrTo(int64(1)) + cfg4.FailureTTL = ptr.Ref(int64(1)) cfg5 := cfg4 cfg5.FailureTTL = nil cfg5.AutostartRequirement = nil - cfg5.RequireActiveVersion = PtrTo(true) + cfg5.RequireActiveVersion = ptr.Ref(true) cfg6 := cfg5 cfg6.RequireActiveVersion = nil cfg6.ACL = testAccTemplateACLConfig{ GroupACL: []testAccTemplateKeyValueConfig{ { - Key: PtrTo(firstUser.OrganizationIDs[0].String()), - Value: PtrTo("use"), + Key: ptr.Ref(firstUser.OrganizationIDs[0].String()), + Value: ptr.Ref("use"), }, }, } cfg7 := cfg6 cfg7.ACL.null = true - cfg7.MaxPortShareLevel = PtrTo("owner") + cfg7.MaxPortShareLevel = ptr.Ref("owner") for _, cfg := range []testAccTemplateResourceConfig{cfg1, cfg2, cfg3, cfg4} { resource.Test(t, resource.TestCase{ @@ -688,7 +689,7 @@ resource "coderd_template" "sample" { }` ctx := context.Background() - client := integration.StartCoder(ctx, t, "template_acc", false) + client := integration.StartCoder(ctx, t, "template_resource_variables_acc", false) cfg = fmt.Sprintf(cfg, client.URL.String(), client.SessionToken()) diff --git a/internal/provider/user_data_source_test.go b/internal/provider/user_data_source_test.go index 2d69d13..6a9309f 100644 --- a/internal/provider/user_data_source_test.go +++ b/internal/provider/user_data_source_test.go @@ -8,6 +8,7 @@ import ( "testing" "text/template" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -53,9 +54,10 @@ func TestAccUserDataSource(t *testing.T) { cfg := testAccUserDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Username: PtrTo(user.Username), + Username: ptr.Ref(user.Username), } resource.Test(t, resource.TestCase{ + IsUnitTest: true, PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, Steps: []resource.TestStep{ @@ -71,9 +73,10 @@ func TestAccUserDataSource(t *testing.T) { cfg := testAccUserDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - ID: PtrTo(user.ID.String()), + ID: ptr.Ref(user.ID.String()), } resource.Test(t, resource.TestCase{ + IsUnitTest: true, PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, // User by ID @@ -91,6 +94,7 @@ func TestAccUserDataSource(t *testing.T) { Token: client.SessionToken(), } resource.Test(t, resource.TestCase{ + IsUnitTest: true, PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, // Neither ID nor Username @@ -103,6 +107,24 @@ func TestAccUserDataSource(t *testing.T) { }) }) + t.Run("InvalidUUIDError", func(t *testing.T) { + cfg := testAccUserDataSourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + ID: ptr.Ref("invalid-uuid"), + } + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg.String(t), + ExpectError: regexp.MustCompile(`The provided value cannot be parsed as a UUID`), + }, + }, + }) + }) } type testAccUserDataSourceConfig struct { diff --git a/internal/provider/user_resource.go b/internal/provider/user_resource.go index a560df5..3fa570e 100644 --- a/internal/provider/user_resource.go +++ b/internal/provider/user_resource.go @@ -22,6 +22,7 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" ) // Ensure provider defined types fully satisfy framework interfaces. @@ -71,8 +72,7 @@ func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, r MarkdownDescription: "Username of the user.", Required: true, Validators: []validator.String{ - stringvalidator.LengthBetween(1, 32), - stringvalidator.RegexMatches(nameValidRegex, "Username must be alphanumeric with hyphens."), + codersdkvalidator.Name(), }, }, "name": schema.StringAttribute{ @@ -80,7 +80,7 @@ func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, r Computed: true, Optional: true, Validators: []validator.String{ - stringvalidator.LengthBetween(1, 128), + codersdkvalidator.UserRealName(), }, }, "email": schema.StringAttribute{ diff --git a/internal/provider/user_resource_test.go b/internal/provider/user_resource_test.go index a7bb470..b9c6bb7 100644 --- a/internal/provider/user_resource_test.go +++ b/internal/provider/user_resource_test.go @@ -7,6 +7,7 @@ import ( "testing" "text/template" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/stretchr/testify/require" @@ -22,22 +23,22 @@ func TestAccUserResource(t *testing.T) { cfg1 := testAccUserResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Username: PtrTo("example"), - Name: PtrTo("Example User"), - Email: PtrTo("example@coder.com"), - Roles: PtrTo([]string{"owner", "auditor"}), - LoginType: PtrTo("password"), - Password: PtrTo("SomeSecurePassword!"), + Username: ptr.Ref("example"), + Name: ptr.Ref("Example User"), + Email: ptr.Ref("example@coder.com"), + Roles: ptr.Ref([]string{"owner", "auditor"}), + LoginType: ptr.Ref("password"), + Password: ptr.Ref("SomeSecurePassword!"), } cfg2 := cfg1 - cfg2.Username = PtrTo("exampleNew") + cfg2.Username = ptr.Ref("exampleNew") cfg3 := cfg2 - cfg3.Name = PtrTo("Example New") + cfg3.Name = ptr.Ref("Example New") cfg4 := cfg3 - cfg4.LoginType = PtrTo("github") + cfg4.LoginType = ptr.Ref("github") cfg4.Password = nil resource.Test(t, resource.TestCase{ diff --git a/internal/provider/util.go b/internal/provider/util.go index 12be3f3..169286f 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -8,22 +8,11 @@ import ( "net/http" "os" "path/filepath" - "regexp" "github.com/coder/coder/v2/codersdk" "github.com/google/uuid" ) -var ( - nameValidRegex = regexp.MustCompile("^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$") - templateVersionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]+(?:[_.-]{1}[a-zA-Z0-9]+)*$`) - displayNameRegex = regexp.MustCompile(`^[^\s](.*[^\s])?$`) -) - -func PtrTo[T any](v T) *T { - return &v -} - func PrintOrNull(v any) string { if v == nil { return "null" @@ -96,11 +85,11 @@ func computeDirectoryHash(directory string) (string, error) { // memberDiff returns the members to add and remove from the group, given the current members and the planned members. // plannedMembers is deliberately our custom type, as Terraform cannot automatically produce `[]uuid.UUID` from a set. -func memberDiff(curMembers []uuid.UUID, plannedMembers []UUID) (add, remove []string) { - curSet := make(map[uuid.UUID]struct{}, len(curMembers)) +func memberDiff(currentMembers []uuid.UUID, plannedMembers []UUID) (add, remove []string) { + curSet := make(map[uuid.UUID]struct{}, len(currentMembers)) planSet := make(map[uuid.UUID]struct{}, len(plannedMembers)) - for _, userID := range curMembers { + for _, userID := range currentMembers { curSet[userID] = struct{}{} } for _, plannedUserID := range plannedMembers { @@ -109,7 +98,7 @@ func memberDiff(curMembers []uuid.UUID, plannedMembers []UUID) (add, remove []st add = append(add, plannedUserID.ValueString()) } } - for _, curUserID := range curMembers { + for _, curUserID := range currentMembers { if _, exists := planSet[curUserID]; !exists { remove = append(remove, curUserID.String()) } diff --git a/internal/provider/uuid.go b/internal/provider/uuid.go index 8cd8912..ac37b04 100644 --- a/internal/provider/uuid.go +++ b/internal/provider/uuid.go @@ -48,16 +48,16 @@ func (t uuidType) ValueFromString(ctx context.Context, in basetypes.StringValue) return NewUUIDUnknown(), diags } - value, err := uuid.Parse(in.ValueString()) - if err != nil { - // The framework doesn't want us to return validation errors here - // for some reason. They get caught by `ValidateAttribute` instead, - // and this function isn't called directly by our provider - UUIDValue - // takes a valid UUID instead of a string. - return NewUUIDUnknown(), diags - } - - return UUIDValue(value), diags + // This function deliberately does not handle invalid UUIDs. + // Instead, `ValidateAttribute` will be called + // on the stored string during `validate` `plan` and `apply`, + // which will also create an error diagnostic. + // For that reason, storing the zero UUID is fine. + v, _ := uuid.Parse(in.ValueString()) + return UUID{ + StringValue: in, + value: v, + }, diags } // ValueFromTerraform implements basetypes.StringTypable. diff --git a/internal/provider/uuid_internal_test.go b/internal/provider/uuid_internal_test.go index 6283bb9..697d9c3 100644 --- a/internal/provider/uuid_internal_test.go +++ b/internal/provider/uuid_internal_test.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/stretchr/testify/require" ) @@ -37,9 +38,12 @@ func TestUUIDTypeValueFromTerraform(t *testing.T) { expected: UUIDValue(ValidUUID), }, { - name: "invalid UUID", - input: tftypes.NewValue(tftypes.String, "invalid"), - expected: NewUUIDUnknown(), + name: "invalid UUID", + input: tftypes.NewValue(tftypes.String, "invalid"), + expected: UUID{ + StringValue: basetypes.NewStringValue("invalid"), + value: uuid.Nil, + }, }, } diff --git a/internal/provider/workspace_proxy_resource_test.go b/internal/provider/workspace_proxy_resource_test.go index a2447ea..88d7013 100644 --- a/internal/provider/workspace_proxy_resource_test.go +++ b/internal/provider/workspace_proxy_resource_test.go @@ -8,6 +8,7 @@ import ( "testing" "text/template" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/stretchr/testify/require" @@ -23,14 +24,14 @@ func TestAccWorkspaceProxyResource(t *testing.T) { cfg1 := testAccWorkspaceProxyResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: PtrTo("example"), - DisplayName: PtrTo("Example WS Proxy"), - Icon: PtrTo("/emojis/1f407.png"), + Name: ptr.Ref("example"), + DisplayName: ptr.Ref("Example WS Proxy"), + Icon: ptr.Ref("/emojis/1f407.png"), } cfg2 := cfg1 - cfg2.Name = PtrTo("example-new") - cfg2.DisplayName = PtrTo("Example WS Proxy New") + cfg2.Name = ptr.Ref("example-new") + cfg2.DisplayName = ptr.Ref("Example WS Proxy New") resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -64,9 +65,9 @@ func TestAccWorkspaceProxyResourceAGPL(t *testing.T) { cfg1 := testAccWorkspaceProxyResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: PtrTo("example"), - DisplayName: PtrTo("Example WS Proxy"), - Icon: PtrTo("/emojis/1f407.png"), + Name: ptr.Ref("example"), + DisplayName: ptr.Ref("Example WS Proxy"), + Icon: ptr.Ref("/emojis/1f407.png"), } resource.Test(t, resource.TestCase{