diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 00000000..1a56c6f8
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,9 @@
+{
+  "name": "terraform-provider-coder",
+  "image": "mcr.microsoft.com/devcontainers/go:1.22",
+  "features": {
+    "ghcr.io/devcontainers/features/terraform:1": {
+      "installTerraformDocs": true
+    }
+  }
+}
diff --git a/.github/workflows/cla.yaml b/.github/workflows/cla.yaml
index 28b5e829..45f72487 100644
--- a/.github/workflows/cla.yaml
+++ b/.github/workflows/cla.yaml
@@ -11,7 +11,7 @@ jobs:
     steps:
       - name: "CLA Assistant"
         if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
-        uses: contributor-assistant/github-action@v2.3.1
+        uses: contributor-assistant/github-action@v2.6.1
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           # the below token should have repo scope and must be manually added by you in the repository's secret
@@ -23,4 +23,4 @@ jobs:
           path-to-document: 'https://github.com/coder/cla/blob/main/README.md'
           # branch should not be protected
           branch: 'main'
-          allowlist: dependabot*
+          allowlist: 'dependabot*,blink-so*'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 530c3557..a6b823b9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,33 +15,86 @@ on:
     tags:
       - "v*"
 jobs:
+  test:
+    name: Run Integration Tests
+    runs-on: ubuntu-latest
+    steps:
+      - name: Set up Go
+        uses: actions/setup-go@v5
+        with:
+          go-version: "1.22"
+        id: go
+
+      - name: Check out code into the Go module directory
+        uses: actions/checkout@v5
+
+      - name: Get dependencies
+        run: |
+          go mod download
+
+      - name: Build
+        env:
+          CGO_ENABLED: "0"
+        run: |
+          go build -v .
+
+      - name: Check Versions
+        id: checkversions
+        run: |
+          source <(go run ./scripts/coderversion)
+          echo "CODER_MAINLINE_VERSION=${CODER_MAINLINE_VERSION}" >> "${GITHUB_OUTPUT}"
+          echo "CODER_STABLE_VERSION=${CODER_STABLE_VERSION}" >> "${GITHUB_OUTPUT}"
+          echo "CODER_OLDSTABLE_VERSION=${CODER_OLDSTABLE_VERSION}" >> "${GITHUB_OUTPUT}"
+
+      - name: Run integration test (mainline)
+        env:
+          CODER_IMAGE: "ghcr.io/coder/coder"
+          CODER_VERSION: ${{ steps.checkversions.outputs.CODER_MAINLINE_VERSION }}
+        run: |
+          go test -v ./integration
+
+      - name: Run integration test (stable)
+        env:
+          CODER_IMAGE: "ghcr.io/coder/coder"
+          CODER_VERSION: ${{ steps.checkversions.outputs.CODER_STABLE_VERSION }}
+        run: |
+          go test -v ./integration
+
+      - name: Run integration test (oldstable)
+        env:
+          CODER_IMAGE: "ghcr.io/coder/coder"
+          CODER_VERSION: ${{ steps.checkversions.outputs.CODER_OLDSTABLE_VERSION }}
+        run: |
+          go test -v ./integration
+
   goreleaser:
     runs-on: ubuntu-latest
+    needs: test
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
       - name: Unshallow
         run: git fetch --prune --unshallow
 
       - name: Set up Go
-        uses: actions/setup-go@v4
+        uses: actions/setup-go@v5
         with:
-          go-version: 1.20.6
+          go-version: '1.22'
 
       - name: Import GPG key
         id: import_gpg
-        uses: crazy-max/ghaction-import-gpg@v6.0.0
+        uses: crazy-max/ghaction-import-gpg@v6.3.0
         with:
           # These secrets will need to be configured for the repository:
           gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
           passphrase: ${{ secrets.PASSPHRASE }}
 
       - name: Run GoReleaser
-        uses: goreleaser/goreleaser-action@v5.0.0
+        uses: goreleaser/goreleaser-action@v6.3.0
         with:
-          version: latest
-          args: release --rm-dist
+          version: '~> v2'
+          args: release --clean
         env:
           GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
           # GitHub sets this automatically
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 905b715d..8b27f78d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -20,22 +20,64 @@ jobs:
     timeout-minutes: 5
     steps:
       - name: Set up Go
-        uses: actions/setup-go@v4
+        uses: actions/setup-go@v5
         with:
-          go-version: "1.20"
+          go-version: "1.22"
         id: go
 
       - name: Check out code into the Go module directory
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
       - name: Get dependencies
         run: |
           go mod download
 
       - name: Build
+        env:
+          CGO_ENABLED: "0"
         run: |
           go build -v .
 
+      - name: Check Versions
+        id: checkversions
+        run: |
+          source <(go run ./scripts/coderversion)
+          echo "CODER_MAINLINE_VERSION=${CODER_MAINLINE_VERSION}" >> "${GITHUB_OUTPUT}"
+          echo "CODER_STABLE_VERSION=${CODER_STABLE_VERSION}" >> "${GITHUB_OUTPUT}"
+          echo "CODER_OLDSTABLE_VERSION=${CODER_OLDSTABLE_VERSION}" >> "${GITHUB_OUTPUT}"
+
+      - name: Run integration test (devel)
+        timeout-minutes: 10
+        env:
+          CODER_IMAGE: "ghcr.io/coder/coder-preview"
+          CODER_VERSION: "latest"
+        run: |
+          go test -v ./integration
+
+      - name: Run integration test (mainline)
+        timeout-minutes: 10
+        env:
+          CODER_IMAGE: "ghcr.io/coder/coder"
+          CODER_VERSION: ${{ steps.checkversions.outputs.CODER_MAINLINE_VERSION }}
+        run: |
+          go test -v ./integration
+
+      - name: Run integration test (stable)
+        timeout-minutes: 10
+        env:
+          CODER_IMAGE: "ghcr.io/coder/coder"
+          CODER_VERSION: ${{ steps.checkversions.outputs.CODER_STABLE_VERSION }}
+        run: |
+          go test -v ./integration
+
+      - name: Run integration test (oldstable)
+        timeout-minutes: 10
+        env:
+          CODER_IMAGE: "ghcr.io/coder/coder"
+          CODER_VERSION: ${{ steps.checkversions.outputs.CODER_OLDSTABLE_VERSION }}
+        run: |
+          go test -v ./integration
+
   # run acceptance tests in a matrix with Terraform core versions
   test:
     name: Matrix Test
@@ -46,26 +88,23 @@ jobs:
       fail-fast: false
       matrix:
         terraform:
-          - "1.0.*"
-          - "1.1.*"
-          - "1.2.*"
-          - "1.3.*"
-          - "1.4.*"
-          - "1.5.*"
+          - "1.9.*"
+          - "1.10.*"
+          - "1.11.*"
     steps:
       - name: Set up Go
-        uses: actions/setup-go@v4
+        uses: actions/setup-go@v5
         with:
-          go-version: "1.20"
+          go-version: "1.22"
         id: go
 
-      - uses: hashicorp/setup-terraform@v2
+      - uses: hashicorp/setup-terraform@v3
         with:
           terraform_version: ${{ matrix.terraform }}
           terraform_wrapper: false
 
       - name: Check out code into the Go module directory
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
       - name: Get dependencies
         run: |
@@ -84,18 +123,18 @@ jobs:
     timeout-minutes: 5
     steps:
       - name: Set up Go
-        uses: actions/setup-go@v4
+        uses: actions/setup-go@v5
         with:
-          go-version: "1.20"
+          go-version: "1.22"
         id: go
 
-      - uses: hashicorp/setup-terraform@v2
+      - uses: hashicorp/setup-terraform@v3
         with:
-          terraform_version: "1.3.*"
+          terraform_version: "latest"
           terraform_wrapper: false
 
       - name: Check out code into the Go module directory
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
       - name: Get dependencies
         run: |
diff --git a/.gitignore b/.gitignore
index e5a80c26..ff9f6a53 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,4 +32,10 @@ website/vendor
 !command/test-fixtures/**/.terraform/
 
 # Keep windows files with windows line endings
-*.winfile eol=crlf
\ No newline at end of file
+*.winfile eol=crlf
+
+# Binary
+terraform-provider-coder
+
+# direnv
+.direnv
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 0e43eeec..8b2c0012 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -1,5 +1,6 @@
 # Visit https://goreleaser.com for documentation on how to customize this
 # behavior.
+version: 2
 before:
   hooks:
     # this is just an example and not a requirement for provider building/publishing
@@ -30,7 +31,7 @@ builds:
       goarch: '386'
   binary: '{{ .ProjectName }}_v{{ .Version }}'
 archives:
-- format: zip
+- formats: [ zip ]
   name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}'
 checksum:
   extra_files:
@@ -54,7 +55,10 @@ release:
   extra_files:
     - glob: 'terraform-registry-manifest.json'
       name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json'
-  # If you want to manually examine the release before its live, uncomment this line:
-  # draft: true
 changelog:
-  skip: true
\ No newline at end of file
+  # see https://goreleaser.com/customization/changelog/
+  use: github-native
+  sort: asc
+  abbrev: 0
+git:
+  tag_sort: -version:creatordate # if two tags reference the same commit, pick the latest one; see https://github.com/goreleaser/goreleaser/issues/4209
\ No newline at end of file
diff --git a/Makefile b/Makefile
index d694a040..38a9a69d 100644
--- a/Makefile
+++ b/Makefile
@@ -5,8 +5,21 @@ fmt:
 
 gen:
 	go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@latest
+	go run ./scripts/docsgen/...
+
+build: terraform-provider-coder
+
+# Builds the provider. Note that as coder/coder is based on
+# alpine, we need to disable cgo.
+terraform-provider-coder: provider/*.go main.go
+	CGO_ENABLED=0 go build .
+
+# Run integration tests
+.PHONY: test-integration
+test-integration: terraform-provider-coder
+	go test -v ./integration
 
 # Run acceptance tests
 .PHONY: testacc
 testacc:
-	TF_ACC=1 go test ./... -v $(TESTARGS) -timeout 120m
\ No newline at end of file
+	TF_ACC=1 go test ./... -v $(TESTARGS) -timeout 120m
diff --git a/README.md b/README.md
index cbec7099..4ae9be15 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,103 @@
 # terraform-provider-coder
 
-See [Coder](https://github.com/coder/coder).
+Terraform provider for [Coder](https://github.com/coder/coder).
+
+### Developing
+
+#### Prerequisites
+
+- [Go](https://golang.org/doc/install)
+- [Terraform](https://learn.hashicorp.com/tutorials/terraform/install-cli)
+
+We recommend using [`nix`](https://nixos.org/download.html) to manage your development environment. If you have `nix` installed, you can run `nix develop` to enter a shell with all the necessary dependencies.
+
+Alternatively, you can install the dependencies manually.
+
+#### Building
+
+Follow the instructions outlined in the [Terraform documentation](https://developer.hashicorp.com/terraform/cli/config/config-file#development-overrides-for-provider-developers)
+to setup your local Terraform to use your local version rather than the registry version.
+
+1. Create a file named `.terraformrc` in your `$HOME` directory
+2. Add the following content:
+
+   ```hcl
+    provider_installation {
+        # Override the coder/coder provider to use your local version
+        dev_overrides {
+          "coder/coder" = "/path/to/terraform-provider-coder"
+        }
+
+        # For all other providers, install them directly from their origin provider
+        # registries as normal. If you omit this, Terraform will _only_ use
+        # the dev_overrides block, and so no other providers will be available.
+        direct {}
+    }
+   ```
+
+3. (optional, but recommended) Validate your configuration:
+   1. Create a new `main.tf` file and include:
+   ```hcl
+   terraform {
+       required_providers {
+           coder = {
+               source = "coder/coder"
+           }
+       }
+   }
+   ```
+   2. Run `terraform init` and observe a warning like `Warning: Provider development overrides are in effect`
+4. Run `make build` to build the provider binary, which Terraform will try locate and execute
+5. All local Terraform runs will now use your local provider!
+6. **NOTE**: We vendor this provider into `github.com/coder/coder`, so if you're testing with a local clone, make sure to run the following in your local clone of `coder`:
+   ```console
+   go mod edit -replace github.com/coder/terraform-provider-coder/v2=/path/to/terraform-provider-coder
+   go mod tidy
+   ```
+   ⚠️ Be sure to include `/v2` in the module path as it needs to match the version declared in the provider’s `go.mod`.
+
+
+#### Terraform Acceptance Tests
+
+To run Terraform acceptance tests, run `make testacc`. This will test the provider against the locally installed version of Terraform.
+
+> [!Note]
+> Our [CI workflow](./github/workflows/test.yml) runs a test matrix against multiple Terraform versions.
+
+#### Integration Tests
+
+The tests under the `./integration` directory perform the following steps:
+
+- Build the local version of the provider,
+- Run an in-memory Coder instance with a specified version,
+- Validate the behaviour of the local provider against that specific version of Coder.
+
+To run these integration tests locally:
+
+1. Pull the version of the Coder image you wish to test:
+
+   ```console
+     docker pull ghcr.io/coder/coder-preview:main-x.y.z-devel-abcd1234
+   ```
+
+1. Run `CODER_IMAGE=ghcr.io/coder/coder-preview CODER_VERSION=main-x.y.z-devel-abcd1234 make test-integration`.
+
+> [!Note]
+> You can specify `CODER_IMAGE` if the Coder image you wish to test is hosted somewhere other than `ghcr.io/coder/coder`.
+> For example, `CODER_IMAGE=example.com/repo/coder CODER_VERSION=foobar make test-integration`.
+
+### How to create a new release
+> [!Warning]
+> Before creating a new release, make sure you have pulled the latest commit from the main branch i.e. `git pull origin main`
+
+1. Create a new tag with a version number (following semantic versioning):
+   ```console
+   git tag -a v2.1.2 -m "v2.1.2"
+   ```
+
+2. Push the tag to the remote repository:
+   ```console
+   git push origin tag v2.1.2
+   ```
+
+A GitHub Actions workflow named "Release" will automatically trigger, run integration tests, and publish the new release.
diff --git a/docs/data-sources/external_auth.md b/docs/data-sources/external_auth.md
index b875b874..d1e6d649 100644
--- a/docs/data-sources/external_auth.md
+++ b/docs/data-sources/external_auth.md
@@ -3,14 +3,28 @@
 page_title: "coder_external_auth Data Source - terraform-provider-coder"
 subcategory: ""
 description: |-
-  Use this data source to require users to authenticate with an external service prior to workspace creation. This can be used to pre-authenticate external services in a workspace. (e.g. gcloud, gh, docker, etc)
+  Use this data source to require users to authenticate with an external service prior to workspace creation. This can be used to pre-authenticate external services https://coder.com/docs/admin/external-auth in a workspace. (e.g. Google Cloud, Github, Docker, etc.)
 ---
 
 # coder_external_auth (Data Source)
 
-Use this data source to require users to authenticate with an external service prior to workspace creation. This can be used to pre-authenticate external services in a workspace. (e.g. gcloud, gh, docker, etc)
+Use this data source to require users to authenticate with an external service prior to workspace creation. This can be used to [pre-authenticate external services](https://coder.com/docs/admin/external-auth) in a workspace. (e.g. Google Cloud, Github, Docker, etc.)
 
+## Example Usage
 
+```terraform
+provider "coder" {}
+
+
+data "coder_external_auth" "github" {
+  id = "github"
+}
+
+data "coder_external_auth" "azure-identity" {
+  id       = "azure-identiy"
+  optional = true
+}
+```
 
 <!-- schema generated by tfplugindocs -->
 ## Schema
@@ -19,6 +33,10 @@ Use this data source to require users to authenticate with an external service p
 
 - `id` (String) The ID of a configured external auth provider set up in your Coder deployment.
 
+### Optional
+
+- `optional` (Boolean) Authenticating with the external auth provider is not required, and can be skipped by users when creating or updating workspaces
+
 ### Read-Only
 
-- `access_token` (String) The access token returned by the external auth provider. This can be used to pre-authenticate command-line tools.
+- `access_token` (String, Sensitive) The access token returned by the external auth provider. This can be used to pre-authenticate command-line tools.
diff --git a/docs/data-sources/git_auth.md b/docs/data-sources/git_auth.md
deleted file mode 100644
index 53e01981..00000000
--- a/docs/data-sources/git_auth.md
+++ /dev/null
@@ -1,48 +0,0 @@
----
-# generated by https://github.com/hashicorp/terraform-plugin-docs
-page_title: "coder_git_auth Data Source - terraform-provider-coder"
-subcategory: ""
-description: |-
-  Use this data source to require users to authenticate with a Git provider prior to workspace creation. This can be used to perform an authenticated git clone in startup scripts.
----
-
-# coder_git_auth (Data Source)
-
-Use this data source to require users to authenticate with a Git provider prior to workspace creation. This can be used to perform an authenticated `git clone` in startup scripts.
-
-## Example Usage
-
-```terraform
-provider "coder" {
-}
-
-data "coder_git_auth" "github" {
-  # Matches the ID of the git auth provider in Coder.
-  id = "github"
-}
-
-resource "coder_agent" "dev" {
-  os   = "linux"
-  arch = "amd64"
-  dir  = "~/coder"
-  env = {
-    GITHUB_TOKEN : data.coder_git_auth.github.access_token
-  }
-  startup_script = <<EOF
-if [ ! -d ~/coder ]; then
-    git clone https://github.com/coder/coder
-fi
-EOF
-}
-```
-
-<!-- schema generated by tfplugindocs -->
-## Schema
-
-### Required
-
-- `id` (String) The identifier of a configured git auth provider set up in your Coder deployment.
-
-### Read-Only
-
-- `access_token` (String) The access token returned by the git authentication provider. This can be used to pre-authenticate command-line tools.
diff --git a/docs/data-sources/parameter.md b/docs/data-sources/parameter.md
index fc8100e7..c1001835 100644
--- a/docs/data-sources/parameter.md
+++ b/docs/data-sources/parameter.md
@@ -10,7 +10,127 @@ description: |-
 
 Use this data source to configure editable options for workspaces.
 
-
+## Example Usage
+
+```terraform
+provider "coder" {}
+
+data "coder_parameter" "example" {
+  name        = "Region"
+  description = "Specify a region to place your workspace."
+  mutable     = false
+  type        = "string"
+  default     = "us-central1-a"
+  option {
+    value = "us-central1-a"
+    name  = "US Central"
+    icon  = "/icons/1f1fa-1f1f8.png"
+  }
+  option {
+    value = "asia-southeast1-a"
+    name  = "Singapore"
+    icon  = "/icons/1f1f8-1f1ec.png"
+  }
+}
+
+data "coder_parameter" "ami" {
+  name        = "Machine Image"
+  description = <<-EOT
+    # Provide the machine image
+    See the [registry](https://container.registry.blah/namespace) for options.
+    EOT
+  option {
+    value = "ami-xxxxxxxx"
+    name  = "Ubuntu"
+    icon  = "/icon/ubuntu.svg"
+  }
+}
+
+data "coder_parameter" "is_public_instance" {
+  name    = "Is public instance?"
+  type    = "bool"
+  icon    = "/icon/docker.svg"
+  default = false
+}
+
+data "coder_parameter" "cores" {
+  name    = "CPU Cores"
+  type    = "number"
+  icon    = "/icon/cpu.svg"
+  default = 3
+  order   = 10
+}
+
+data "coder_parameter" "disk_size" {
+  name    = "Disk Size"
+  type    = "number"
+  default = "5"
+  order   = 8
+  validation {
+    # This can apply to number.
+    min       = 0
+    max       = 10
+    monotonic = "increasing"
+  }
+}
+
+data "coder_parameter" "cat_lives" {
+  name    = "Cat Lives"
+  type    = "number"
+  default = "9"
+  validation {
+    # This can apply to number.
+    min       = 0
+    max       = 10
+    monotonic = "decreasing"
+  }
+}
+
+data "coder_parameter" "fairy_tale" {
+  name      = "Fairy Tale"
+  type      = "string"
+  mutable   = true
+  default   = "Hansel and Gretel"
+  ephemeral = true
+}
+
+data "coder_parameter" "users" {
+  name         = "system_users"
+  display_name = "System users"
+  type         = "list(string)"
+  default      = jsonencode(["root", "user1", "user2"])
+}
+
+data "coder_parameter" "home_volume_size" {
+  name        = "Home Volume Size"
+  description = <<-EOF
+  How large should your home volume be?
+  EOF
+  type        = "number"
+  default     = 30
+  mutable     = true
+  order       = 3
+
+  option {
+    name  = "30GB"
+    value = 30
+  }
+
+  option {
+    name  = "60GB"
+    value = 60
+  }
+
+  option {
+    name  = "100GB"
+    value = 100
+  }
+
+  validation {
+    monotonic = "increasing"
+  }
+}
+```
 
 <!-- schema generated by tfplugindocs -->
 ## Schema
@@ -25,11 +145,13 @@ Use this data source to configure editable options for workspaces.
 - `description` (String) Describe what this parameter does.
 - `display_name` (String) The displayed name of the parameter as it will appear in the interface.
 - `ephemeral` (Boolean) The value of an ephemeral parameter will not be preserved between consecutive workspace builds.
-- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a built-in icon with `data.coder_workspace.me.access_url + "/icon/<path>"`.
+- `form_type` (String) The type of this parameter. Must be one of: `"radio"`, `"slider"`, `"input"`, `"dropdown"`, `"checkbox"`, `"switch"`, `"multi-select"`, `"tag-select"`, `"textarea"`, `"error"`.
+- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/<path>"`.
 - `mutable` (Boolean) Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!
-- `option` (Block List, Max: 64) Each "option" block defines a value for a user to select from. (see [below for nested schema](#nestedblock--option))
+- `option` (Block List) Each `option` block defines a value for a user to select from. (see [below for nested schema](#nestedblock--option))
 - `order` (Number) The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order).
-- `type` (String) The type of this parameter. Must be one of: "number", "string", "bool", or "list(string)".
+- `styling` (String) JSON encoded string containing the metadata for controlling the appearance of this parameter in the UI. This option is purely cosmetic and does not affect the function of the parameter in terraform. See [styling options documentation](https://coder.com/docs/admin/templates/extending-templates/dynamic-parameters#available-styling-options) for available styling attributes.
+- `type` (String) The type of this parameter. Must be one of: `"string"`, `"number"`, `"bool"`, `"list(string)"`.
 - `validation` (Block List, Max: 1) Validate the input of a parameter. (see [below for nested schema](#nestedblock--validation))
 
 ### Read-Only
@@ -49,7 +171,7 @@ Required:
 Optional:
 
 - `description` (String) Describe what selecting this value does.
-- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a built-in icon with `data.coder_workspace.me.access_url + "/icon/<path>"`.
+- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/<path>"`.
 
 
 <a id="nestedblock--validation"></a>
@@ -57,13 +179,13 @@ Optional:
 
 Optional:
 
-- `error` (String) An error message to display if the value doesn't match the provided regex.
-- `max` (Number) The maximum of a number parameter.
-- `min` (Number) The minimum of a number parameter.
+- `error` (String) An error message to display if the value breaks the validation rules. The following placeholders are supported: `{max}`, `{min}`, and `{value}`.
+- `max` (Number) The maximum value of a number parameter.
+- `min` (Number) The minimum value of a number parameter.
 - `monotonic` (String) Number monotonicity, either increasing or decreasing.
 - `regex` (String) A regex for the input parameter to match against.
 
 Read-Only:
 
-- `max_disabled` (Boolean) Helper field to check if max is present
-- `min_disabled` (Boolean) Helper field to check if min is present
+- `max_disabled` (Boolean) Helper field to check if `max` is present
+- `min_disabled` (Boolean) Helper field to check if `min` is present
diff --git a/docs/data-sources/provisioner.md b/docs/data-sources/provisioner.md
index 4316aeea..ba930a2a 100644
--- a/docs/data-sources/provisioner.md
+++ b/docs/data-sources/provisioner.md
@@ -10,13 +10,33 @@ description: |-
 
 Use this data source to get information about the Coder provisioner.
 
+## Example Usage
 
+```terraform
+provider "coder" {}
+
+data "coder_provisioner" "dev" {}
+
+data "coder_workspace" "dev" {}
+
+resource "coder_agent" "main" {
+  arch = data.coder_provisioner.dev.arch
+  os   = data.coder_provisioner.dev.os
+  dir  = "/workspace"
+  display_apps {
+    vscode          = true
+    vscode_insiders = false
+    web_terminal    = true
+    ssh_helper      = false
+  }
+}
+```
 
 <!-- schema generated by tfplugindocs -->
 ## Schema
 
 ### Read-Only
 
-- `arch` (String) The architecture of the host. This exposes `runtime.GOARCH` (see https://pkg.go.dev/runtime#pkg-constants).
+- `arch` (String) The architecture of the host. This exposes `runtime.GOARCH` (see [Go constants](https://pkg.go.dev/runtime#pkg-constants)).
 - `id` (String) The ID of this resource.
-- `os` (String) The operating system of the host. This exposes `runtime.GOOS` (see https://pkg.go.dev/runtime#pkg-constants).
+- `os` (String) The operating system of the host. This exposes `runtime.GOOS` (see [Go constants](https://pkg.go.dev/runtime#pkg-constants)).
diff --git a/docs/data-sources/workspace.md b/docs/data-sources/workspace.md
index 4733edd4..4dacdfc3 100644
--- a/docs/data-sources/workspace.md
+++ b/docs/data-sources/workspace.md
@@ -13,11 +13,51 @@ Use this data source to get information for the active workspace build.
 ## Example Usage
 
 ```terraform
-data "coder_workspace" "dev" {
+provider "coder" {}
+
+provider "docker" {}
+
+data "coder_workspace" "me" {}
+
+data "coder_workspace_owner" "me" {}
+
+resource "coder_agent" "dev" {
+  arch = "amd64"
+  os   = "linux"
+  dir  = "/workspace"
 }
 
-resource "kubernetes_pod" "dev" {
-  count = data.coder_workspace.dev.transition == "start" ? 1 : 0
+resource "docker_container" "workspace" {
+  count = data.coder_workspace.me.start_count
+  image = docker_image.main.name
+  # Uses lower() to avoid Docker restriction on container names.
+  name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
+  # Hostname makes the shell more user friendly: coder@my-workspace:~$
+  hostname = data.coder_workspace.me.name
+  # Use the docker gateway if the access URL is 127.0.0.1
+  entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
+  env        = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
+  host {
+    host = "host.docker.internal"
+    ip   = "host-gateway"
+  }
+  # Add labels in Docker to keep track of orphan resources.
+  labels {
+    label = "coder.owner"
+    value = data.coder_workspace_owner.me.name
+  }
+  labels {
+    label = "coder.owner_id"
+    value = data.coder_workspace_owner.me.id
+  }
+  labels {
+    label = "coder.workspace_id"
+    value = data.coder_workspace.me.id
+  }
+  labels {
+    label = "coder.workspace_name"
+    value = data.coder_workspace.me.name
+  }
 }
 ```
 
@@ -29,13 +69,12 @@ resource "kubernetes_pod" "dev" {
 - `access_port` (Number) The access port of the Coder deployment provisioning this workspace.
 - `access_url` (String) The access URL of the Coder deployment provisioning this workspace.
 - `id` (String) UUID of the workspace.
+- `is_prebuild` (Boolean) Similar to `prebuild_count`, but a boolean value instead of a count. This is set to true if the workspace is a currently unassigned prebuild. Once the workspace is assigned, this value will be false.
+- `is_prebuild_claim` (Boolean) Indicates whether a prebuilt workspace has just been claimed and this is the first `apply` after that occurrence.
 - `name` (String) Name of the workspace.
-- `owner` (String) Username of the workspace owner.
-- `owner_email` (String) Email address of the workspace owner.
-- `owner_id` (String) UUID of the workspace owner.
-- `owner_oidc_access_token` (String) A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.
-- `owner_session_token` (String) Session token for authenticating with a Coder deployment. It is regenerated everytime a workspace is started.
-- `start_count` (Number) A computed count based on "transition" state. If "start", count will equal 1.
+- `prebuild_count` (Number) A computed count, equal to 1 if the workspace is a currently unassigned prebuild. Use this to conditionally act on the status of a prebuild. Actions that do not require user identity can be taken when this value is set to 1. Actions that should only be taken once the workspace has been assigned to a user may be taken when this value is set to 0.
+- `start_count` (Number) A computed count based on `transition` state. If `start`, count will equal 1.
 - `template_id` (String) ID of the workspace's template.
 - `template_name` (String) Name of the workspace's template.
-- `transition` (String) Either "start" or "stop". Use this to start/stop resources with "count".
+- `template_version` (String) Version of the workspace's template.
+- `transition` (String) Either `start` or `stop`. Use this to start/stop resources with `count`.
diff --git a/docs/data-sources/workspace_owner.md b/docs/data-sources/workspace_owner.md
new file mode 100644
index 00000000..f16480ef
--- /dev/null
+++ b/docs/data-sources/workspace_owner.md
@@ -0,0 +1,67 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "coder_workspace_owner Data Source - terraform-provider-coder"
+subcategory: ""
+description: |-
+  Use this data source to fetch information about the workspace owner.
+---
+
+# coder_workspace_owner (Data Source)
+
+Use this data source to fetch information about the workspace owner.
+
+## Example Usage
+
+```terraform
+provider "coder" {}
+
+data "coder_workspace_owner" "me" {}
+
+resource "coder_agent" "dev" {
+  arch = "amd64"
+  os   = "linux"
+  dir  = "/workspace"
+  env = {
+    OIDC_TOKEN : data.coder_workspace_owner.me.oidc_access_token,
+  }
+}
+
+# Add git credentials from coder_workspace_owner
+resource "coder_env" "git_author_name" {
+  agent_id = coder_agent.agent_id
+  name     = "GIT_AUTHOR_NAME"
+  value    = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
+}
+
+resource "coder_env" "git_author_email" {
+  agent_id = coder_agent.dev.id
+  name     = "GIT_AUTHOR_EMAIL"
+  value    = data.coder_workspace_owner.me.email
+  count    = data.coder_workspace_owner.me.email != "" ? 1 : 0
+}
+```
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Read-Only
+
+- `email` (String) The email address of the user.
+- `full_name` (String) The full name of the user.
+- `groups` (List of String) The groups of which the user is a member.
+- `id` (String) The UUID of the workspace owner.
+- `login_type` (String) The type of login the user has.
+- `name` (String) The username of the user.
+- `oidc_access_token` (String, Sensitive) A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.
+- `rbac_roles` (List of Object) The RBAC roles of which the user is assigned. (see [below for nested schema](#nestedatt--rbac_roles))
+- `session_token` (String, Sensitive) Session token for authenticating with a Coder deployment. It is regenerated every time a workspace is started.
+- `ssh_private_key` (String, Sensitive) The user's generated SSH private key.
+- `ssh_public_key` (String) The user's generated SSH public key.
+
+<a id="nestedatt--rbac_roles"></a>
+### Nested Schema for `rbac_roles`
+
+Read-Only:
+
+- `name` (String)
+- `org_id` (String)
diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md
new file mode 100644
index 00000000..e7de98e4
--- /dev/null
+++ b/docs/data-sources/workspace_preset.md
@@ -0,0 +1,101 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "coder_workspace_preset Data Source - terraform-provider-coder"
+subcategory: ""
+description: |-
+  Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are defined by the template but not defined by the preset will still be configurable when creating a workspace.
+---
+
+# coder_workspace_preset (Data Source)
+
+Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are defined by the template but not defined by the preset will still be configurable when creating a workspace.
+
+## Example Usage
+
+```terraform
+provider "coder" {}
+
+# presets can be used to predefine common configurations for workspaces
+# Parameters are referenced by their name. Each parameter must be defined in the preset.
+# Values defined by the preset must pass validation for the parameter.
+# See the coder_parameter data source's documentation for examples of how to define
+# parameters like the ones used below.
+data "coder_workspace_preset" "example" {
+  name        = "example"
+  description = "Example description of what this preset does."
+  icon        = "/icon/example.svg"
+  parameters = {
+    (data.coder_parameter.example.name) = "us-central1-a"
+    (data.coder_parameter.ami.name)     = "ami-xxxxxxxx"
+  }
+}
+
+# Example of a default preset that will be pre-selected for users
+data "coder_workspace_preset" "standard" {
+  name        = "Standard"
+  description = "A workspace preset with medium compute in the US West region."
+  icon        = "/icon/standard.svg"
+  default     = true
+  parameters = {
+    (data.coder_parameter.instance_type.name) = "t3.medium"
+    (data.coder_parameter.region.name)        = "us-west-2"
+  }
+}
+```
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- `name` (String) The name of the workspace preset.
+
+### Optional
+
+- `default` (Boolean) Whether this preset should be selected by default when creating a workspace. Only one preset per template can be marked as default.
+- `description` (String) Describe what this preset does.
+- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/<path>"`.
+- `parameters` (Map of String) Workspace parameters that will be set by the workspace preset. For simple templates that only need prebuilds, you may define a preset with zero parameters. Because workspace parameters may change between Coder template versions, preset parameters are allowed to define values for parameters that do not exist in the current template version.
+- `prebuilds` (Block Set, Max: 1) Configuration for prebuilt workspaces associated with this preset. Coder will maintain a pool of standby workspaces based on this configuration. When a user creates a workspace using this preset, they are assigned a prebuilt workspace instead of waiting for a new one to build. See prebuilt workspace documentation [here](https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces.md) (see [below for nested schema](#nestedblock--prebuilds))
+
+### Read-Only
+
+- `id` (String) The preset ID is automatically generated and may change between runs. It is recommended to use the `name` attribute to identify the preset.
+
+<a id="nestedblock--prebuilds"></a>
+### Nested Schema for `prebuilds`
+
+Required:
+
+- `instances` (Number) The number of workspaces to keep in reserve for this preset.
+
+Optional:
+
+- `expiration_policy` (Block Set, Max: 1) Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date. (see [below for nested schema](#nestedblock--prebuilds--expiration_policy))
+- `scheduling` (Block List, Max: 1) Configuration block that defines scheduling behavior for prebuilds. Use this to automatically adjust the number of prebuild instances based on a schedule. (see [below for nested schema](#nestedblock--prebuilds--scheduling))
+
+<a id="nestedblock--prebuilds--expiration_policy"></a>
+### Nested Schema for `prebuilds.expiration_policy`
+
+Required:
+
+- `ttl` (Number) Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.
+
+
+<a id="nestedblock--prebuilds--scheduling"></a>
+### Nested Schema for `prebuilds.scheduling`
+
+Required:
+
+- `schedule` (Block List, Min: 1) One or more schedule blocks that define when to scale the number of prebuild instances. (see [below for nested schema](#nestedblock--prebuilds--scheduling--schedule))
+- `timezone` (String) The timezone to use for the prebuild schedules (e.g., "UTC", "America/New_York"). 
+Timezone must be a valid timezone in the IANA timezone database. 
+See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete list of valid timezone identifiers and https://www.iana.org/time-zones for the official IANA timezone database.
+
+<a id="nestedblock--prebuilds--scheduling--schedule"></a>
+### Nested Schema for `prebuilds.scheduling.schedule`
+
+Required:
+
+- `cron` (String) A cron expression that defines when this schedule should be active. The cron expression must be in the format "* HOUR DOM MONTH DAY-OF-WEEK" where HOUR is 0-23, DOM (day-of-month) is 1-31, MONTH is 1-12, and DAY-OF-WEEK is 0-6 (Sunday-Saturday). The minute field must be "*" to ensure the schedule covers entire hours rather than specific minute intervals.
+- `instances` (Number) The number of prebuild instances to maintain during this schedule period.
diff --git a/docs/data-sources/workspace_tags.md b/docs/data-sources/workspace_tags.md
new file mode 100644
index 00000000..010adfe3
--- /dev/null
+++ b/docs/data-sources/workspace_tags.md
@@ -0,0 +1,77 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "coder_workspace_tags Data Source - terraform-provider-coder"
+subcategory: ""
+description: |-
+  Use this data source to configure workspace tags to select provisioners.
+---
+
+# coder_workspace_tags (Data Source)
+
+Use this data source to configure workspace tags to select provisioners.
+
+## Example Usage
+
+```terraform
+provider "coder" {}
+
+data "coder_parameter" "os_selector" {
+  name         = "os_selector"
+  display_name = "Operating System"
+  mutable      = false
+
+  default = "osx"
+
+  option {
+    icon  = "/icons/linux.png"
+    name  = "Linux"
+    value = "linux"
+  }
+  option {
+    icon  = "/icons/osx.png"
+    name  = "OSX"
+    value = "osx"
+  }
+  option {
+    icon  = "/icons/windows.png"
+    name  = "Windows"
+    value = "windows"
+  }
+}
+
+data "coder_parameter" "feature_cache_enabled" {
+  name         = "feature_cache_enabled"
+  display_name = "Enable cache?"
+  type         = "bool"
+
+  default = false
+}
+
+data "coder_parameter" "feature_debug_enabled" {
+  name         = "feature_debug_enabled"
+  display_name = "Enable debug?"
+  type         = "bool"
+
+  default = true
+}
+
+data "coder_workspace_tags" "custom_workspace_tags" {
+  tags = {
+    "cluster" = "developers"
+    "os"      = data.coder_parameter.os_selector.value
+    "debug"   = "${data.coder_parameter.feature_debug_enabled.value}+12345"
+    "cache"   = data.coder_parameter.feature_cache_enabled.value == "true" ? "nix-with-cache" : "no-cache"
+  }
+}
+```
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Optional
+
+- `tags` (Map of String) Key-value map with workspace tags
+
+### Read-Only
+
+- `id` (String) The ID of this resource.
diff --git a/docs/guides/version-2-upgrade.md b/docs/guides/version-2-upgrade.md
new file mode 100644
index 00000000..9c8063f4
--- /dev/null
+++ b/docs/guides/version-2-upgrade.md
@@ -0,0 +1,103 @@
+---
+page_title: "Version 2 Upgrade Guide"
+---
+
+# Version 2 Upgrade Guide
+
+Version 2.0.0 of the Coder provider for Terraform is a major release that introduces some changes that you will need to consider when upgrading.
+This guide is intended to help with the process, and focuses only on the changes from version 1.X to version 2.0.0.
+
+!> Using Version 2.0.0 of the Coder provider requires Coder Server version [`2.18.0`](https://github.com/coder/coder/releases/tag/v2.18.0) or later.
+
+Upgrade topics:
+
+- [Provider Version Configuration](#provider-version-configuration)
+- [Provider Arguments](#provider-arguments)
+- [Data Source: `coder_git_auth`](#data-source-coder_git_auth)
+- [Data Source: `coder_workspace`](#data-source-coder_workspace)
+
+## Provider Version Configuration
+
+-> Before upgrading to version 2.0.0, please first upgrade to the most recent 1.X version and ensure that your environment successfully runs [`terraform plan`](https://developer.hashicorp.com/terraform/cli/commands/plan) without unexpected changes or deprecation notices.
+
+We highly recommend using [version constraints](https://developer.hashicorp.com/terraform/language/providers/requirements#version-constraints) when configuring Terraform providers.
+
+
+For example, given the previous configuration:
+
+```terraform
+terraform {
+  required_providers {
+    coder = {
+      source  = "coder/coder"
+      version = "~> 1.0.0"
+    }
+  }
+}
+
+provider "coder" {
+  feature_use_managed_variables = true
+}
+```
+
+Update to the latest 2.X version:
+
+```terraform
+terraform {
+  required_providers {
+    coder = {
+      source  = "coder/coder"
+      version = "~> 2.0.0"
+    }
+  }
+}
+
+provider "coder" {}
+```
+
+## Provider Arguments
+
+Version 2.0.0 removes the [`feature_use_managed_variables`](https://registry.terraform.io/providers/coder/coder/1.0.4/docs#feature_use_managed_variables-1) argument from the `provider` block.
+
+
+## Data Source: `coder_git_auth`
+
+If you are using the [`coder_git_auth`](https://registry.terraform.io/providers/coder/coder/1.0.4/docs/data-sources/git_auth) data source, you must replace it with the [`coder_external_auth`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/external_auth) data source. The `coder_external_auth` data source is a more generic data source that can be used to create any external authentication provider which supports OAuth2.
+
+For example, given the previous configuration:
+
+```terraform
+data "coder_git_auth" "example" {
+  id = "example"
+}
+```
+
+Update to the new data source:
+
+```terraform
+data "coder_external_auth" "example" {
+  id = "example"
+}
+```
+
+## Data Source: `coder_workspace`
+
+If you are using the `owner` properties of the [`coder_workspace`](https://registry.terraform.io/providers/coder/coder/1.0.4/docs/data-sources/workspace) data source, you must remove them and use the [`coder_workspace_owner`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner) data source instead. The `coder_workspace_owner` data source provides additional properties of the workspace owner.
+
+Update your Terraform configuration to use the `coder_workspace_owner` data source instead and update the following attributes:
+
+```terraform
+
+data "coder_workspace_owner" "me" {}
+
+```
+
+- Remove `owner_id` attribute. Use [`data.coder_workspace_owner.me.id`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#id) instead.
+- Remove `owner` attribute. Use [`data.coder_workspace_owner.me.name`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#name) instead.
+- Remove `owner_name` attribute. Use [`data.coder_workspace_owner.me.full_name`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#full_name) instead.
+- Remove `owner_email` attribute. Use [`data.coder_workspace_owner.me.email`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#email) instead.
+- Remove `owner_groups` attribute. Use [`data.coder_workspace_owner.me.groups`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#groups) instead.
+- Remove `owner_oidc_access_token` attribute. Use [`data.coder_workspace_owner.me.oidc_access_token`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#oidc_access_token) instead.
+- Remove `owner_session_token` attribute. Use [`data.coder_workspace_owner.me.session_token`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#session_token) instead.
+
+->While we do not anticipate these changes to affect existing resources, we strongly advice reviewing the plan produced by Terraform to ensure no resources are accidentally removed or altered in an undesired way. If you encounter any unexpected behavior, please report it by opening a GitHub [issue](https://github.com/coder/terraform-provider-coder/issues).
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
index 13bf6b44..c30f1477 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -3,11 +3,17 @@
 page_title: "Coder Provider"
 subcategory: "Infrastructure"
 description: |-
-  
+    Terraform provider for managing Coder templates, which are the underlying infrastructure for Coder workspaces.
 ---
 
 # Coder Provider
 
+Terraform provider for managing Coder [templates](https://coder.com/docs/admin/templates), which are the underlying infrastructure for Coder [workspaces](https://coder.com/docs/user-guides/workspace-management).
+
+-> Requires Coder [v2.18.0](https://github.com/coder/coder/releases/tag/v2.18.0) or later.
+
+!> [`coder_git_auth`](https://registry.terraform.io/providers/coder/coder/1.0.4/docs/data-sources/git_auth) and owner related fields of [`coder_workspace`](https://registry.terraform.io/providers/coder/coder/1.0.4/docs/data-sources/workspace) data source have been removed. Follow the [Version 2 Upgrade Guide](https://registry.terraform.io/providers/coder/coder/latest/docs/guides/version-2-upgrade) to update your code.
+
 ## Example
 
 ```terraform
@@ -62,5 +68,4 @@ resource "google_compute_instance" "dev" {
 
 ### Optional
 
-- `feature_use_managed_variables` (Boolean, Deprecated) Feature: use managed Terraform variables. The feature flag is not used anymore as Terraform variables are now exclusively utilized for template-wide variables.
 - `url` (String) The URL to access Coder.
\ No newline at end of file
diff --git a/docs/resources/agent.md b/docs/resources/agent.md
index 7e20ade4..87ab4239 100644
--- a/docs/resources/agent.md
+++ b/docs/resources/agent.md
@@ -17,15 +17,35 @@ data "coder_workspace" "me" {
 }
 
 resource "coder_agent" "dev" {
-  os   = "linux"
-  arch = "amd64"
-  dir  = "/workspace"
+  os            = "linux"
+  arch          = "amd64"
+  dir           = "/workspace"
+  api_key_scope = "all"
   display_apps {
     vscode          = true
     vscode_insiders = false
     web_terminal    = true
     ssh_helper      = false
   }
+
+  metadata {
+    display_name = "CPU Usage"
+    key          = "cpu_usage"
+    script       = "coder stat cpu"
+    interval     = 10
+    timeout      = 1
+    order        = 2
+  }
+  metadata {
+    display_name = "RAM Usage"
+    key          = "ram_usage"
+    script       = "coder stat mem"
+    interval     = 10
+    timeout      = 1
+    order        = 1
+  }
+
+  order = 1
 }
 
 resource "kubernetes_pod" "dev" {
@@ -47,31 +67,31 @@ resource "kubernetes_pod" "dev" {
 
 ### Required
 
-- `arch` (String) The architecture the agent will run on. Must be one of: "amd64", "armv7", "arm64".
-- `os` (String) The operating system the agent will run on. Must be one of: "linux", "darwin", or "windows".
+- `arch` (String) The architecture the agent will run on. Must be one of: `"amd64"`, `"armv7"`, `"arm64"`.
+- `os` (String) The operating system the agent will run on. Must be one of: `"linux"`, `"darwin"`, or `"windows"`.
 
 ### Optional
 
-- `auth` (String) The authentication type the agent will use. Must be one of: "token", "google-instance-identity", "aws-instance-identity", "azure-instance-identity".
+- `api_key_scope` (String) Controls what API routes the agent token can access. Options: `all` (full access) or `no_user_data` (blocks `/external-auth`, `/gitsshkey`, and `/gitauth` routes)
+- `auth` (String) The authentication type the agent will use. Must be one of: `"token"`, `"google-instance-identity"`, `"aws-instance-identity"`, `"azure-instance-identity"`.
 - `connection_timeout` (Number) Time in seconds until the agent is marked as timed out when a connection with the server cannot be established. A value of zero never marks the agent as timed out.
-- `dir` (String) The starting directory when a user creates a shell session. Defaults to $HOME.
+- `dir` (String) The starting directory when a user creates a shell session. Defaults to `"$HOME"`.
 - `display_apps` (Block Set, Max: 1) The list of built-in apps to display in the agent bar. (see [below for nested schema](#nestedblock--display_apps))
 - `env` (Map of String) A mapping of environment variables to set inside the workspace.
-- `login_before_ready` (Boolean, Deprecated) This option defines whether or not the user can (by default) login to the workspace before it is ready. Ready means that e.g. the startup_script is done and has exited. When enabled, users may see an incomplete workspace when logging in.
-- `metadata` (Block List) Each "metadata" block defines a single item consisting of a key/value pair. This feature is in alpha and may break in future releases. (see [below for nested schema](#nestedblock--metadata))
-- `motd_file` (String) The path to a file within the workspace containing a message to display to users when they login via SSH. A typical value would be /etc/motd.
-- `shutdown_script` (String) A script to run before the agent is stopped. The script should exit when it is done to signal that the workspace can be stopped.
-- `shutdown_script_timeout` (Number) Time in seconds until the agent lifecycle status is marked as timed out during shutdown, this happens when the shutdown script has not completed (exited) in the given time.
-- `startup_script` (String) A script to run after the agent starts. The script should exit when it is done to signal that the agent is ready.
-- `startup_script_behavior` (String) This option sets the behavior of the `startup_script`. When set to "blocking", the startup_script must exit before the workspace is ready. When set to "non-blocking", the startup_script may run in the background and the workspace will be ready immediately. Default is "non-blocking", although "blocking" is recommended.
-- `startup_script_timeout` (Number) Time in seconds until the agent lifecycle status is marked as timed out during start, this happens when the startup script has not completed (exited) in the given time.
+- `metadata` (Block List) Each `metadata` block defines a single item consisting of a key/value pair. This feature is in alpha and may break in future releases. (see [below for nested schema](#nestedblock--metadata))
+- `motd_file` (String) The path to a file within the workspace containing a message to display to users when they login via SSH. A typical value would be `"/etc/motd"`.
+- `order` (Number) The order determines the position of agents in the UI presentation. The lowest order is shown first and agents with equal order are sorted by name (ascending order).
+- `resources_monitoring` (Block Set, Max: 1) The resources monitoring configuration for this agent. (see [below for nested schema](#nestedblock--resources_monitoring))
+- `shutdown_script` (String) A script to run before the agent is stopped. The script should exit when it is done to signal that the workspace can be stopped. This option is an alias for defining a `coder_script` resource with `run_on_stop` set to `true`.
+- `startup_script` (String) A script to run after the agent starts. The script should exit when it is done to signal that the agent is ready. This option is an alias for defining a `coder_script` resource with `run_on_start` set to `true`.
+- `startup_script_behavior` (String) This option sets the behavior of the `startup_script`. When set to `"blocking"`, the `startup_script` must exit before the workspace is ready. When set to `"non-blocking"`, the `startup_script` may run in the background and the workspace will be ready immediately. Default is `"non-blocking"`, although `"blocking"` is recommended. This option is an alias for defining a `coder_script` resource with `start_blocks_login` set to `true` (blocking).
 - `troubleshooting_url` (String) A URL to a document with instructions for troubleshooting problems with the agent.
 
 ### Read-Only
 
 - `id` (String) The ID of this resource.
 - `init_script` (String) Run this script on startup of an instance to initialize the agent.
-- `token` (String, Sensitive) Set the environment variable "CODER_AGENT_TOKEN" with this token to authenticate an agent.
+- `token` (String, Sensitive) Set the environment variable `CODER_AGENT_TOKEN` with this token to authenticate an agent.
 
 <a id="nestedblock--display_apps"></a>
 ### Nested Schema for `display_apps`
@@ -97,4 +117,32 @@ Required:
 Optional:
 
 - `display_name` (String) The user-facing name of this value.
+- `order` (Number) The order determines the position of agent metadata in the UI presentation. The lowest order is shown first and metadata with equal order are sorted by key (ascending order).
 - `timeout` (Number) The maximum time the command is allowed to run in seconds.
+
+
+<a id="nestedblock--resources_monitoring"></a>
+### Nested Schema for `resources_monitoring`
+
+Optional:
+
+- `memory` (Block Set, Max: 1) The memory monitoring configuration for this agent. (see [below for nested schema](#nestedblock--resources_monitoring--memory))
+- `volume` (Block Set) The volumes monitoring configuration for this agent. (see [below for nested schema](#nestedblock--resources_monitoring--volume))
+
+<a id="nestedblock--resources_monitoring--memory"></a>
+### Nested Schema for `resources_monitoring.memory`
+
+Required:
+
+- `enabled` (Boolean) Enable memory monitoring for this agent.
+- `threshold` (Number) The memory usage threshold in percentage at which to trigger an alert. Value should be between 0 and 100.
+
+
+<a id="nestedblock--resources_monitoring--volume"></a>
+### Nested Schema for `resources_monitoring.volume`
+
+Required:
+
+- `enabled` (Boolean) Enable volume monitoring for this agent.
+- `path` (String) The path of the volume to monitor.
+- `threshold` (Number) The volume usage threshold in percentage at which to trigger an alert. Value should be between 0 and 100.
diff --git a/docs/resources/agent_instance.md b/docs/resources/agent_instance.md
index fa8574fa..ec855b12 100644
--- a/docs/resources/agent_instance.md
+++ b/docs/resources/agent_instance.md
@@ -3,12 +3,12 @@
 page_title: "coder_agent_instance Resource - terraform-provider-coder"
 subcategory: ""
 description: |-
-  Use this resource to associate an instance ID with an agent for zero-trust authentication. This association is done automatically for "googlecomputeinstance", "awsinstance", "azurermlinuxvirtualmachine", and "azurermwindowsvirtual_machine" resources.
+  Use this resource to associate an instance ID with an agent for zero-trust authentication. This association is done automatically for "google_compute_instance", "aws_instance", "azurerm_linux_virtual_machine", and "azurerm_windows_virtual_machine" resources.
 ---
 
 # coder_agent_instance (Resource)
 
-Use this resource to associate an instance ID with an agent for zero-trust authentication. This association is done automatically for "google_compute_instance", "aws_instance", "azurerm_linux_virtual_machine", and "azurerm_windows_virtual_machine" resources.
+Use this resource to associate an instance ID with an agent for zero-trust authentication. This association is done automatically for `"google_compute_instance"`, `"aws_instance"`, `"azurerm_linux_virtual_machine"`, and `"azurerm_windows_virtual_machine"` resources.
 
 ## Example Usage
 
@@ -34,7 +34,7 @@ resource "coder_agent_instance" "dev" {
 
 ### Required
 
-- `agent_id` (String) The "id" property of a "coder_agent" resource to associate with.
+- `agent_id` (String) The `id` property of a `coder_agent` resource to associate with.
 - `instance_id` (String) The instance identifier of a provisioned resource.
 
 ### Read-Only
diff --git a/docs/resources/ai_task.md b/docs/resources/ai_task.md
new file mode 100644
index 00000000..1922ef59
--- /dev/null
+++ b/docs/resources/ai_task.md
@@ -0,0 +1,31 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "coder_ai_task Resource - terraform-provider-coder"
+subcategory: ""
+description: |-
+  Use this resource to define Coder tasks.
+---
+
+# coder_ai_task (Resource)
+
+Use this resource to define Coder tasks.
+
+
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- `sidebar_app` (Block Set, Min: 1, Max: 1) The coder_app to display in the sidebar. Usually a chat interface with the AI agent running in the workspace, like https://github.com/coder/agentapi. (see [below for nested schema](#nestedblock--sidebar_app))
+
+### Read-Only
+
+- `id` (String) A unique identifier for this resource.
+
+<a id="nestedblock--sidebar_app"></a>
+### Nested Schema for `sidebar_app`
+
+Required:
+
+- `id` (String) A reference to an existing `coder_app` resource in your template.
diff --git a/docs/resources/app.md b/docs/resources/app.md
index 53a8c127..6be99cf3 100644
--- a/docs/resources/app.md
+++ b/docs/resources/app.md
@@ -33,6 +33,7 @@ resource "coder_app" "code-server" {
   url          = "http://localhost:13337"
   share        = "owner"
   subdomain    = false
+  open_in      = "window"
   healthcheck {
     url       = "http://localhost:13337/healthz"
     interval  = 5
@@ -47,14 +48,6 @@ resource "coder_app" "vim" {
   icon         = "${data.coder_workspace.me.access_url}/icon/vim.svg"
   command      = "vim"
 }
-
-resource "coder_app" "intellij" {
-  agent_id     = coder_agent.dev.id
-  icon         = "${data.coder_workspace.me.access_url}/icon/intellij.svg"
-  slug         = "intellij"
-  display_name = "JetBrains IntelliJ"
-  command      = "projector run"
-}
 ```
 
 <!-- schema generated by tfplugindocs -->
@@ -62,21 +55,23 @@ resource "coder_app" "intellij" {
 
 ### Required
 
-- `agent_id` (String) The "id" property of a "coder_agent" resource to associate with.
+- `agent_id` (String) The `id` property of a `coder_agent` resource to associate with.
 - `slug` (String) A hostname-friendly name for the app. This is used in URLs to access the app. May contain alphanumerics and hyphens. Cannot start/end with a hyphen or contain two consecutive hyphens.
 
 ### Optional
 
-- `command` (String) A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either "command" or "url" may be specified, but not both.
+- `command` (String) A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either `command` or `url` may be specified, but not both.
 - `display_name` (String) A display name to identify the app. Defaults to the slug.
-- `external` (Boolean) Specifies whether "url" is opened on the client machine instead of proxied through the workspace.
+- `external` (Boolean) Specifies whether `url` is opened on the client machine instead of proxied through the workspace.
+- `group` (String) The name of a group that this app belongs to.
 - `healthcheck` (Block Set, Max: 1) HTTP health checking to determine the application readiness. (see [below for nested schema](#nestedblock--healthcheck))
-- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a built-in icon with `data.coder_workspace.me.access_url + "/icon/<path>"`.
-- `name` (String, Deprecated) A display name to identify the app.
-- `relative_path` (Boolean, Deprecated) Specifies whether the URL will be accessed via a relative path or wildcard. Use if wildcard routing is unavailable. Defaults to true.
-- `share` (String) Determines the "level" which the application is shared at. Valid levels are "owner" (default), "authenticated" and "public". Level "owner" disables sharing on the app, so only the workspace owner can access it. Level "authenticated" shares the app with all authenticated users. Level "public" shares it with any user, including unauthenticated users. Permitted application sharing levels can be configured site-wide via a flag on `coder server` (Enterprise only).
-- `subdomain` (Boolean) Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. Defaults to false.
-- `url` (String) A URL to be proxied to from inside the workspace. This should be of the form "http://localhost:PORT[/SUBPATH]". Either "command" or "url" may be specified, but not both.
+- `hidden` (Boolean) Determines if the app is visible in the UI (minimum Coder version: v2.16).
+- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/<path>"`.
+- `open_in` (String) Determines where the app will be opened. Valid values are `"tab"` and `"slim-window" (default)`. `"tab"` opens in a new tab in the same browser window. `"slim-window"` opens a new browser window without navigation controls.
+- `order` (Number) The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order).
+- `share` (String) Determines the level which the application is shared at. Valid levels are `"owner"` (default), `"authenticated"` and `"public"`. Level `"owner"` disables sharing on the app, so only the workspace owner can access it. Level `"authenticated"` shares the app with all authenticated users. Level `"public"` shares it with any user, including unauthenticated users. Permitted application sharing levels can be configured site-wide via a flag on `coder server` (Enterprise only).
+- `subdomain` (Boolean) Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. If wildcards have not been setup by the administrator then apps with `subdomain` set to `true` will not be accessible. Defaults to `false`.
+- `url` (String) An external url if `external=true` or a URL to be proxied to from inside the workspace. This should be of the form `http://localhost:PORT[/SUBPATH]`. Either `command` or `url` may be specified, but not both.
 
 ### Read-Only
 
@@ -89,4 +84,4 @@ Required:
 
 - `interval` (Number) Duration in seconds to wait between healthcheck requests.
 - `threshold` (Number) Number of consecutive heathcheck failures before returning an unhealthy status.
-- `url` (String) HTTP address used determine the application readiness. A successful health check is a HTTP response code less than 500 returned before healthcheck.interval seconds.
+- `url` (String) HTTP address used determine the application readiness. A successful health check is a HTTP response code less than 500 returned before `healthcheck.interval` seconds.
diff --git a/docs/resources/devcontainer.md b/docs/resources/devcontainer.md
new file mode 100644
index 00000000..06d7f6f3
--- /dev/null
+++ b/docs/resources/devcontainer.md
@@ -0,0 +1,32 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "coder_devcontainer Resource - terraform-provider-coder"
+subcategory: ""
+description: |-
+  Define a Dev Container the agent should know of and attempt to autostart.
+  -> This resource is only available in Coder v2.21 and later.
+---
+
+# coder_devcontainer (Resource)
+
+Define a Dev Container the agent should know of and attempt to autostart.
+
+-> This resource is only available in Coder v2.21 and later.
+
+
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- `agent_id` (String) The `id` property of a `coder_agent` resource to associate with.
+- `workspace_folder` (String) The workspace folder to for the Dev Container.
+
+### Optional
+
+- `config_path` (String) The path to the Dev Container configuration file (devcontainer.json).
+
+### Read-Only
+
+- `id` (String) The ID of this resource.
diff --git a/docs/resources/env.md b/docs/resources/env.md
new file mode 100644
index 00000000..3a5a7f3a
--- /dev/null
+++ b/docs/resources/env.md
@@ -0,0 +1,51 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "coder_env Resource - terraform-provider-coder"
+subcategory: ""
+description: |-
+  Use this resource to set an environment variable in a workspace. Note that this resource cannot be used to overwrite existing environment variables set on the coder_agent resource.
+---
+
+# coder_env (Resource)
+
+Use this resource to set an environment variable in a workspace. Note that this resource cannot be used to overwrite existing environment variables set on the `coder_agent` resource.
+
+## Example Usage
+
+```terraform
+data "coder_workspace" "me" {}
+
+resource "coder_agent" "dev" {
+  os   = "linux"
+  arch = "amd64"
+  dir  = "/workspace"
+}
+
+resource "coder_env" "welcome_message" {
+  agent_id = coder_agent.dev.id
+  name     = "WELCOME_MESSAGE"
+  value    = "Welcome to your Coder workspace!"
+}
+
+resource "coder_env" "internal_api_url" {
+  agent_id = coder_agent.dev.id
+  name     = "INTERNAL_API_URL"
+  value    = "https://api.internal.company.com/v1"
+}
+```
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- `agent_id` (String) The `id` property of a `coder_agent` resource to associate with.
+- `name` (String) The name of the environment variable.
+
+### Optional
+
+- `value` (String) The value of the environment variable.
+
+### Read-Only
+
+- `id` (String) The ID of this resource.
diff --git a/docs/resources/external_agent.md b/docs/resources/external_agent.md
new file mode 100644
index 00000000..b6d0b8ae
--- /dev/null
+++ b/docs/resources/external_agent.md
@@ -0,0 +1,24 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "coder_external_agent Resource - terraform-provider-coder"
+subcategory: ""
+description: |-
+  Define an external agent to be used in a workspace.
+---
+
+# coder_external_agent (Resource)
+
+Define an external agent to be used in a workspace.
+
+
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- `agent_id` (String) The `id` property of a `coder_agent` resource to associate with.
+
+### Read-Only
+
+- `id` (String) The ID of this resource.
diff --git a/docs/resources/metadata.md b/docs/resources/metadata.md
index 2d67e526..d8329ea9 100644
--- a/docs/resources/metadata.md
+++ b/docs/resources/metadata.md
@@ -3,12 +3,15 @@
 page_title: "coder_metadata Resource - terraform-provider-coder"
 subcategory: ""
 description: |-
-  Use this resource to attach metadata to a resource. They will be displayed in the Coder dashboard.
+  Use this resource to attach metadata to a resource. They will be displayed in the Coder dashboard alongside the resource. The resource containing the agent, and it's metadata, will be shown by default.
+  Alternatively, to attach metadata to the agent, use a metadata block within a coder_agent resource.
 ---
 
 # coder_metadata (Resource)
 
-Use this resource to attach metadata to a resource. They will be displayed in the Coder dashboard.
+Use this resource to attach metadata to a resource. They will be displayed in the Coder dashboard alongside the resource. The resource containing the agent, and it's metadata, will be shown by default. 
+
+Alternatively, to attach metadata to the agent, use a `metadata` block within a `coder_agent` resource.
 
 ## Example Usage
 
@@ -18,6 +21,13 @@ data "coder_workspace" "me" {
 
 resource "kubernetes_pod" "dev" {
   count = data.coder_workspace.me.start_count
+  metadata {
+    name      = "k8s_example"
+    namespace = "example"
+  }
+  spec {
+    # Draw the rest of the pod!
+  }
 }
 
 resource "tls_private_key" "example_key_pair" {
@@ -52,14 +62,14 @@ resource "coder_metadata" "pod_info" {
 
 ### Required
 
-- `resource_id` (String) The "id" property of another resource that metadata should be attached to.
+- `resource_id` (String) The `id` property of another resource that metadata should be attached to.
 
 ### Optional
 
 - `daily_cost` (Number) (Enterprise) The cost of this resource every 24 hours. Use the smallest denomination of your preferred currency. For example, if you work in USD, use cents.
 - `hide` (Boolean) Hide the resource from the UI.
-- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a built-in icon with `data.coder_workspace.me.access_url + "/icon/<path>"`.
-- `item` (Block List) Each "item" block defines a single metadata item consisting of a key/value pair. (see [below for nested schema](#nestedblock--item))
+- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/<path>"`.
+- `item` (Block List) Each `item` block defines a single metadata item consisting of a key/value pair. (see [below for nested schema](#nestedblock--item))
 
 ### Read-Only
 
@@ -74,8 +84,8 @@ Required:
 
 Optional:
 
-- `sensitive` (Boolean) Set to "true" to for items such as API keys whose values should be hidden from view by default. Note that this does not prevent metadata from being retrieved using the API, so it is not suitable for secrets that should not be exposed to workspace users.
-- `value` (String) The value of this metadata item.
+- `sensitive` (Boolean) Set to `true` to for items such as API keys whose values should be hidden from view by default. Note that this does not prevent metadata from being retrieved using the API, so it is not suitable for secrets that should not be exposed to workspace users.
+- `value` (String) The value of this metadata item. Supports basic Markdown, including hyperlinks.
 
 Read-Only:
 
diff --git a/docs/resources/script.md b/docs/resources/script.md
index 51213aac..21bfaec9 100644
--- a/docs/resources/script.md
+++ b/docs/resources/script.md
@@ -3,33 +3,99 @@
 page_title: "coder_script Resource - terraform-provider-coder"
 subcategory: ""
 description: |-
-  Use this resource to run a script from an agent.
+  Use this resource to run a script from an agent. When multiple scripts are assigned to the same agent, they are executed in parallel.
 ---
 
 # coder_script (Resource)
 
-Use this resource to run a script from an agent.
+Use this resource to run a script from an agent. When multiple scripts are assigned to the same agent, they are executed in parallel.
 
+## Example Usage
 
+```terraform
+data "coder_workspace" "me" {}
+
+resource "coder_agent" "dev" {
+  os   = "linux"
+  arch = "amd64"
+  dir  = "/workspace"
+}
+
+resource "coder_script" "dotfiles" {
+  agent_id     = coder_agent.dev.id
+  display_name = "Dotfiles"
+  icon         = "/icon/dotfiles.svg"
+  run_on_start = true
+  script = templatefile("~/get_dotfiles.sh", {
+    DOTFILES_URI : var.dotfiles_uri,
+    DOTFILES_USER : var.dotfiles_user
+  })
+}
+
+resource "coder_script" "code-server" {
+  agent_id           = coder_agent.dev.id
+  display_name       = "code-server"
+  icon               = "/icon/code.svg"
+  run_on_start       = true
+  start_blocks_login = true
+  script = templatefile("./install-code-server.sh", {
+    LOG_PATH : "/tmp/code-server.log"
+  })
+}
+
+resource "coder_script" "nightly_update" {
+  agent_id     = coder_agent.dev.id
+  display_name = "Nightly update"
+  icon         = "/icon/database.svg"
+  cron         = "0 0 22 * * *" # Run at 22:00 (10 PM) every day
+  script       = <<EOF
+    #!/bin/sh
+    echo "Running nightly update"
+    sudo apt-get update
+  EOF
+}
+
+resource "coder_script" "every_5_minutes" {
+  agent_id     = coder_agent.dev.id
+  display_name = "Health check"
+  icon         = "/icon/heart.svg"
+  cron         = "0 */5 * * * *" # Run every 5 minutes
+  script       = <<EOF
+    #!/bin/sh
+    echo "Health check at $(date)"
+  EOF
+}
+
+resource "coder_script" "shutdown" {
+  agent_id     = coder_agent.dev.id
+  display_name = "Stop daemon server"
+  run_on_stop  = true
+  icon         = "/icons/memory.svg"
+  script       = <<EOF
+    #!/bin/sh 
+    kill $(lsof -i :3002 -t) >/tmp/pid.log 2>&1 &
+  EOF
+}
+```
 
 <!-- schema generated by tfplugindocs -->
 ## Schema
 
 ### Required
 
-- `agent_id` (String) The "id" property of a "coder_agent" resource to associate with.
+- `agent_id` (String) The `id` property of a `coder_agent` resource to associate with.
 - `display_name` (String) The display name of the script to display logs in the dashboard.
-- `script` (String) The script to run.
+- `script` (String) The content of the script that will be run.
 
 ### Optional
 
-- `cron` (String) The cron schedule to run the script on. This is a cron expression.
-- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a built-in icon with `data.coder_workspace.me.access_url + "/icon/<path>"`.
+- `cron` (String) The cron schedule to run the script on. This uses a 6-field cron expression format: `seconds minutes hours day-of-month month day-of-week`. Note that this differs from the standard Unix 5-field format by including seconds as the first field. Examples: `"0 0 22 * * *"` (daily at 10 PM), `"0 */5 * * * *"` (every 5 minutes), `"30 0 9 * * 1-5"` (weekdays at 9:30 AM).
+- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/<path>"`.
 - `log_path` (String) The path of a file to write the logs to. If relative, it will be appended to tmp.
-- `run_on_start` (Boolean) This option defines whether or not the script should run when the agent starts.
-- `run_on_stop` (Boolean) This option defines whether or not the script should run when the agent stops.
-- `start_blocks_login` (Boolean) This option defines whether or not the user can (by default) login to the workspace before this script completes running on start. When enabled, users may see an incomplete workspace when logging in.
-- `timeout` (Number) Time in seconds until the agent lifecycle status is marked as timed out, this happens when the script has not completed (exited) in the given time.
+- `run_on_start` (Boolean) This option defines whether or not the script should run when the agent starts. The script should exit when it is done to signal that the agent is ready.
+- `run_on_stop` (Boolean) This option defines whether or not the script should run when the agent stops. The script should exit when it is done to signal that the workspace can be stopped.
+- `start_blocks_login` (Boolean) This option determines whether users can log in immediately or must wait for the workspace to finish running this script upon startup. If not enabled, users may encounter an incomplete workspace when logging in. This option only sets the default, the user can still manually override the behavior.
+- `timeout` (Number) Time in seconds that the script is allowed to run. If the script does not complete within this time, the script is terminated and the agent lifecycle status is marked as timed out. A value of zero (default) means no timeout.
 
 ### Read-Only
 
diff --git a/examples/data-sources/coder_external_auth/data-source.tf b/examples/data-sources/coder_external_auth/data-source.tf
new file mode 100644
index 00000000..330ff216
--- /dev/null
+++ b/examples/data-sources/coder_external_auth/data-source.tf
@@ -0,0 +1,11 @@
+provider "coder" {}
+
+
+data "coder_external_auth" "github" {
+  id = "github"
+}
+
+data "coder_external_auth" "azure-identity" {
+  id       = "azure-identiy"
+  optional = true
+}
diff --git a/examples/data-sources/coder_git_auth/data-source.tf b/examples/data-sources/coder_git_auth/data-source.tf
deleted file mode 100644
index eeed89aa..00000000
--- a/examples/data-sources/coder_git_auth/data-source.tf
+++ /dev/null
@@ -1,21 +0,0 @@
-provider "coder" {
-}
-
-data "coder_git_auth" "github" {
-  # Matches the ID of the git auth provider in Coder.
-  id = "github"
-}
-
-resource "coder_agent" "dev" {
-  os   = "linux"
-  arch = "amd64"
-  dir  = "~/coder"
-  env = {
-    GITHUB_TOKEN : data.coder_git_auth.github.access_token
-  }
-  startup_script = <<EOF
-if [ ! -d ~/coder ]; then
-    git clone https://github.com/coder/coder
-fi
-EOF
-}
\ No newline at end of file
diff --git a/examples/resources/coder_parameter/resource.tf b/examples/data-sources/coder_parameter/data-source.tf
similarity index 73%
rename from examples/resources/coder_parameter/resource.tf
rename to examples/data-sources/coder_parameter/data-source.tf
index 3c674f1c..ac0de7cb 100644
--- a/examples/resources/coder_parameter/resource.tf
+++ b/examples/data-sources/coder_parameter/data-source.tf
@@ -5,16 +5,16 @@ data "coder_parameter" "example" {
   description = "Specify a region to place your workspace."
   mutable     = false
   type        = "string"
-  default     = "asia-central1-a"
+  default     = "us-central1-a"
   option {
     value = "us-central1-a"
     name  = "US Central"
-    icon  = "/icon/usa.svg"
+    icon  = "/icons/1f1fa-1f1f8.png"
   }
   option {
-    value = "asia-central1-a"
-    name  = "Asia"
-    icon  = "/icon/asia.svg"
+    value = "asia-southeast1-a"
+    name  = "Singapore"
+    icon  = "/icons/1f1f8-1f1ec.png"
   }
 }
 
@@ -84,4 +84,34 @@ data "coder_parameter" "users" {
   display_name = "System users"
   type         = "list(string)"
   default      = jsonencode(["root", "user1", "user2"])
+}
+
+data "coder_parameter" "home_volume_size" {
+  name        = "Home Volume Size"
+  description = <<-EOF
+  How large should your home volume be?
+  EOF
+  type        = "number"
+  default     = 30
+  mutable     = true
+  order       = 3
+
+  option {
+    name  = "30GB"
+    value = 30
+  }
+
+  option {
+    name  = "60GB"
+    value = 60
+  }
+
+  option {
+    name  = "100GB"
+    value = 100
+  }
+
+  validation {
+    monotonic = "increasing"
+  }
 }
\ No newline at end of file
diff --git a/examples/data-sources/coder_provisioner/data-source.tf b/examples/data-sources/coder_provisioner/data-source.tf
new file mode 100644
index 00000000..a94823ed
--- /dev/null
+++ b/examples/data-sources/coder_provisioner/data-source.tf
@@ -0,0 +1,17 @@
+provider "coder" {}
+
+data "coder_provisioner" "dev" {}
+
+data "coder_workspace" "dev" {}
+
+resource "coder_agent" "main" {
+  arch = data.coder_provisioner.dev.arch
+  os   = data.coder_provisioner.dev.os
+  dir  = "/workspace"
+  display_apps {
+    vscode          = true
+    vscode_insiders = false
+    web_terminal    = true
+    ssh_helper      = false
+  }
+}
\ No newline at end of file
diff --git a/examples/data-sources/coder_resources_monitoring/data-source.tf b/examples/data-sources/coder_resources_monitoring/data-source.tf
new file mode 100644
index 00000000..94bc55ed
--- /dev/null
+++ b/examples/data-sources/coder_resources_monitoring/data-source.tf
@@ -0,0 +1,26 @@
+provider "coder" {}
+
+data "coder_provisioner" "dev" {}
+
+data "coder_workspace" "dev" {}
+
+resource "coder_agent" "main" {
+  arch = data.coder_provisioner.dev.arch
+  os   = data.coder_provisioner.dev.os
+  resources_monitoring {
+    memory {
+      enabled   = true
+      threshold = 80
+    }
+    volume {
+      path      = "/volume1"
+      enabled   = true
+      threshold = 80
+    }
+    volume {
+      path      = "/volume2"
+      enabled   = true
+      threshold = 100
+    }
+  }
+}
\ No newline at end of file
diff --git a/examples/data-sources/coder_workspace/data-source.tf b/examples/data-sources/coder_workspace/data-source.tf
index 4898439b..8eb4a8f8 100644
--- a/examples/data-sources/coder_workspace/data-source.tf
+++ b/examples/data-sources/coder_workspace/data-source.tf
@@ -1,6 +1,46 @@
-data "coder_workspace" "dev" {
+provider "coder" {}
+
+provider "docker" {}
+
+data "coder_workspace" "me" {}
+
+data "coder_workspace_owner" "me" {}
+
+resource "coder_agent" "dev" {
+  arch = "amd64"
+  os   = "linux"
+  dir  = "/workspace"
 }
 
-resource "kubernetes_pod" "dev" {
-  count = data.coder_workspace.dev.transition == "start" ? 1 : 0
+resource "docker_container" "workspace" {
+  count = data.coder_workspace.me.start_count
+  image = docker_image.main.name
+  # Uses lower() to avoid Docker restriction on container names.
+  name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
+  # Hostname makes the shell more user friendly: coder@my-workspace:~$
+  hostname = data.coder_workspace.me.name
+  # Use the docker gateway if the access URL is 127.0.0.1
+  entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
+  env        = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
+  host {
+    host = "host.docker.internal"
+    ip   = "host-gateway"
+  }
+  # Add labels in Docker to keep track of orphan resources.
+  labels {
+    label = "coder.owner"
+    value = data.coder_workspace_owner.me.name
+  }
+  labels {
+    label = "coder.owner_id"
+    value = data.coder_workspace_owner.me.id
+  }
+  labels {
+    label = "coder.workspace_id"
+    value = data.coder_workspace.me.id
+  }
+  labels {
+    label = "coder.workspace_name"
+    value = data.coder_workspace.me.name
+  }
 }
diff --git a/examples/data-sources/coder_workspace_owner/data-source.tf b/examples/data-sources/coder_workspace_owner/data-source.tf
new file mode 100644
index 00000000..cad73e1e
--- /dev/null
+++ b/examples/data-sources/coder_workspace_owner/data-source.tf
@@ -0,0 +1,26 @@
+provider "coder" {}
+
+data "coder_workspace_owner" "me" {}
+
+resource "coder_agent" "dev" {
+  arch = "amd64"
+  os   = "linux"
+  dir  = "/workspace"
+  env = {
+    OIDC_TOKEN : data.coder_workspace_owner.me.oidc_access_token,
+  }
+}
+
+# Add git credentials from coder_workspace_owner
+resource "coder_env" "git_author_name" {
+  agent_id = coder_agent.agent_id
+  name     = "GIT_AUTHOR_NAME"
+  value    = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
+}
+
+resource "coder_env" "git_author_email" {
+  agent_id = coder_agent.dev.id
+  name     = "GIT_AUTHOR_EMAIL"
+  value    = data.coder_workspace_owner.me.email
+  count    = data.coder_workspace_owner.me.email != "" ? 1 : 0
+}
\ No newline at end of file
diff --git a/examples/data-sources/coder_workspace_preset/data-source.tf b/examples/data-sources/coder_workspace_preset/data-source.tf
new file mode 100644
index 00000000..89150761
--- /dev/null
+++ b/examples/data-sources/coder_workspace_preset/data-source.tf
@@ -0,0 +1,28 @@
+provider "coder" {}
+
+# presets can be used to predefine common configurations for workspaces
+# Parameters are referenced by their name. Each parameter must be defined in the preset.
+# Values defined by the preset must pass validation for the parameter.
+# See the coder_parameter data source's documentation for examples of how to define
+# parameters like the ones used below.
+data "coder_workspace_preset" "example" {
+  name        = "example"
+  description = "Example description of what this preset does."
+  icon        = "/icon/example.svg"
+  parameters = {
+    (data.coder_parameter.example.name) = "us-central1-a"
+    (data.coder_parameter.ami.name)     = "ami-xxxxxxxx"
+  }
+}
+
+# Example of a default preset that will be pre-selected for users
+data "coder_workspace_preset" "standard" {
+  name        = "Standard"
+  description = "A workspace preset with medium compute in the US West region."
+  icon        = "/icon/standard.svg"
+  default     = true
+  parameters = {
+    (data.coder_parameter.instance_type.name) = "t3.medium"
+    (data.coder_parameter.region.name)        = "us-west-2"
+  }
+}
diff --git a/examples/data-sources/coder_workspace_tags/data-source.tf b/examples/data-sources/coder_workspace_tags/data-source.tf
new file mode 100644
index 00000000..6c26b061
--- /dev/null
+++ b/examples/data-sources/coder_workspace_tags/data-source.tf
@@ -0,0 +1,50 @@
+provider "coder" {}
+
+data "coder_parameter" "os_selector" {
+  name         = "os_selector"
+  display_name = "Operating System"
+  mutable      = false
+
+  default = "osx"
+
+  option {
+    icon  = "/icons/linux.png"
+    name  = "Linux"
+    value = "linux"
+  }
+  option {
+    icon  = "/icons/osx.png"
+    name  = "OSX"
+    value = "osx"
+  }
+  option {
+    icon  = "/icons/windows.png"
+    name  = "Windows"
+    value = "windows"
+  }
+}
+
+data "coder_parameter" "feature_cache_enabled" {
+  name         = "feature_cache_enabled"
+  display_name = "Enable cache?"
+  type         = "bool"
+
+  default = false
+}
+
+data "coder_parameter" "feature_debug_enabled" {
+  name         = "feature_debug_enabled"
+  display_name = "Enable debug?"
+  type         = "bool"
+
+  default = true
+}
+
+data "coder_workspace_tags" "custom_workspace_tags" {
+  tags = {
+    "cluster" = "developers"
+    "os"      = data.coder_parameter.os_selector.value
+    "debug"   = "${data.coder_parameter.feature_debug_enabled.value}+12345"
+    "cache"   = data.coder_parameter.feature_cache_enabled.value == "true" ? "nix-with-cache" : "no-cache"
+  }
+}
\ No newline at end of file
diff --git a/examples/resources/coder_agent/resource.tf b/examples/resources/coder_agent/resource.tf
index d5235a18..7c219604 100644
--- a/examples/resources/coder_agent/resource.tf
+++ b/examples/resources/coder_agent/resource.tf
@@ -2,15 +2,35 @@ data "coder_workspace" "me" {
 }
 
 resource "coder_agent" "dev" {
-  os   = "linux"
-  arch = "amd64"
-  dir  = "/workspace"
+  os            = "linux"
+  arch          = "amd64"
+  dir           = "/workspace"
+  api_key_scope = "all"
   display_apps {
     vscode          = true
     vscode_insiders = false
     web_terminal    = true
     ssh_helper      = false
   }
+
+  metadata {
+    display_name = "CPU Usage"
+    key          = "cpu_usage"
+    script       = "coder stat cpu"
+    interval     = 10
+    timeout      = 1
+    order        = 2
+  }
+  metadata {
+    display_name = "RAM Usage"
+    key          = "ram_usage"
+    script       = "coder stat mem"
+    interval     = 10
+    timeout      = 1
+    order        = 1
+  }
+
+  order = 1
 }
 
 resource "kubernetes_pod" "dev" {
diff --git a/examples/resources/coder_app/resource.tf b/examples/resources/coder_app/resource.tf
index 20fd8b41..8aea7b99 100644
--- a/examples/resources/coder_app/resource.tf
+++ b/examples/resources/coder_app/resource.tf
@@ -18,6 +18,7 @@ resource "coder_app" "code-server" {
   url          = "http://localhost:13337"
   share        = "owner"
   subdomain    = false
+  open_in      = "window"
   healthcheck {
     url       = "http://localhost:13337/healthz"
     interval  = 5
@@ -32,11 +33,3 @@ resource "coder_app" "vim" {
   icon         = "${data.coder_workspace.me.access_url}/icon/vim.svg"
   command      = "vim"
 }
-
-resource "coder_app" "intellij" {
-  agent_id     = coder_agent.dev.id
-  icon         = "${data.coder_workspace.me.access_url}/icon/intellij.svg"
-  slug         = "intellij"
-  display_name = "JetBrains IntelliJ"
-  command      = "projector run"
-}
diff --git a/examples/resources/coder_env/resource.tf b/examples/resources/coder_env/resource.tf
new file mode 100644
index 00000000..9f8e28f2
--- /dev/null
+++ b/examples/resources/coder_env/resource.tf
@@ -0,0 +1,19 @@
+data "coder_workspace" "me" {}
+
+resource "coder_agent" "dev" {
+  os   = "linux"
+  arch = "amd64"
+  dir  = "/workspace"
+}
+
+resource "coder_env" "welcome_message" {
+  agent_id = coder_agent.dev.id
+  name     = "WELCOME_MESSAGE"
+  value    = "Welcome to your Coder workspace!"
+}
+
+resource "coder_env" "internal_api_url" {
+  agent_id = coder_agent.dev.id
+  name     = "INTERNAL_API_URL"
+  value    = "https://api.internal.company.com/v1"
+}
\ No newline at end of file
diff --git a/examples/resources/coder_metadata/resource.tf b/examples/resources/coder_metadata/resource.tf
index c4facfbb..0491ce57 100644
--- a/examples/resources/coder_metadata/resource.tf
+++ b/examples/resources/coder_metadata/resource.tf
@@ -3,6 +3,13 @@ data "coder_workspace" "me" {
 
 resource "kubernetes_pod" "dev" {
   count = data.coder_workspace.me.start_count
+  metadata {
+    name      = "k8s_example"
+    namespace = "example"
+  }
+  spec {
+    # Draw the rest of the pod!
+  }
 }
 
 resource "tls_private_key" "example_key_pair" {
diff --git a/examples/resources/coder_script/resource.tf b/examples/resources/coder_script/resource.tf
new file mode 100644
index 00000000..53c9dfb8
--- /dev/null
+++ b/examples/resources/coder_script/resource.tf
@@ -0,0 +1,63 @@
+data "coder_workspace" "me" {}
+
+resource "coder_agent" "dev" {
+  os   = "linux"
+  arch = "amd64"
+  dir  = "/workspace"
+}
+
+resource "coder_script" "dotfiles" {
+  agent_id     = coder_agent.dev.id
+  display_name = "Dotfiles"
+  icon         = "/icon/dotfiles.svg"
+  run_on_start = true
+  script = templatefile("~/get_dotfiles.sh", {
+    DOTFILES_URI : var.dotfiles_uri,
+    DOTFILES_USER : var.dotfiles_user
+  })
+}
+
+resource "coder_script" "code-server" {
+  agent_id           = coder_agent.dev.id
+  display_name       = "code-server"
+  icon               = "/icon/code.svg"
+  run_on_start       = true
+  start_blocks_login = true
+  script = templatefile("./install-code-server.sh", {
+    LOG_PATH : "/tmp/code-server.log"
+  })
+}
+
+resource "coder_script" "nightly_update" {
+  agent_id     = coder_agent.dev.id
+  display_name = "Nightly update"
+  icon         = "/icon/database.svg"
+  cron         = "0 0 22 * * *" # Run at 22:00 (10 PM) every day
+  script       = <<EOF
+    #!/bin/sh
+    echo "Running nightly update"
+    sudo apt-get update
+  EOF
+}
+
+resource "coder_script" "every_5_minutes" {
+  agent_id     = coder_agent.dev.id
+  display_name = "Health check"
+  icon         = "/icon/heart.svg"
+  cron         = "0 */5 * * * *" # Run every 5 minutes
+  script       = <<EOF
+    #!/bin/sh
+    echo "Health check at $(date)"
+  EOF
+}
+
+resource "coder_script" "shutdown" {
+  agent_id     = coder_agent.dev.id
+  display_name = "Stop daemon server"
+  run_on_stop  = true
+  icon         = "/icons/memory.svg"
+  script       = <<EOF
+    #!/bin/sh 
+    kill $(lsof -i :3002 -t) >/tmp/pid.log 2>&1 &
+  EOF
+}
\ No newline at end of file
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 00000000..ecf99d98
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,61 @@
+{
+  "nodes": {
+    "flake-utils": {
+      "inputs": {
+        "systems": "systems"
+      },
+      "locked": {
+        "lastModified": 1710146030,
+        "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1746422338,
+        "narHash": "sha256-NTtKOTLQv6dPfRe00OGSywg37A1FYqldS6xiNmqBUYc=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "5b35d248e9206c1f3baf8de6a7683fee126364aa",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-24.11",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "flake-utils": "flake-utils",
+        "nixpkgs": "nixpkgs"
+      }
+    },
+    "systems": {
+      "locked": {
+        "lastModified": 1681028828,
+        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+        "owner": "nix-systems",
+        "repo": "default",
+        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-systems",
+        "repo": "default",
+        "type": "github"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 00000000..40ceb861
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,29 @@
+{
+  description = "Terraform provider for Coder";
+
+  inputs = {
+    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
+    flake-utils.url = "github:numtide/flake-utils";
+  };
+
+  outputs = { nixpkgs, flake-utils, ... }:
+    flake-utils.lib.eachDefaultSystem (system:
+      let
+        pkgs = import nixpkgs {
+          inherit system;
+          config = {
+            allowUnfree = true;
+          };
+        };
+      in
+      {
+        devShell = pkgs.mkShell {
+          name = "devShell";
+          buildInputs = with pkgs; [
+            terraform
+            go_1_24
+          ];
+        };
+      }
+    );
+}
diff --git a/go.mod b/go.mod
index 983c1bdb..370fd5f3 100644
--- a/go.mod
+++ b/go.mod
@@ -1,64 +1,94 @@
-module github.com/coder/terraform-provider-coder
+module github.com/coder/terraform-provider-coder/v2
 
-go 1.20
+go 1.24.2
 
 require (
-	github.com/google/uuid v1.3.1
-	github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320
-	github.com/hashicorp/terraform-plugin-sdk/v2 v2.20.0
+	github.com/docker/docker v26.1.5+incompatible
+	github.com/google/uuid v1.6.0
+	github.com/hashicorp/go-cty v1.5.0
+	github.com/hashicorp/terraform-plugin-log v0.9.0
+	github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0
+	github.com/masterminds/semver v1.5.0
 	github.com/mitchellh/mapstructure v1.5.0
-	github.com/stretchr/testify v1.8.4
+	github.com/robfig/cron/v3 v3.0.1
+	github.com/stretchr/testify v1.10.0
+	golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8
+	golang.org/x/mod v0.25.0
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1
 )
 
 require (
-	github.com/Microsoft/go-winio v0.5.2 // indirect
+	github.com/Masterminds/semver v1.5.0 // indirect
+	github.com/Microsoft/go-winio v0.6.2 // indirect
+	github.com/ProtonMail/go-crypto v1.1.6 // indirect
 	github.com/agext/levenshtein v1.2.3 // indirect
-	github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
+	github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
+	github.com/cloudflare/circl v1.6.1 // indirect
+	github.com/containerd/log v0.1.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
-	github.com/fatih/color v1.13.0 // indirect
-	github.com/golang/protobuf v1.5.2 // indirect
-	github.com/google/go-cmp v0.5.9 // indirect
+	github.com/distribution/reference v0.6.0 // indirect
+	github.com/docker/go-connections v0.5.0 // indirect
+	github.com/docker/go-units v0.5.0 // indirect
+	github.com/fatih/color v1.16.0 // indirect
+	github.com/felixge/httpsnoop v1.0.4 // indirect
+	github.com/go-logr/logr v1.4.2 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/gogo/protobuf v1.3.2 // indirect
+	github.com/golang/protobuf v1.5.4 // indirect
+	github.com/google/go-cmp v0.7.0 // indirect
 	github.com/hashicorp/errwrap v1.1.0 // indirect
 	github.com/hashicorp/go-checkpoint v0.5.0 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
-	github.com/hashicorp/go-hclog v1.2.1 // indirect
+	github.com/hashicorp/go-hclog v1.6.3 // indirect
 	github.com/hashicorp/go-multierror v1.1.1 // indirect
-	github.com/hashicorp/go-plugin v1.4.4 // indirect
+	github.com/hashicorp/go-plugin v1.6.3 // indirect
+	github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
 	github.com/hashicorp/go-uuid v1.0.3 // indirect
-	github.com/hashicorp/go-version v1.6.0 // indirect
-	github.com/hashicorp/hc-install v0.4.0 // indirect
-	github.com/hashicorp/hcl/v2 v2.13.0 // indirect
+	github.com/hashicorp/go-version v1.7.0 // indirect
+	github.com/hashicorp/hc-install v0.9.2 // indirect
+	github.com/hashicorp/hcl/v2 v2.23.0 // indirect
 	github.com/hashicorp/logutils v1.0.0 // indirect
-	github.com/hashicorp/terraform-exec v0.17.2 // indirect
-	github.com/hashicorp/terraform-json v0.14.0 // indirect
-	github.com/hashicorp/terraform-plugin-go v0.12.0 // indirect
-	github.com/hashicorp/terraform-plugin-log v0.7.0 // indirect
-	github.com/hashicorp/terraform-registry-address v0.0.0-20220623143253-7d51757b572c // indirect
-	github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 // indirect
-	github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 // indirect
-	github.com/kr/pretty v0.3.0 // indirect
-	github.com/mattn/go-colorable v0.1.12 // indirect
-	github.com/mattn/go-isatty v0.0.14 // indirect
+	github.com/hashicorp/terraform-exec v0.23.0 // indirect
+	github.com/hashicorp/terraform-json v0.25.0 // indirect
+	github.com/hashicorp/terraform-plugin-go v0.27.0 // indirect
+	github.com/hashicorp/terraform-registry-address v0.2.5 // indirect
+	github.com/hashicorp/terraform-svchost v0.1.1 // indirect
+	github.com/hashicorp/yamux v0.1.1 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mitchellh/copystructure v1.2.0 // indirect
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/mitchellh/go-wordwrap v1.0.1 // indirect
 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
+	github.com/moby/docker-image-spec v1.3.1 // indirect
+	github.com/moby/term v0.5.0 // indirect
+	github.com/morikuni/aec v1.0.0 // indirect
 	github.com/oklog/run v1.0.0 // indirect
+	github.com/opencontainers/go-digest v1.0.0 // indirect
+	github.com/opencontainers/image-spec v1.1.0 // indirect
+	github.com/pkg/errors v0.9.1 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/robfig/cron/v3 v3.0.1 // indirect
-	github.com/rogpeppe/go-internal v1.8.0 // indirect
 	github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
-	github.com/vmihailenco/msgpack/v4 v4.3.12 // indirect
-	github.com/vmihailenco/tagparser v0.1.1 // indirect
-	github.com/zclconf/go-cty v1.10.0 // indirect
-	golang.org/x/crypto v0.0.0-20220517005047-85d78b3ac167 // indirect
-	golang.org/x/net v0.7.0 // indirect
-	golang.org/x/sys v0.5.0 // indirect
-	golang.org/x/text v0.7.0 // indirect
-	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect
-	google.golang.org/grpc v1.53.0 // indirect
-	google.golang.org/protobuf v1.28.1 // indirect
+	github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
+	github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
+	github.com/zclconf/go-cty v1.16.2 // indirect
+	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect
+	go.opentelemetry.io/otel v1.34.0 // indirect
+	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect
+	go.opentelemetry.io/otel/metric v1.34.0 // indirect
+	go.opentelemetry.io/otel/trace v1.34.0 // indirect
+	golang.org/x/crypto v0.38.0 // indirect
+	golang.org/x/net v0.39.0 // indirect
+	golang.org/x/sync v0.14.0 // indirect
+	golang.org/x/sys v0.33.0 // indirect
+	golang.org/x/text v0.25.0 // indirect
+	golang.org/x/time v0.5.0 // indirect
+	golang.org/x/tools v0.22.0 // indirect
+	google.golang.org/appengine v1.6.8 // indirect
+	google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
+	google.golang.org/grpc v1.72.1 // indirect
+	google.golang.org/protobuf v1.36.6 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
+	gotest.tools/v3 v3.5.1 // indirect
 )
diff --git a/go.sum b/go.sum
index fe598df5..ff0ed9c7 100644
--- a/go.sum
+++ b/go.sum
@@ -1,131 +1,149 @@
-cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
-github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
-github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA=
-github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
-github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ=
-github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
-github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk=
-github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
+dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
+github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
+github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
+github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
 github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
 github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
-github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
-github.com/apparentlymart/go-dump v0.0.0-20190214190832-042adf3cf4a0 h1:MzVXffFUye+ZcSR6opIgz9Co7WcDx6ZcY+RjfFHoA0I=
-github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk=
 github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec=
-github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
-github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
-github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
+github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
+github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
+github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
+github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
+github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
+github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
+github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
-github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
-github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/docker v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g=
+github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
+github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
-github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
-github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
-github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
-github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
-github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34=
-github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
-github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
-github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4=
-github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
+github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
+github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
+github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
+github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60=
+github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
+github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
 github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
-github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
 github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU=
 github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg=
 github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
-github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUKaCaGKZ/dR2roBXv0vKbSCnssIldfQdI=
-github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs=
-github.com/hashicorp/go-hclog v1.2.1 h1:YQsLlGDJgwhXFpucSPyVbCBviQtjlHv3jLTlp8YmtEw=
-github.com/hashicorp/go-hclog v1.2.1/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
+github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0=
+github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM=
+github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
+github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
 github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
 github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
-github.com/hashicorp/go-plugin v1.4.4 h1:NVdrSdFRt3SkZtNckJ6tog7gbpRrcbOjQi/rgF7JYWQ=
-github.com/hashicorp/go-plugin v1.4.4/go.mod h1:viDMjcLJuDui6pXb8U4HVfb8AamCWhHGUjr2IrTF67s=
+github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg=
+github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0=
+github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
+github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
 github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
 github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
 github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
-github.com/hashicorp/go-version v1.5.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
-github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
-github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
-github.com/hashicorp/hc-install v0.4.0 h1:cZkRFr1WVa0Ty6x5fTvL1TuO1flul231rWkGH92oYYk=
-github.com/hashicorp/hc-install v0.4.0/go.mod h1:5d155H8EC5ewegao9A4PUTMNPZaq+TbOzkJJZ4vrXeI=
-github.com/hashicorp/hcl/v2 v2.13.0 h1:0Apadu1w6M11dyGFxWnmhhcMjkbAiKCv7G1r/2QgCNc=
-github.com/hashicorp/hcl/v2 v2.13.0/go.mod h1:e4z5nxYlWNPdDSNYX+ph14EvWYMFm3eP0zIUqPc2jr0=
+github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
+github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24=
+github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I=
+github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos=
+github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
 github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
 github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
-github.com/hashicorp/terraform-exec v0.17.2 h1:EU7i3Fh7vDUI9nNRdMATCEfnm9axzTnad8zszYZ73Go=
-github.com/hashicorp/terraform-exec v0.17.2/go.mod h1:tuIbsL2l4MlwwIZx9HPM+LOV9vVyEfBYu2GsO1uH3/8=
-github.com/hashicorp/terraform-json v0.14.0 h1:sh9iZ1Y8IFJLx+xQiKHGud6/TSUCM0N8e17dKDpqV7s=
-github.com/hashicorp/terraform-json v0.14.0/go.mod h1:5A9HIWPkk4e5aeeXIBbkcOvaZbIYnAIkEyqP2pNSckM=
-github.com/hashicorp/terraform-plugin-go v0.12.0 h1:6wW9mT1dSs0Xq4LR6HXj1heQ5ovr5GxXNJwkErZzpJw=
-github.com/hashicorp/terraform-plugin-go v0.12.0/go.mod h1:kwhmaWHNDvT1B3QiSJdAtrB/D4RaKSY/v3r2BuoWK4M=
-github.com/hashicorp/terraform-plugin-log v0.7.0 h1:SDxJUyT8TwN4l5b5/VkiTIaQgY6R+Y2BQ0sRZftGKQs=
-github.com/hashicorp/terraform-plugin-log v0.7.0/go.mod h1:p4R1jWBXRTvL4odmEkFfDdhUjHf9zcs/BCoNHAc7IK4=
-github.com/hashicorp/terraform-plugin-sdk/v2 v2.20.0 h1:+KxZULPsbjpAVoP0WNj/8aVW6EqpcX5JcUcQ5wl7Da4=
-github.com/hashicorp/terraform-plugin-sdk/v2 v2.20.0/go.mod h1:DwGJG3KNxIPluVk6hexvDfYR/MS/eKGpiztJoT3Bbbw=
-github.com/hashicorp/terraform-registry-address v0.0.0-20220623143253-7d51757b572c h1:D8aRO6+mTqHfLsK/BC3j5OAoogv1WLRWzY1AaTo3rBg=
-github.com/hashicorp/terraform-registry-address v0.0.0-20220623143253-7d51757b572c/go.mod h1:Wn3Na71knbXc1G8Lh+yu/dQWWJeFQEpDeJMtWMtlmNI=
-github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 h1:HKLsbzeOsfXmKNpr3GiT18XAblV0BjCbzL8KQAMZGa0=
-github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734/go.mod h1:kNDNcF7sN4DocDLBkQYz73HGKwN1ANB1blq4lIYLYvg=
-github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 h1:xixZ2bWeofWV68J+x6AzmKuVM/JWCQwkWm6GW/MUR6I=
-github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
-github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
-github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
+github.com/hashicorp/terraform-exec v0.23.0 h1:MUiBM1s0CNlRFsCLJuM5wXZrzA3MnPYEsiXmzATMW/I=
+github.com/hashicorp/terraform-exec v0.23.0/go.mod h1:mA+qnx1R8eePycfwKkCRk3Wy65mwInvlpAeOwmA7vlY=
+github.com/hashicorp/terraform-json v0.25.0 h1:rmNqc/CIfcWawGiwXmRuiXJKEiJu1ntGoxseG1hLhoQ=
+github.com/hashicorp/terraform-json v0.25.0/go.mod h1:sMKS8fiRDX4rVlR6EJUMudg1WcanxCMoWwTLkgZP/vc=
+github.com/hashicorp/terraform-plugin-go v0.27.0 h1:ujykws/fWIdsi6oTUT5Or4ukvEan4aN9lY+LOxVP8EE=
+github.com/hashicorp/terraform-plugin-go v0.27.0/go.mod h1:FDa2Bb3uumkTGSkTFpWSOwWJDwA7bf3vdP3ltLDTH6o=
+github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0=
+github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow=
+github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 h1:NFPMacTrY/IdcIcnUB+7hsore1ZaRWU9cnB6jFoBnIM=
+github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0/go.mod h1:QYmYnLfsosrxjCnGY1p9c7Zj6n9thnEE+7RObeYs3fA=
+github.com/hashicorp/terraform-registry-address v0.2.5 h1:2GTftHqmUhVOeuu9CW3kwDkRe4pcBDq0uuK5VJngU1M=
+github.com/hashicorp/terraform-registry-address v0.2.5/go.mod h1:PpzXWINwB5kuVS5CA7m1+eO2f1jKb5ZDIxrOPfpnGkg=
+github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ=
+github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc=
+github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
+github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
-github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
-github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE=
-github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck=
-github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
+github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
+github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
-github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
-github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
-github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
+github.com/masterminds/semver v1.5.0 h1:hTxJTTY7tjvnWMrl08O6u3G6BLlKVwxSz01lVac9P8U=
+github.com/masterminds/semver v1.5.0/go.mod h1:s7KNT9fnd7edGzwwP7RBX4H0v/CYd5qdOLfkL1V75yg=
 github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
 github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
-github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
 github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
 github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
@@ -134,125 +152,158 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
 github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
-github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
-github.com/nsf/jsondiff v0.0.0-20200515183724-f29ed568f4ce h1:RPclfga2SEJmgMmz2k+Mg7cowZ8yv4Trqw9UsJby758=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
+github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
+github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw=
 github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
-github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
+github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
+github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
+github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
-github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
-github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
-github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4=
-github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
-github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
-github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
+github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+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/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
 github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
 github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
-github.com/vmihailenco/msgpack/v4 v4.3.12 h1:07s4sz9IReOgdikxLTKNbBdqDMLsjPKXwvCazn8G65U=
-github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4=
-github.com/vmihailenco/tagparser v0.1.1 h1:quXMXlA39OCbd2wAdTsGDlK9RkOk6Wuw+x37wVyIuWY=
-github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
-github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
-github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
-github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s=
-github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8=
-github.com/zclconf/go-cty v1.10.0 h1:mp9ZXQeIcN8kAwuqorjH+Q+njbJKjLrvB2yIh4q7U+0=
-github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
-github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8=
-golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
+github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
+github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
+github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70=
+github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
+github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
+github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0=
+go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
+go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY=
+go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
+go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
+go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
+go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
+go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
+go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
+go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
+go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
+go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94=
+go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
-golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
-golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20220517005047-85d78b3ac167 h1:O8uGbHCqlTp2P6QJSLmCojM4mN6UemYv8K+dCnmHmu0=
-golang.org/x/crypto v0.0.0-20220517005047-85d78b3ac167/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
+golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
+golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
+golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
+golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20191009170851-d66e71096ffb/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
-golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
-golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
+golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
+golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
+golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
+golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
+golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
+golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
-google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f h1:BWUVssLB0HVOSY78gIdvk1dTVYtT1y8SBWtPYuTJ/6w=
-google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
-google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
-google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
+google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
+google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
+google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
+google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
+google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
+google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
-google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
 gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
+gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
diff --git a/integration/coder-app-hidden/main.tf b/integration/coder-app-hidden/main.tf
new file mode 100644
index 00000000..6376b25d
--- /dev/null
+++ b/integration/coder-app-hidden/main.tf
@@ -0,0 +1,62 @@
+terraform {
+  required_providers {
+    coder = {
+      source = "coder/coder"
+    }
+    local = {
+      source = "hashicorp/local"
+    }
+  }
+}
+
+data "coder_workspace" "me" {}
+
+resource "coder_agent" "dev" {
+  os   = "linux"
+  arch = "amd64"
+  dir  = "/workspace"
+}
+
+resource "coder_app" "hidden" {
+  agent_id = coder_agent.dev.id
+  slug     = "hidden"
+  share    = "owner"
+  hidden   = true
+}
+
+resource "coder_app" "visible" {
+  agent_id = coder_agent.dev.id
+  slug     = "visible"
+  share    = "owner"
+  hidden   = false
+}
+
+resource "coder_app" "defaulted" {
+  agent_id = coder_agent.dev.id
+  slug     = "defaulted"
+  share    = "owner"
+}
+
+locals {
+  # NOTE: these must all be strings in the output
+  output = {
+    "coder_app.hidden.hidden"    = tostring(coder_app.hidden.hidden)
+    "coder_app.visible.hidden"   = tostring(coder_app.visible.hidden)
+    "coder_app.defaulted.hidden" = tostring(coder_app.defaulted.hidden)
+  }
+}
+
+variable "output_path" {
+  type = string
+}
+
+resource "local_file" "output" {
+  filename = var.output_path
+  content  = jsonencode(local.output)
+}
+
+output "output" {
+  value     = local.output
+  sensitive = true
+}
+
diff --git a/integration/coder-app-open-in/main.tf b/integration/coder-app-open-in/main.tf
new file mode 100644
index 00000000..f06947ae
--- /dev/null
+++ b/integration/coder-app-open-in/main.tf
@@ -0,0 +1,54 @@
+terraform {
+  required_providers {
+    coder = {
+      source = "coder/coder"
+    }
+    local = {
+      source = "hashicorp/local"
+    }
+  }
+}
+
+data "coder_workspace" "me" {}
+
+resource "coder_agent" "dev" {
+  os   = "linux"
+  arch = "amd64"
+  dir  = "/workspace"
+}
+
+resource "coder_app" "tab" {
+  agent_id = coder_agent.dev.id
+  slug     = "tab"
+  share    = "owner"
+  open_in  = "tab"
+}
+
+resource "coder_app" "defaulted" {
+  agent_id = coder_agent.dev.id
+  slug     = "defaulted"
+  share    = "owner"
+}
+
+locals {
+  # NOTE: these must all be strings in the output
+  output = {
+    "coder_app.tab.open_in"       = tostring(coder_app.tab.open_in)
+    "coder_app.defaulted.open_in" = tostring(coder_app.defaulted.open_in)
+  }
+}
+
+variable "output_path" {
+  type = string
+}
+
+resource "local_file" "output" {
+  filename = var.output_path
+  content  = jsonencode(local.output)
+}
+
+output "output" {
+  value     = local.output
+  sensitive = true
+}
+
diff --git a/integration/integration_test.go b/integration/integration_test.go
new file mode 100644
index 00000000..423447c9
--- /dev/null
+++ b/integration/integration_test.go
@@ -0,0 +1,423 @@
+package integration
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"os/signal"
+	"path/filepath"
+	"runtime"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/image"
+	"github.com/docker/docker/client"
+	"github.com/docker/docker/pkg/stdcopy"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"golang.org/x/exp/slices"
+	"golang.org/x/mod/semver"
+)
+
+// TestIntegration performs an integration test against an ephemeral Coder deployment.
+// For each directory containing a `main.tf` under `/integration`, performs the following:
+//   - Pushes the template to a temporary Coder instance running in Docker
+//   - Creates a workspace from the template. Templates here are expected to create a
+//     local_file resource containing JSON that can be marshalled as a map[string]string
+//   - Fetches the content of the JSON file created and compares it against the expected output.
+//
+// NOTE: all interfaces to this Coder deployment are performed without github.com/coder/coder/v2/codersdk
+// in order to avoid a circular dependency.
+func TestIntegration(t *testing.T) {
+	if os.Getenv("TF_ACC") == "1" {
+		t.Skip("Skipping integration tests during tf acceptance tests")
+	}
+
+	coderImg := os.Getenv("CODER_IMAGE")
+	if coderImg == "" {
+		coderImg = "ghcr.io/coder/coder"
+	}
+
+	coderVersion := os.Getenv("CODER_VERSION")
+	if coderVersion == "" {
+		coderVersion = "latest"
+	}
+
+	timeoutStr := os.Getenv("TIMEOUT_MINS")
+	if timeoutStr == "" {
+		timeoutStr = "10"
+	}
+	timeoutMins, err := strconv.Atoi(timeoutStr)
+	require.NoError(t, err, "invalid value specified for timeout")
+	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMins)*time.Minute)
+	t.Cleanup(cancel)
+	ctrID := setup(ctx, t, t.Name(), coderImg, coderVersion)
+
+	for _, tt := range []struct {
+		// Name of the folder under `integration/` containing a test template
+		name string
+		// Minimum coder version for which to run this test
+		minVersion string
+		// map of string to regex to be passed to assertOutput()
+		expectedOutput map[string]string
+	}{
+		{
+			name:       "test-data-source",
+			minVersion: "v0.0.0",
+			expectedOutput: map[string]string{
+				"provisioner.arch":                runtime.GOARCH,
+				"provisioner.id":                  `[a-zA-Z0-9-]+`,
+				"provisioner.os":                  runtime.GOOS,
+				"workspace.access_port":           `\d+`,
+				"workspace.access_url":            `https?://\D+:\d+`,
+				"workspace.id":                    `[a-zA-z0-9-]+`,
+				"workspace.name":                  `test-data-source`,
+				"workspace.start_count":           `1`,
+				"workspace.template_id":           `[a-zA-Z0-9-]+`,
+				"workspace.template_name":         `test-data-source`,
+				"workspace.template_version":      `.+`,
+				"workspace.transition":            `start`,
+				"workspace_parameter.name":        `param`,
+				"workspace_parameter.description": `param description`,
+				// TODO (sasswart): the cli doesn't support presets yet.
+				// once it does, the value for workspace_parameter.value
+				// will be the preset value.
+				"workspace_parameter.value":                                 `param value`,
+				"workspace_parameter.icon":                                  `param icon`,
+				"workspace_preset.name":                                     `preset`,
+				"workspace_preset.description":                              `preset description`,
+				"workspace_preset.icon":                                     `preset icon`,
+				"workspace_preset.default":                                  `true`,
+				"workspace_preset.parameters.param":                         `preset param value`,
+				"workspace_preset.prebuilds.instances":                      `1`,
+				"workspace_preset.prebuilds.expiration_policy.ttl":          `86400`,
+				"workspace_preset.prebuilds.scheduling.timezone":            `UTC`,
+				"workspace_preset.prebuilds.scheduling.schedule0.cron":      `\* 8-18 \* \* 1-5`,
+				"workspace_preset.prebuilds.scheduling.schedule0.instances": `3`,
+				"workspace_preset.prebuilds.scheduling.schedule1.cron":      `\* 8-14 \* \* 6`,
+				"workspace_preset.prebuilds.scheduling.schedule1.instances": `1`,
+			},
+		},
+		{
+			name:       "workspace-owner",
+			minVersion: "v2.12.0",
+			expectedOutput: map[string]string{
+				"provisioner.arch":                  runtime.GOARCH,
+				"provisioner.id":                    `[a-zA-Z0-9-]+`,
+				"provisioner.os":                    runtime.GOOS,
+				"workspace.access_port":             `\d+`,
+				"workspace.access_url":              `https?://\D+:\d+`,
+				"workspace.id":                      `[a-zA-z0-9-]+`,
+				"workspace.name":                    ``,
+				"workspace.start_count":             `1`,
+				"workspace.template_id":             `[a-zA-Z0-9-]+`,
+				"workspace.template_name":           `workspace-owner`,
+				"workspace.template_version":        `.+`,
+				"workspace.transition":              `start`,
+				"workspace_owner.email":             `testing@coder\.com`,
+				"workspace_owner.full_name":         `default`,
+				"workspace_owner.groups":            `\[(\"Everyone\")?\]`,
+				"workspace_owner.id":                `[a-zA-Z0-9-]+`,
+				"workspace_owner.name":              `testing`,
+				"workspace_owner.oidc_access_token": `^$`, // TODO: test OIDC integration
+				"workspace_owner.session_token":     `.+`,
+				"workspace_owner.ssh_private_key":   `(?s)^.+?BEGIN OPENSSH PRIVATE KEY.+?END OPENSSH PRIVATE KEY.+?$`,
+				"workspace_owner.ssh_public_key":    `(?s)^ssh-ed25519.+$`,
+				"workspace_owner.login_type":        ``,
+				"workspace_owner.rbac_roles":        ``,
+			},
+		},
+		{
+			name:       "workspace-owner-filled",
+			minVersion: "v2.18.0",
+			expectedOutput: map[string]string{
+				"provisioner.arch":                  runtime.GOARCH,
+				"provisioner.id":                    `[a-zA-Z0-9-]+`,
+				"provisioner.os":                    runtime.GOOS,
+				"workspace.access_port":             `\d+`,
+				"workspace.access_url":              `https?://\D+:\d+`,
+				"workspace.id":                      `[a-zA-z0-9-]+`,
+				"workspace.name":                    ``,
+				"workspace.start_count":             `1`,
+				"workspace.template_id":             `[a-zA-Z0-9-]+`,
+				"workspace.template_name":           `workspace-owner`,
+				"workspace.template_version":        `.+`,
+				"workspace.transition":              `start`,
+				"workspace_owner.email":             `testing@coder\.com`,
+				"workspace_owner.full_name":         `default`,
+				"workspace_owner.groups":            `\[(\"Everyone\")?\]`,
+				"workspace_owner.id":                `[a-zA-Z0-9-]+`,
+				"workspace_owner.name":              `testing`,
+				"workspace_owner.oidc_access_token": `^$`, // TODO: test OIDC integration
+				"workspace_owner.session_token":     `.+`,
+				"workspace_owner.ssh_private_key":   `(?s)^.+?BEGIN OPENSSH PRIVATE KEY.+?END OPENSSH PRIVATE KEY.+?$`,
+				"workspace_owner.ssh_public_key":    `(?s)^ssh-ed25519.+$`,
+				"workspace_owner.login_type":        `password`,
+				"workspace_owner.rbac_roles":        ``,
+			},
+		},
+		{
+			name:       "workspace-owner-rbac-roles",
+			minVersion: "v2.21.0", // anticipated version, update as required
+			expectedOutput: map[string]string{
+				"provisioner.arch":                  runtime.GOARCH,
+				"provisioner.id":                    `[a-zA-Z0-9-]+`,
+				"provisioner.os":                    runtime.GOOS,
+				"workspace.access_port":             `\d+`,
+				"workspace.access_url":              `https?://\D+:\d+`,
+				"workspace.id":                      `[a-zA-z0-9-]+`,
+				"workspace.name":                    ``,
+				"workspace.start_count":             `1`,
+				"workspace.template_id":             `[a-zA-Z0-9-]+`,
+				"workspace.template_name":           `workspace-owner`,
+				"workspace.template_version":        `.+`,
+				"workspace.transition":              `start`,
+				"workspace_owner.email":             `testing@coder\.com`,
+				"workspace_owner.full_name":         `default`,
+				"workspace_owner.groups":            `\[(\"Everyone\")?\]`,
+				"workspace_owner.id":                `[a-zA-Z0-9-]+`,
+				"workspace_owner.name":              `testing`,
+				"workspace_owner.oidc_access_token": `^$`, // TODO: test OIDC integration
+				"workspace_owner.session_token":     `.+`,
+				"workspace_owner.ssh_private_key":   `(?s)^.+?BEGIN OPENSSH PRIVATE KEY.+?END OPENSSH PRIVATE KEY.+?$`,
+				"workspace_owner.ssh_public_key":    `(?s)^ssh-ed25519.+$`,
+				"workspace_owner.login_type":        `password`,
+				// org_id will either be a uuid or an empty string for site wide roles.
+				"workspace_owner.rbac_roles": `(?is)\[(\{"name":"[a-z0-9-:]+","org_id":"[a-f0-9-]*"\},?)+\]`,
+			},
+		},
+		{
+			name:       "coder-app-open-in",
+			minVersion: "v2.19.0",
+			expectedOutput: map[string]string{
+				"coder_app.tab.open_in":       "tab",
+				"coder_app.defaulted.open_in": "slim-window",
+			},
+		},
+		{
+			name:       "coder-app-hidden",
+			minVersion: "v0.0.0",
+			expectedOutput: map[string]string{
+				"coder_app.hidden.hidden":    "true",
+				"coder_app.visible.hidden":   "false",
+				"coder_app.defaulted.hidden": "false",
+			},
+		},
+	} {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			if coderVersion != "latest" && semver.Compare(coderVersion, tt.minVersion) < 0 {
+				t.Skipf("skipping due to CODER_VERSION %q < minVersion %q", coderVersion, tt.minVersion)
+			}
+			// Given: we have an existing Coder deployment running locally
+			// Import named template
+
+			// NOTE: Template create command was deprecated after this version
+			// ref: https://github.com/coder/coder/pull/11390
+			templateCreateCmd := "push"
+			if semver.Compare(coderVersion, "v2.7.0") < 1 {
+				t.Logf("using now-deprecated templates create command for older coder version")
+				templateCreateCmd = "create"
+			}
+			_, rc := execContainer(ctx, t, ctrID, fmt.Sprintf(`coder templates %s %s --directory /src/integration/%s --var output_path=/tmp/%s.json --yes`, templateCreateCmd, tt.name, tt.name, tt.name))
+			require.Equal(t, 0, rc)
+
+			// Check if parameters.yaml exists
+			_, rc = execContainer(ctx, t, ctrID, fmt.Sprintf(`stat /src/integration/%s/parameters.yaml 2>/dev/null > /dev/null`, tt.name))
+			hasParameters := rc == 0
+			var includeParameters string
+			if hasParameters {
+				// If it exists, include it in the create command
+				includeParameters = fmt.Sprintf(`--rich-parameter-file /src/integration/%s/parameters.yaml`, tt.name)
+			}
+
+			// Create a workspace
+			_, rc = execContainer(ctx, t, ctrID, fmt.Sprintf(`coder create %s -t %s %s --yes`, tt.name, tt.name, includeParameters))
+			require.Equal(t, 0, rc)
+			// Fetch the output created by the template
+			out, rc := execContainer(ctx, t, ctrID, fmt.Sprintf(`cat /tmp/%s.json`, tt.name))
+			require.Equal(t, 0, rc)
+			actual := make(map[string]string)
+			require.NoError(t, json.NewDecoder(strings.NewReader(out)).Decode(&actual))
+			assertOutput(t, tt.expectedOutput, actual)
+		})
+	}
+}
+
+func setup(ctx context.Context, t *testing.T, name, coderImg, coderVersion string) string {
+	var (
+		// For this test to work, we pass in a custom terraformrc to use
+		// the locally built version of the provider.
+		testTerraformrc = `provider_installation {
+		dev_overrides {
+		  "coder/coder" = "/src"
+		}
+		  direct{}
+	  }`
+		localURL = "http://localhost:3000"
+	)
+
+	t.Logf("using coder image %s:%s", coderImg, coderVersion)
+
+	// Ensure the binary is built
+	binPath, err := filepath.Abs("../terraform-provider-coder")
+	require.NoError(t, err)
+	if _, err := os.Stat(binPath); os.IsNotExist(err) {
+		t.Fatalf("not found: %q - please build the provider first", binPath)
+	}
+	tmpDir := t.TempDir()
+	// Create a terraformrc to point to our freshly built provider!
+	tfrcPath := filepath.Join(tmpDir, "integration.tfrc")
+	err = os.WriteFile(tfrcPath, []byte(testTerraformrc), 0o644)
+	require.NoError(t, err, "write terraformrc to tempdir")
+
+	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
+	require.NoError(t, err, "init docker client")
+
+	srcPath, err := filepath.Abs("..")
+	require.NoError(t, err, "get abs path of parent")
+	t.Logf("src path is %s\n", srcPath)
+
+	// Ensure the image is available locally.
+	refStr := coderImg + ":" + coderVersion
+	ensureImage(ctx, t, cli, refStr)
+
+	// Stand up a temporary Coder instance
+	ctr, err := cli.ContainerCreate(ctx, &container.Config{
+		Image: refStr,
+		Env: []string{
+			"CODER_ACCESS_URL=" + localURL,             // Set explicitly to avoid creating try.coder.app URLs.
+			"CODER_IN_MEMORY=true",                     // We don't necessarily care about real persistence here.
+			"CODER_TELEMETRY_ENABLE=false",             // Avoid creating noise.
+			"CODER_VERBOSE=TRUE",                       // Debug logging.
+			"TF_CLI_CONFIG_FILE=/tmp/integration.tfrc", // Our custom tfrc from above.
+			"TF_LOG=DEBUG",                             // Debug logging in Terraform provider
+		},
+		Labels: map[string]string{},
+	}, &container.HostConfig{
+		Binds: []string{
+			tfrcPath + ":/tmp/integration.tfrc", // Custom tfrc from above.
+			srcPath + ":/src",                   // Bind-mount in the repo with the built binary and templates.
+		},
+	}, nil, nil, "terraform-provider-coder-integration-"+name)
+	require.NoError(t, err, "create test deployment")
+
+	t.Logf("created container %s\n", ctr.ID)
+	var cleanupOnce sync.Once
+	removeContainer := func() {
+		t.Logf("stopping container %s\n", ctr.ID)
+		_ = cli.ContainerRemove(ctx, ctr.ID, container.RemoveOptions{
+			Force: true,
+		})
+	}
+	// Ensure the container is cleaned up if you press Ctrl+C.
+	sigCh := make(chan os.Signal, 1)
+	signal.Notify(sigCh, os.Interrupt)
+	go func() {
+		<-sigCh
+		cleanupOnce.Do(removeContainer)
+		os.Exit(1)
+	}()
+
+	t.Cleanup(func() { // Make sure we clean up after ourselves.
+		cleanupOnce.Do(removeContainer)
+	})
+
+	err = cli.ContainerStart(ctx, ctr.ID, container.StartOptions{})
+	require.NoError(t, err, "start container")
+	t.Logf("started container %s\n", ctr.ID)
+
+	// nolint:gosec // For testing only.
+	var (
+		testEmail    = "testing@coder.com"
+		testPassword = "InsecurePassw0rd!"
+		testUsername = "testing"
+	)
+
+	// Wait for container to come up
+	require.Eventually(t, func() bool {
+		_, rc := execContainer(ctx, t, ctr.ID, fmt.Sprintf(`curl -s --fail %s/api/v2/buildinfo`, localURL))
+		if rc == 0 {
+			return true
+		}
+		t.Logf("not ready yet...")
+		return false
+	}, 10*time.Second, time.Second, "coder failed to become ready in time")
+
+	// Perform first time setup
+	_, rc := execContainer(ctx, t, ctr.ID, fmt.Sprintf(`coder login %s --first-user-email=%q --first-user-password=%q --first-user-trial=false --first-user-username=%q`, localURL, testEmail, testPassword, testUsername))
+	require.Equal(t, 0, rc, "failed to perform first-time setup")
+	return ctr.ID
+}
+
+func ensureImage(ctx context.Context, t *testing.T, cli *client.Client, ref string) {
+	t.Helper()
+
+	t.Logf("ensuring image %q", ref)
+	images, err := cli.ImageList(ctx, image.ListOptions{})
+	require.NoError(t, err, "list images")
+	for _, img := range images {
+		if slices.Contains(img.RepoTags, ref) {
+			t.Logf("image %q found locally, not pulling", ref)
+			return
+		}
+	}
+	t.Logf("image %s not found locally, attempting to pull", ref)
+	resp, err := cli.ImagePull(ctx, ref, image.PullOptions{})
+	require.NoError(t, err)
+	_, err = io.ReadAll(resp)
+	require.NoError(t, err)
+}
+
+// execContainer executes the given command in the given container and returns
+// the output and the exit code of the command.
+func execContainer(ctx context.Context, t *testing.T, containerID, command string) (string, int) {
+	t.Helper()
+	t.Logf("exec container cmd: %q", command)
+	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
+	require.NoError(t, err, "connect to docker")
+	defer cli.Close()
+	execConfig := types.ExecConfig{
+		AttachStdout: true,
+		AttachStderr: true,
+		Cmd:          []string{"/bin/sh", "-c", command},
+	}
+	ex, err := cli.ContainerExecCreate(ctx, containerID, execConfig)
+	require.NoError(t, err, "create container exec")
+	resp, err := cli.ContainerExecAttach(ctx, ex.ID, types.ExecStartCheck{})
+	require.NoError(t, err, "attach to container exec")
+	defer resp.Close()
+	var buf bytes.Buffer
+	_, err = stdcopy.StdCopy(&buf, &buf, resp.Reader)
+	require.NoError(t, err, "read stdout")
+	out := buf.String()
+	t.Log("exec container output:\n" + out)
+	execResp, err := cli.ContainerExecInspect(ctx, ex.ID)
+	require.NoError(t, err, "get exec exit code")
+	return out, execResp.ExitCode
+}
+
+// assertOutput asserts that, for each key-value pair in expected:
+// 1. actual[k] as a regex matches expected[k], and
+// 2. the set of keys of expected are not a subset of actual.
+func assertOutput(t *testing.T, expected, actual map[string]string) {
+	t.Helper()
+
+	for expectedKey, expectedValExpr := range expected {
+		actualVal := actual[expectedKey]
+		assert.Regexp(t, expectedValExpr, actualVal, "output key %q does not have expected value", expectedKey)
+	}
+	for actualKey := range actual {
+		_, ok := expected[actualKey]
+		assert.True(t, ok, "unexpected field in actual %q=%q", actualKey, actual[actualKey])
+	}
+}
diff --git a/integration/test-data-source/main.tf b/integration/test-data-source/main.tf
new file mode 100644
index 00000000..cc4802d3
--- /dev/null
+++ b/integration/test-data-source/main.tf
@@ -0,0 +1,96 @@
+terraform {
+  required_providers {
+    coder = {
+      source = "coder/coder"
+    }
+    local = {
+      source = "hashicorp/local"
+    }
+  }
+}
+
+// TODO: test coder_external_auth
+// data coder_external_auth "me" {}
+data "coder_provisioner" "me" {}
+data "coder_workspace" "me" {}
+data "coder_workspace_owner" "me" {}
+data "coder_parameter" "param" {
+  name        = "param"
+  description = "param description"
+  icon        = "param icon"
+}
+data "coder_workspace_preset" "preset" {
+  name        = "preset"
+  description = "preset description"
+  icon        = "preset icon"
+  default     = true
+  parameters = {
+    (data.coder_parameter.param.name) = "preset param value"
+  }
+
+  prebuilds {
+    instances = 1
+    expiration_policy {
+      ttl = 86400
+    }
+    scheduling {
+      timezone = "UTC"
+      schedule {
+        cron      = "* 8-18 * * 1-5"
+        instances = 3
+      }
+      schedule {
+        cron      = "* 8-14 * * 6"
+        instances = 1
+      }
+    }
+  }
+}
+
+locals {
+  # NOTE: these must all be strings in the output
+  output = {
+    "provisioner.arch" : data.coder_provisioner.me.arch,
+    "provisioner.id" : data.coder_provisioner.me.id,
+    "provisioner.os" : data.coder_provisioner.me.os,
+    "workspace.access_port" : tostring(data.coder_workspace.me.access_port),
+    "workspace.access_url" : data.coder_workspace.me.access_url,
+    "workspace.id" : data.coder_workspace.me.id,
+    "workspace.name" : data.coder_workspace.me.name,
+    "workspace.start_count" : tostring(data.coder_workspace.me.start_count),
+    "workspace.template_id" : data.coder_workspace.me.template_id,
+    "workspace.template_name" : data.coder_workspace.me.template_name,
+    "workspace.template_version" : data.coder_workspace.me.template_version,
+    "workspace.transition" : data.coder_workspace.me.transition,
+    "workspace_parameter.name" : data.coder_parameter.param.name,
+    "workspace_parameter.description" : data.coder_parameter.param.description,
+    "workspace_parameter.value" : data.coder_parameter.param.value,
+    "workspace_parameter.icon" : data.coder_parameter.param.icon,
+    "workspace_preset.name" : data.coder_workspace_preset.preset.name,
+    "workspace_preset.description" : data.coder_workspace_preset.preset.description,
+    "workspace_preset.icon" : data.coder_workspace_preset.preset.icon,
+    "workspace_preset.default" : tostring(data.coder_workspace_preset.preset.default),
+    "workspace_preset.parameters.param" : data.coder_workspace_preset.preset.parameters.param,
+    "workspace_preset.prebuilds.instances" : tostring(one(data.coder_workspace_preset.preset.prebuilds).instances),
+    "workspace_preset.prebuilds.expiration_policy.ttl" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).expiration_policy).ttl),
+    "workspace_preset.prebuilds.scheduling.timezone" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).timezone),
+    "workspace_preset.prebuilds.scheduling.schedule0.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[0].cron),
+    "workspace_preset.prebuilds.scheduling.schedule0.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[0].instances),
+    "workspace_preset.prebuilds.scheduling.schedule1.cron" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[1].cron),
+    "workspace_preset.prebuilds.scheduling.schedule1.instances" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).scheduling).schedule[1].instances),
+  }
+}
+
+variable "output_path" {
+  type = string
+}
+
+resource "local_file" "output" {
+  filename = var.output_path
+  content  = jsonencode(local.output)
+}
+
+output "output" {
+  value     = local.output
+  sensitive = true
+}
diff --git a/integration/test-data-source/parameters.yaml b/integration/test-data-source/parameters.yaml
new file mode 100644
index 00000000..0e75c133
--- /dev/null
+++ b/integration/test-data-source/parameters.yaml
@@ -0,0 +1 @@
+param: "param value"
\ No newline at end of file
diff --git a/integration/workspace-owner-filled/main.tf b/integration/workspace-owner-filled/main.tf
new file mode 100644
index 00000000..d2de5661
--- /dev/null
+++ b/integration/workspace-owner-filled/main.tf
@@ -0,0 +1,59 @@
+terraform {
+  required_providers {
+    coder = {
+      source = "coder/coder"
+    }
+    local = {
+      source = "hashicorp/local"
+    }
+  }
+}
+
+// TODO: test coder_external_auth
+// data coder_external_auth "me" {}
+data "coder_provisioner" "me" {}
+data "coder_workspace" "me" {}
+data "coder_workspace_owner" "me" {}
+
+locals {
+  # NOTE: these must all be strings in the output
+  output = {
+    "provisioner.arch" : data.coder_provisioner.me.arch,
+    "provisioner.id" : data.coder_provisioner.me.id,
+    "provisioner.os" : data.coder_provisioner.me.os,
+    "workspace.access_port" : tostring(data.coder_workspace.me.access_port),
+    "workspace.access_url" : data.coder_workspace.me.access_url,
+    "workspace.id" : data.coder_workspace.me.id,
+    "workspace.name" : data.coder_workspace.me.name,
+    "workspace.start_count" : tostring(data.coder_workspace.me.start_count),
+    "workspace.template_id" : data.coder_workspace.me.template_id,
+    "workspace.template_name" : data.coder_workspace.me.template_name,
+    "workspace.template_version" : data.coder_workspace.me.template_version,
+    "workspace.transition" : data.coder_workspace.me.transition,
+    "workspace_owner.email" : data.coder_workspace_owner.me.email,
+    "workspace_owner.full_name" : data.coder_workspace_owner.me.full_name,
+    "workspace_owner.groups" : jsonencode(data.coder_workspace_owner.me.groups),
+    "workspace_owner.id" : data.coder_workspace_owner.me.id,
+    "workspace_owner.name" : data.coder_workspace_owner.me.name,
+    "workspace_owner.oidc_access_token" : data.coder_workspace_owner.me.oidc_access_token,
+    "workspace_owner.session_token" : data.coder_workspace_owner.me.session_token,
+    "workspace_owner.ssh_private_key" : data.coder_workspace_owner.me.ssh_private_key,
+    "workspace_owner.ssh_public_key" : data.coder_workspace_owner.me.ssh_public_key,
+    "workspace_owner.login_type" : data.coder_workspace_owner.me.login_type,
+    "workspace_owner.rbac_roles" : jsonencode(data.coder_workspace_owner.me.rbac_roles),
+  }
+}
+
+variable "output_path" {
+  type = string
+}
+
+resource "local_file" "output" {
+  filename = var.output_path
+  content  = jsonencode(local.output)
+}
+
+output "output" {
+  value     = local.output
+  sensitive = true
+}
diff --git a/integration/workspace-owner-rbac-roles/main.tf b/integration/workspace-owner-rbac-roles/main.tf
new file mode 100644
index 00000000..66b79282
--- /dev/null
+++ b/integration/workspace-owner-rbac-roles/main.tf
@@ -0,0 +1,57 @@
+terraform {
+  required_providers {
+    coder = {
+      source = "coder/coder"
+    }
+    local = {
+      source = "hashicorp/local"
+    }
+  }
+}
+
+data "coder_provisioner" "me" {}
+data "coder_workspace" "me" {}
+data "coder_workspace_owner" "me" {}
+
+locals {
+  # NOTE: these must all be strings in the output
+  output = {
+    "provisioner.arch" : data.coder_provisioner.me.arch,
+    "provisioner.id" : data.coder_provisioner.me.id,
+    "provisioner.os" : data.coder_provisioner.me.os,
+    "workspace.access_port" : tostring(data.coder_workspace.me.access_port),
+    "workspace.access_url" : data.coder_workspace.me.access_url,
+    "workspace.id" : data.coder_workspace.me.id,
+    "workspace.name" : data.coder_workspace.me.name,
+    "workspace.start_count" : tostring(data.coder_workspace.me.start_count),
+    "workspace.template_id" : data.coder_workspace.me.template_id,
+    "workspace.template_name" : data.coder_workspace.me.template_name,
+    "workspace.template_version" : data.coder_workspace.me.template_version,
+    "workspace.transition" : data.coder_workspace.me.transition,
+    "workspace_owner.email" : data.coder_workspace_owner.me.email,
+    "workspace_owner.full_name" : data.coder_workspace_owner.me.full_name,
+    "workspace_owner.groups" : jsonencode(data.coder_workspace_owner.me.groups),
+    "workspace_owner.id" : data.coder_workspace_owner.me.id,
+    "workspace_owner.name" : data.coder_workspace_owner.me.name,
+    "workspace_owner.oidc_access_token" : data.coder_workspace_owner.me.oidc_access_token,
+    "workspace_owner.session_token" : data.coder_workspace_owner.me.session_token,
+    "workspace_owner.ssh_private_key" : data.coder_workspace_owner.me.ssh_private_key,
+    "workspace_owner.ssh_public_key" : data.coder_workspace_owner.me.ssh_public_key,
+    "workspace_owner.login_type" : data.coder_workspace_owner.me.login_type,
+    "workspace_owner.rbac_roles" : jsonencode(data.coder_workspace_owner.me.rbac_roles),
+  }
+}
+
+variable "output_path" {
+  type = string
+}
+
+resource "local_file" "output" {
+  filename = var.output_path
+  content  = jsonencode(local.output)
+}
+
+output "output" {
+  value     = local.output
+  sensitive = true
+}
diff --git a/integration/workspace-owner/main.tf b/integration/workspace-owner/main.tf
new file mode 100644
index 00000000..d2de5661
--- /dev/null
+++ b/integration/workspace-owner/main.tf
@@ -0,0 +1,59 @@
+terraform {
+  required_providers {
+    coder = {
+      source = "coder/coder"
+    }
+    local = {
+      source = "hashicorp/local"
+    }
+  }
+}
+
+// TODO: test coder_external_auth
+// data coder_external_auth "me" {}
+data "coder_provisioner" "me" {}
+data "coder_workspace" "me" {}
+data "coder_workspace_owner" "me" {}
+
+locals {
+  # NOTE: these must all be strings in the output
+  output = {
+    "provisioner.arch" : data.coder_provisioner.me.arch,
+    "provisioner.id" : data.coder_provisioner.me.id,
+    "provisioner.os" : data.coder_provisioner.me.os,
+    "workspace.access_port" : tostring(data.coder_workspace.me.access_port),
+    "workspace.access_url" : data.coder_workspace.me.access_url,
+    "workspace.id" : data.coder_workspace.me.id,
+    "workspace.name" : data.coder_workspace.me.name,
+    "workspace.start_count" : tostring(data.coder_workspace.me.start_count),
+    "workspace.template_id" : data.coder_workspace.me.template_id,
+    "workspace.template_name" : data.coder_workspace.me.template_name,
+    "workspace.template_version" : data.coder_workspace.me.template_version,
+    "workspace.transition" : data.coder_workspace.me.transition,
+    "workspace_owner.email" : data.coder_workspace_owner.me.email,
+    "workspace_owner.full_name" : data.coder_workspace_owner.me.full_name,
+    "workspace_owner.groups" : jsonencode(data.coder_workspace_owner.me.groups),
+    "workspace_owner.id" : data.coder_workspace_owner.me.id,
+    "workspace_owner.name" : data.coder_workspace_owner.me.name,
+    "workspace_owner.oidc_access_token" : data.coder_workspace_owner.me.oidc_access_token,
+    "workspace_owner.session_token" : data.coder_workspace_owner.me.session_token,
+    "workspace_owner.ssh_private_key" : data.coder_workspace_owner.me.ssh_private_key,
+    "workspace_owner.ssh_public_key" : data.coder_workspace_owner.me.ssh_public_key,
+    "workspace_owner.login_type" : data.coder_workspace_owner.me.login_type,
+    "workspace_owner.rbac_roles" : jsonencode(data.coder_workspace_owner.me.rbac_roles),
+  }
+}
+
+variable "output_path" {
+  type = string
+}
+
+resource "local_file" "output" {
+  filename = var.output_path
+  content  = jsonencode(local.output)
+}
+
+output "output" {
+  value     = local.output
+  sensitive = true
+}
diff --git a/main.go b/main.go
index 000436a3..ef606a6d 100644
--- a/main.go
+++ b/main.go
@@ -1,9 +1,11 @@
 package main
 
 import (
+	"flag"
+
 	"github.com/hashicorp/terraform-plugin-sdk/v2/plugin"
 
-	"github.com/coder/terraform-provider-coder/provider"
+	"github.com/coder/terraform-provider-coder/v2/provider"
 )
 
 // Run the docs generation tool, check its repository for more information on how it works and how docs
@@ -11,7 +13,15 @@ import (
 //go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs
 
 func main() {
-	plugin.Serve(&plugin.ServeOpts{
+	debug := flag.Bool("debug", false, "Enable debug mode for the provider")
+	flag.Parse()
+
+	opts := &plugin.ServeOpts{
+		Debug:        *debug,
+		ProviderAddr: "registry.terraform.io/coder/coder",
 		ProviderFunc: provider.New,
-	})
+	}
+
+	servePprof()
+	plugin.Serve(opts)
 }
diff --git a/pprof_unix.go b/pprof_unix.go
new file mode 100644
index 00000000..717bc01b
--- /dev/null
+++ b/pprof_unix.go
@@ -0,0 +1,42 @@
+//go:build !windows
+
+package main
+
+import (
+	"net"
+	"net/http"
+	"net/http/pprof"
+	"os"
+)
+
+// servePprof starts an HTTP server running the pprof goroutine handler on a local unix domain socket. As described in
+// https://github.com/coder/coder/issues/14726 it appears this process is sometimes hanging, unable to exit cleanly,
+// and this prevents additional Coder builds that try to reinstall this provider.  A goroutine dump should allow us to
+// determine what is hanging.
+//
+// This function is best-effort, and just returns early if we fail to set up the directory/listener. We don't want to
+// block the normal functioning of the provider.
+func servePprof() {
+	// Coder runs terraform in a per-build subdirectory of the work directory.  The per-build subdirectory uses a
+	// generated name and is deleted at the end of a build, so we want to place our unix socket up one directory level
+	// in the provisionerd work directory, so we can connect to it from provisionerd.
+	err := os.Mkdir("../.coder", 0o700)
+	if err != nil && !os.IsExist(err) {
+		return
+	}
+
+	// remove the old file, if it exists. It's probably from the last run of the provider
+	if err = os.Remove("../.coder/pprof"); err != nil && !os.IsNotExist(err) {
+		return
+	}
+	l, err := net.Listen("unix", "../.coder/pprof")
+	if err != nil {
+		return
+	}
+	mux := http.NewServeMux()
+	mux.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine"))
+	srv := http.Server{Handler: mux}
+	go srv.Serve(l)
+	// We just leave the server and domain socket up forever. Go programs exit when the `main()` function returns, so
+	// this won't block exiting, and it ensures the pprof server stays up for the entire lifetime of the provider.
+}
diff --git a/pprof_windows.go b/pprof_windows.go
new file mode 100644
index 00000000..05cd3143
--- /dev/null
+++ b/pprof_windows.go
@@ -0,0 +1,6 @@
+//go:build windows
+
+package main
+
+// servePprof is not supported on Windows
+func servePprof() {}
diff --git a/provider/agent.go b/provider/agent.go
index 9b5ac3f3..32da2e58 100644
--- a/provider/agent.go
+++ b/provider/agent.go
@@ -2,24 +2,35 @@ package provider
 
 import (
 	"context"
+	"crypto/sha256"
+	"encoding/hex"
 	"fmt"
-	"os"
+	"path/filepath"
 	"reflect"
 	"strings"
 
 	"github.com/google/uuid"
+	"github.com/hashicorp/go-cty/cty"
+	"github.com/hashicorp/terraform-plugin-log/tflog"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
+	"golang.org/x/xerrors"
+
+	"github.com/coder/terraform-provider-coder/v2/provider/helpers"
 )
 
 func agentResource() *schema.Resource {
 	return &schema.Resource{
+		SchemaVersion: 1,
+
 		Description: "Use this resource to associate an agent.",
 		CreateContext: func(ctx context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics {
-			// This should be a real authentication token!
-			resourceData.SetId(uuid.NewString())
-			err := resourceData.Set("token", uuid.NewString())
+			agentID := uuid.NewString()
+			resourceData.SetId(agentID)
+
+			token := agentAuthToken(ctx, "")
+			err := resourceData.Set("token", token)
 			if err != nil {
 				return diag.FromErr(err)
 			}
@@ -38,13 +49,16 @@ func agentResource() *schema.Resource {
 					return diag.FromErr(err)
 				}
 			}
+
 			return updateInitScript(resourceData, i)
 		},
 		ReadWithoutTimeout: func(ctx context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics {
-			err := resourceData.Set("token", uuid.NewString())
+			token := agentAuthToken(ctx, "")
+			err := resourceData.Set("token", token)
 			if err != nil {
 				return diag.FromErr(err)
 			}
+
 			if _, ok := resourceData.GetOk("display_apps"); !ok {
 				err = resourceData.Set("display_apps", []interface{}{
 					map[string]bool{
@@ -66,6 +80,17 @@ func agentResource() *schema.Resource {
 			return nil
 		},
 		Schema: map[string]*schema.Schema{
+			"api_key_scope": {
+				Type:        schema.TypeString,
+				Optional:    true,
+				Default:     "all",
+				ForceNew:    true,
+				Description: "Controls what API routes the agent token can access. Options: `all` (full access) or `no_user_data` (blocks `/external-auth`, `/gitsshkey`, and `/gitauth` routes)",
+				ValidateFunc: validation.StringInSlice([]string{
+					"all",
+					"no_user_data",
+				}, false),
+			},
 			"init_script": {
 				Type:        schema.TypeString,
 				Computed:    true,
@@ -75,7 +100,7 @@ func agentResource() *schema.Resource {
 				Type:         schema.TypeString,
 				ForceNew:     true,
 				Required:     true,
-				Description:  `The architecture the agent will run on. Must be one of: "amd64", "armv7", "arm64".`,
+				Description:  "The architecture the agent will run on. Must be one of: `\"amd64\"`, `\"armv7\"`, `\"arm64\"`.",
 				ValidateFunc: validation.StringInSlice([]string{"amd64", "armv7", "arm64"}, false),
 			},
 			"auth": {
@@ -83,14 +108,14 @@ func agentResource() *schema.Resource {
 				Default:      "token",
 				ForceNew:     true,
 				Optional:     true,
-				Description:  `The authentication type the agent will use. Must be one of: "token", "google-instance-identity", "aws-instance-identity", "azure-instance-identity".`,
+				Description:  "The authentication type the agent will use. Must be one of: `\"token\"`, `\"google-instance-identity\"`, `\"aws-instance-identity\"`, `\"azure-instance-identity\"`.",
 				ValidateFunc: validation.StringInSlice([]string{"token", "google-instance-identity", "aws-instance-identity", "azure-instance-identity"}, false),
 			},
 			"dir": {
 				Type:        schema.TypeString,
 				ForceNew:    true,
 				Optional:    true,
-				Description: "The starting directory when a user creates a shell session. Defaults to $HOME.",
+				Description: "The starting directory when a user creates a shell session. Defaults to `\"$HOME\"`.",
 			},
 			"env": {
 				ForceNew:    true,
@@ -102,41 +127,25 @@ func agentResource() *schema.Resource {
 				Type:         schema.TypeString,
 				ForceNew:     true,
 				Required:     true,
-				Description:  `The operating system the agent will run on. Must be one of: "linux", "darwin", or "windows".`,
+				Description:  "The operating system the agent will run on. Must be one of: `\"linux\"`, `\"darwin\"`, or `\"windows\"`.",
 				ValidateFunc: validation.StringInSlice([]string{"linux", "darwin", "windows"}, false),
 			},
 			"startup_script": {
 				ForceNew:    true,
-				Description: "A script to run after the agent starts. The script should exit when it is done to signal that the agent is ready.",
+				Description: "A script to run after the agent starts. The script should exit when it is done to signal that the agent is ready. This option is an alias for defining a `coder_script` resource with `run_on_start` set to `true`.",
 				Type:        schema.TypeString,
 				Optional:    true,
 			},
-			"startup_script_timeout": {
-				Type:         schema.TypeInt,
-				Default:      300,
-				ForceNew:     true,
-				Optional:     true,
-				Description:  "Time in seconds until the agent lifecycle status is marked as timed out during start, this happens when the startup script has not completed (exited) in the given time.",
-				ValidateFunc: validation.IntAtLeast(1),
-			},
 			"shutdown_script": {
 				Type:        schema.TypeString,
 				ForceNew:    true,
 				Optional:    true,
-				Description: "A script to run before the agent is stopped. The script should exit when it is done to signal that the workspace can be stopped.",
-			},
-			"shutdown_script_timeout": {
-				Type:         schema.TypeInt,
-				Default:      300,
-				ForceNew:     true,
-				Optional:     true,
-				Description:  "Time in seconds until the agent lifecycle status is marked as timed out during shutdown, this happens when the shutdown script has not completed (exited) in the given time.",
-				ValidateFunc: validation.IntAtLeast(1),
+				Description: "A script to run before the agent is stopped. The script should exit when it is done to signal that the workspace can be stopped. This option is an alias for defining a `coder_script` resource with `run_on_stop` set to `true`.",
 			},
 			"token": {
 				ForceNew:    true,
 				Sensitive:   true,
-				Description: `Set the environment variable "CODER_AGENT_TOKEN" with this token to authenticate an agent.`,
+				Description: "Set the environment variable `CODER_AGENT_TOKEN` with this token to authenticate an agent.",
 				Type:        schema.TypeString,
 				Computed:    true,
 			},
@@ -158,36 +167,19 @@ func agentResource() *schema.Resource {
 				Type:        schema.TypeString,
 				ForceNew:    true,
 				Optional:    true,
-				Description: "The path to a file within the workspace containing a message to display to users when they login via SSH. A typical value would be /etc/motd.",
-			},
-			"login_before_ready": {
-				// Note: When this is removed, "startup_script_behavior" should
-				// be set to "non-blocking" by default (instead of empty string).
-				Type:          schema.TypeBool,
-				Default:       true,
-				ForceNew:      true,
-				Optional:      true,
-				Description:   "This option defines whether or not the user can (by default) login to the workspace before it is ready. Ready means that e.g. the startup_script is done and has exited. When enabled, users may see an incomplete workspace when logging in.",
-				Deprecated:    "Configure startup_script_behavior instead. This attribute will be removed in a future version of the provider.",
-				ConflictsWith: []string{"startup_script_behavior"},
+				Description: "The path to a file within the workspace containing a message to display to users when they login via SSH. A typical value would be `\"/etc/motd\"`.",
 			},
 			"startup_script_behavior": {
-				// Note: Our default value is "non-blocking" but we do not set
-				// it here because we want to be able to differentiate between
-				// the user setting this or "login_before_ready". For all
-				// intents and purposes, until deprecation, setting
-				// "login_before_ready = false" is equivalent to setting
-				// "startup_script_behavior = blocking".
-				Type:          schema.TypeString,
-				ForceNew:      true,
-				Optional:      true,
-				Description:   "This option sets the behavior of the `startup_script`. When set to \"blocking\", the startup_script must exit before the workspace is ready. When set to \"non-blocking\", the startup_script may run in the background and the workspace will be ready immediately. Default is \"non-blocking\", although \"blocking\" is recommended.",
-				ValidateFunc:  validation.StringInSlice([]string{"blocking", "non-blocking"}, false),
-				ConflictsWith: []string{"login_before_ready"},
+				Type:         schema.TypeString,
+				Default:      "non-blocking",
+				ForceNew:     true,
+				Optional:     true,
+				Description:  "This option sets the behavior of the `startup_script`. When set to `\"blocking\"`, the `startup_script` must exit before the workspace is ready. When set to `\"non-blocking\"`, the `startup_script` may run in the background and the workspace will be ready immediately. Default is `\"non-blocking\"`, although `\"blocking\"` is recommended. This option is an alias for defining a `coder_script` resource with `start_blocks_login` set to `true` (blocking).",
+				ValidateFunc: validation.StringInSlice([]string{"blocking", "non-blocking"}, false),
 			},
 			"metadata": {
 				Type:        schema.TypeList,
-				Description: "Each \"metadata\" block defines a single item consisting of a key/value pair. This feature is in alpha and may break in future releases.",
+				Description: "Each `metadata` block defines a single item consisting of a key/value pair. This feature is in alpha and may break in future releases.",
 				ForceNew:    true,
 				Optional:    true,
 				Elem: &schema.Resource{
@@ -225,6 +217,12 @@ func agentResource() *schema.Resource {
 							ForceNew:    true,
 							Required:    true,
 						},
+						"order": {
+							Type:        schema.TypeInt,
+							Description: "The order determines the position of agent metadata in the UI presentation. The lowest order is shown first and metadata with equal order are sorted by key (ascending order).",
+							ForceNew:    true,
+							Optional:    true,
+						},
 					},
 				},
 			},
@@ -275,6 +273,149 @@ func agentResource() *schema.Resource {
 					},
 				},
 			},
+			"order": {
+				Type:        schema.TypeInt,
+				Description: "The order determines the position of agents in the UI presentation. The lowest order is shown first and agents with equal order are sorted by name (ascending order).",
+				ForceNew:    true,
+				Optional:    true,
+			},
+			"resources_monitoring": {
+				Type:        schema.TypeSet,
+				Description: "The resources monitoring configuration for this agent.",
+				ForceNew:    true,
+				Optional:    true,
+				MaxItems:    1,
+				Elem: &schema.Resource{
+					Schema: map[string]*schema.Schema{
+						"memory": {
+							Type:        schema.TypeSet,
+							Description: "The memory monitoring configuration for this agent.",
+							ForceNew:    true,
+							Optional:    true,
+							MaxItems:    1,
+							Elem: &schema.Resource{
+								Schema: map[string]*schema.Schema{
+									"enabled": {
+										Type:        schema.TypeBool,
+										Description: "Enable memory monitoring for this agent.",
+										ForceNew:    true,
+										Required:    true,
+									},
+									"threshold": {
+										Type:         schema.TypeInt,
+										Description:  "The memory usage threshold in percentage at which to trigger an alert. Value should be between 0 and 100.",
+										ForceNew:     true,
+										Required:     true,
+										ValidateFunc: validation.IntBetween(0, 100),
+									},
+								},
+							},
+						},
+						"volume": {
+							Type:        schema.TypeSet,
+							Description: "The volumes monitoring configuration for this agent.",
+							ForceNew:    true,
+							Optional:    true,
+							Elem: &schema.Resource{
+								Schema: map[string]*schema.Schema{
+									"path": {
+										Type:        schema.TypeString,
+										Description: "The path of the volume to monitor.",
+										ForceNew:    true,
+										Required:    true,
+										ValidateDiagFunc: func(i interface{}, s cty.Path) diag.Diagnostics {
+											path, ok := i.(string)
+											if !ok {
+												return diag.Errorf("volume path must be a string")
+											}
+											if path == "" {
+												return diag.Errorf("volume path must not be empty")
+											}
+
+											if !filepath.IsAbs(i.(string)) {
+												return diag.Errorf("volume path must be an absolute path")
+											}
+
+											return nil
+										},
+									},
+									"enabled": {
+										Type:        schema.TypeBool,
+										Description: "Enable volume monitoring for this agent.",
+										ForceNew:    true,
+										Required:    true,
+									},
+									"threshold": {
+										Type:         schema.TypeInt,
+										Description:  "The volume usage threshold in percentage at which to trigger an alert. Value should be between 0 and 100.",
+										ForceNew:     true,
+										Required:     true,
+										ValidateFunc: validation.IntBetween(0, 100),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		CustomizeDiff: func(ctx context.Context, rd *schema.ResourceDiff, i any) error {
+			if rd.HasChange("metadata") {
+				keys := map[string]bool{}
+				metadata, ok := rd.Get("metadata").([]any)
+				if !ok {
+					return xerrors.Errorf("unexpected type %T for metadata, expected []any", rd.Get("metadata"))
+				}
+				for _, t := range metadata {
+					obj, ok := t.(map[string]any)
+					if !ok {
+						return xerrors.Errorf("unexpected type %T for metadata, expected map[string]any", t)
+					}
+					key, ok := obj["key"].(string)
+					if !ok {
+						return xerrors.Errorf("unexpected type %T for metadata key, expected string", obj["key"])
+					}
+					if keys[key] {
+						return xerrors.Errorf("duplicate agent metadata key %q", key)
+					}
+					keys[key] = true
+				}
+			}
+
+			if rd.HasChange("resources_monitoring") {
+				monitors, ok := rd.Get("resources_monitoring").(*schema.Set)
+				if !ok {
+					return xerrors.Errorf("unexpected type %T for resources_monitoring.0.volume, expected []any", rd.Get("resources_monitoring.0.volume"))
+				}
+
+				monitor := monitors.List()[0].(map[string]any)
+
+				volumes, ok := monitor["volume"].(*schema.Set)
+				if !ok {
+					return xerrors.Errorf("unexpected type %T for resources_monitoring.0.volume, expected []any", monitor["volume"])
+				}
+
+				paths := map[string]bool{}
+				for _, volume := range volumes.List() {
+					obj, ok := volume.(map[string]any)
+					if !ok {
+						return xerrors.Errorf("unexpected type %T for volume, expected map[string]any", volume)
+					}
+
+					// print path for debug purpose
+
+					path, ok := obj["path"].(string)
+					if !ok {
+						return xerrors.Errorf("unexpected type %T for volume path, expected string", obj["path"])
+					}
+					if paths[path] {
+						return xerrors.Errorf("duplicate volume path %q", path)
+					}
+					paths[path] = true
+				}
+			}
+
+			return nil
 		},
 	}
 }
@@ -282,9 +423,9 @@ func agentResource() *schema.Resource {
 func agentInstanceResource() *schema.Resource {
 	return &schema.Resource{
 		Description: "Use this resource to associate an instance ID with an agent for zero-trust " +
-			"authentication. This association is done automatically for \"google_compute_instance\", " +
-			"\"aws_instance\", \"azurerm_linux_virtual_machine\", and " +
-			"\"azurerm_windows_virtual_machine\" resources.",
+			"authentication. This association is done automatically for `\"google_compute_instance\"`, " +
+			"`\"aws_instance\"`, `\"azurerm_linux_virtual_machine\"`, and " +
+			"`\"azurerm_windows_virtual_machine\"` resources.",
 		CreateContext: func(c context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics {
 			resourceData.SetId(uuid.NewString())
 			return nil
@@ -298,14 +439,14 @@ func agentInstanceResource() *schema.Resource {
 		Schema: map[string]*schema.Schema{
 			"agent_id": {
 				Type:        schema.TypeString,
-				Description: `The "id" property of a "coder_agent" resource to associate with.`,
+				Description: "The `id` property of a `coder_agent` resource to associate with.",
 				ForceNew:    true,
 				Required:    true,
 			},
 			"instance_id": {
 				ForceNew:    true,
 				Required:    true,
-				Description: `The instance identifier of a provisioned resource.`,
+				Description: "The instance identifier of a provisioned resource.",
 				Type:        schema.TypeString,
 			},
 		},
@@ -335,7 +476,7 @@ func updateInitScript(resourceData *schema.ResourceData, i interface{}) diag.Dia
 	if err != nil {
 		return diag.Errorf("parse access url: %s", err)
 	}
-	script := os.Getenv(fmt.Sprintf("CODER_AGENT_SCRIPT_%s_%s", operatingSystem, arch))
+	script := helpers.OptionalEnv(fmt.Sprintf("CODER_AGENT_SCRIPT_%s_%s", operatingSystem, arch))
 	if script != "" {
 		script = strings.ReplaceAll(script, "${ACCESS_URL}", accessURL.String())
 		script = strings.ReplaceAll(script, "${AUTH_TYPE}", auth)
@@ -346,3 +487,37 @@ func updateInitScript(resourceData *schema.ResourceData, i interface{}) diag.Dia
 	}
 	return nil
 }
+
+func agentAuthToken(ctx context.Context, agentID string) string {
+	existingToken := helpers.OptionalEnv(RunningAgentTokenEnvironmentVariable(agentID))
+	if existingToken == "" {
+		// Most of the time, we will generate a new token for the agent.
+		// In the case of a prebuilt workspace being claimed, we will override with
+		// an existing token provided below.
+		token := uuid.NewString()
+		return token
+	}
+
+	// An existing token was provided for this agent. That means that this
+	// is a prebuilt workspace in the process of being claimed.
+	// We should reuse the token.
+	tflog.Info(ctx, "using provided agent token for prebuild", map[string]interface{}{
+		"agent_id": agentID,
+	})
+	return existingToken
+}
+
+// RunningAgentTokenEnvironmentVariable returns the name of an environment variable
+// that contains the token to use for the running agent. This is used for prebuilds,
+// where we want to reuse the same token for the next iteration of a workspace agent
+// before and after the workspace was claimed by a user.
+//
+// By reusing an existing token, we can avoid the need to change a value that may have been
+// used immutably. Thus, allowing us to avoid reprovisioning resources that may take a long time
+// to replace.
+//
+// agentID is unused for now, but will be used as soon as we support multiple agents.
+func RunningAgentTokenEnvironmentVariable(agentID string) string {
+	sum := sha256.Sum256([]byte(agentID))
+	return "CODER_RUNNING_WORKSPACE_AGENT_TOKEN_" + hex.EncodeToString(sum[:])
+}
diff --git a/provider/agent_test.go b/provider/agent_test.go
index 9026385c..82e8691f 100644
--- a/provider/agent_test.go
+++ b/provider/agent_test.go
@@ -6,20 +6,15 @@ import (
 	"testing"
 
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
 	"github.com/stretchr/testify/require"
-
-	"github.com/coder/terraform-provider-coder/provider"
 )
 
 func TestAgent(t *testing.T) {
 	t.Parallel()
 	resource.Test(t, resource.TestCase{
-		Providers: map[string]*schema.Provider{
-			"coder": provider.New(),
-		},
-		IsUnitTest: true,
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
 		Steps: []resource.TestStep{{
 			Config: `
 				provider "coder" {
@@ -34,11 +29,10 @@ func TestAgent(t *testing.T) {
 						hi = "test"
 					}
 					startup_script = "echo test"
-					startup_script_timeout = 120
 					troubleshooting_url = "https://example.com/troubleshoot"
 					motd_file = "/etc/motd"
 					shutdown_script = "echo bye bye"
-					shutdown_script_timeout = 120
+					order = 4
 				}
 				`,
 			Check: func(state *terraform.State) error {
@@ -54,12 +48,11 @@ func TestAgent(t *testing.T) {
 					"dir",
 					"env.hi",
 					"startup_script",
-					"startup_script_timeout",
 					"connection_timeout",
 					"troubleshooting_url",
 					"motd_file",
 					"shutdown_script",
-					"shutdown_script_timeout",
+					"order",
 				} {
 					value := resource.Primary.Attributes[key]
 					t.Logf("%q = %q", key, value)
@@ -107,43 +100,13 @@ func TestAgent_StartupScriptBehavior(t *testing.T) {
 				require.Equal(t, "non-blocking", state.Primary.Attributes["startup_script_behavior"])
 			},
 		},
-		{
-			Name: "login_before_ready (deprecated)",
-			Config: `
-				resource "coder_agent" "new" {
-					os = "linux"
-					arch = "amd64"
-					login_before_ready = false
-				}
-			`,
-			Check: func(state *terraform.ResourceState) {
-				require.Equal(t, "false", state.Primary.Attributes["login_before_ready"])
-				// startup_script_behavior must be empty, this indicates that
-				// login_before_ready should be used instead.
-				require.Equal(t, "", state.Primary.Attributes["startup_script_behavior"])
-			},
-		},
-		{
-			Name: "no login_before_ready with startup_script_behavior",
-			Config: `
-				resource "coder_agent" "new" {
-					os = "linux"
-					arch = "amd64"
-					login_before_ready = false
-					startup_script_behavior = "blocking"
-				}
-			`,
-			ExpectError: regexp.MustCompile("conflicts with"),
-		},
 	} {
 		tc := tc
 		t.Run(tc.Name, func(t *testing.T) {
 			t.Parallel()
 			resource.Test(t, resource.TestCase{
-				Providers: map[string]*schema.Provider{
-					"coder": provider.New(),
-				},
-				IsUnitTest: true,
+				ProviderFactories: coderFactory(),
+				IsUnitTest:        true,
 				Steps: []resource.TestStep{{
 					Config:      tc.Config,
 					ExpectError: tc.ExpectError,
@@ -166,10 +129,8 @@ func TestAgent_StartupScriptBehavior(t *testing.T) {
 func TestAgent_Instance(t *testing.T) {
 	t.Parallel()
 	resource.Test(t, resource.TestCase{
-		Providers: map[string]*schema.Provider{
-			"coder": provider.New(),
-		},
-		IsUnitTest: true,
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
 		Steps: []resource.TestStep{{
 			Config: `
 				provider "coder" {
@@ -207,10 +168,8 @@ func TestAgent_Instance(t *testing.T) {
 func TestAgent_Metadata(t *testing.T) {
 	t.Parallel()
 	resource.Test(t, resource.TestCase{
-		Providers: map[string]*schema.Provider{
-			"coder": provider.New(),
-		},
-		IsUnitTest: true,
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
 		Steps: []resource.TestStep{{
 			Config: `
 				provider "coder" {
@@ -225,6 +184,7 @@ func TestAgent_Metadata(t *testing.T) {
 						script = "ps aux | wc -l"
 						interval = 5
 						timeout = 1
+						order = 7
 					}
 				}
 				`,
@@ -244,20 +204,350 @@ func TestAgent_Metadata(t *testing.T) {
 				require.Equal(t, "ps aux | wc -l", attr["metadata.0.script"])
 				require.Equal(t, "5", attr["metadata.0.interval"])
 				require.Equal(t, "1", attr["metadata.0.timeout"])
+				require.Equal(t, "7", attr["metadata.0.order"])
 				return nil
 			},
 		}},
 	})
 }
 
+func TestAgent_ResourcesMonitoring(t *testing.T) {
+	t.Parallel()
+
+	t.Run("OK", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+				provider "coder" {
+					url = "https://example.com"
+				}
+				resource "coder_agent" "dev" {
+					os = "linux"
+					arch = "amd64"
+					resources_monitoring {
+						memory {
+							enabled = true
+							threshold = 80
+						}
+						volume {
+							path = "/volume1"
+							enabled = true
+							threshold = 80
+						}
+						volume {
+							path = "/volume2"
+							enabled = true
+							threshold = 100
+						}
+					}
+				}`,
+				Check: func(state *terraform.State) error {
+					require.Len(t, state.Modules, 1)
+					require.Len(t, state.Modules[0].Resources, 1)
+
+					resource := state.Modules[0].Resources["coder_agent.dev"]
+					require.NotNil(t, resource)
+
+					attr := resource.Primary.Attributes
+					require.Equal(t, "1", attr["resources_monitoring.#"])
+					require.Equal(t, "1", attr["resources_monitoring.0.memory.#"])
+					require.Equal(t, "2", attr["resources_monitoring.0.volume.#"])
+					require.Equal(t, "80", attr["resources_monitoring.0.memory.0.threshold"])
+					require.Equal(t, "/volume1", attr["resources_monitoring.0.volume.0.path"])
+					require.Equal(t, "100", attr["resources_monitoring.0.volume.1.threshold"])
+					require.Equal(t, "/volume2", attr["resources_monitoring.0.volume.1.path"])
+					return nil
+				},
+			}},
+		})
+	})
+
+	t.Run("OnlyMemory", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+					provider "coder" {
+						url = "https://example.com"
+					}
+					resource "coder_agent" "dev" {
+						os = "linux"
+						arch = "amd64"
+						resources_monitoring {
+							memory {
+								enabled = true
+								threshold = 80
+							}
+						}
+					}`,
+				Check: func(state *terraform.State) error {
+					require.Len(t, state.Modules, 1)
+					require.Len(t, state.Modules[0].Resources, 1)
+
+					resource := state.Modules[0].Resources["coder_agent.dev"]
+					require.NotNil(t, resource)
+
+					attr := resource.Primary.Attributes
+					require.Equal(t, "1", attr["resources_monitoring.#"])
+					require.Equal(t, "1", attr["resources_monitoring.0.memory.#"])
+					require.Equal(t, "80", attr["resources_monitoring.0.memory.0.threshold"])
+					return nil
+				},
+			}},
+		})
+	})
+	t.Run("MultipleMemory", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+					provider "coder" {
+						url = "https://example.com"
+					}
+					resource "coder_agent" "dev" {
+						os = "linux"
+						arch = "amd64"
+						resources_monitoring {
+							memory {
+								enabled = true
+								threshold = 80
+							}
+							memory {
+								enabled = true
+								threshold = 90
+							}
+						}
+					}`,
+				ExpectError: regexp.MustCompile(`No more than 1 "memory" blocks are allowed`),
+			}},
+		})
+	})
+
+	t.Run("InvalidThreshold", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+					provider "coder" {
+						url = "https://example.com"
+					}
+					resource "coder_agent" "dev" {
+						os = "linux"
+						arch = "amd64"
+						resources_monitoring {
+							memory {
+								enabled = true
+								threshold = 101
+							}
+						}
+					}`,
+				Check:       nil,
+				ExpectError: regexp.MustCompile(`expected resources_monitoring\.0\.memory\.0\.threshold to be in the range \(0 - 100\), got 101`),
+			}},
+		})
+	})
+
+	t.Run("DuplicatePaths", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+					provider "coder" {
+						url = "https://example.com"
+					}
+					resource "coder_agent" "dev" {
+						os = "linux"
+						arch = "amd64"
+						resources_monitoring {
+							volume {
+								path = "/volume1"
+								enabled = true
+								threshold = 80
+							}
+							volume {
+								path = "/volume1"
+								enabled = true
+								threshold = 100
+							}
+						}
+					}`,
+				ExpectError: regexp.MustCompile("duplicate volume path"),
+			}},
+		})
+	})
+
+	t.Run("NoPath", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+					provider "coder" {
+						url = "https://example.com"
+					}
+					resource "coder_agent" "dev" {
+						os = "linux"
+						arch = "amd64"
+						resources_monitoring {
+							volume {
+								enabled = true
+								threshold = 80
+							}
+						}
+					}`,
+				ExpectError: regexp.MustCompile(`The argument "path" is required, but no definition was found.`),
+			}},
+		})
+	})
+
+	t.Run("NonAbsPath", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+					provider "coder" {
+						url = "https://example.com"
+					}
+					resource "coder_agent" "dev" {
+						os = "linux"
+						arch = "amd64"
+						resources_monitoring {
+							volume {
+								path = "tmp"
+								enabled = true
+								threshold = 80
+							}
+						}
+					}`,
+				Check:       nil,
+				ExpectError: regexp.MustCompile(`volume path must be an absolute path`),
+			}},
+		})
+	})
+
+	t.Run("EmptyPath", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+					provider "coder" {
+						url = "https://example.com"
+					}
+					resource "coder_agent" "dev" {
+						os = "linux"
+						arch = "amd64"
+						resources_monitoring {
+							volume {
+								path = ""
+								enabled = true
+								threshold = 80
+							}
+						}
+					}`,
+				Check:       nil,
+				ExpectError: regexp.MustCompile(`volume path must not be empty`),
+			}},
+		})
+	})
+
+	t.Run("ThresholdMissing", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+					provider "coder" {
+						url = "https://example.com"
+					}
+					resource "coder_agent" "dev" {
+						os = "linux"
+						arch = "amd64"
+						resources_monitoring {
+							volume {
+								path = "/volume1"
+								enabled = true
+							}
+						}
+					}`,
+				Check:       nil,
+				ExpectError: regexp.MustCompile(`The argument "threshold" is required, but no definition was found.`),
+			}},
+		})
+	})
+	t.Run("EnabledMissing", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+					provider "coder" {
+						url = "https://example.com"
+					}
+					resource "coder_agent" "dev" {
+						os = "linux"
+						arch = "amd64"
+						resources_monitoring {
+							memory {
+								threshold = 80
+							}
+						}
+					}`,
+				Check:       nil,
+				ExpectError: regexp.MustCompile(`The argument "enabled" is required, but no definition was found.`),
+			}},
+		})
+	})
+}
+
+func TestAgent_MetadataDuplicateKeys(t *testing.T) {
+	t.Parallel()
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+				provider "coder" {
+					url = "https://example.com"
+				}
+				resource "coder_agent" "dev" {
+					os = "linux"
+					arch = "amd64"
+					metadata {
+						key = "process_count"
+						display_name = "Process Count"
+						script = "ps aux | wc -l"
+						interval = 5
+						timeout = 1
+					}
+					metadata {
+						key = "process_count"
+						display_name = "Process Count"
+						script = "ps aux | wc -l"
+						interval = 5
+						timeout = 1
+					}
+				}
+				`,
+			ExpectError: regexp.MustCompile("duplicate agent metadata key"),
+			PlanOnly:    true,
+		}},
+	})
+}
+
 func TestAgent_DisplayApps(t *testing.T) {
 	t.Parallel()
 	t.Run("OK", func(t *testing.T) {
 		resource.Test(t, resource.TestCase{
-			Providers: map[string]*schema.Provider{
-				"coder": provider.New(),
-			},
-			IsUnitTest: true,
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
 			Steps: []resource.TestStep{{
 				// Test the fields with non-default values.
 				Config: `
@@ -273,7 +563,7 @@ func TestAgent_DisplayApps(t *testing.T) {
 							web_terminal = false
 							port_forwarding_helper = false
 							ssh_helper = false
-						} 
+						}
 					}
 					`,
 				Check: func(state *terraform.State) error {
@@ -307,10 +597,8 @@ func TestAgent_DisplayApps(t *testing.T) {
 
 	t.Run("Subset", func(t *testing.T) {
 		resource.Test(t, resource.TestCase{
-			Providers: map[string]*schema.Provider{
-				"coder": provider.New(),
-			},
-			IsUnitTest: true,
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
 			Steps: []resource.TestStep{{
 				// Test the fields with non-default values.
 				Config: `
@@ -323,7 +611,7 @@ func TestAgent_DisplayApps(t *testing.T) {
 						display_apps {
 							vscode_insiders = true
 							web_terminal = true
-						} 
+						}
 					}
 					`,
 				Check: func(state *terraform.State) error {
@@ -354,10 +642,8 @@ func TestAgent_DisplayApps(t *testing.T) {
 	// Assert all the defaults are set correctly.
 	t.Run("Omitted", func(t *testing.T) {
 		resource.Test(t, resource.TestCase{
-			Providers: map[string]*schema.Provider{
-				"coder": provider.New(),
-			},
-			IsUnitTest: true,
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
 			Steps: []resource.TestStep{{
 				Config: `
 					provider "coder" {
@@ -399,10 +685,8 @@ func TestAgent_DisplayApps(t *testing.T) {
 
 	t.Run("InvalidApp", func(t *testing.T) {
 		resource.Test(t, resource.TestCase{
-			Providers: map[string]*schema.Provider{
-				"coder": provider.New(),
-			},
-			IsUnitTest: true,
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
 			Steps: []resource.TestStep{{
 				// Test the fields with non-default values.
 				Config: `
@@ -418,12 +702,103 @@ func TestAgent_DisplayApps(t *testing.T) {
 							web_terminal = false
 							port_forwarding_helper = false
 							ssh_helper = false
-						} 
+						}
 					}
 					`,
 				ExpectError: regexp.MustCompile(`An argument named "fake_app" is not expected here.`),
 			}},
 		})
 	})
+}
+
+// TestAgent_APIKeyScope tests valid states/transitions and invalid values for api_key_scope.
+func TestAgent_APIKeyScope(t *testing.T) {
+	t.Parallel()
+
+	t.Run("ValidTransitions", func(t *testing.T) {
+		t.Parallel()
 
+		resourceName := "coder_agent.test_scope_valid"
+
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{
+				// Step 1: Default value
+				{
+					Config: `
+						provider "coder" {
+							url = "https://example.com"
+						}
+						resource "coder_agent" "test_scope_valid" {
+							os   = "linux"
+							arch = "amd64"
+							# api_key_scope is omitted, should default to "default"
+						}
+					`,
+					Check: resource.ComposeTestCheckFunc(
+						resource.TestCheckResourceAttr(resourceName, "api_key_scope", "all"),
+					),
+				},
+				// Step 2: Explicit "default"
+				{
+					Config: `
+						provider "coder" {
+							url = "https://example.com"
+						}
+						resource "coder_agent" "test_scope_valid" {
+							os              = "linux"
+							arch            = "amd64"
+							api_key_scope   = "all"
+						}
+					`,
+					Check: resource.ComposeTestCheckFunc(
+						resource.TestCheckResourceAttr(resourceName, "api_key_scope", "all"),
+					),
+				},
+				// Step 3: Explicit "no_user_data"
+				{
+					Config: `
+						provider "coder" {
+							url = "https://example.com"
+						}
+						resource "coder_agent" "test_scope_valid" {
+							os              = "linux"
+							arch            = "amd64"
+							api_key_scope   = "no_user_data"
+						}
+					`,
+					Check: resource.ComposeTestCheckFunc(
+						resource.TestCheckResourceAttr(resourceName, "api_key_scope", "no_user_data"),
+					),
+				},
+			},
+		})
+	})
+
+	t.Run("InvalidValue", func(t *testing.T) {
+		t.Parallel()
+
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{
+				// Step 1: Invalid value check
+				{
+					Config: `
+						provider "coder" {
+							url = "https://example.com"
+						}
+						resource "coder_agent" "test_scope_invalid" { // Use unique name
+							os              = "linux"
+							arch            = "amd64"
+							api_key_scope   = "invalid-scope"
+						}
+					`,
+					ExpectError: regexp.MustCompile(`expected api_key_scope to be one of \["all" "no_user_data"\], got invalid-scope`),
+					PlanOnly:    true,
+				},
+			},
+		})
+	})
 }
diff --git a/provider/ai_task.go b/provider/ai_task.go
new file mode 100644
index 00000000..76b19f3c
--- /dev/null
+++ b/provider/ai_task.go
@@ -0,0 +1,61 @@
+package provider
+
+import (
+	"context"
+
+	"github.com/google/uuid"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
+)
+
+type AITask struct {
+	ID         string             `mapstructure:"id"`
+	SidebarApp []AITaskSidebarApp `mapstructure:"sidebar_app"`
+}
+
+type AITaskSidebarApp struct {
+	ID string `mapstructure:"id"`
+}
+
+// TaskPromptParameterName is the name of the parameter which is *required* to be defined when a coder_ai_task is used.
+const TaskPromptParameterName = "AI Prompt"
+
+func aiTask() *schema.Resource {
+	return &schema.Resource{
+		SchemaVersion: 1,
+
+		Description: "Use this resource to define Coder tasks.",
+		CreateContext: func(c context.Context, resourceData *schema.ResourceData, i any) diag.Diagnostics {
+			resourceData.SetId(uuid.NewString())
+			return nil
+		},
+		ReadContext:   schema.NoopContext,
+		DeleteContext: schema.NoopContext,
+		Schema: map[string]*schema.Schema{
+			"id": {
+				Type:        schema.TypeString,
+				Description: "A unique identifier for this resource.",
+				Computed:    true,
+			},
+			"sidebar_app": {
+				Type:        schema.TypeSet,
+				Description: "The coder_app to display in the sidebar. Usually a chat interface with the AI agent running in the workspace, like https://github.com/coder/agentapi.",
+				ForceNew:    true,
+				Required:    true,
+				MaxItems:    1,
+				Elem: &schema.Resource{
+					Schema: map[string]*schema.Schema{
+						"id": {
+							Type:         schema.TypeString,
+							Description:  "A reference to an existing `coder_app` resource in your template.",
+							Required:     true,
+							ForceNew:     true,
+							ValidateFunc: validation.IsUUID,
+						},
+					},
+				},
+			},
+		},
+	}
+}
diff --git a/provider/ai_task_test.go b/provider/ai_task_test.go
new file mode 100644
index 00000000..5f7a8a49
--- /dev/null
+++ b/provider/ai_task_test.go
@@ -0,0 +1,82 @@
+package provider_test
+
+import (
+	"regexp"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+	"github.com/stretchr/testify/require"
+)
+
+func TestAITask(t *testing.T) {
+	t.Parallel()
+
+	t.Run("OK", func(t *testing.T) {
+		t.Parallel()
+
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+				provider "coder" {
+				}
+				resource "coder_agent" "dev" {
+					os = "linux"
+					arch = "amd64"
+				}
+				resource "coder_app" "code-server" {
+					agent_id = coder_agent.dev.id
+					slug = "code-server"
+					display_name = "code-server"
+					icon = "builtin:vim"
+					url = "http://localhost:13337"
+					open_in = "slim-window"
+				}
+				resource "coder_ai_task" "test" {
+					sidebar_app {
+						id = coder_app.code-server.id
+					}
+				}
+				`,
+				Check: func(state *terraform.State) error {
+					require.Len(t, state.Modules, 1)
+					resource := state.Modules[0].Resources["coder_ai_task.test"]
+					require.NotNil(t, resource)
+					for _, key := range []string{
+						"id",
+						"sidebar_app.#",
+					} {
+						value := resource.Primary.Attributes[key]
+						require.NotNil(t, value)
+						require.Greater(t, len(value), 0)
+					}
+					require.Equal(t, "1", resource.Primary.Attributes["sidebar_app.#"])
+					return nil
+				},
+			}},
+		})
+	})
+
+	t.Run("InvalidSidebarAppID", func(t *testing.T) {
+		t.Parallel()
+
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+				provider "coder" {
+				}
+				resource "coder_ai_task" "test" {
+					sidebar_app {
+						id = "not-a-uuid"
+					}
+				}
+				`,
+				ExpectError: regexp.MustCompile(`expected "sidebar_app.0.id" to be a valid UUID`),
+			}},
+		})
+	})
+}
diff --git a/provider/app.go b/provider/app.go
index d8471788..52996a72 100644
--- a/provider/app.go
+++ b/provider/app.go
@@ -2,13 +2,14 @@ package provider
 
 import (
 	"context"
-	"net/url"
 	"regexp"
 
 	"github.com/google/uuid"
 	"github.com/hashicorp/go-cty/cty"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+
+	"github.com/coder/terraform-provider-coder/v2/provider/helpers"
 )
 
 var (
@@ -23,23 +24,59 @@ var (
 	appSlugRegex = regexp.MustCompile(`^[a-z0-9](-?[a-z0-9])*$`)
 )
 
+const (
+	appDisplayNameMaxLength = 64 // database column limit
+	appGroupNameMaxLength   = 64
+)
+
 func appResource() *schema.Resource {
 	return &schema.Resource{
+		SchemaVersion: 1,
+
 		Description: "Use this resource to define shortcuts to access applications in a workspace.",
-		CreateContext: func(c context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics {
+		CreateContext: func(c context.Context, resourceData *schema.ResourceData, i any) diag.Diagnostics {
 			resourceData.SetId(uuid.NewString())
-			return nil
+
+			diags := diag.Diagnostics{}
+
+			hiddenData := resourceData.Get("hidden")
+			if hidden, ok := hiddenData.(bool); !ok {
+				return diag.Errorf("hidden should be a bool")
+			} else if hidden {
+				if _, ok := resourceData.GetOk("display_name"); ok {
+					diags = append(diags, diag.Diagnostic{
+						Severity: diag.Warning,
+						Summary:  "`display_name` set when app is hidden",
+					})
+				}
+
+				if _, ok := resourceData.GetOk("icon"); ok {
+					diags = append(diags, diag.Diagnostic{
+						Severity: diag.Warning,
+						Summary:  "`icon` set when app is hidden",
+					})
+				}
+
+				if _, ok := resourceData.GetOk("order"); ok {
+					diags = append(diags, diag.Diagnostic{
+						Severity: diag.Warning,
+						Summary:  "`order` set when app is hidden",
+					})
+				}
+			}
+
+			return diags
 		},
-		ReadContext: func(c context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics {
+		ReadContext: func(c context.Context, resourceData *schema.ResourceData, i any) diag.Diagnostics {
 			return nil
 		},
-		DeleteContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
+		DeleteContext: func(ctx context.Context, rd *schema.ResourceData, i any) diag.Diagnostics {
 			return nil
 		},
 		Schema: map[string]*schema.Schema{
 			"agent_id": {
 				Type:        schema.TypeString,
-				Description: `The "id" property of a "coder_agent" resource to associate with.`,
+				Description: "The `id` property of a `coder_agent` resource to associate with.",
 				ForceNew:    true,
 				Required:    true,
 			},
@@ -47,7 +84,7 @@ func appResource() *schema.Resource {
 				Type: schema.TypeString,
 				Description: "A command to run in a terminal opening this app. In the web, " +
 					"this will open in a new tab. In the CLI, this will SSH and execute the command. " +
-					"Either \"command\" or \"url\" may be specified, but not both.",
+					"Either `command` or `url` may be specified, but not both.",
 				ConflictsWith: []string{"url"},
 				Optional:      true,
 				ForceNew:      true,
@@ -55,17 +92,11 @@ func appResource() *schema.Resource {
 			"icon": {
 				Type: schema.TypeString,
 				Description: "A URL to an icon that will display in the dashboard. View built-in " +
-					"icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a " +
-					"built-in icon with `data.coder_workspace.me.access_url + \"/icon/<path>\"`.",
-				ForceNew: true,
-				Optional: true,
-				ValidateFunc: func(i interface{}, s string) ([]string, []error) {
-					_, err := url.Parse(s)
-					if err != nil {
-						return nil, []error{err}
-					}
-					return nil, nil
-				},
+					"icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a " +
+					"built-in icon with `\"${data.coder_workspace.me.access_url}/icon/<path>\"`.",
+				ForceNew:     true,
+				Optional:     true,
+				ValidateFunc: helpers.ValidateURL,
 			},
 			"slug": {
 				Type: schema.TypeString,
@@ -75,14 +106,14 @@ func appResource() *schema.Resource {
 					"hyphen or contain two consecutive hyphens.",
 				ForceNew: true,
 				Required: true,
-				ValidateDiagFunc: func(val interface{}, c cty.Path) diag.Diagnostics {
+				ValidateDiagFunc: func(val any, c cty.Path) diag.Diagnostics {
 					valStr, ok := val.(string)
 					if !ok {
 						return diag.Errorf("expected string, got %T", val)
 					}
 
 					if !appSlugRegex.MatchString(valStr) {
-						return diag.Errorf("invalid coder_app slug, must be a valid hostname (%q, cannot contain two consecutive hyphens or start/end with a hyphen): %q", appSlugRegex.String(), valStr)
+						return diag.Errorf(`invalid "coder_app" slug, must be a valid hostname (%q, cannot contain two consecutive hyphens or start/end with a hyphen): %q`, appSlugRegex.String(), valStr)
 					}
 
 					return nil
@@ -93,48 +124,42 @@ func appResource() *schema.Resource {
 				Description: "A display name to identify the app. Defaults to the slug.",
 				ForceNew:    true,
 				Optional:    true,
-			},
-			"name": {
-				Type:          schema.TypeString,
-				Description:   "A display name to identify the app.",
-				Deprecated:    "`name` on apps is deprecated, use `display_name` instead",
-				ForceNew:      true,
-				Optional:      true,
-				ConflictsWith: []string{"display_name"},
+				ValidateDiagFunc: func(val any, c cty.Path) diag.Diagnostics {
+					valStr, ok := val.(string)
+					if !ok {
+						return diag.Errorf("expected string, got %T", val)
+					}
+
+					if len(valStr) > appDisplayNameMaxLength {
+						return diag.Errorf("display name is too long (max %d characters)", appDisplayNameMaxLength)
+					}
+					return nil
+				},
 			},
 			"subdomain": {
 				Type: schema.TypeBool,
 				Description: "Determines whether the app will be accessed via it's own " +
 					"subdomain or whether it will be accessed via a path on Coder. If " +
 					"wildcards have not been setup by the administrator then apps with " +
-					"\"subdomain\" set to true will not be accessible. Defaults to false.",
+					"`subdomain` set to `true` will not be accessible. Defaults to `false`.",
 				ForceNew: true,
 				Optional: true,
 			},
-			"relative_path": {
-				Type:       schema.TypeBool,
-				Deprecated: "`relative_path` on apps is deprecated, use `subdomain` instead.",
-				Description: "Specifies whether the URL will be accessed via a relative " +
-					"path or wildcard. Use if wildcard routing is unavailable. Defaults to true.",
-				ForceNew:      true,
-				Optional:      true,
-				ConflictsWith: []string{"subdomain"},
-			},
 			"share": {
 				Type: schema.TypeString,
-				Description: `Determines the "level" which the application ` +
-					`is shared at. Valid levels are "owner" (default), ` +
-					`"authenticated" and "public". Level "owner" disables ` +
+				Description: "Determines the level which the application " +
+					"is shared at. Valid levels are `\"owner\"` (default), " +
+					"`\"authenticated\"` and `\"public\"`. Level `\"owner\"` disables " +
 					"sharing on the app, so only the workspace owner can " +
-					`access it. Level "authenticated" shares the app with ` +
-					`all authenticated users. Level "public" shares it with ` +
+					"access it. Level `\"authenticated\"` shares the app with " +
+					"all authenticated users. Level `\"public\"` shares it with " +
 					"any user, including unauthenticated users. Permitted " +
 					"application sharing levels can be configured site-wide " +
 					"via a flag on `coder server` (Enterprise only).",
 				ForceNew: true,
 				Optional: true,
 				Default:  "owner",
-				ValidateDiagFunc: func(val interface{}, c cty.Path) diag.Diagnostics {
+				ValidateDiagFunc: func(val any, c cty.Path) diag.Diagnostics {
 					valStr, ok := val.(string)
 					if !ok {
 						return diag.Errorf("expected string, got %T", val)
@@ -145,21 +170,21 @@ func appResource() *schema.Resource {
 						return nil
 					}
 
-					return diag.Errorf(`invalid app share %q, must be one of "owner", "authenticated", "public"`, valStr)
+					return diag.Errorf("invalid app share %q, must be one of \"owner\", \"authenticated\", \"public\"", valStr)
 				},
 			},
 			"url": {
 				Type: schema.TypeString,
-				Description: "A URL to be proxied to from inside the workspace. " +
-					"This should be of the form \"http://localhost:PORT[/SUBPATH]\". " +
-					"Either \"command\" or \"url\" may be specified, but not both.",
+				Description: "An external url if `external=true` or a URL to be proxied to from inside the workspace. " +
+					"This should be of the form `http://localhost:PORT[/SUBPATH]`. " +
+					"Either `command` or `url` may be specified, but not both.",
 				ForceNew:      true,
 				Optional:      true,
 				ConflictsWith: []string{"command"},
 			},
 			"external": {
 				Type: schema.TypeBool,
-				Description: "Specifies whether \"url\" is opened on the client machine " +
+				Description: "Specifies whether `url` is opened on the client machine " +
 					"instead of proxied through the workspace.",
 				Default:       false,
 				ForceNew:      true,
@@ -177,7 +202,7 @@ func appResource() *schema.Resource {
 					Schema: map[string]*schema.Schema{
 						"url": {
 							Type:        schema.TypeString,
-							Description: "HTTP address used determine the application readiness. A successful health check is a HTTP response code less than 500 returned before healthcheck.interval seconds.",
+							Description: "HTTP address used determine the application readiness. A successful health check is a HTTP response code less than 500 returned before `healthcheck.interval` seconds.",
 							ForceNew:    true,
 							Required:    true,
 						},
@@ -196,6 +221,58 @@ func appResource() *schema.Resource {
 					},
 				},
 			},
+			"group": {
+				Type:        schema.TypeString,
+				Description: "The name of a group that this app belongs to.",
+				ForceNew:    true,
+				Optional:    true,
+				ValidateDiagFunc: func(val any, c cty.Path) diag.Diagnostics {
+					valStr, ok := val.(string)
+					if !ok {
+						return diag.Errorf("expected string, got %T", val)
+					}
+
+					if len(valStr) > appGroupNameMaxLength {
+						return diag.Errorf("group name is too long (max %d characters)", appGroupNameMaxLength)
+					}
+					return nil
+				},
+			},
+			"order": {
+				Type:        schema.TypeInt,
+				Description: "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order).",
+				ForceNew:    true,
+				Optional:    true,
+			},
+			"hidden": {
+				Type:        schema.TypeBool,
+				Description: "Determines if the app is visible in the UI (minimum Coder version: v2.16).",
+				Default:     false,
+				ForceNew:    true,
+				Optional:    true,
+			},
+			"open_in": {
+				Type: schema.TypeString,
+				Description: "Determines where the app will be opened. Valid values are `\"tab\"` and `\"slim-window\" (default)`. " +
+					"`\"tab\"` opens in a new tab in the same browser window. " +
+					"`\"slim-window\"` opens a new browser window without navigation controls.",
+				ForceNew: true,
+				Optional: true,
+				Default:  "slim-window",
+				ValidateDiagFunc: func(val any, c cty.Path) diag.Diagnostics {
+					valStr, ok := val.(string)
+					if !ok {
+						return diag.Errorf("expected string, got %T", val)
+					}
+
+					switch valStr {
+					case "tab", "slim-window":
+						return nil
+					}
+
+					return diag.Errorf(`invalid "coder_app" open_in value, must be one of "tab", "slim-window": %q`, valStr)
+				},
+			},
 		},
 	}
 }
diff --git a/provider/app_test.go b/provider/app_test.go
index 3d4f44bb..2b9a5580 100644
--- a/provider/app_test.go
+++ b/provider/app_test.go
@@ -6,9 +6,7 @@ import (
 	"strconv"
 	"testing"
 
-	"github.com/coder/terraform-provider-coder/provider"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
 	"github.com/stretchr/testify/require"
 )
@@ -20,10 +18,8 @@ func TestApp(t *testing.T) {
 		t.Parallel()
 
 		resource.Test(t, resource.TestCase{
-			Providers: map[string]*schema.Provider{
-				"coder": provider.New(),
-			},
-			IsUnitTest: true,
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
 			Steps: []resource.TestStep{{
 				Config: `
 				provider "coder" {
@@ -44,6 +40,10 @@ func TestApp(t *testing.T) {
 						interval = 5
 						threshold = 6
 					}
+					group = "Apps"
+					order = 4
+					hidden = false
+					open_in = "slim-window"
 				}
 				`,
 				Check: func(state *terraform.State) error {
@@ -64,6 +64,10 @@ func TestApp(t *testing.T) {
 						"healthcheck.0.url",
 						"healthcheck.0.interval",
 						"healthcheck.0.threshold",
+						"group",
+						"order",
+						"hidden",
+						"open_in",
 					} {
 						value := resource.Primary.Attributes[key]
 						t.Logf("%q = %q", key, value)
@@ -98,6 +102,7 @@ func TestApp(t *testing.T) {
 				display_name = "Testing"
 				url = "https://google.com"
 				external = true
+				open_in = "slim-window"
 			}
 			`,
 			external: true,
@@ -116,6 +121,7 @@ func TestApp(t *testing.T) {
 				url = "https://google.com"
 				external = true
 				subdomain = true
+				open_in = "slim-window"
 			}
 			`,
 			expectError: regexp.MustCompile("conflicts with subdomain"),
@@ -125,10 +131,8 @@ func TestApp(t *testing.T) {
 			t.Run(tc.name, func(t *testing.T) {
 				t.Parallel()
 				resource.Test(t, resource.TestCase{
-					Providers: map[string]*schema.Provider{
-						"coder": provider.New(),
-					},
-					IsUnitTest: true,
+					ProviderFactories: coderFactory(),
+					IsUnitTest:        true,
 					Steps: []resource.TestStep{{
 						Config: tc.config,
 						Check: func(state *terraform.State) error {
@@ -211,6 +215,7 @@ func TestApp(t *testing.T) {
 						interval = 5
 						threshold = 6
 					}
+					open_in = "slim-window"
 				}
 				`, sharingLine)
 
@@ -231,10 +236,103 @@ func TestApp(t *testing.T) {
 				}
 
 				resource.Test(t, resource.TestCase{
-					Providers: map[string]*schema.Provider{
-						"coder": provider.New(),
-					},
-					IsUnitTest: true,
+					ProviderFactories: coderFactory(),
+					IsUnitTest:        true,
+					Steps: []resource.TestStep{{
+						Config:      config,
+						Check:       checkFn,
+						ExpectError: c.expectError,
+					}},
+				})
+			})
+		}
+	})
+
+	t.Run("OpenIn", func(t *testing.T) {
+		t.Parallel()
+
+		cases := []struct {
+			name        string
+			value       string
+			expectValue string
+			expectError *regexp.Regexp
+		}{
+			{
+				name:        "default",
+				value:       "", // default
+				expectValue: "slim-window",
+			},
+			{
+				name:        "InvalidValue",
+				value:       "nonsense",
+				expectError: regexp.MustCompile(`invalid "coder_app" open_in value, must be one of "tab", "slim-window": "nonsense"`),
+			},
+			{
+				name:        "ExplicitSlimWindow",
+				value:       "slim-window",
+				expectValue: "slim-window",
+			},
+			{
+				name:        "ExplicitTab",
+				value:       "tab",
+				expectValue: "tab",
+			},
+		}
+
+		for _, c := range cases {
+			c := c
+
+			t.Run(c.name, func(t *testing.T) {
+				t.Parallel()
+
+				config := `
+				provider "coder" {
+				}
+				resource "coder_agent" "dev" {
+					os = "linux"
+					arch = "amd64"
+				}
+				resource "coder_app" "code-server" {
+					agent_id = coder_agent.dev.id
+					slug = "code-server"
+					display_name = "code-server"
+					icon = "builtin:vim"
+					url = "http://localhost:13337"
+					healthcheck {
+						url = "http://localhost:13337/healthz"
+						interval = 5
+						threshold = 6
+					}`
+
+				if c.value != "" {
+					config += fmt.Sprintf(`
+					open_in = %q
+					`, c.value)
+				}
+
+				config += `
+				}
+				`
+
+				checkFn := func(state *terraform.State) error {
+					require.Len(t, state.Modules, 1)
+					require.Len(t, state.Modules[0].Resources, 2)
+					resource := state.Modules[0].Resources["coder_app.code-server"]
+					require.NotNil(t, resource)
+
+					// Read share and ensure it matches the expected
+					// value.
+					value := resource.Primary.Attributes["open_in"]
+					require.Equal(t, c.expectValue, value)
+					return nil
+				}
+				if c.expectError != nil {
+					checkFn = nil
+				}
+
+				resource.Test(t, resource.TestCase{
+					ProviderFactories: coderFactory(),
+					IsUnitTest:        true,
 					Steps: []resource.TestStep{{
 						Config:      config,
 						Check:       checkFn,
@@ -244,4 +342,197 @@ func TestApp(t *testing.T) {
 			})
 		}
 	})
+
+	t.Run("Hidden", func(t *testing.T) {
+		t.Parallel()
+
+		cases := []struct {
+			name   string
+			config string
+			hidden bool
+			openIn string
+		}{{
+			name: "Is Hidden",
+			config: `
+			provider "coder" {}
+			resource "coder_agent" "dev" {
+				os = "linux"
+				arch = "amd64"
+			}
+			resource "coder_app" "test" {
+				agent_id = coder_agent.dev.id
+				slug = "test"
+				display_name = "Testing"
+				url = "https://google.com"
+				external = true
+				hidden = true
+				open_in = "slim-window"
+			}
+			`,
+			hidden: true,
+			openIn: "slim-window",
+		}, {
+			name: "Is Not Hidden",
+			config: `
+			provider "coder" {}
+			resource "coder_agent" "dev" {
+				os = "linux"
+				arch = "amd64"
+			}
+			resource "coder_app" "test" {
+				agent_id = coder_agent.dev.id
+				slug = "test"
+				display_name = "Testing"
+				url = "https://google.com"
+				external = true
+				hidden = false
+				open_in = "tab"
+			}
+			`,
+			hidden: false,
+			openIn: "tab",
+		}}
+		for _, tc := range cases {
+			tc := tc
+			t.Run(tc.name, func(t *testing.T) {
+				t.Parallel()
+				resource.Test(t, resource.TestCase{
+					ProviderFactories: coderFactory(),
+					IsUnitTest:        true,
+					Steps: []resource.TestStep{{
+						Config: tc.config,
+						Check: func(state *terraform.State) error {
+							require.Len(t, state.Modules, 1)
+							require.Len(t, state.Modules[0].Resources, 2)
+							resource := state.Modules[0].Resources["coder_app.test"]
+							require.NotNil(t, resource)
+							require.Equal(t, strconv.FormatBool(tc.hidden), resource.Primary.Attributes["hidden"])
+							require.Equal(t, tc.openIn, resource.Primary.Attributes["open_in"])
+							return nil
+						},
+						ExpectError: nil,
+					}},
+				})
+			})
+		}
+	})
+
+	t.Run("DisplayName", func(t *testing.T) {
+		t.Parallel()
+
+		cases := []struct {
+			name        string
+			displayName string
+			expectValue string
+			expectError *regexp.Regexp
+		}{
+			{
+				name:        "Empty",
+				displayName: "",
+			},
+			{
+				name:        "Regular",
+				displayName: "Regular Application",
+			},
+			{
+				name:        "DisplayNameStillOK",
+				displayName: "0123456789012345678901234567890123456789012345678901234567890123",
+			},
+			{
+				name:        "DisplayNameTooLong",
+				displayName: "01234567890123456789012345678901234567890123456789012345678901234",
+				expectError: regexp.MustCompile("display name is too long"),
+			},
+		}
+
+		for _, c := range cases {
+			c := c
+
+			t.Run(c.name, func(t *testing.T) {
+				t.Parallel()
+
+				config := fmt.Sprintf(`
+				provider "coder" {
+				}
+				resource "coder_agent" "dev" {
+					os = "linux"
+					arch = "amd64"
+				}
+				resource "coder_app" "code-server" {
+					agent_id = coder_agent.dev.id
+					slug = "code-server"
+					display_name = "%s"
+					url = "http://localhost:13337"
+					open_in = "slim-window"
+				}
+				`, c.displayName)
+
+				resource.Test(t, resource.TestCase{
+					ProviderFactories: coderFactory(),
+					IsUnitTest:        true,
+					Steps: []resource.TestStep{{
+						Config:      config,
+						ExpectError: c.expectError,
+					}},
+				})
+			})
+		}
+	})
+
+	t.Run("Icon", func(t *testing.T) {
+		t.Parallel()
+
+		cases := []struct {
+			name        string
+			icon        string
+			expectError *regexp.Regexp
+		}{
+			{
+				name: "Empty",
+				icon: "",
+			},
+			{
+				name: "ValidURL",
+				icon: "/icon/region.svg",
+			},
+			{
+				name:        "InvalidURL",
+				icon:        "/icon%.svg",
+				expectError: regexp.MustCompile("invalid URL escape"),
+			},
+		}
+
+		for _, c := range cases {
+			c := c
+
+			t.Run(c.name, func(t *testing.T) {
+				t.Parallel()
+
+				config := fmt.Sprintf(`
+				provider "coder" {}
+				resource "coder_agent" "dev" {
+					os = "linux"
+					arch = "amd64"
+				}
+				resource "coder_app" "code-server" {
+					agent_id = coder_agent.dev.id
+					slug = "code-server"
+					display_name = "Testing"
+					url = "http://localhost:13337"
+					open_in = "slim-window"
+					icon = "%s"
+				}
+				`, c.icon)
+
+				resource.Test(t, resource.TestCase{
+					ProviderFactories: coderFactory(),
+					IsUnitTest:        true,
+					Steps: []resource.TestStep{{
+						Config:      config,
+						ExpectError: c.expectError,
+					}},
+				})
+			})
+		}
+	})
 }
diff --git a/provider/decode_test.go b/provider/decode_test.go
index a5e20b14..947ebf79 100644
--- a/provider/decode_test.go
+++ b/provider/decode_test.go
@@ -7,7 +7,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
-	"github.com/coder/terraform-provider-coder/provider"
+	"github.com/coder/terraform-provider-coder/v2/provider"
 )
 
 func TestDecode(t *testing.T) {
diff --git a/provider/devcontainer.go b/provider/devcontainer.go
new file mode 100644
index 00000000..81a31194
--- /dev/null
+++ b/provider/devcontainer.go
@@ -0,0 +1,46 @@
+package provider
+
+import (
+	"context"
+
+	"github.com/google/uuid"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
+)
+
+func devcontainerResource() *schema.Resource {
+	return &schema.Resource{
+		SchemaVersion: 1,
+
+		Description: "Define a Dev Container the agent should know of and attempt to autostart.\n\n-> This resource is only available in Coder v2.21 and later.",
+		CreateContext: func(_ context.Context, rd *schema.ResourceData, _ interface{}) diag.Diagnostics {
+			rd.SetId(uuid.NewString())
+
+			return nil
+		},
+		ReadContext:   schema.NoopContext,
+		DeleteContext: schema.NoopContext,
+		Schema: map[string]*schema.Schema{
+			"agent_id": {
+				Type:        schema.TypeString,
+				Description: "The `id` property of a `coder_agent` resource to associate with.",
+				ForceNew:    true,
+				Required:    true,
+			},
+			"workspace_folder": {
+				Type:         schema.TypeString,
+				Description:  "The workspace folder to for the Dev Container.",
+				ForceNew:     true,
+				Required:     true,
+				ValidateFunc: validation.StringIsNotEmpty,
+			},
+			"config_path": {
+				Type:        schema.TypeString,
+				Description: "The path to the Dev Container configuration file (devcontainer.json).",
+				ForceNew:    true,
+				Optional:    true,
+			},
+		},
+	}
+}
diff --git a/provider/devcontainer_test.go b/provider/devcontainer_test.go
new file mode 100644
index 00000000..784cfb0d
--- /dev/null
+++ b/provider/devcontainer_test.go
@@ -0,0 +1,98 @@
+package provider_test
+
+import (
+	"regexp"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+)
+
+func TestDevcontainer(t *testing.T) {
+	t.Parallel()
+
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+			provider "coder" {
+			}
+			resource "coder_devcontainer" "example" {
+				agent_id = "king"
+				workspace_folder = "/workspace"
+				config_path = "/workspace/devcontainer.json"
+			}
+			`,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				script := state.Modules[0].Resources["coder_devcontainer.example"]
+				require.NotNil(t, script)
+				t.Logf("script attributes: %#v", script.Primary.Attributes)
+				for key, expected := range map[string]string{
+					"agent_id":         "king",
+					"workspace_folder": "/workspace",
+					"config_path":      "/workspace/devcontainer.json",
+				} {
+					require.Equal(t, expected, script.Primary.Attributes[key])
+				}
+				return nil
+			},
+		}},
+	})
+}
+
+func TestDevcontainerNoConfigPath(t *testing.T) {
+	t.Parallel()
+
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+			provider "coder" {
+			}
+			resource "coder_devcontainer" "example" {
+				agent_id = "king"
+				workspace_folder = "/workspace"
+			}
+			`,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				script := state.Modules[0].Resources["coder_devcontainer.example"]
+				require.NotNil(t, script)
+				t.Logf("script attributes: %#v", script.Primary.Attributes)
+				for key, expected := range map[string]string{
+					"agent_id":         "king",
+					"workspace_folder": "/workspace",
+				} {
+					require.Equal(t, expected, script.Primary.Attributes[key])
+				}
+				return nil
+			},
+		}},
+	})
+}
+
+func TestDevcontainerNoWorkspaceFolder(t *testing.T) {
+	t.Parallel()
+
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+			provider "coder" {
+			}
+			resource "coder_devcontainer" "example" {
+				agent_id = ""
+			}
+			`,
+			ExpectError: regexp.MustCompile(`The argument "workspace_folder" is required, but no definition was found.`),
+		}},
+	})
+}
diff --git a/provider/env.go b/provider/env.go
new file mode 100644
index 00000000..d45201ee
--- /dev/null
+++ b/provider/env.go
@@ -0,0 +1,50 @@
+package provider
+
+import (
+	"context"
+	"regexp"
+
+	"github.com/google/uuid"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
+)
+
+func envResource() *schema.Resource {
+	return &schema.Resource{
+		SchemaVersion: 1,
+
+		Description: "Use this resource to set an environment variable in a workspace. Note that this resource cannot be used to overwrite existing environment variables set on the `coder_agent` resource.",
+		CreateContext: func(_ context.Context, rd *schema.ResourceData, _ interface{}) diag.Diagnostics {
+			rd.SetId(uuid.NewString())
+
+			return nil
+		},
+		ReadContext:   schema.NoopContext,
+		DeleteContext: schema.NoopContext,
+		Schema: map[string]*schema.Schema{
+			"agent_id": {
+				Type:        schema.TypeString,
+				Description: "The `id` property of a `coder_agent` resource to associate with.",
+				ForceNew:    true,
+				Required:    true,
+			},
+			"name": {
+				Type:        schema.TypeString,
+				Description: "The name of the environment variable.",
+				ForceNew:    true,
+				Required:    true,
+				ValidateFunc: validation.StringMatch(
+					regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`),
+					"must be a valid environment variable name",
+				),
+			},
+			"value": {
+				Type:        schema.TypeString,
+				Description: "The value of the environment variable.",
+				ForceNew:    true,
+				Optional:    true,
+			},
+		},
+	}
+}
diff --git a/provider/env_test.go b/provider/env_test.go
new file mode 100644
index 00000000..a5892254
--- /dev/null
+++ b/provider/env_test.go
@@ -0,0 +1,119 @@
+package provider_test
+
+import (
+	"regexp"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+)
+
+func TestEnv(t *testing.T) {
+	t.Parallel()
+
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+			provider "coder" {
+			}
+			resource "coder_env" "example" {
+				agent_id = "king"
+				name = "MESSAGE"
+				value = "Believe in yourself and there will come a day when others will have no choice but to believe with you."
+			}
+			`,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				script := state.Modules[0].Resources["coder_env.example"]
+				require.NotNil(t, script)
+				t.Logf("script attributes: %#v", script.Primary.Attributes)
+				for key, expected := range map[string]string{
+					"agent_id": "king",
+					"name":     "MESSAGE",
+					"value":    "Believe in yourself and there will come a day when others will have no choice but to believe with you.",
+				} {
+					require.Equal(t, expected, script.Primary.Attributes[key])
+				}
+				return nil
+			},
+		}},
+	})
+}
+
+func TestEnvEmptyValue(t *testing.T) {
+	t.Parallel()
+
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+			provider "coder" {
+			}
+			resource "coder_env" "example" {
+				agent_id = "king"
+				name = "MESSAGE"
+			}
+			`,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				script := state.Modules[0].Resources["coder_env.example"]
+				require.NotNil(t, script)
+				t.Logf("script attributes: %#v", script.Primary.Attributes)
+				for key, expected := range map[string]string{
+					"agent_id": "king",
+					"name":     "MESSAGE",
+					"value":    "",
+				} {
+					require.Equal(t, expected, script.Primary.Attributes[key])
+				}
+				return nil
+			},
+		}},
+	})
+}
+
+func TestEnvBadName(t *testing.T) {
+	t.Parallel()
+
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+			provider "coder" {
+			}
+			resource "coder_env" "example" {
+				agent_id = ""
+				name = "bad-name"
+			}
+			`,
+			ExpectError: regexp.MustCompile(`must be a valid environment variable name`),
+		}},
+	})
+}
+
+func TestEnvNoName(t *testing.T) {
+	t.Parallel()
+
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+			provider "coder" {
+			}
+			resource "coder_env" "example" {
+				agent_id = ""
+			}
+			`,
+			ExpectError: regexp.MustCompile(`The argument "name" is required, but no definition was found.`),
+		}},
+	})
+}
diff --git a/provider/examples_test.go b/provider/examples_test.go
index 9be7ce02..1d17b1ba 100644
--- a/provider/examples_test.go
+++ b/provider/examples_test.go
@@ -1,31 +1,38 @@
 package provider_test
 
 import (
+	"fmt"
 	"os"
 	"testing"
 
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"github.com/stretchr/testify/require"
-
-	"github.com/coder/terraform-provider-coder/provider"
 )
 
 func TestExamples(t *testing.T) {
 	t.Parallel()
 
-	t.Run("coder_parameter", func(t *testing.T) {
-		t.Parallel()
+	for _, testDir := range []string{
+		"coder_parameter",
+		"coder_workspace_tags",
+		"coder_resources_monitoring",
+	} {
+		t.Run(testDir, func(t *testing.T) {
+			testDir := testDir
+			t.Parallel()
 
-		resource.Test(t, resource.TestCase{
-			Providers: map[string]*schema.Provider{
-				"coder": provider.New(),
-			},
-			IsUnitTest: true,
-			Steps: []resource.TestStep{{
-				Config: mustReadFile(t, "../examples/resources/coder_parameter/resource.tf"),
-			}},
+			resourceTest(t, testDir)
 		})
+	}
+}
+
+func resourceTest(t *testing.T, testDir string) {
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: mustReadFile(t, fmt.Sprintf("../examples/data-sources/%s/data-source.tf", testDir)),
+		}},
 	})
 }
 
diff --git a/provider/external_agent.go b/provider/external_agent.go
new file mode 100644
index 00000000..2856c2bf
--- /dev/null
+++ b/provider/external_agent.go
@@ -0,0 +1,31 @@
+package provider
+
+import (
+	"context"
+
+	"github.com/google/uuid"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+)
+
+func externalAgentResource() *schema.Resource {
+	return &schema.Resource{
+		SchemaVersion: 1,
+
+		Description: "Define an external agent to be used in a workspace.",
+		CreateContext: func(ctx context.Context, rd *schema.ResourceData, _ interface{}) diag.Diagnostics {
+			rd.SetId(uuid.NewString())
+			return nil
+		},
+		ReadContext:   schema.NoopContext,
+		DeleteContext: schema.NoopContext,
+		Schema: map[string]*schema.Schema{
+			"agent_id": {
+				Type:        schema.TypeString,
+				Description: "The `id` property of a `coder_agent` resource to associate with.",
+				ForceNew:    true,
+				Required:    true,
+			},
+		},
+	}
+}
diff --git a/provider/external_agent_test.go b/provider/external_agent_test.go
new file mode 100644
index 00000000..f0638b45
--- /dev/null
+++ b/provider/external_agent_test.go
@@ -0,0 +1,49 @@
+package provider_test
+
+import (
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+	"github.com/stretchr/testify/require"
+)
+
+func TestExternalAgent(t *testing.T) {
+	t.Parallel()
+
+	t.Run("OK", func(t *testing.T) {
+		t.Parallel()
+
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+				provider "coder" {
+				}
+
+				resource "coder_agent" "dev" {
+					os = "linux"
+					arch = "amd64"
+				}
+				
+				resource "coder_external_agent" "dev" {
+					agent_id = coder_agent.dev.id
+				}
+				`,
+				Check: func(state *terraform.State) error {
+					require.Len(t, state.Modules, 1)
+					require.Len(t, state.Modules[0].Resources, 2)
+
+					agentResource := state.Modules[0].Resources["coder_agent.dev"]
+					require.NotNil(t, agentResource)
+					externalAgentResource := state.Modules[0].Resources["coder_external_agent.dev"]
+					require.NotNil(t, externalAgentResource)
+
+					require.Equal(t, agentResource.Primary.Attributes["id"], externalAgentResource.Primary.Attributes["agent_id"])
+					return nil
+				},
+			}},
+		})
+	})
+}
diff --git a/provider/externalauth.go b/provider/externalauth.go
index 89ab5ecc..b278ecc1 100644
--- a/provider/externalauth.go
+++ b/provider/externalauth.go
@@ -3,16 +3,19 @@ package provider
 import (
 	"context"
 	"fmt"
-	"os"
 
 	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+
+	"github.com/coder/terraform-provider-coder/v2/provider/helpers"
 )
 
 // externalAuthDataSource returns a schema for an external authentication data source.
 func externalAuthDataSource() *schema.Resource {
 	return &schema.Resource{
-		Description: "Use this data source to require users to authenticate with an external service prior to workspace creation. This can be used to pre-authenticate external services in a workspace. (e.g. gcloud, gh, docker, etc)",
+		SchemaVersion: 1,
+
+		Description: "Use this data source to require users to authenticate with an external service prior to workspace creation. This can be used to [pre-authenticate external services](https://coder.com/docs/admin/external-auth) in a workspace. (e.g. Google Cloud, Github, Docker, etc.)",
 		ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
 			id, ok := rd.Get("id").(string)
 			if !ok || id == "" {
@@ -20,7 +23,7 @@ func externalAuthDataSource() *schema.Resource {
 			}
 			rd.SetId(id)
 
-			accessToken := os.Getenv(ExternalAuthAccessTokenEnvironmentVariable(id))
+			accessToken := helpers.OptionalEnv(ExternalAuthAccessTokenEnvironmentVariable(id))
 			rd.Set("access_token", accessToken)
 			return nil
 		},
@@ -32,8 +35,14 @@ func externalAuthDataSource() *schema.Resource {
 			},
 			"access_token": {
 				Type:        schema.TypeString,
-				Computed:    true,
 				Description: "The access token returned by the external auth provider. This can be used to pre-authenticate command-line tools.",
+				Computed:    true,
+				Sensitive:   true,
+			},
+			"optional": {
+				Type:        schema.TypeBool,
+				Description: "Authenticating with the external auth provider is not required, and can be skipped by users when creating or updating workspaces",
+				Optional:    true,
 			},
 		},
 	}
diff --git a/provider/externalauth_test.go b/provider/externalauth_test.go
index a320684b..4c328a45 100644
--- a/provider/externalauth_test.go
+++ b/provider/externalauth_test.go
@@ -3,10 +3,7 @@ package provider_test
 import (
 	"testing"
 
-	"github.com/coder/terraform-provider-coder/provider"
-
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
 
 	"github.com/stretchr/testify/require"
@@ -16,16 +13,45 @@ func TestExternalAuth(t *testing.T) {
 	t.Parallel()
 
 	resource.Test(t, resource.TestCase{
-		Providers: map[string]*schema.Provider{
-			"coder": provider.New(),
-		},
-		IsUnitTest: true,
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+			provider "coder" {
+			}
+			data "coder_external_auth" "github" {
+				id = "github"
+			}
+			`,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				resource := state.Modules[0].Resources["data.coder_external_auth.github"]
+				require.NotNil(t, resource)
+
+				attribs := resource.Primary.Attributes
+				require.Equal(t, "github", attribs["id"])
+				require.Equal(t, "", attribs["optional"])
+
+				return nil
+			},
+		}},
+	})
+}
+
+func TestOptionalExternalAuth(t *testing.T) {
+	t.Parallel()
+
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
 		Steps: []resource.TestStep{{
 			Config: `
 			provider "coder" {
 			}
 			data "coder_external_auth" "github" {
 				id = "github"
+				optional = true
 			}
 			`,
 			Check: func(state *terraform.State) error {
@@ -36,6 +62,7 @@ func TestExternalAuth(t *testing.T) {
 
 				attribs := resource.Primary.Attributes
 				require.Equal(t, "github", attribs["id"])
+				require.Equal(t, "true", attribs["optional"])
 
 				return nil
 			},
diff --git a/provider/formtype.go b/provider/formtype.go
new file mode 100644
index 00000000..931179da
--- /dev/null
+++ b/provider/formtype.go
@@ -0,0 +1,170 @@
+package provider
+
+import (
+	"slices"
+
+	"golang.org/x/xerrors"
+)
+
+// OptionType is a type of option that can be used in the 'type' argument of
+// a parameter. These should match types as defined in terraform:
+//
+//	https://developer.hashicorp.com/terraform/language/expressions/types
+//
+// The value have to be string literals, as type constraint keywords are not
+// supported in providers.
+type OptionType = string
+
+const (
+	OptionTypeString     OptionType = "string"
+	OptionTypeNumber     OptionType = "number"
+	OptionTypeBoolean    OptionType = "bool"
+	OptionTypeListString OptionType = "list(string)"
+)
+
+func OptionTypes() []OptionType {
+	return []OptionType{
+		OptionTypeString,
+		OptionTypeNumber,
+		OptionTypeBoolean,
+		OptionTypeListString,
+	}
+}
+
+// ParameterFormType is the list of supported form types for display in
+// the Coder "create workspace" form. These form types are functional as well
+// as cosmetic. Refer to `formTypeTruthTable` for the allowed pairings.
+// For example, "multi-select" has the type "list(string)" but the option
+// values are "string".
+type ParameterFormType string
+
+const (
+	ParameterFormTypeDefault     ParameterFormType = ""
+	ParameterFormTypeRadio       ParameterFormType = "radio"
+	ParameterFormTypeSlider      ParameterFormType = "slider"
+	ParameterFormTypeInput       ParameterFormType = "input"
+	ParameterFormTypeDropdown    ParameterFormType = "dropdown"
+	ParameterFormTypeCheckbox    ParameterFormType = "checkbox"
+	ParameterFormTypeSwitch      ParameterFormType = "switch"
+	ParameterFormTypeMultiSelect ParameterFormType = "multi-select"
+	ParameterFormTypeTagSelect   ParameterFormType = "tag-select"
+	ParameterFormTypeTextArea    ParameterFormType = "textarea"
+	ParameterFormTypeError       ParameterFormType = "error"
+)
+
+// ParameterFormTypes should be kept in sync with the enum list above.
+func ParameterFormTypes() []ParameterFormType {
+	return []ParameterFormType{
+		// Intentionally omit "ParameterFormTypeDefault" from this set.
+		// It is a valid enum, but will always be mapped to a real value when
+		// being used.
+		ParameterFormTypeRadio,
+		ParameterFormTypeSlider,
+		ParameterFormTypeInput,
+		ParameterFormTypeDropdown,
+		ParameterFormTypeCheckbox,
+		ParameterFormTypeSwitch,
+		ParameterFormTypeMultiSelect,
+		ParameterFormTypeTagSelect,
+		ParameterFormTypeTextArea,
+		ParameterFormTypeError,
+	}
+}
+
+// formTypeTruthTable is a map of [`type`][`optionCount` > 0] to `form_type`.
+// The first value in the slice is the default value assuming `form_type` is
+// not specified.
+//
+// The boolean key indicates whether the `options` field is specified.
+// | Type              | Options | Specified Form Type | form_type      | Notes                          |
+// |-------------------|---------|---------------------|----------------|--------------------------------|
+// | `string` `number` | Y       |                     | `radio`        |                                |
+// | `string` `number` | Y       | `dropdown`          | `dropdown`     |                                |
+// | `string` `number` | N       |                     | `input`        |                                |
+// | `string`          | N       | 'textarea'          | `textarea`     |                                |
+// | `number`          | N       | 'slider'            | `slider`       | min/max validation             |
+// | `bool`            | Y       |                     | `radio`        |                                |
+// | `bool`            | N       |                     | `checkbox`     |                                |
+// | `bool`            | N       | `switch`            | `switch`       |                                |
+// | `list(string)`    | Y       |                     | `radio`        |                                |
+// | `list(string)`    | N       |                     | `tag-select`   |                                |
+// | `list(string)`    | Y       | `multi-select`      | `multi-select` | Option values will be `string` |
+var formTypeTruthTable = map[OptionType]map[bool][]ParameterFormType{
+	OptionTypeString: {
+		true:  {ParameterFormTypeRadio, ParameterFormTypeDropdown},
+		false: {ParameterFormTypeInput, ParameterFormTypeTextArea},
+	},
+	OptionTypeNumber: {
+		true:  {ParameterFormTypeRadio, ParameterFormTypeDropdown},
+		false: {ParameterFormTypeInput, ParameterFormTypeSlider},
+	},
+	OptionTypeBoolean: {
+		true:  {ParameterFormTypeRadio, ParameterFormTypeDropdown},
+		false: {ParameterFormTypeCheckbox, ParameterFormTypeSwitch},
+	},
+	OptionTypeListString: {
+		true:  {ParameterFormTypeRadio, ParameterFormTypeMultiSelect},
+		false: {ParameterFormTypeTagSelect},
+	},
+}
+
+// ValidateFormType handles the truth table for the valid set of `type` and
+// `form_type` options.
+// The OptionType is also returned because it is possible the 'type' of the
+// 'value' & 'default' fields is different from the 'type' of the options.
+// The use case is when using multi-select. The options are 'string' and the
+// value is 'list(string)'.
+func ValidateFormType(paramType OptionType, optionCount int, specifiedFormType ParameterFormType) (OptionType, ParameterFormType, error) {
+	optionsExist := optionCount > 0
+	allowed, ok := formTypeTruthTable[paramType][optionsExist]
+	if !ok || len(allowed) == 0 {
+		// This error should really never be hit, as the provider sdk does an enum validation.
+		return paramType, specifiedFormType, xerrors.Errorf("\"type\" attribute=%q is not supported, choose one of %v", paramType, OptionTypes())
+	}
+
+	if specifiedFormType == ParameterFormTypeDefault {
+		// handle the default case
+		specifiedFormType = allowed[0]
+	}
+
+	if !slices.Contains(allowed, specifiedFormType) {
+		optionMsg := ""
+		opposite := formTypeTruthTable[paramType][!optionsExist]
+
+		// This extra message tells a user if they are using a valid form_type
+		// for a 'type', but it is invalid because options do/do-not exist.
+		// It serves as a more helpful error message.
+		//
+		// Eg: form_type=slider is valid for type=number, but invalid if options exist.
+		// And this error message is more accurate than just saying "form_type=slider is
+		// not valid for type=number".
+		if slices.Contains(opposite, specifiedFormType) {
+			if optionsExist {
+				optionMsg = " when options exist"
+			} else {
+				optionMsg = " when options do not exist"
+			}
+		}
+		return paramType, specifiedFormType,
+			xerrors.Errorf("\"form_type\" attribute=%q is not supported for \"type\"=%q%s, choose one of %v",
+				specifiedFormType, paramType,
+				optionMsg, toStrings(allowed))
+	}
+
+	// This is the only current special case. If 'multi-select' is selected, the type
+	// of 'value' and an options 'value' are different. The type of the parameter is
+	// `list(string)` but the type of the individual options is `string`.
+	if paramType == OptionTypeListString && specifiedFormType == ParameterFormTypeMultiSelect {
+		return OptionTypeString, ParameterFormTypeMultiSelect, nil
+	}
+
+	return paramType, specifiedFormType, nil
+}
+
+func toStrings[A ~string](l []A) []string {
+	var r []string
+	for _, v := range l {
+		r = append(r, string(v))
+	}
+	return r
+}
diff --git a/provider/formtype_test.go b/provider/formtype_test.go
new file mode 100644
index 00000000..2f3dff53
--- /dev/null
+++ b/provider/formtype_test.go
@@ -0,0 +1,434 @@
+package provider_test
+
+import (
+	"encoding/json"
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/coder/terraform-provider-coder/v2/provider"
+)
+
+// formTypeTestCase is the config for a single test case.
+type formTypeTestCase struct {
+	name        string
+	config      formTypeCheck
+	assert      paramAssert
+	expectError *regexp.Regexp
+}
+
+// paramAssert is asserted on the provider's parsed terraform state.
+type paramAssert struct {
+	FormType provider.ParameterFormType
+	Type     provider.OptionType
+	Styling  json.RawMessage
+}
+
+// formTypeCheck is a struct that helps build the terraform config
+type formTypeCheck struct {
+	formType   provider.ParameterFormType
+	optionType provider.OptionType
+	options    bool
+
+	// optional to inform the assert
+	customOptions []string
+	defValue      string
+	styling       json.RawMessage
+}
+
+func (c formTypeCheck) String() string {
+	return fmt.Sprintf("%s_%s_%t", c.formType, c.optionType, c.options)
+}
+
+func TestValidateFormType(t *testing.T) {
+	t.Parallel()
+
+	// formTypesChecked keeps track of all checks run. It will be used to
+	// ensure all combinations of form_type and option_type are tested.
+	// All untested options are assumed to throw an error.
+	var formTypesChecked sync.Map
+
+	expectType := func(expected provider.ParameterFormType, opts formTypeCheck) formTypeTestCase {
+		ftname := opts.formType
+		if ftname == "" {
+			ftname = "default"
+		}
+
+		if opts.styling == nil {
+			// Try passing arbitrary data in, as anything should be accepted
+			opts.styling, _ = json.Marshal(map[string]any{
+				"foo":      "bar",
+				"disabled": true,
+				"nested": map[string]any{
+					"foo": "bar",
+				},
+			})
+		}
+
+		return formTypeTestCase{
+			name: fmt.Sprintf("%s_%s_%t",
+				ftname,
+				opts.optionType,
+				opts.options,
+			),
+			config: opts,
+			assert: paramAssert{
+				FormType: expected,
+				Type:     opts.optionType,
+				Styling:  opts.styling,
+			},
+			expectError: nil,
+		}
+	}
+
+	// expectSameFormType just assumes the FormType in the check is the expected
+	// FormType. Using `expectType` these fields can differ
+	expectSameFormType := func(opts formTypeCheck) formTypeTestCase {
+		return expectType(opts.formType, opts)
+	}
+
+	cases := []formTypeTestCase{
+		{
+			// When nothing is specified
+			name:   "defaults",
+			config: formTypeCheck{},
+			assert: paramAssert{
+				FormType: provider.ParameterFormTypeInput,
+				Type:     provider.OptionTypeString,
+				Styling:  []byte("{}"),
+			},
+		},
+		// All default behaviors. Essentially legacy behavior.
+		//	String
+		expectType(provider.ParameterFormTypeRadio, formTypeCheck{
+			options:    true,
+			optionType: provider.OptionTypeString,
+		}),
+		expectType(provider.ParameterFormTypeInput, formTypeCheck{
+			options:    false,
+			optionType: provider.OptionTypeString,
+		}),
+		//	Number
+		expectType(provider.ParameterFormTypeRadio, formTypeCheck{
+			options:    true,
+			optionType: provider.OptionTypeNumber,
+		}),
+		expectType(provider.ParameterFormTypeInput, formTypeCheck{
+			options:    false,
+			optionType: provider.OptionTypeNumber,
+		}),
+		//	Boolean
+		expectType(provider.ParameterFormTypeRadio, formTypeCheck{
+			options:    true,
+			optionType: provider.OptionTypeBoolean,
+		}),
+		expectType(provider.ParameterFormTypeCheckbox, formTypeCheck{
+			options:    false,
+			optionType: provider.OptionTypeBoolean,
+		}),
+		//	List(string)
+		expectType(provider.ParameterFormTypeRadio, formTypeCheck{
+			options:    true,
+			optionType: provider.OptionTypeListString,
+		}),
+		expectType(provider.ParameterFormTypeTagSelect, formTypeCheck{
+			options:    false,
+			optionType: provider.OptionTypeListString,
+		}),
+
+		// ---- New Behavior
+		//	String
+		expectSameFormType(formTypeCheck{
+			options:    true,
+			optionType: provider.OptionTypeString,
+			formType:   provider.ParameterFormTypeDropdown,
+		}),
+		expectSameFormType(formTypeCheck{
+			options:    true,
+			optionType: provider.OptionTypeString,
+			formType:   provider.ParameterFormTypeRadio,
+		}),
+		expectSameFormType(formTypeCheck{
+			options:    false,
+			optionType: provider.OptionTypeString,
+			formType:   provider.ParameterFormTypeInput,
+		}),
+		expectSameFormType(formTypeCheck{
+			options:    false,
+			optionType: provider.OptionTypeString,
+			formType:   provider.ParameterFormTypeTextArea,
+		}),
+		//	Number
+		expectSameFormType(formTypeCheck{
+			options:    true,
+			optionType: provider.OptionTypeNumber,
+			formType:   provider.ParameterFormTypeDropdown,
+		}),
+		expectSameFormType(formTypeCheck{
+			options:    true,
+			optionType: provider.OptionTypeNumber,
+			formType:   provider.ParameterFormTypeRadio,
+		}),
+		expectSameFormType(formTypeCheck{
+			options:    false,
+			optionType: provider.OptionTypeNumber,
+			formType:   provider.ParameterFormTypeInput,
+		}),
+		expectSameFormType(formTypeCheck{
+			options:    false,
+			optionType: provider.OptionTypeNumber,
+			formType:   provider.ParameterFormTypeSlider,
+		}),
+		//	Boolean
+		expectSameFormType(formTypeCheck{
+			options:    true,
+			optionType: provider.OptionTypeBoolean,
+			formType:   provider.ParameterFormTypeRadio,
+		}),
+		expectSameFormType(formTypeCheck{
+			options:    true,
+			optionType: provider.OptionTypeBoolean,
+			formType:   provider.ParameterFormTypeDropdown,
+		}),
+		expectSameFormType(formTypeCheck{
+			options:    false,
+			optionType: provider.OptionTypeBoolean,
+			formType:   provider.ParameterFormTypeSwitch,
+		}),
+		expectSameFormType(formTypeCheck{
+			options:    false,
+			optionType: provider.OptionTypeBoolean,
+			formType:   provider.ParameterFormTypeCheckbox,
+		}),
+		//	List(string)
+		expectSameFormType(formTypeCheck{
+			options:    true,
+			optionType: provider.OptionTypeListString,
+			formType:   provider.ParameterFormTypeRadio,
+		}),
+		expectSameFormType(formTypeCheck{
+			options:       true,
+			optionType:    provider.OptionTypeListString,
+			formType:      provider.ParameterFormTypeMultiSelect,
+			customOptions: []string{"red", "blue", "green"},
+			defValue:      `["red", "blue"]`,
+		}),
+		expectSameFormType(formTypeCheck{
+			options:    false,
+			optionType: provider.OptionTypeListString,
+			formType:   provider.ParameterFormTypeTagSelect,
+		}),
+
+		// Some manual test cases
+		{
+			name: "list_string_bad_default",
+			config: formTypeCheck{
+				formType:      provider.ParameterFormTypeMultiSelect,
+				optionType:    provider.OptionTypeListString,
+				customOptions: []string{"red", "blue", "green"},
+				defValue:      `["red", "yellow"]`,
+				styling:       nil,
+			},
+			expectError: regexp.MustCompile("is not a valid option"),
+		},
+	}
+
+	passed := t.Run("TabledTests", func(t *testing.T) {
+		// TabledCases runs through all the manual test cases
+		for _, c := range cases {
+			t.Run(c.name, func(t *testing.T) {
+				t.Parallel()
+				if _, ok := formTypesChecked.Load(c.config.String()); ok {
+					t.Log("Duplicated form type check, delete this extra test case")
+					t.Fatalf("form type %q already checked", c.config.String())
+				}
+
+				formTypesChecked.Store(c.config.String(), struct{}{})
+				formTypeTest(t, c)
+			})
+		}
+	})
+
+	if !passed {
+		// Do not run additional tests and pollute the output
+		t.Log("Tests failed, will not run the assumed error cases")
+		return
+	}
+
+	// AssumeErrorCases assumes any uncovered test will return an error. Not covered
+	// cases in the truth table are assumed to be invalid. So if the tests above
+	// cover all valid cases, this asserts all the invalid cases.
+	//
+	// This test consequentially ensures all valid cases are covered manually above.
+	t.Run("AssumeErrorCases", func(t *testing.T) {
+		// requiredChecks loops through all possible form_type and option_type
+		// combinations.
+		requiredChecks := make([]formTypeCheck, 0)
+		for _, ft := range append(provider.ParameterFormTypes(), "") {
+			for _, ot := range provider.OptionTypes() {
+				requiredChecks = append(requiredChecks, formTypeCheck{
+					formType:   ft,
+					optionType: ot,
+					options:    false,
+				})
+				requiredChecks = append(requiredChecks, formTypeCheck{
+					formType:   ft,
+					optionType: ot,
+					options:    true,
+				})
+			}
+		}
+
+		for _, check := range requiredChecks {
+			if _, alreadyChecked := formTypesChecked.Load(check.String()); alreadyChecked {
+				continue
+			}
+
+			ftName := check.formType
+			if ftName == "" {
+				ftName = "default"
+			}
+			fc := formTypeTestCase{
+				name: fmt.Sprintf("%s_%s_%t",
+					ftName,
+					check.optionType,
+					check.options,
+				),
+				config:      check,
+				assert:      paramAssert{},
+				expectError: regexp.MustCompile("is not supported"),
+			}
+
+			t.Run(fc.name, func(t *testing.T) {
+				t.Parallel()
+
+				// This is just helpful log output to give the boilerplate
+				// to write the manual test.
+				tcText := fmt.Sprintf(`
+					expectSameFormType(%s, ezconfigOpts{
+						Options:    %t,
+						OptionType: %q,
+						FormType:   %q,
+					}),
+				//`, "<expected_form_type>", check.options, check.optionType, check.formType)
+
+				logDebugInfo := formTypeTest(t, fc)
+				if !logDebugInfo {
+					t.Logf("To construct this test case:\n%s", tcText)
+				}
+			})
+
+		}
+	})
+}
+
+// createTF converts a formTypeCheck into a terraform config string.
+func createTF(paramName string, cfg formTypeCheck) (defaultValue string, tf string) {
+	options := cfg.customOptions
+	if cfg.options && len(cfg.customOptions) == 0 {
+		switch cfg.optionType {
+		case provider.OptionTypeString:
+			options = []string{"foo"}
+			defaultValue = "foo"
+		case provider.OptionTypeBoolean:
+			options = []string{"true", "false"}
+			defaultValue = "true"
+		case provider.OptionTypeNumber:
+			options = []string{"1"}
+			defaultValue = "1"
+		case provider.OptionTypeListString:
+			options = []string{`["red", "blue"]`}
+			defaultValue = `["red", "blue"]`
+		default:
+			panic(fmt.Sprintf("unknown option type %q when generating options", cfg.optionType))
+		}
+	}
+
+	if cfg.defValue == "" {
+		cfg.defValue = defaultValue
+	}
+
+	var body strings.Builder
+	if cfg.defValue != "" {
+		body.WriteString(fmt.Sprintf("default = %q\n", cfg.defValue))
+	}
+	if cfg.formType != "" {
+		body.WriteString(fmt.Sprintf("form_type = %q\n", cfg.formType))
+	}
+	if cfg.optionType != "" {
+		body.WriteString(fmt.Sprintf("type = %q\n", cfg.optionType))
+	}
+	if cfg.styling != nil {
+		body.WriteString(fmt.Sprintf("styling = %s\n", strconv.Quote(string(cfg.styling))))
+	}
+
+	for i, opt := range options {
+		body.WriteString("option {\n")
+		body.WriteString(fmt.Sprintf("name = \"val_%d\"\n", i))
+		body.WriteString(fmt.Sprintf("value = %q\n", opt))
+		body.WriteString("}\n")
+	}
+
+	return cfg.defValue, fmt.Sprintf(`
+				provider "coder" {
+				}
+				data "coder_parameter" "%s" {
+					name = "%s"
+					%s
+				}
+		`, paramName, paramName, body.String())
+}
+
+func formTypeTest(t *testing.T, c formTypeTestCase) bool {
+	t.Helper()
+	const paramName = "test_param"
+	// logDebugInfo is just a guess used for logging. It's not important. It cannot
+	// determine for sure if the test passed because the terraform test runner is a
+	// black box. It does not indicate if the test passed or failed. Since this is
+	// just used for logging, this is good enough.
+	logDebugInfo := true
+
+	def, tf := createTF(paramName, c.config)
+	checkFn := func(state *terraform.State) error {
+		require.Len(t, state.Modules, 1)
+		require.Len(t, state.Modules[0].Resources, 1)
+
+		key := strings.Join([]string{"data", "coder_parameter", paramName}, ".")
+		param := state.Modules[0].Resources[key]
+
+		logDebugInfo = logDebugInfo && assert.Equal(t, def, param.Primary.Attributes["default"], "default value")
+		logDebugInfo = logDebugInfo && assert.Equal(t, string(c.assert.FormType), param.Primary.Attributes["form_type"], "form_type")
+		logDebugInfo = logDebugInfo && assert.Equal(t, string(c.assert.Type), param.Primary.Attributes["type"], "type")
+		logDebugInfo = logDebugInfo && assert.JSONEq(t, string(c.assert.Styling), param.Primary.Attributes["styling"], "styling")
+
+		return nil
+	}
+	if c.expectError != nil {
+		checkFn = nil
+	}
+
+	resource.Test(t, resource.TestCase{
+		IsUnitTest:        true,
+		ProviderFactories: coderFactory(),
+		Steps: []resource.TestStep{
+			{
+				Config:      tf,
+				Check:       checkFn,
+				ExpectError: c.expectError,
+			},
+		},
+	})
+
+	if !logDebugInfo {
+		t.Logf("Terraform config:\n%s", tf)
+	}
+	return logDebugInfo
+}
diff --git a/provider/gitauth.go b/provider/gitauth.go
deleted file mode 100644
index aa36d493..00000000
--- a/provider/gitauth.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package provider
-
-import (
-	"context"
-	"fmt"
-	"os"
-
-	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
-)
-
-// gitAuthDataSource returns a schema for a Git authentication data source.
-func gitAuthDataSource() *schema.Resource {
-	return &schema.Resource{
-		DeprecationMessage: "Use the `coder_external_auth` data source instead.",
-		Description:        "Use this data source to require users to authenticate with a Git provider prior to workspace creation. This can be used to perform an authenticated `git clone` in startup scripts.",
-		ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
-			rawID, ok := rd.GetOk("id")
-			if !ok {
-				return diag.Errorf("id is required")
-			}
-			id, ok := rawID.(string)
-			if !ok {
-				return diag.Errorf("unexpected type %q for id", rawID)
-			}
-			rd.SetId(id)
-
-			accessToken := os.Getenv(GitAuthAccessTokenEnvironmentVariable(id))
-			rd.Set("access_token", accessToken)
-
-			return nil
-		},
-		Schema: map[string]*schema.Schema{
-			"id": {
-				Type:        schema.TypeString,
-				Required:    true,
-				Description: "The identifier of a configured git auth provider set up in your Coder deployment.",
-			},
-			"access_token": {
-				Type:        schema.TypeString,
-				Computed:    true,
-				Description: "The access token returned by the git authentication provider. This can be used to pre-authenticate command-line tools.",
-			},
-		},
-	}
-}
-
-func GitAuthAccessTokenEnvironmentVariable(id string) string {
-	return fmt.Sprintf("CODER_GIT_AUTH_ACCESS_TOKEN_%s", id)
-}
diff --git a/provider/gitauth_test.go b/provider/gitauth_test.go
deleted file mode 100644
index 481d79f5..00000000
--- a/provider/gitauth_test.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package provider_test
-
-import (
-	"testing"
-
-	"github.com/coder/terraform-provider-coder/provider"
-
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
-
-	"github.com/stretchr/testify/require"
-)
-
-func TestGitAuth(t *testing.T) {
-	t.Parallel()
-
-	resource.Test(t, resource.TestCase{
-		Providers: map[string]*schema.Provider{
-			"coder": provider.New(),
-		},
-		IsUnitTest: true,
-		Steps: []resource.TestStep{{
-			Config: `
-			provider "coder" {
-			}
-			data "coder_git_auth" "github" {
-				id = "github"
-			}
-			`,
-			Check: func(state *terraform.State) error {
-				require.Len(t, state.Modules, 1)
-				require.Len(t, state.Modules[0].Resources, 1)
-				resource := state.Modules[0].Resources["data.coder_git_auth.github"]
-				require.NotNil(t, resource)
-
-				attribs := resource.Primary.Attributes
-				require.Equal(t, "github", attribs["id"])
-
-				return nil
-			},
-		}},
-	})
-}
diff --git a/provider/helpers/env.go b/provider/helpers/env.go
new file mode 100644
index 00000000..6ffab3f6
--- /dev/null
+++ b/provider/helpers/env.go
@@ -0,0 +1,37 @@
+package helpers
+
+import (
+	"fmt"
+	"os"
+)
+
+// RequireEnv requires environment variable to be present.
+// The constraint can be verified only during execution of the workspace build
+// (determined with env `CODER_WORKSPACE_BUILD_ID`).
+func RequireEnv(name string) (string, error) {
+	if os.Getenv("CODER_WORKSPACE_BUILD_ID") == "" {
+		return os.Getenv(name), nil
+	}
+
+	val := os.Getenv(name)
+	if val == "" {
+		return "", fmt.Errorf("%s is required", name)
+	}
+	return val, nil
+}
+
+// OptionalEnv returns the value for environment variable if it exists,
+// otherwise returns an empty string.
+func OptionalEnv(name string) string {
+	return OptionalEnvOrDefault(name, "")
+}
+
+// OptionalEnvOrDefault returns the value for environment variable if it exists,
+// otherwise returns the default value.
+func OptionalEnvOrDefault(name, defaultValue string) string {
+	val := os.Getenv(name)
+	if val == "" {
+		return defaultValue
+	}
+	return val
+}
diff --git a/provider/helpers/schedule_validation.go b/provider/helpers/schedule_validation.go
new file mode 100644
index 00000000..c5a6972f
--- /dev/null
+++ b/provider/helpers/schedule_validation.go
@@ -0,0 +1,187 @@
+package helpers
+
+import (
+	"strconv"
+	"strings"
+
+	"golang.org/x/xerrors"
+)
+
+// ValidateSchedules checks if any schedules overlap
+func ValidateSchedules(schedules []string) error {
+	for i := 0; i < len(schedules); i++ {
+		for j := i + 1; j < len(schedules); j++ {
+			overlap, err := SchedulesOverlap(schedules[i], schedules[j])
+			if err != nil {
+				return xerrors.Errorf("invalid schedule: %w", err)
+			}
+			if overlap {
+				return xerrors.Errorf("schedules overlap: %s and %s",
+					schedules[i], schedules[j])
+			}
+		}
+	}
+	return nil
+}
+
+// SchedulesOverlap checks if two schedules overlap by checking
+// all cron fields separately
+func SchedulesOverlap(schedule1, schedule2 string) (bool, error) {
+	// Get cron fields
+	fields1 := strings.Fields(schedule1)
+	fields2 := strings.Fields(schedule2)
+
+	if len(fields1) != 5 {
+		return false, xerrors.Errorf("schedule %q has %d fields, expected 5 fields (minute hour day-of-month month day-of-week)", schedule1, len(fields1))
+	}
+	if len(fields2) != 5 {
+		return false, xerrors.Errorf("schedule %q has %d fields, expected 5 fields (minute hour day-of-month month day-of-week)", schedule2, len(fields2))
+	}
+
+	// Check if months overlap
+	monthsOverlap, err := MonthsOverlap(fields1[3], fields2[3])
+	if err != nil {
+		return false, xerrors.Errorf("invalid month range: %w", err)
+	}
+	if !monthsOverlap {
+		return false, nil
+	}
+
+	// Check if days overlap (DOM OR DOW)
+	daysOverlap, err := DaysOverlap(fields1[2], fields1[4], fields2[2], fields2[4])
+	if err != nil {
+		return false, xerrors.Errorf("invalid day range: %w", err)
+	}
+	if !daysOverlap {
+		return false, nil
+	}
+
+	// Check if hours overlap
+	hoursOverlap, err := HoursOverlap(fields1[1], fields2[1])
+	if err != nil {
+		return false, xerrors.Errorf("invalid hour range: %w", err)
+	}
+
+	return hoursOverlap, nil
+}
+
+// MonthsOverlap checks if two month ranges overlap
+func MonthsOverlap(months1, months2 string) (bool, error) {
+	return CheckOverlap(months1, months2, 12)
+}
+
+// HoursOverlap checks if two hour ranges overlap
+func HoursOverlap(hours1, hours2 string) (bool, error) {
+	return CheckOverlap(hours1, hours2, 23)
+}
+
+// DomOverlap checks if two day-of-month ranges overlap
+func DomOverlap(dom1, dom2 string) (bool, error) {
+	return CheckOverlap(dom1, dom2, 31)
+}
+
+// DowOverlap checks if two day-of-week ranges overlap
+func DowOverlap(dow1, dow2 string) (bool, error) {
+	return CheckOverlap(dow1, dow2, 6)
+}
+
+// DaysOverlap checks if two day ranges overlap, considering both DOM and DOW.
+// Returns true if both DOM and DOW overlap, or if one is * and the other overlaps.
+func DaysOverlap(dom1, dow1, dom2, dow2 string) (bool, error) {
+	// If either DOM is *, we only need to check DOW overlap
+	if dom1 == "*" || dom2 == "*" {
+		return DowOverlap(dow1, dow2)
+	}
+
+	// If either DOW is *, we only need to check DOM overlap
+	if dow1 == "*" || dow2 == "*" {
+		return DomOverlap(dom1, dom2)
+	}
+
+	// If both DOM and DOW are specified, we need to check both
+	// because the schedule runs when either matches
+	domOverlap, err := DomOverlap(dom1, dom2)
+	if err != nil {
+		return false, err
+	}
+	dowOverlap, err := DowOverlap(dow1, dow2)
+	if err != nil {
+		return false, err
+	}
+
+	// If either DOM or DOW overlaps, the schedules overlap
+	return domOverlap || dowOverlap, nil
+}
+
+// CheckOverlap is a function to check if two ranges overlap
+func CheckOverlap(range1, range2 string, maxValue int) (bool, error) {
+	set1, err := ParseRange(range1, maxValue)
+	if err != nil {
+		return false, err
+	}
+	set2, err := ParseRange(range2, maxValue)
+	if err != nil {
+		return false, err
+	}
+
+	for value := range set1 {
+		if set2[value] {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+// ParseRange converts a cron range to a set of integers
+// maxValue is the maximum allowed value (e.g., 23 for hours, 6 for DOW, 12 for months, 31 for DOM)
+func ParseRange(input string, maxValue int) (map[int]bool, error) {
+	result := make(map[int]bool)
+
+	// Handle "*" case
+	if input == "*" {
+		for i := 0; i <= maxValue; i++ {
+			result[i] = true
+		}
+		return result, nil
+	}
+
+	// Parse ranges like "1-3,5,7-9"
+	parts := strings.Split(input, ",")
+	for _, part := range parts {
+		if strings.Contains(part, "-") {
+			// Handle range like "1-3"
+			rangeParts := strings.Split(part, "-")
+			start, err := strconv.Atoi(rangeParts[0])
+			if err != nil {
+				return nil, xerrors.Errorf("invalid start value in range: %w", err)
+			}
+			end, err := strconv.Atoi(rangeParts[1])
+			if err != nil {
+				return nil, xerrors.Errorf("invalid end value in range: %w", err)
+			}
+
+			// Validate range
+			if start < 0 || end > maxValue || start > end {
+				return nil, xerrors.Errorf("invalid range %d-%d: values must be between 0 and %d", start, end, maxValue)
+			}
+
+			for i := start; i <= end; i++ {
+				result[i] = true
+			}
+		} else {
+			// Handle single value
+			value, err := strconv.Atoi(part)
+			if err != nil {
+				return nil, xerrors.Errorf("invalid value: %w", err)
+			}
+
+			// Validate value
+			if value < 0 || value > maxValue {
+				return nil, xerrors.Errorf("invalid value %d: must be between 0 and %d", value, maxValue)
+			}
+
+			result[value] = true
+		}
+	}
+	return result, nil
+}
diff --git a/provider/helpers/schedule_validation_test.go b/provider/helpers/schedule_validation_test.go
new file mode 100644
index 00000000..2971fd07
--- /dev/null
+++ b/provider/helpers/schedule_validation_test.go
@@ -0,0 +1,585 @@
+// schedule_validation_test.go
+
+package helpers_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/coder/terraform-provider-coder/v2/provider/helpers"
+)
+
+func TestParseRange(t *testing.T) {
+	t.Parallel()
+	testCases := []struct {
+		name      string
+		input     string
+		maxValue  int
+		expected  map[int]bool
+		expectErr bool
+	}{
+		{
+			name:     "Wildcard",
+			input:    "*",
+			maxValue: 5,
+			expected: map[int]bool{
+				0: true, 1: true, 2: true, 3: true, 4: true, 5: true,
+			},
+		},
+		{
+			name:     "Single value",
+			input:    "3",
+			maxValue: 5,
+			expected: map[int]bool{
+				3: true,
+			},
+		},
+		{
+			name:     "Range",
+			input:    "1-3",
+			maxValue: 5,
+			expected: map[int]bool{
+				1: true, 2: true, 3: true,
+			},
+		},
+		{
+			name:     "Complex range",
+			input:    "1-3,5,7-9",
+			maxValue: 9,
+			expected: map[int]bool{
+				1: true, 2: true, 3: true, 5: true, 7: true, 8: true, 9: true,
+			},
+		},
+		{
+			name:      "Value too high",
+			input:     "6",
+			maxValue:  5,
+			expectErr: true,
+		},
+		{
+			name:      "Range too high",
+			input:     "4-6",
+			maxValue:  5,
+			expectErr: true,
+		},
+		{
+			name:      "Invalid range",
+			input:     "3-1",
+			maxValue:  5,
+			expectErr: true,
+		},
+		{
+			name:      "Invalid value",
+			input:     "abc",
+			maxValue:  5,
+			expectErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		testCase := testCase
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+			result, err := helpers.ParseRange(testCase.input, testCase.maxValue)
+			if testCase.expectErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			require.Equal(t, testCase.expected, result)
+		})
+	}
+}
+
+func TestCheckOverlap(t *testing.T) {
+	t.Parallel()
+	testCases := []struct {
+		name      string
+		range1    string
+		range2    string
+		maxValue  int
+		overlap   bool
+		expectErr bool
+	}{
+		{
+			name:     "Same range",
+			range1:   "1-5",
+			range2:   "1-5",
+			maxValue: 10,
+			overlap:  true,
+		},
+		{
+			name:     "Different ranges",
+			range1:   "1-3",
+			range2:   "4-6",
+			maxValue: 10,
+			overlap:  false,
+		},
+		{
+			name:     "Overlapping ranges",
+			range1:   "1-5",
+			range2:   "4-8",
+			maxValue: 10,
+			overlap:  true,
+		},
+		{
+			name:     "Wildcard overlap",
+			range1:   "*",
+			range2:   "3-5",
+			maxValue: 10,
+			overlap:  true,
+		},
+		{
+			name:     "Complex ranges",
+			range1:   "1-3,5,7-9",
+			range2:   "2-4,6,8-10",
+			maxValue: 10,
+			overlap:  true,
+		},
+		{
+			name:     "Single values",
+			range1:   "1",
+			range2:   "1",
+			maxValue: 10,
+			overlap:  true,
+		},
+		{
+			name:     "Single value vs range",
+			range1:   "1",
+			range2:   "1-3",
+			maxValue: 10,
+			overlap:  true,
+		},
+		{
+			name:      "Invalid range - value too high",
+			range1:    "11",
+			range2:    "1-3",
+			maxValue:  10,
+			expectErr: true,
+		},
+		{
+			name:      "Invalid range - negative value",
+			range1:    "-1",
+			range2:    "1-3",
+			maxValue:  10,
+			expectErr: true,
+		},
+		{
+			name:      "Invalid range - malformed",
+			range1:    "1-",
+			range2:    "1-3",
+			maxValue:  10,
+			expectErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		testCase := testCase
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+			overlap, err := helpers.CheckOverlap(testCase.range1, testCase.range2, testCase.maxValue)
+			if testCase.expectErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			require.Equal(t, testCase.overlap, overlap)
+		})
+	}
+}
+
+func TestOverlapWrappers(t *testing.T) {
+	t.Parallel()
+	testCases := []struct {
+		name        string
+		range1      string
+		range2      string
+		overlap     bool
+		expectErr   bool
+		overlapFunc func(string, string) (bool, error)
+	}{
+		// HoursOverlap tests (max 23)
+		{
+			name:        "Valid hour range",
+			range1:      "23",
+			range2:      "23",
+			overlap:     true,
+			overlapFunc: helpers.HoursOverlap,
+		},
+		{
+			name:        "Invalid hour range",
+			range1:      "24",
+			range2:      "24",
+			expectErr:   true,
+			overlapFunc: helpers.HoursOverlap,
+		},
+
+		// MonthsOverlap tests (max 12)
+		{
+			name:        "Valid month range",
+			range1:      "12",
+			range2:      "12",
+			overlap:     true,
+			overlapFunc: helpers.MonthsOverlap,
+		},
+		{
+			name:        "Invalid month range",
+			range1:      "13",
+			range2:      "13",
+			expectErr:   true,
+			overlapFunc: helpers.MonthsOverlap,
+		},
+
+		// DomOverlap tests (max 31)
+		{
+			name:        "Valid day of month range",
+			range1:      "31",
+			range2:      "31",
+			overlap:     true,
+			overlapFunc: helpers.DomOverlap,
+		},
+		{
+			name:        "Invalid day of month range",
+			range1:      "32",
+			range2:      "32",
+			expectErr:   true,
+			overlapFunc: helpers.DomOverlap,
+		},
+
+		// DowOverlap tests (max 6)
+		{
+			name:        "Valid day of week range",
+			range1:      "6",
+			range2:      "6",
+			overlap:     true,
+			overlapFunc: helpers.DowOverlap,
+		},
+		{
+			name:        "Invalid day of week range",
+			range1:      "7",
+			range2:      "7",
+			expectErr:   true,
+			overlapFunc: helpers.DowOverlap,
+		},
+	}
+
+	for _, testCase := range testCases {
+		testCase := testCase
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+			overlap, err := testCase.overlapFunc(testCase.range1, testCase.range2)
+			if testCase.expectErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			require.Equal(t, testCase.overlap, overlap)
+		})
+	}
+}
+
+func TestDaysOverlap(t *testing.T) {
+	t.Parallel()
+	testCases := []struct {
+		name      string
+		dom1      string
+		dow1      string
+		dom2      string
+		dow2      string
+		overlap   bool
+		expectErr bool
+	}{
+		{
+			name:    "DOM overlap only",
+			dom1:    "1-15",
+			dow1:    "1-3",
+			dom2:    "10-20",
+			dow2:    "4-6",
+			overlap: true, // true because DOM overlaps (10-15)
+		},
+		{
+			name:    "DOW overlap only",
+			dom1:    "1-15",
+			dow1:    "1-3",
+			dom2:    "16-31",
+			dow2:    "3-5",
+			overlap: true, // true because DOW overlaps (3)
+		},
+		{
+			name:    "Both DOM and DOW overlap",
+			dom1:    "1-15",
+			dow1:    "1-3",
+			dom2:    "10-20",
+			dow2:    "3-5",
+			overlap: true, // true because both overlap
+		},
+		{
+			name:    "No overlap",
+			dom1:    "1-15",
+			dow1:    "1-3",
+			dom2:    "16-31",
+			dow2:    "4-6",
+			overlap: false, // false because neither overlaps
+		},
+		{
+			name:    "Both DOW wildcard - DOM overlaps",
+			dom1:    "1-15",
+			dow1:    "*",
+			dom2:    "10-20",
+			dow2:    "*",
+			overlap: true, // true because DOM overlaps (10-15)
+		},
+		{
+			name:    "Both DOW wildcard - DOM doesn't overlap",
+			dom1:    "1-15",
+			dow1:    "*",
+			dom2:    "16-31",
+			dow2:    "*",
+			overlap: false, // false because DOM doesn't overlap
+		},
+	}
+
+	for _, testCase := range testCases {
+		testCase := testCase
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+			overlap, err := helpers.DaysOverlap(testCase.dom1, testCase.dow1, testCase.dom2, testCase.dow2)
+			if testCase.expectErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			require.Equal(t, testCase.overlap, overlap)
+		})
+	}
+}
+
+func TestSchedulesOverlap(t *testing.T) {
+	t.Parallel()
+	testCases := []struct {
+		name           string
+		s1             string
+		s2             string
+		overlap        bool
+		expectedErrMsg string
+	}{
+		// Basic overlap cases
+		{
+			name:    "Same schedule",
+			s1:      "* 9-18 * * 1-5",
+			s2:      "* 9-18 * * 1-5",
+			overlap: true,
+		},
+		{
+			name:    "Different hours - no overlap",
+			s1:      "* 9-12 * * 1-5",
+			s2:      "* 13-18 * * 1-5",
+			overlap: false,
+		},
+		{
+			name:    "Different hours - partial overlap",
+			s1:      "* 9-14 * * 1-5",
+			s2:      "* 12-18 * * 1-5",
+			overlap: true,
+		},
+		{
+			name:    "Different hours - one contained in another",
+			s1:      "* 9-18 * * 1-5",
+			s2:      "* 12-14 * * 1-5",
+			overlap: true,
+		},
+
+		// Day of week overlap cases (with wildcard DOM)
+		{
+			name:    "Different DOW with wildcard DOM",
+			s1:      "* 9-18 * * 1,3,5", // Mon,Wed,Fri
+			s2:      "* 9-18 * * 2,4,6", // Tue,Thu,Sat
+			overlap: false,              // No overlap because DOW ranges don't overlap
+		},
+		{
+			name:    "Different DOW with wildcard DOM - complex ranges",
+			s1:      "* 9-18 * * 1-3", // Mon-Wed
+			s2:      "* 9-18 * * 4-5", // Thu-Fri
+			overlap: false,            // No overlap because DOW ranges don't overlap
+		},
+
+		// Day of week overlap cases (with specific DOM)
+		{
+			name:    "Different DOW with specific DOM - no overlap",
+			s1:      "* 9-18 1 * 1-3",
+			s2:      "* 9-18 2 * 4-5",
+			overlap: false, // No overlap because different DOM and DOW
+		},
+		{
+			name:    "Different DOW with specific DOM - partial overlap",
+			s1:      "* 9-18 1 * 1-4",
+			s2:      "* 9-18 1 * 3-5",
+			overlap: true, // Overlaps because same DOM
+		},
+		{
+			name:    "Different DOW with specific DOM - complex ranges",
+			s1:      "* 9-18 1 * 1,3,5",
+			s2:      "* 9-18 1 * 2,4,6",
+			overlap: true, // Overlaps because same DOM
+		},
+
+		// Wildcard cases
+		{
+			name:    "Wildcard hours vs specific hours",
+			s1:      "* * * * 1-5",
+			s2:      "* 9-18 * * 1-5",
+			overlap: true,
+		},
+		{
+			name:    "Wildcard DOW vs specific DOW",
+			s1:      "* 9-18 * * *",
+			s2:      "* 9-18 * * 1-5",
+			overlap: true,
+		},
+		{
+			name:    "Both wildcard DOW",
+			s1:      "* 9-18 * * *",
+			s2:      "* 9-18 * * *",
+			overlap: true,
+		},
+
+		// Complex time ranges
+		{
+			name:    "Complex hour ranges - no overlap",
+			s1:      "* 9-11,13-15 * * 1-5",
+			s2:      "* 12,16-18 * * 1-5",
+			overlap: false,
+		},
+		{
+			name:    "Complex hour ranges - partial overlap",
+			s1:      "* 9-11,13-15 * * 1-5",
+			s2:      "* 10-12,14-16 * * 1-5",
+			overlap: true,
+		},
+		{
+			name:    "Complex hour ranges - contained",
+			s1:      "* 9-18 * * 1-5",
+			s2:      "* 10-11,13-14 * * 1-5",
+			overlap: true,
+		},
+
+		// Error cases (keeping minimal)
+		{
+			name:           "Invalid hour range",
+			s1:             "* 25-26 * * 1-5",
+			s2:             "* 9-18 * * 1-5",
+			expectedErrMsg: "invalid hour range",
+		},
+		{
+			name:           "Invalid month range",
+			s1:             "* 9-18 * 13 1-5",
+			s2:             "* 9-18 * * 1-5",
+			expectedErrMsg: "invalid month range",
+		},
+		{
+			name:           "Invalid field count - too few fields",
+			s1:             "* 9-18 * *",
+			s2:             "* 9-18 * * 1-5",
+			expectedErrMsg: "has 4 fields, expected 5 fields",
+		},
+		{
+			name:           "Invalid field count - too many fields",
+			s1:             "* 9-18 * * 1-5 *",
+			s2:             "* 9-18 * * 1-5",
+			expectedErrMsg: "has 6 fields, expected 5 fields",
+		},
+		{
+			name:           "Invalid field count - s2 has too few fields",
+			s1:             "* 9-18 * * 1-5",
+			s2:             "* 9-18 * *",
+			expectedErrMsg: "has 4 fields, expected 5 fields",
+		},
+	}
+
+	for _, testCase := range testCases {
+		testCase := testCase
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			overlap, err := helpers.SchedulesOverlap(testCase.s1, testCase.s2)
+			if testCase.expectedErrMsg != "" {
+				require.Error(t, err)
+				require.Contains(t, err.Error(), testCase.expectedErrMsg)
+			} else {
+				require.NoError(t, err)
+				require.Equal(t, testCase.overlap, overlap)
+			}
+		})
+	}
+}
+
+func TestValidateSchedules(t *testing.T) {
+	t.Parallel()
+	testCases := []struct {
+		name           string
+		schedules      []string
+		expectedErrMsg string
+	}{
+		// Basic validation
+		{
+			name:      "Empty schedules",
+			schedules: []string{},
+		},
+		{
+			name: "Single valid schedule",
+			schedules: []string{
+				"* 9-18 * * 1-5",
+			},
+		},
+
+		// Non-overlapping schedules
+		{
+			name: "Multiple valid non-overlapping schedules",
+			schedules: []string{
+				"* 9-12 * * 1-5",
+				"* 13-18 * * 1-5",
+			},
+		},
+		{
+			name: "Multiple valid non-overlapping schedules",
+			schedules: []string{
+				"* 9-18 * * 1-5",
+				"* 9-13 * * 6,0",
+			},
+		},
+
+		// Overlapping schedules
+		{
+			name: "Two overlapping schedules",
+			schedules: []string{
+				"* 9-14 * * 1-5",
+				"* 12-18 * * 1-5",
+			},
+			expectedErrMsg: "schedules overlap: * 9-14 * * 1-5 and * 12-18 * * 1-5",
+		},
+		{
+			name: "Three schedules with only second and third overlapping",
+			schedules: []string{
+				"* 9-11 * * 1-5",  // 9AM-11AM (no overlap)
+				"* 12-18 * * 1-5", // 12PM-6PM
+				"* 15-20 * * 1-5", // 3PM-8PM (overlaps with second)
+			},
+			expectedErrMsg: "schedules overlap: * 12-18 * * 1-5 and * 15-20 * * 1-5",
+		},
+	}
+
+	for _, testCase := range testCases {
+		testCase := testCase
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+			err := helpers.ValidateSchedules(testCase.schedules)
+			if testCase.expectedErrMsg != "" {
+				require.Error(t, err)
+				require.Contains(t, err.Error(), testCase.expectedErrMsg)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
diff --git a/provider/helpers/validation.go b/provider/helpers/validation.go
new file mode 100644
index 00000000..9cc21b89
--- /dev/null
+++ b/provider/helpers/validation.go
@@ -0,0 +1,22 @@
+package helpers
+
+import (
+	"fmt"
+	"net/url"
+)
+
+// ValidateURL validates that value is a valid URL string.
+// Accepts empty strings, local file paths, file:// URLs, and http/https URLs.
+// Example: for `icon = "/icon/region.svg"`, value is `/icon/region.svg` and label is `icon`.
+func ValidateURL(value any, label string) ([]string, []error) {
+	val, ok := value.(string)
+	if !ok {
+		return nil, []error{fmt.Errorf("expected %q to be a string", label)}
+	}
+
+	if _, err := url.Parse(val); err != nil {
+		return nil, []error{err}
+	}
+	
+	return nil, nil
+}
diff --git a/provider/helpers/validation_test.go b/provider/helpers/validation_test.go
new file mode 100644
index 00000000..557bae41
--- /dev/null
+++ b/provider/helpers/validation_test.go
@@ -0,0 +1,151 @@
+package helpers
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateURL(t *testing.T) {
+	tests := []struct {
+		name          string
+		value         any
+		label         string
+		expectError   bool
+		errorContains string
+	}{
+		// Valid cases
+		{
+			name:        "empty string",
+			value:       "",
+			label:       "url",
+			expectError: false,
+		},
+		{
+			name:        "valid http URL",
+			value:       "http://example.com",
+			label:       "url",
+			expectError: false,
+		},
+		{
+			name:        "valid https URL",
+			value:       "https://example.com/path",
+			label:       "url",
+			expectError: false,
+		},
+		{
+			name:        "absolute file path",
+			value:       "/path/to/file",
+			label:       "url",
+			expectError: false,
+		},
+		{
+			name:        "relative file path",
+			value:       "./file.txt",
+			label:       "url",
+			expectError: false,
+		},
+		{
+			name:        "relative path up directory",
+			value:       "../config.json",
+			label:       "url",
+			expectError: false,
+		},
+		{
+			name:        "simple filename",
+			value:       "file.txt",
+			label:       "url",
+			expectError: false,
+		},
+		{
+			name:        "URL with query params",
+			value:       "https://example.com/search?q=test",
+			label:       "url",
+			expectError: false,
+		},
+		{
+			name:        "URL with fragment",
+			value:       "https://example.com/page#section",
+			label:       "url",
+			expectError: false,
+		},
+
+		// Various URL schemes that url.Parse accepts
+		{
+			name:        "file URL scheme",
+			value:       "file:///path/to/file",
+			label:       "url",
+			expectError: false,
+		},
+		{
+			name:        "ftp scheme",
+			value:       "ftp://files.example.com/file.txt",
+			label:       "url",
+			expectError: false,
+		},
+		{
+			name:        "mailto scheme",
+			value:       "mailto:user@example.com",
+			label:       "url",
+			expectError: false,
+		},
+		{
+			name:        "tel scheme",
+			value:       "tel:+1234567890",
+			label:       "url",
+			expectError: false,
+		},
+		{
+			name:        "data scheme",
+			value:       "data:text/plain;base64,SGVsbG8=",
+			label:       "url",
+			expectError: false,
+		},
+
+		// Invalid cases
+		{
+			name:          "non-string type - int",
+			value:         123,
+			label:         "url",
+			expectError:   true,
+			errorContains: "expected \"url\" to be a string",
+		},
+		{
+			name:          "non-string type - nil",
+			value:         nil,
+			label:         "config_url",
+			expectError:   true,
+			errorContains: "expected \"config_url\" to be a string",
+		},
+		{
+			name:          "invalid URL with spaces",
+			value:         "http://example .com",
+			label:         "url",
+			expectError:   true,
+			errorContains: "invalid character",
+		},
+		{
+			name:          "malformed URL",
+			value:         "http://[::1:80",
+			label:         "endpoint",
+			expectError:   true,
+			errorContains: "missing ']'",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			warnings, errors := ValidateURL(tt.value, tt.label)
+
+			if tt.expectError {
+				require.Len(t, errors, 1, "expected an error but got none")
+				require.Contains(t, errors[0].Error(), tt.errorContains)
+			} else {
+				require.Empty(t, errors, "expected no errors but got: %v", errors)
+			}
+
+			// Should always return nil for warnings
+			require.Nil(t, warnings, "expected warnings to be nil but got: %v", warnings)
+		})
+	}
+}
diff --git a/provider/metadata.go b/provider/metadata.go
index 3bd5e6f5..5ed6d478 100644
--- a/provider/metadata.go
+++ b/provider/metadata.go
@@ -2,17 +2,23 @@ package provider
 
 import (
 	"context"
-	"net/url"
 
 	"github.com/google/uuid"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+	"golang.org/x/xerrors"
+
+	"github.com/coder/terraform-provider-coder/v2/provider/helpers"
 )
 
 func metadataResource() *schema.Resource {
 	return &schema.Resource{
+		SchemaVersion: 1,
+
 		Description: "Use this resource to attach metadata to a resource. They will be " +
-			"displayed in the Coder dashboard.",
+			"displayed in the Coder dashboard alongside the resource. " +
+			"The resource containing the agent, and it's metadata, will be shown by default. " + "\n\n" +
+			"Alternatively, to attach metadata to the agent, use a `metadata` block within a `coder_agent` resource.",
 		CreateContext: func(c context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics {
 			resourceData.SetId(uuid.NewString())
 
@@ -36,7 +42,7 @@ func metadataResource() *schema.Resource {
 		Schema: map[string]*schema.Schema{
 			"resource_id": {
 				Type:        schema.TypeString,
-				Description: "The \"id\" property of another resource that metadata should be attached to.",
+				Description: "The `id` property of another resource that metadata should be attached to.",
 				ForceNew:    true,
 				Required:    true,
 			},
@@ -49,17 +55,11 @@ func metadataResource() *schema.Resource {
 			"icon": {
 				Type: schema.TypeString,
 				Description: "A URL to an icon that will display in the dashboard. View built-in " +
-					"icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a " +
-					"built-in icon with `data.coder_workspace.me.access_url + \"/icon/<path>\"`.",
-				ForceNew: true,
-				Optional: true,
-				ValidateFunc: func(i interface{}, s string) ([]string, []error) {
-					_, err := url.Parse(s)
-					if err != nil {
-						return nil, []error{err}
-					}
-					return nil, nil
-				},
+					"icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a " +
+					"built-in icon with `\"${data.coder_workspace.me.access_url}/icon/<path>\"`.",
+				ForceNew:     true,
+				Optional:     true,
+				ValidateFunc: helpers.ValidateURL,
 			},
 			"daily_cost": {
 				Type: schema.TypeInt,
@@ -71,7 +71,7 @@ func metadataResource() *schema.Resource {
 			},
 			"item": {
 				Type:        schema.TypeList,
-				Description: "Each \"item\" block defines a single metadata item consisting of a key/value pair.",
+				Description: "Each `item` block defines a single metadata item consisting of a key/value pair.",
 				ForceNew:    true,
 				Optional:    true,
 				Elem: &schema.Resource{
@@ -84,13 +84,13 @@ func metadataResource() *schema.Resource {
 						},
 						"value": {
 							Type:        schema.TypeString,
-							Description: "The value of this metadata item.",
+							Description: "The value of this metadata item. Supports basic Markdown, including hyperlinks.",
 							ForceNew:    true,
 							Optional:    true,
 						},
 						"sensitive": {
 							Type: schema.TypeBool,
-							Description: "Set to \"true\" to for items such as API keys whose values should be " +
+							Description: "Set to `true` to for items such as API keys whose values should be " +
 								"hidden from view by default. Note that this does not prevent metadata from " +
 								"being retrieved using the API, so it is not suitable for secrets that should " +
 								"not be exposed to workspace users.",
@@ -107,5 +107,31 @@ func metadataResource() *schema.Resource {
 				},
 			},
 		},
+		CustomizeDiff: func(ctx context.Context, rd *schema.ResourceDiff, i interface{}) error {
+			if !rd.HasChange("item") {
+				return nil
+			}
+
+			keys := map[string]bool{}
+			metadata, ok := rd.Get("item").([]any)
+			if !ok {
+				return xerrors.Errorf("unexpected type %T for items, expected []any", rd.Get("metadata"))
+			}
+			for _, t := range metadata {
+				obj, ok := t.(map[string]any)
+				if !ok {
+					return xerrors.Errorf("unexpected type %T for item, expected map[string]any", t)
+				}
+				key, ok := obj["key"].(string)
+				if !ok {
+					return xerrors.Errorf("unexpected type %T for items 'key' attribute, expected string", obj["key"])
+				}
+				if keys[key] {
+					return xerrors.Errorf("duplicate resource metadata key %q", key)
+				}
+				keys[key] = true
+			}
+			return nil
+		},
 	}
 }
diff --git a/provider/metadata_test.go b/provider/metadata_test.go
index 0243f48d..3164e65b 100644
--- a/provider/metadata_test.go
+++ b/provider/metadata_test.go
@@ -4,21 +4,16 @@ import (
 	"regexp"
 	"testing"
 
-	"github.com/coder/terraform-provider-coder/provider"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
 	"github.com/stretchr/testify/require"
 )
 
 func TestMetadata(t *testing.T) {
 	t.Parallel()
-	prov := provider.New()
 	resource.Test(t, resource.TestCase{
-		Providers: map[string]*schema.Provider{
-			"coder": prov,
-		},
-		IsUnitTest: true,
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
 		Steps: []resource.TestStep{{
 			Config: `
 				provider "coder" {
@@ -95,12 +90,9 @@ func TestMetadata(t *testing.T) {
 
 func TestMetadataDuplicateKeys(t *testing.T) {
 	t.Parallel()
-	prov := provider.New()
 	resource.Test(t, resource.TestCase{
-		Providers: map[string]*schema.Provider{
-			"coder": prov,
-		},
-		IsUnitTest: true,
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
 		Steps: []resource.TestStep{{
 			Config: `
 				provider "coder" {
@@ -123,7 +115,8 @@ func TestMetadataDuplicateKeys(t *testing.T) {
 					}
 				}
 				`,
-			ExpectError: regexp.MustCompile("duplicate metadata key"),
+			PlanOnly:    true,
+			ExpectError: regexp.MustCompile("duplicate resource metadata key"),
 		}},
 	})
 }
diff --git a/provider/parameter.go b/provider/parameter.go
index 339bb017..ca1239f4 100644
--- a/provider/parameter.go
+++ b/provider/parameter.go
@@ -6,10 +6,10 @@ import (
 	"encoding/hex"
 	"encoding/json"
 	"fmt"
-	"net/url"
 	"os"
 	"regexp"
 	"strconv"
+	"strings"
 
 	"github.com/google/uuid"
 	"github.com/hashicorp/go-cty/cty"
@@ -18,6 +18,12 @@ import (
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
 	"github.com/mitchellh/mapstructure"
 	"golang.org/x/xerrors"
+
+	"github.com/coder/terraform-provider-coder/v2/provider/helpers"
+)
+
+var (
+	defaultValuePath = cty.Path{cty.GetAttrStep{Name: "default"}}
 )
 
 type Option struct {
@@ -45,13 +51,13 @@ const (
 )
 
 type Parameter struct {
-	Value       string
 	Name        string
 	DisplayName string `mapstructure:"display_name"`
 	Description string
-	Type        string
+	Type        OptionType
+	FormType    ParameterFormType `mapstructure:"form_type"`
 	Mutable     bool
-	Default     string
+	Default     *string
 	Icon        string
 	Option      []Option
 	Validation  []Validation
@@ -62,6 +68,8 @@ type Parameter struct {
 
 func parameterDataSource() *schema.Resource {
 	return &schema.Resource{
+		SchemaVersion: 1,
+
 		Description: "Use this data source to configure editable options for workspaces.",
 		ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
 			rd.SetId(uuid.NewString())
@@ -78,11 +86,11 @@ func parameterDataSource() *schema.Resource {
 
 			var parameter Parameter
 			err = mapstructure.Decode(struct {
-				Value       interface{}
 				Name        interface{}
-				DisplayName interface{}
+				DisplayName interface{} `mapstructure:"display_name"`
 				Description interface{}
 				Type        interface{}
+				FormType    interface{} `mapstructure:"form_type"`
 				Mutable     interface{}
 				Default     interface{}
 				Icon        interface{}
@@ -92,16 +100,22 @@ func parameterDataSource() *schema.Resource {
 				Order       interface{}
 				Ephemeral   interface{}
 			}{
-				Value:       rd.Get("value"),
 				Name:        rd.Get("name"),
 				DisplayName: rd.Get("display_name"),
 				Description: rd.Get("description"),
 				Type:        rd.Get("type"),
+				FormType:    rd.Get("form_type"),
 				Mutable:     rd.Get("mutable"),
-				Default:     rd.Get("default"),
-				Icon:        rd.Get("icon"),
-				Option:      rd.Get("option"),
-				Validation:  fixedValidation,
+				Default: func() *string {
+					if rd.GetRawConfig().AsValueMap()["default"].IsNull() {
+						return nil
+					}
+					val, _ := rd.Get("default").(string)
+					return &val
+				}(),
+				Icon:       rd.Get("icon"),
+				Option:     rd.Get("option"),
+				Validation: fixedValidation,
 				Optional: func() bool {
 					// This hack allows for checking if the "default" field is present in the .tf file.
 					// If "default" is missing or is "null", then it means that this field is required,
@@ -116,19 +130,6 @@ func parameterDataSource() *schema.Resource {
 			if err != nil {
 				return diag.Errorf("decode parameter: %s", err)
 			}
-			var value string
-			if parameter.Default != "" {
-				err := valueIsType(parameter.Type, parameter.Default)
-				if err != nil {
-					return err
-				}
-				value = parameter.Default
-			}
-			envValue, ok := os.LookupEnv(ParameterEnvironmentVariable(parameter.Name))
-			if ok {
-				value = envValue
-			}
-			rd.Set("value", value)
 
 			if !parameter.Mutable && parameter.Ephemeral {
 				return diag.Errorf("parameter can't be immutable and ephemeral")
@@ -138,41 +139,31 @@ func parameterDataSource() *schema.Resource {
 				return diag.Errorf("ephemeral parameter requires the default property")
 			}
 
-			if len(parameter.Validation) == 1 {
-				validation := &parameter.Validation[0]
-				err = validation.Valid(parameter.Type, value)
-				if err != nil {
-					return diag.FromErr(err)
-				}
+			var input *string
+			envValue, ok := os.LookupEnv(ParameterEnvironmentVariable(parameter.Name))
+			if ok {
+				input = &envValue
 			}
 
-			if len(parameter.Option) > 0 {
-				names := map[string]interface{}{}
-				values := map[string]interface{}{}
-				for _, option := range parameter.Option {
-					_, exists := names[option.Name]
-					if exists {
-						return diag.Errorf("multiple options cannot have the same name %q", option.Name)
-					}
-					_, exists = values[option.Value]
-					if exists {
-						return diag.Errorf("multiple options cannot have the same value %q", option.Value)
-					}
-					err := valueIsType(parameter.Type, option.Value)
-					if err != nil {
-						return err
-					}
-					values[option.Value] = nil
-					names[option.Name] = nil
-				}
+			var previous *string
+			envPreviousValue, ok := os.LookupEnv(ParameterEnvironmentVariablePrevious(parameter.Name))
+			if ok {
+				previous = &envPreviousValue
+			}
 
-				if parameter.Default != "" {
-					_, defaultIsValid := values[parameter.Default]
-					if !defaultIsValid {
-						return diag.Errorf("default value %q must be defined as one of options", parameter.Default)
-					}
-				}
+			value, diags := parameter.ValidateInput(input, previous)
+			if diags.HasError() {
+				return diags
 			}
+
+			// Always set back the value, as it can be sourced from the default
+			rd.Set("value", value)
+
+			// Set the form_type, as if it was unset, a default form_type will be updated on
+			// the parameter struct. Always set back the updated form_type to be more
+			// specific than the default empty string.
+			rd.Set("form_type", parameter.FormType)
+
 			return nil
 		},
 		Schema: map[string]*schema.Schema{
@@ -200,8 +191,23 @@ func parameterDataSource() *schema.Resource {
 				Type:         schema.TypeString,
 				Default:      "string",
 				Optional:     true,
-				ValidateFunc: validation.StringInSlice([]string{"number", "string", "bool", "list(string)"}, false),
-				Description:  `The type of this parameter. Must be one of: "number", "string", "bool", or "list(string)".`,
+				ValidateFunc: validation.StringInSlice(toStrings(OptionTypes()), false),
+				Description:  fmt.Sprintf("The type of this parameter. Must be one of: `\"%s\"`.", strings.Join(toStrings(OptionTypes()), "\"`, `\"")),
+			},
+			"form_type": {
+				Type:         schema.TypeString,
+				Default:      ParameterFormTypeDefault,
+				Optional:     true,
+				ValidateFunc: validation.StringInSlice(toStrings(ParameterFormTypes()), false),
+				Description:  fmt.Sprintf("The type of this parameter. Must be one of: `\"%s\"`.", strings.Join(toStrings(ParameterFormTypes()), "\"`, `\"")),
+			},
+			"styling": {
+				Type:    schema.TypeString,
+				Default: `{}`,
+				Description: "JSON encoded string containing the metadata for controlling the appearance of this parameter in the UI. " +
+					"This option is purely cosmetic and does not affect the function of the parameter in terraform. " +
+					"See [styling options documentation](https://coder.com/docs/admin/templates/extending-templates/dynamic-parameters#available-styling-options) for available styling attributes.",
+				Optional: true,
 			},
 			"mutable": {
 				Type:        schema.TypeBool,
@@ -217,25 +223,17 @@ func parameterDataSource() *schema.Resource {
 			"icon": {
 				Type: schema.TypeString,
 				Description: "A URL to an icon that will display in the dashboard. View built-in " +
-					"icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a " +
-					"built-in icon with `data.coder_workspace.me.access_url + \"/icon/<path>\"`.",
-				ForceNew: true,
-				Optional: true,
-				ValidateFunc: func(i interface{}, s string) ([]string, []error) {
-					_, err := url.Parse(s)
-					if err != nil {
-						return nil, []error{err}
-					}
-					return nil, nil
-				},
+					"icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a " +
+					"built-in icon with `\"${data.coder_workspace.me.access_url}/icon/<path>\"`.",
+				ForceNew:     true,
+				Optional:     true,
+				ValidateFunc: helpers.ValidateURL,
 			},
 			"option": {
-				Type:          schema.TypeList,
-				Description:   "Each \"option\" block defines a value for a user to select from.",
-				ForceNew:      true,
-				Optional:      true,
-				MaxItems:      64,
-				ConflictsWith: []string{"validation"},
+				Type:        schema.TypeList,
+				Description: "Each `option` block defines a value for a user to select from.",
+				ForceNew:    true,
+				Optional:    true,
 				Elem: &schema.Resource{
 					Schema: map[string]*schema.Schema{
 						"name": {
@@ -259,48 +257,41 @@ func parameterDataSource() *schema.Resource {
 						"icon": {
 							Type: schema.TypeString,
 							Description: "A URL to an icon that will display in the dashboard. View built-in " +
-								"icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a " +
-								"built-in icon with `data.coder_workspace.me.access_url + \"/icon/<path>\"`.",
-							ForceNew: true,
-							Optional: true,
-							ValidateFunc: func(i interface{}, s string) ([]string, []error) {
-								_, err := url.Parse(s)
-								if err != nil {
-									return nil, []error{err}
-								}
-								return nil, nil
-							},
+								"icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a " +
+								"built-in icon with `\"${data.coder_workspace.me.access_url}/icon/<path>\"`.",
+							ForceNew:     true,
+							Optional:     true,
+							ValidateFunc: helpers.ValidateURL,
 						},
 					},
 				},
 			},
 			"validation": {
-				Type:          schema.TypeList,
-				MaxItems:      1,
-				Optional:      true,
-				Description:   "Validate the input of a parameter.",
-				ConflictsWith: []string{"option"},
+				Type:        schema.TypeList,
+				MaxItems:    1,
+				Optional:    true,
+				Description: "Validate the input of a parameter.",
 				Elem: &schema.Resource{
 					Schema: map[string]*schema.Schema{
 						"min": {
 							Type:        schema.TypeInt,
 							Optional:    true,
-							Description: "The minimum of a number parameter.",
+							Description: "The minimum value of a number parameter.",
 						},
 						"min_disabled": {
 							Type:        schema.TypeBool,
 							Computed:    true,
-							Description: "Helper field to check if min is present",
+							Description: "Helper field to check if `min` is present",
 						},
 						"max": {
 							Type:        schema.TypeInt,
 							Optional:    true,
-							Description: "The maximum of a number parameter.",
+							Description: "The maximum value of a number parameter.",
 						},
 						"max_disabled": {
 							Type:        schema.TypeBool,
 							Computed:    true,
-							Description: "Helper field to check if max is present",
+							Description: "Helper field to check if `max` is present",
 						},
 						"monotonic": {
 							Type:        schema.TypeString,
@@ -314,10 +305,9 @@ func parameterDataSource() *schema.Resource {
 							Optional:      true,
 						},
 						"error": {
-							Type:         schema.TypeString,
-							Optional:     true,
-							RequiredWith: []string{"validation.0.regex"},
-							Description:  "An error message to display if the value doesn't match the provided regex.",
+							Type:        schema.TypeString,
+							Optional:    true,
+							Description: "An error message to display if the value breaks the validation rules. The following placeholders are supported: `{max}`, `{min}`, and `{value}`.",
 						},
 					},
 				},
@@ -376,51 +366,247 @@ func fixValidationResourceData(rawConfig cty.Value, validation interface{}) (int
 	return vArr, nil
 }
 
-func valueIsType(typ, value string) diag.Diagnostics {
+func valueIsType(typ OptionType, value string) error {
 	switch typ {
-	case "number":
+	case OptionTypeNumber:
 		_, err := strconv.ParseFloat(value, 64)
 		if err != nil {
-			return diag.Errorf("%q is not a number", value)
+			return fmt.Errorf("%q is not a number", value)
 		}
-	case "bool":
+	case OptionTypeBoolean:
 		_, err := strconv.ParseBool(value)
 		if err != nil {
-			return diag.Errorf("%q is not a bool", value)
+			return fmt.Errorf("%q is not a bool", value)
 		}
-	case "list(string)":
-		var items []string
-		err := json.Unmarshal([]byte(value), &items)
+	case OptionTypeListString:
+		_, err := valueIsListString(value)
 		if err != nil {
-			return diag.Errorf("%q is not an array of strings", value)
+			return err
 		}
-	case "string":
+	case OptionTypeString:
 		// Anything is a string!
 	default:
-		return diag.Errorf("invalid type %q", typ)
+		return fmt.Errorf("invalid type %q", typ)
 	}
 	return nil
 }
 
-func (v *Validation) Valid(typ, value string) error {
-	if typ != "number" {
+func (v *Parameter) ValidateInput(input *string, previous *string) (string, diag.Diagnostics) {
+	var err error
+	var optionType OptionType
+
+	valuePath := cty.Path{}
+	value := input
+	if input == nil {
+		value = v.Default
+		if v.Default != nil {
+			valuePath = defaultValuePath
+		}
+	}
+
+	// optionType might differ from parameter.Type. This is ok, and parameter.Type
+	// should be used for the value type, and optionType for options.
+	optionType, v.FormType, err = ValidateFormType(v.Type, len(v.Option), v.FormType)
+	if err != nil {
+		return "", diag.Diagnostics{
+			{
+				Severity:      diag.Error,
+				Summary:       "Invalid form_type for parameter",
+				Detail:        err.Error(),
+				AttributePath: cty.Path{cty.GetAttrStep{Name: "form_type"}},
+			},
+		}
+	}
+
+	optionValues, diags := v.ValidOptions(optionType)
+	if diags.HasError() {
+		return "", diags
+	}
+
+	// TODO: This is a bit of a hack. The current behavior states if validation
+	//   is given, then apply validation to unset values.
+	//   value == nil should not be accepted in the first place.
+	//   To fix this, value should be coerced to an empty string
+	//   if it is nil. Then let the validation logic always apply.
+	if len(v.Validation) == 0 && value == nil {
+		return "", nil
+	}
+
+	// forcedValue ensures the value is not-nil.
+	var forcedValue string
+	if value != nil {
+		forcedValue = *value
+	}
+
+	d := v.validValue(forcedValue, previous, optionType, optionValues, valuePath)
+	if d.HasError() {
+		return "", d
+	}
+
+	err = valueIsType(v.Type, forcedValue)
+	if err != nil {
+		return "", diag.Diagnostics{
+			{
+				Severity: diag.Error,
+				Summary:  fmt.Sprintf("Parameter value is not of type %q", v.Type),
+				Detail:   err.Error(),
+			},
+		}
+	}
+
+	return forcedValue, nil
+}
+
+func (v *Parameter) ValidOptions(optionType OptionType) (map[string]struct{}, diag.Diagnostics) {
+	optionNames := map[string]struct{}{}
+	optionValues := map[string]struct{}{}
+
+	var diags diag.Diagnostics
+	for _, option := range v.Option {
+		_, exists := optionNames[option.Name]
+		if exists {
+			return nil, diag.Diagnostics{{
+				Severity: diag.Error,
+				Summary:  "Option names must be unique.",
+				Detail:   fmt.Sprintf("multiple options found with the same name %q", option.Name),
+			}}
+		}
+
+		_, exists = optionValues[option.Value]
+		if exists {
+			return nil, diag.Diagnostics{{
+				Severity: diag.Error,
+				Summary:  "Option values must be unique.",
+				Detail:   fmt.Sprintf("multiple options found with the same value %q", option.Value),
+			}}
+		}
+
+		err := valueIsType(optionType, option.Value)
+		if err != nil {
+			diags = append(diags, diag.Diagnostic{
+				Severity: diag.Error,
+				Summary:  fmt.Sprintf("Option %q with value=%q is not of type %q", option.Name, option.Value, optionType),
+				Detail:   err.Error(),
+			})
+			continue
+		}
+		optionValues[option.Value] = struct{}{}
+		optionNames[option.Name] = struct{}{}
+
+		// Option values are assumed to be valid. Do not call validValue on them.
+	}
+
+	if diags != nil && diags.HasError() {
+		return nil, diags
+	}
+	return optionValues, nil
+}
+
+func (v *Parameter) validValue(value string, previous *string, optionType OptionType, optionValues map[string]struct{}, path cty.Path) diag.Diagnostics {
+	// name is used for constructing more precise error messages.
+	name := "Value"
+	if path.Equals(defaultValuePath) {
+		name = "Default value"
+	}
+
+	// First validate if the value is a valid option
+	if len(optionValues) > 0 {
+		if v.Type == OptionTypeListString && optionType == OptionTypeString {
+			// If the type is list(string) and optionType is string, we have
+			// to ensure all elements of the value exist as options.
+			listValues, err := valueIsListString(value)
+			if err != nil {
+				return diag.Diagnostics{
+					{
+						Severity:      diag.Error,
+						Summary:       "When using list(string) type, value must be a json encoded list of strings",
+						Detail:        err.Error(),
+						AttributePath: path,
+					},
+				}
+			}
+
+			// missing is used to construct a more helpful error message
+			var missing []string
+			for _, listValue := range listValues {
+				_, isValid := optionValues[listValue]
+				if !isValid {
+					missing = append(missing, listValue)
+				}
+			}
+
+			if len(missing) > 0 {
+				return diag.Diagnostics{
+					{
+						Severity: diag.Error,
+						Summary:  fmt.Sprintf("%ss must be a valid option", name),
+						Detail: fmt.Sprintf(
+							"%s %q is not a valid option, values %q are missing from the options",
+							name, value, strings.Join(missing, ", "),
+						),
+						AttributePath: path,
+					},
+				}
+			}
+		} else {
+			_, isValid := optionValues[value]
+			if !isValid {
+				extra := ""
+				if value == "" {
+					extra = ". The value is empty, did you forget to set it with a default or from user input?"
+				}
+				return diag.Diagnostics{
+					{
+						Severity:      diag.Error,
+						Summary:       fmt.Sprintf("%s must be a valid option%s", name, extra),
+						Detail:        fmt.Sprintf("the value %q must be defined as one of options", value),
+						AttributePath: path,
+					},
+				}
+			}
+		}
+	}
+
+	if len(v.Validation) == 1 {
+		validCheck := &v.Validation[0]
+		err := validCheck.Valid(v.Type, value, previous)
+		if err != nil {
+			return diag.Diagnostics{
+				{
+					Severity:      diag.Error,
+					Summary:       fmt.Sprintf("Invalid parameter %s according to 'validation' block", strings.ToLower(name)),
+					Detail:        err.Error(),
+					AttributePath: path,
+				},
+			}
+		}
+	}
+
+	return nil
+}
+
+func (v *Validation) Valid(typ OptionType, value string, previous *string) error {
+	if typ != OptionTypeNumber {
 		if !v.MinDisabled {
 			return fmt.Errorf("a min cannot be specified for a %s type", typ)
 		}
 		if !v.MaxDisabled {
 			return fmt.Errorf("a max cannot be specified for a %s type", typ)
 		}
+		if v.Monotonic != "" {
+			return fmt.Errorf("monotonic validation can only be specified for number types, not %s types", typ)
+		}
 	}
-	if typ != "string" && v.Regex != "" {
+	if typ != OptionTypeString && v.Regex != "" {
 		return fmt.Errorf("a regex cannot be specified for a %s type", typ)
 	}
 	switch typ {
-	case "bool":
+	case OptionTypeBoolean:
 		if value != "true" && value != "false" {
 			return fmt.Errorf(`boolean value can be either "true" or "false"`)
 		}
 		return nil
-	case "string":
+	case OptionTypeString:
 		if v.Regex == "" {
 			return nil
 		}
@@ -435,21 +621,49 @@ func (v *Validation) Valid(typ, value string) error {
 		if !matched {
 			return fmt.Errorf("%s (value %q does not match %q)", v.Error, value, regex)
 		}
-	case "number":
+	case OptionTypeNumber:
 		num, err := strconv.Atoi(value)
 		if err != nil {
-			return fmt.Errorf("value %q is not a number", value)
+			return takeFirstError(v.errorRendered(value), fmt.Errorf("value %q is not a number", value))
 		}
 		if !v.MinDisabled && num < v.Min {
-			return fmt.Errorf("value %d is less than the minimum %d", num, v.Min)
+			return takeFirstError(v.errorRendered(value), fmt.Errorf("value %d is less than the minimum %d", num, v.Min))
 		}
 		if !v.MaxDisabled && num > v.Max {
-			return fmt.Errorf("value %d is more than the maximum %d", num, v.Max)
+			return takeFirstError(v.errorRendered(value), fmt.Errorf("value %d is more than the maximum %d", num, v.Max))
 		}
 		if v.Monotonic != "" && v.Monotonic != ValidationMonotonicIncreasing && v.Monotonic != ValidationMonotonicDecreasing {
 			return fmt.Errorf("number monotonicity can be either %q or %q", ValidationMonotonicIncreasing, ValidationMonotonicDecreasing)
 		}
-	case "list(string)":
+
+		switch v.Monotonic {
+		case "":
+			// No monotonicity check
+		case ValidationMonotonicIncreasing, ValidationMonotonicDecreasing:
+			if previous != nil { // Only check if previous value exists
+				previousNum, err := strconv.Atoi(*previous)
+				if err != nil {
+					// Do not throw an error for the previous value not being a number. Throwing an
+					// error here would cause an unrepairable state for the user. This is
+					// unfortunate, but there is not much we can do at this point.
+					// TODO: Maybe we should enforce this, and have the calling coderd
+					//  do something to resolve it. Such as doing this check before calling
+					//  terraform apply.
+					break
+				}
+
+				if v.Monotonic == ValidationMonotonicIncreasing && !(num >= previousNum) {
+					return fmt.Errorf("parameter value '%d' must be equal or greater than previous value: %d", num, previousNum)
+				}
+
+				if v.Monotonic == ValidationMonotonicDecreasing && !(num <= previousNum) {
+					return fmt.Errorf("parameter value '%d' must be equal or lower than previous value: %d", num, previousNum)
+				}
+			}
+		default:
+			return fmt.Errorf("number monotonicity can be either %q or %q", ValidationMonotonicIncreasing, ValidationMonotonicDecreasing)
+		}
+	case OptionTypeListString:
 		var listOfStrings []string
 		err := json.Unmarshal([]byte(value), &listOfStrings)
 		if err != nil {
@@ -459,6 +673,15 @@ func (v *Validation) Valid(typ, value string) error {
 	return nil
 }
 
+func valueIsListString(value string) ([]string, error) {
+	var items []string
+	err := json.Unmarshal([]byte(value), &items)
+	if err != nil {
+		return nil, fmt.Errorf("value %q is not a valid list of strings", value)
+	}
+	return items, nil
+}
+
 // ParameterEnvironmentVariable returns the environment variable to specify for
 // a parameter by it's name. It's hashed because spaces and special characters
 // can be used in parameter names that may not be valid in env vars.
@@ -466,3 +689,32 @@ func ParameterEnvironmentVariable(name string) string {
 	sum := sha256.Sum256([]byte(name))
 	return "CODER_PARAMETER_" + hex.EncodeToString(sum[:])
 }
+
+// ParameterEnvironmentVariablePrevious returns the environment variable to
+// specify for a parameter's previous value. This is used for workspace
+// subsequent builds after the first. Primarily to validate monotonicity in the
+// `validation` block.
+func ParameterEnvironmentVariablePrevious(name string) string {
+	sum := sha256.Sum256([]byte(name))
+	return "CODER_PARAMETER_PREVIOUS_" + hex.EncodeToString(sum[:])
+}
+
+func takeFirstError(errs ...error) error {
+	for _, err := range errs {
+		if err != nil {
+			return err
+		}
+	}
+	return xerrors.Errorf("developer error: error message is not provided")
+}
+
+func (v *Validation) errorRendered(value string) error {
+	if v.Error == "" {
+		return nil
+	}
+	r := strings.NewReplacer(
+		"{min}", fmt.Sprintf("%d", v.Min),
+		"{max}", fmt.Sprintf("%d", v.Max),
+		"{value}", value)
+	return xerrors.Errorf(r.Replace(v.Error))
+}
diff --git a/provider/parameter_test.go b/provider/parameter_test.go
index 48cf4829..35f045b9 100644
--- a/provider/parameter_test.go
+++ b/provider/parameter_test.go
@@ -1,14 +1,19 @@
 package provider_test
 
 import (
+	"fmt"
+	"os"
 	"regexp"
+	"strconv"
+	"strings"
 	"testing"
 
-	"github.com/coder/terraform-provider-coder/provider"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+
+	"github.com/coder/terraform-provider-coder/v2/provider"
 )
 
 func TestParameter(t *testing.T) {
@@ -25,6 +30,7 @@ func TestParameter(t *testing.T) {
 				name = "region"
 				display_name = "Region"
 				type = "string"
+ 				form_type = "dropdown"
 				description = <<-EOT
 					# Select the machine image
 					See the [registry](https://container.registry.blah/namespace) for options.
@@ -54,6 +60,7 @@ func TestParameter(t *testing.T) {
 				"name":                 "region",
 				"display_name":         "Region",
 				"type":                 "string",
+				"form_type":            "dropdown",
 				"description":          "# Select the machine image\nSee the [registry](https://container.registry.blah/namespace) for options.\n",
 				"mutable":              "true",
 				"icon":                 "/icon/region.svg",
@@ -73,11 +80,12 @@ func TestParameter(t *testing.T) {
 			}
 		},
 	}, {
-		Name: "ValidationWithOptions",
+		Name: "RegexValidationWithOptions",
 		Config: `
 			data "coder_parameter" "region" {
 				name = "Region"
 				type = "number"
+				default = 1
 				option {
 					name = "1"
 					value = "1"
@@ -88,7 +96,24 @@ func TestParameter(t *testing.T) {
 				}
 			}
 			`,
-		ExpectError: regexp.MustCompile("conflicts with option"),
+		ExpectError: regexp.MustCompile("a regex cannot be specified for a number type"),
+	}, {
+		Name: "MonotonicValidationWithNonNumberType",
+		Config: `
+			data "coder_parameter" "region" {
+				name = "Region"
+				type = "string"
+				default = "1"
+				option {
+					name = "1"
+					value = "1"
+				}
+				validation {
+					monotonic = "increasing"
+				}
+			}
+			`,
+		ExpectError: regexp.MustCompile("monotonic validation can only be specified for number types, not string types"),
 	}, {
 		Name: "ValidationRegexMissingError",
 		Config: `
@@ -119,6 +144,7 @@ func TestParameter(t *testing.T) {
 			for key, expected := range map[string]string{
 				"name":                      "Region",
 				"type":                      "number",
+				"form_type":                 "input",
 				"validation.#":              "1",
 				"default":                   "2",
 				"validation.0.min":          "1",
@@ -270,7 +296,7 @@ func TestParameter(t *testing.T) {
 				}
 			}
 			`,
-		ExpectError: regexp.MustCompile("cannot have the same name"),
+		ExpectError: regexp.MustCompile("Option names must be unique"),
 	}, {
 		Name: "DuplicateOptionValue",
 		Config: `
@@ -287,7 +313,7 @@ func TestParameter(t *testing.T) {
 				}
 			}
 			`,
-		ExpectError: regexp.MustCompile("cannot have the same value"),
+		ExpectError: regexp.MustCompile("Option values must be unique"),
 	}, {
 		Name: "RequiredParameterNoDefault",
 		Config: `
@@ -424,6 +450,54 @@ data "coder_parameter" "region" {
 				require.Equal(t, expected, state.Primary.Attributes[key])
 			}
 		},
+	}, {
+		Name: "NumberValidation_MonotonicWithOptions",
+		Config: `
+			data "coder_parameter" "region" {
+			  name        = "Region"
+			  type        = "number"
+			  description = <<-EOF
+			  Always pick a larger region.
+			  EOF
+			  default     = 1
+
+			  option {
+				name  = "Small"
+				value = 1
+			  }
+
+			  option {
+				name  = "Medium"
+				value = 2
+			  }
+
+			  option {
+				name  = "Large"
+				value = 3
+			  }
+
+			  validation {
+				monotonic = "increasing"
+			  }
+			}
+			`,
+		Check: func(state *terraform.ResourceState) {
+			for key, expected := range map[string]any{
+				"name":                   "Region",
+				"type":                   "number",
+				"validation.#":           "1",
+				"option.0.name":          "Small",
+				"option.0.value":         "1",
+				"option.1.name":          "Medium",
+				"option.1.value":         "2",
+				"option.2.name":          "Large",
+				"option.2.value":         "3",
+				"default":                "1",
+				"validation.0.monotonic": "increasing",
+			} {
+				require.Equal(t, expected, state.Primary.Attributes[key])
+			}
+		},
 	}, {
 		Name: "NumberValidation_Min",
 		Config: `
@@ -527,6 +601,20 @@ data "coder_parameter" "region" {
 			}
 			`,
 		ExpectError: regexp.MustCompile("is more than the maximum"),
+	}, {
+		Name: "NumberValidation_CustomError",
+		Config: `
+			data "coder_parameter" "region" {
+				name = "Region"
+				type = "number"
+				default = 5
+				validation {
+					max = 3
+					error = "foobar"
+				}
+			}
+			`,
+		ExpectError: regexp.MustCompile("foobar"),
 	}, {
 		Name: "NumberValidation_NotInRange",
 		Config: `
@@ -577,15 +665,39 @@ data "coder_parameter" "region" {
 			}
 			`,
 		ExpectError: regexp.MustCompile("ephemeral parameter requires the default property"),
-	}} {
+	}, {
+		Name: "InvalidIconURL",
+		Config: `
+			data "coder_parameter" "region" {
+				name = "Region"
+				type = "string"
+				icon = "/icon%.svg"
+			}
+			`,
+		ExpectError: regexp.MustCompile("invalid URL escape"),
+	}, {
+		Name: "OptionInvalidIconURL",
+		Config: `
+			data "coder_parameter" "region" {
+				name = "Region"
+				type = "string"
+				option {
+					name = "1"
+					value = "1"
+					icon = "/icon%.svg"
+					description = "Something!"
+				}
+			}
+			`,
+		ExpectError: regexp.MustCompile("invalid URL escape"),
+	},
+	} {
 		tc := tc
 		t.Run(tc.Name, func(t *testing.T) {
 			t.Parallel()
 			resource.Test(t, resource.TestCase{
-				Providers: map[string]*schema.Provider{
-					"coder": provider.New(),
-				},
-				IsUnitTest: true,
+				ProviderFactories: coderFactory(),
+				IsUnitTest:        true,
 				Steps: []resource.TestStep{{
 					Config:      tc.Config,
 					ExpectError: tc.ExpectError,
@@ -605,16 +717,410 @@ data "coder_parameter" "region" {
 	}
 }
 
+func TestParameterValidation(t *testing.T) {
+	t.Parallel()
+	opts := func(vals ...string) []provider.Option {
+		options := make([]provider.Option, 0, len(vals))
+		for _, val := range vals {
+			options = append(options, provider.Option{
+				Name:  val,
+				Value: val,
+			})
+		}
+		return options
+	}
+
+	for _, tc := range []struct {
+		Name        string
+		Parameter   provider.Parameter
+		Value       string
+		ExpectError *regexp.Regexp
+	}{
+		{
+			Name: "ValidStringParameter",
+			Parameter: provider.Parameter{
+				Type: "string",
+			},
+			Value: "alpha",
+		},
+		// Test invalid states
+		{
+			Name: "InvalidFormType",
+			Parameter: provider.Parameter{
+				Type:     "string",
+				Option:   opts("alpha", "bravo", "charlie"),
+				FormType: provider.ParameterFormTypeSlider,
+			},
+			Value:       "alpha",
+			ExpectError: regexp.MustCompile("Invalid form_type for parameter"),
+		},
+		{
+			Name: "NotInOptions",
+			Parameter: provider.Parameter{
+				Type:   "string",
+				Option: opts("alpha", "bravo", "charlie"),
+			},
+			Value:       "delta", // not in option set
+			ExpectError: regexp.MustCompile("Value must be a valid option"),
+		},
+		{
+			Name: "NumberNotInOptions",
+			Parameter: provider.Parameter{
+				Type:   "number",
+				Option: opts("1", "2", "3"),
+			},
+			Value:       "0", // not in option set
+			ExpectError: regexp.MustCompile("Value must be a valid option"),
+		},
+		{
+			Name: "NonUniqueOptionNames",
+			Parameter: provider.Parameter{
+				Type:   "string",
+				Option: opts("alpha", "alpha"),
+			},
+			Value:       "alpha",
+			ExpectError: regexp.MustCompile("Option names must be unique"),
+		},
+		{
+			Name: "NonUniqueOptionValues",
+			Parameter: provider.Parameter{
+				Type: "string",
+				Option: []provider.Option{
+					{Name: "Alpha", Value: "alpha"},
+					{Name: "AlphaAgain", Value: "alpha"},
+				},
+			},
+			Value:       "alpha",
+			ExpectError: regexp.MustCompile("Option values must be unique"),
+		},
+		{
+			Name: "IncorrectValueTypeOption",
+			Parameter: provider.Parameter{
+				Type:   "number",
+				Option: opts("not-a-number"),
+			},
+			Value:       "5",
+			ExpectError: regexp.MustCompile("is not a number"),
+		},
+		{
+			Name: "IncorrectValueType",
+			Parameter: provider.Parameter{
+				Type: "number",
+			},
+			Value:       "not-a-number",
+			ExpectError: regexp.MustCompile("Parameter value is not of type \"number\""),
+		},
+		{
+			Name: "NotListStringDefault",
+			Parameter: provider.Parameter{
+				Type:    "list(string)",
+				Default: ptr("not-a-list"),
+			},
+			ExpectError: regexp.MustCompile("not a valid list of strings"),
+		},
+		{
+			Name: "NotListStringDefault",
+			Parameter: provider.Parameter{
+				Type: "list(string)",
+			},
+			Value:       "not-a-list",
+			ExpectError: regexp.MustCompile("not a valid list of strings"),
+		},
+		{
+			Name: "DefaultListStringNotInOptions",
+			Parameter: provider.Parameter{
+				Type:     "list(string)",
+				Default:  ptr(`["red", "yellow", "black"]`),
+				Option:   opts("red", "blue", "green"),
+				FormType: provider.ParameterFormTypeMultiSelect,
+			},
+			Value:       `["red", "yellow", "black"]`,
+			ExpectError: regexp.MustCompile("is not a valid option, values \"yellow, black\" are missing from the options"),
+		},
+		{
+			Name: "ListStringNotInOptions",
+			Parameter: provider.Parameter{
+				Type:     "list(string)",
+				Default:  ptr(`["red"]`),
+				Option:   opts("red", "blue", "green"),
+				FormType: provider.ParameterFormTypeMultiSelect,
+			},
+			Value:       `["red", "yellow", "black"]`,
+			ExpectError: regexp.MustCompile("is not a valid option, values \"yellow, black\" are missing from the options"),
+		},
+		{
+			Name: "InvalidMiniumum",
+			Parameter: provider.Parameter{
+				Type:    "number",
+				Default: ptr("5"),
+				Validation: []provider.Validation{{
+					Min:   10,
+					Error: "must be greater than 10",
+				}},
+			},
+			ExpectError: regexp.MustCompile("must be greater than 10"),
+		},
+	} {
+		tc := tc
+		t.Run(tc.Name, func(t *testing.T) {
+			t.Parallel()
+			value := &tc.Value
+			_, diags := tc.Parameter.ValidateInput(value, nil)
+			if tc.ExpectError != nil {
+				require.True(t, diags.HasError())
+				errMsg := fmt.Sprintf("%+v", diags[0]) // close enough
+				require.Truef(t, tc.ExpectError.MatchString(errMsg), "got: %s", errMsg)
+			} else {
+				if !assert.False(t, diags.HasError()) {
+					t.Logf("got: %+v", diags[0])
+				}
+			}
+		})
+	}
+}
+
+// TestParameterValidationEnforcement tests various parameter states and the
+// validation enforcement that should be applied to them. The table is described
+// by a markdown table. This is done so that the test cases can be more easily
+// edited and read.
+//
+// Copy and paste the table to https://www.tablesgenerator.com/markdown_tables for easier editing
+//
+//nolint:paralleltest,tparallel // Parameters load values from env vars
+func TestParameterValidationEnforcement(t *testing.T) {
+	// Some interesting observations:
+	// - Validation logic does not apply to the value of 'options'
+	//	- [NumDefInvOpt] So an invalid option can be present and selected, but would fail
+	// - Validation logic does not apply to the default if a value is given
+	//	- [NumIns/DefInv] So the default can be invalid if an input value is valid.
+	//	  The value is therefore not really optional, but it is marked as such.
+	table, err := os.ReadFile("testdata/parameter_table.md")
+	require.NoError(t, err)
+
+	type row struct {
+		Name        string
+		Types       []string
+		InputValue  string
+		Default     string
+		Options     []string
+		Validation  *provider.Validation
+		OutputValue string
+		Optional    bool
+		CreateError *regexp.Regexp
+		Previous    *string
+	}
+
+	rows := make([]row, 0)
+	lines := strings.Split(string(table), "\n")
+	validMinMax := regexp.MustCompile("^[0-9]*-[0-9]*$")
+	for _, line := range lines[2:] {
+		columns := strings.Split(line, "|")
+		columns = columns[1 : len(columns)-1]
+		for i := range columns {
+			// Trim the whitespace from all columns
+			columns[i] = strings.TrimSpace(columns[i])
+		}
+
+		if columns[0] == "" {
+			continue // Skip rows with empty names
+		}
+
+		cname, ctype, cprev, cinput, cdefault, coptions, cvalidation, _, coutput, coptional, cerr :=
+			columns[0], columns[1], columns[2], columns[3], columns[4], columns[5], columns[6], columns[7], columns[8], columns[9], columns[10]
+
+		optional, err := strconv.ParseBool(coptional)
+		if coptional != "" {
+			// Value does not matter if not specified
+			require.NoError(t, err)
+		}
+
+		var rerr *regexp.Regexp
+		if cerr != "" {
+			rerr, err = regexp.Compile(cerr)
+			if err != nil {
+				t.Fatalf("failed to parse error column %q: %v", cerr, err)
+			}
+		}
+
+		var options []string
+		if coptions != "" {
+			options = strings.Split(coptions, ",")
+		}
+
+		var validation *provider.Validation
+		if cvalidation != "" {
+			switch {
+			case cvalidation == provider.ValidationMonotonicIncreasing || cvalidation == provider.ValidationMonotonicDecreasing:
+				validation = &provider.Validation{
+					MinDisabled: true,
+					MaxDisabled: true,
+					Monotonic:   cvalidation,
+					Error:       "monotonicity",
+				}
+			case validMinMax.MatchString(cvalidation):
+				// Min-Max validation should look like:
+				//	1-10    :: min=1, max=10
+				//	-10     :: max=10
+				//	1-      :: min=1
+				parts := strings.Split(cvalidation, "-")
+				min, _ := strconv.ParseInt(parts[0], 10, 64)
+				max, _ := strconv.ParseInt(parts[1], 10, 64)
+				validation = &provider.Validation{
+					Min:         int(min),
+					MinDisabled: parts[0] == "",
+					Max:         int(max),
+					MaxDisabled: parts[1] == "",
+					Monotonic:   "",
+					Regex:       "",
+					Error:       "{min} < {value} < {max}",
+				}
+			default:
+				validation = &provider.Validation{
+					Min:         0,
+					MinDisabled: true,
+					Max:         0,
+					MaxDisabled: true,
+					Monotonic:   "",
+					Regex:       cvalidation,
+					Error:       "regex error",
+				}
+			}
+		}
+
+		var prev *string
+		if cprev != "" {
+			prev = ptr(cprev)
+			if cprev == `""` {
+				prev = ptr("")
+			}
+		}
+		rows = append(rows, row{
+			Name:        cname,
+			Types:       strings.Split(ctype, ","),
+			InputValue:  cinput,
+			Default:     cdefault,
+			Options:     options,
+			Validation:  validation,
+			OutputValue: coutput,
+			Optional:    optional,
+			CreateError: rerr,
+			Previous:    prev,
+		})
+	}
+
+	stringLiteral := func(s string) string {
+		if s == "" {
+			return `""`
+		}
+		return fmt.Sprintf("%q", s)
+	}
+
+	for rowIndex, row := range rows {
+		for _, rt := range row.Types {
+			//nolint:paralleltest,tparallel // Parameters load values from env vars
+			t.Run(fmt.Sprintf("%d|%s:%s", rowIndex, row.Name, rt), func(t *testing.T) {
+				if row.InputValue != "" {
+					t.Setenv(provider.ParameterEnvironmentVariable("parameter"), row.InputValue)
+				}
+				if row.Previous != nil {
+					t.Setenv(provider.ParameterEnvironmentVariablePrevious("parameter"), *row.Previous)
+				}
+
+				if row.CreateError != nil && row.OutputValue != "" {
+					t.Errorf("output value %q should not be set if both errors are set", row.OutputValue)
+				}
+
+				var cfg strings.Builder
+				cfg.WriteString("data \"coder_parameter\" \"parameter\" {\n")
+				cfg.WriteString("\tname = \"parameter\"\n")
+				if rt == "multi-select" || rt == "tag-select" {
+					cfg.WriteString(fmt.Sprintf("\ttype = \"%s\"\n", "list(string)"))
+					cfg.WriteString(fmt.Sprintf("\tform_type = \"%s\"\n", rt))
+				} else {
+					cfg.WriteString(fmt.Sprintf("\ttype = \"%s\"\n", rt))
+				}
+				if row.Default != "" {
+					cfg.WriteString(fmt.Sprintf("\tdefault = %s\n", stringLiteral(row.Default)))
+				}
+
+				for _, opt := range row.Options {
+					cfg.WriteString("\toption {\n")
+					cfg.WriteString(fmt.Sprintf("\t\tname = %s\n", stringLiteral(opt)))
+					cfg.WriteString(fmt.Sprintf("\t\tvalue = %s\n", stringLiteral(opt)))
+					cfg.WriteString("\t}\n")
+				}
+
+				if row.Validation != nil {
+					cfg.WriteString("\tvalidation {\n")
+					if !row.Validation.MinDisabled {
+						cfg.WriteString(fmt.Sprintf("\t\tmin = %d\n", row.Validation.Min))
+					}
+					if !row.Validation.MaxDisabled {
+						cfg.WriteString(fmt.Sprintf("\t\tmax = %d\n", row.Validation.Max))
+					}
+					if row.Validation.Monotonic != "" {
+						cfg.WriteString(fmt.Sprintf("\t\tmonotonic = \"%s\"\n", row.Validation.Monotonic))
+					}
+					if row.Validation.Regex != "" {
+						cfg.WriteString(fmt.Sprintf("\t\tregex = %q\n", row.Validation.Regex))
+					}
+					cfg.WriteString(fmt.Sprintf("\t\terror = %q\n", row.Validation.Error))
+					cfg.WriteString("\t}\n")
+				}
+
+				cfg.WriteString("}\n")
+				resource.Test(t, resource.TestCase{
+					ProviderFactories: coderFactory(),
+					IsUnitTest:        true,
+					Steps: []resource.TestStep{{
+						Config:      cfg.String(),
+						ExpectError: row.CreateError,
+						Check: func(state *terraform.State) error {
+							require.Len(t, state.Modules, 1)
+							require.Len(t, state.Modules[0].Resources, 1)
+							param := state.Modules[0].Resources["data.coder_parameter.parameter"]
+							require.NotNil(t, param)
+
+							if row.Default == "" {
+								_, ok := param.Primary.Attributes["default"]
+								require.False(t, ok, "default should not be set")
+							} else {
+								require.Equal(t, strings.Trim(row.Default, `"`), param.Primary.Attributes["default"])
+							}
+
+							if row.OutputValue == "" {
+								_, ok := param.Primary.Attributes["value"]
+								require.False(t, ok, "output value should not be set")
+							} else {
+								require.Equal(t, strings.Trim(row.OutputValue, `"`), param.Primary.Attributes["value"])
+							}
+
+							for key, expected := range map[string]string{
+								"optional": strconv.FormatBool(row.Optional),
+							} {
+								require.Equal(t, expected, param.Primary.Attributes[key], "optional")
+							}
+
+							return nil
+						},
+					}},
+				})
+			})
+		}
+	}
+}
+
 func TestValueValidatesType(t *testing.T) {
 	t.Parallel()
 	for _, tc := range []struct {
-		Name,
-		Type,
-		Value,
-		Regex,
-		RegexError string
-		Min,
-		Max int
+		Name                     string
+		Type                     provider.OptionType
+		Value                    string
+		Previous                 *string
+		Regex                    string
+		RegexError               string
+		Min                      int
+		Max                      int
 		MinDisabled, MaxDisabled bool
 		Monotonic                string
 		Error                    *regexp.Regexp
@@ -698,6 +1204,75 @@ func TestValueValidatesType(t *testing.T) {
 		Min:       0,
 		Max:       2,
 		Monotonic: "decreasing",
+	}, {
+		Name:        "IncreasingMonotonicityEqual",
+		Type:        "number",
+		Previous:    ptr("1"),
+		Value:       "1",
+		Monotonic:   "increasing",
+		MinDisabled: true,
+		MaxDisabled: true,
+	}, {
+		Name:        "DecreasingMonotonicityEqual",
+		Type:        "number",
+		Value:       "1",
+		Previous:    ptr("1"),
+		Monotonic:   "decreasing",
+		MinDisabled: true,
+		MaxDisabled: true,
+	}, {
+		Name:        "IncreasingMonotonicityGreater",
+		Type:        "number",
+		Previous:    ptr("0"),
+		Value:       "1",
+		Monotonic:   "increasing",
+		MinDisabled: true,
+		MaxDisabled: true,
+	}, {
+		Name:        "DecreasingMonotonicityGreater",
+		Type:        "number",
+		Value:       "1",
+		Previous:    ptr("0"),
+		Monotonic:   "decreasing",
+		MinDisabled: true,
+		MaxDisabled: true,
+		Error:       regexp.MustCompile("must be equal or"),
+	}, {
+		Name:        "IncreasingMonotonicityLesser",
+		Type:        "number",
+		Previous:    ptr("2"),
+		Value:       "1",
+		Monotonic:   "increasing",
+		MinDisabled: true,
+		MaxDisabled: true,
+		Error:       regexp.MustCompile("must be equal or"),
+	}, {
+		Name:        "DecreasingMonotonicityLesser",
+		Type:        "number",
+		Value:       "1",
+		Previous:    ptr("2"),
+		Monotonic:   "decreasing",
+		MinDisabled: true,
+		MaxDisabled: true,
+	}, {
+		Name:        "ValidListOfStrings",
+		Type:        "list(string)",
+		Value:       `["first","second","third"]`,
+		MinDisabled: true,
+		MaxDisabled: true,
+	}, {
+		Name:        "InvalidListOfStrings",
+		Type:        "list(string)",
+		Value:       `["first","second","third"`,
+		MinDisabled: true,
+		MaxDisabled: true,
+		Error:       regexp.MustCompile("is not valid list of strings"),
+	}, {
+		Name:        "EmptyListOfStrings",
+		Type:        "list(string)",
+		Value:       `[]`,
+		MinDisabled: true,
+		MaxDisabled: true,
 	}, {
 		Name:        "ValidListOfStrings",
 		Type:        "list(string)",
@@ -730,7 +1305,7 @@ func TestValueValidatesType(t *testing.T) {
 				Regex:       tc.Regex,
 				Error:       tc.RegexError,
 			}
-			err := v.Valid(tc.Type, tc.Value)
+			err := v.Valid(tc.Type, tc.Value, tc.Previous)
 			if tc.Error != nil {
 				require.Error(t, err)
 				require.True(t, tc.Error.MatchString(err.Error()), "got: %s", err.Error())
@@ -740,3 +1315,47 @@ func TestValueValidatesType(t *testing.T) {
 		})
 	}
 }
+
+func TestParameterWithManyOptions(t *testing.T) {
+	t.Parallel()
+
+	const maxItemsInTest = 1024
+
+	var options strings.Builder
+	for i := 0; i < maxItemsInTest; i++ {
+		_, _ = options.WriteString(fmt.Sprintf(`option {
+					name = "%d"
+					value = "%d"
+				}
+`, i, i))
+	}
+
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: fmt.Sprintf(`data "coder_parameter" "region" {
+				name = "Region"
+				type = "string"
+				%s
+			}`, options.String()),
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				param := state.Modules[0].Resources["data.coder_parameter.region"]
+
+				for i := 0; i < maxItemsInTest; i++ {
+					name, _ := param.Primary.Attributes[fmt.Sprintf("option.%d.name", i)]
+					value, _ := param.Primary.Attributes[fmt.Sprintf("option.%d.value", i)]
+					require.Equal(t, fmt.Sprintf("%d", i), name)
+					require.Equal(t, fmt.Sprintf("%d", i), value)
+				}
+				return nil
+			},
+		}},
+	})
+}
+
+func ptr[T any](v T) *T {
+	return &v
+}
diff --git a/provider/provider.go b/provider/provider.go
index 6556146e..5a2f1972 100644
--- a/provider/provider.go
+++ b/provider/provider.go
@@ -10,6 +10,8 @@ import (
 	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"golang.org/x/xerrors"
+
+	"github.com/coder/terraform-provider-coder/v2/provider/helpers"
 )
 
 type config struct {
@@ -26,21 +28,8 @@ func New() *schema.Provider {
 				Optional:    true,
 				// The "CODER_AGENT_URL" environment variable is used by default
 				// as the Access URL when generating scripts.
-				DefaultFunc: schema.EnvDefaultFunc("CODER_AGENT_URL", "https://mydeployment.coder.com"),
-				ValidateFunc: func(i interface{}, s string) ([]string, []error) {
-					_, err := url.Parse(s)
-					if err != nil {
-						return nil, []error{err}
-					}
-					return nil, nil
-				},
-			},
-			"feature_use_managed_variables": {
-				Type:        schema.TypeBool,
-				Description: "Feature: use managed Terraform variables. The feature flag is not used anymore as Terraform variables are now exclusively utilized for template-wide variables.",
-				Default:     true,
-				Optional:    true,
-				Deprecated:  "Terraform variables are now exclusively utilized for template-wide variables after the removal of support for legacy parameters.",
+				DefaultFunc:  schema.EnvDefaultFunc("CODER_AGENT_URL", "https://mydeployment.coder.com"),
+				ValidateFunc: helpers.ValidateURL,
 			},
 		},
 		ConfigureContextFunc: func(c context.Context, resourceData *schema.ResourceData) (interface{}, diag.Diagnostics) {
@@ -68,18 +57,24 @@ func New() *schema.Provider {
 			}, nil
 		},
 		DataSourcesMap: map[string]*schema.Resource{
-			"coder_workspace":     workspaceDataSource(),
-			"coder_provisioner":   provisionerDataSource(),
-			"coder_parameter":     parameterDataSource(),
-			"coder_git_auth":      gitAuthDataSource(),
-			"coder_external_auth": externalAuthDataSource(),
+			"coder_workspace":        workspaceDataSource(),
+			"coder_workspace_tags":   workspaceTagDataSource(),
+			"coder_provisioner":      provisionerDataSource(),
+			"coder_parameter":        parameterDataSource(),
+			"coder_external_auth":    externalAuthDataSource(),
+			"coder_workspace_owner":  workspaceOwnerDataSource(),
+			"coder_workspace_preset": workspacePresetDataSource(),
 		},
 		ResourcesMap: map[string]*schema.Resource{
 			"coder_agent":          agentResource(),
 			"coder_agent_instance": agentInstanceResource(),
+			"coder_ai_task":        aiTask(),
 			"coder_app":            appResource(),
 			"coder_metadata":       metadataResource(),
 			"coder_script":         scriptResource(),
+			"coder_env":            envResource(),
+			"coder_devcontainer":   devcontainerResource(),
+			"coder_external_agent": externalAgentResource(),
 		},
 	}
 }
@@ -102,14 +97,8 @@ func populateIsNull(resourceData *schema.ResourceData) (result interface{}, err
 	items := rawPlan.GetAttr("item").AsValueSlice()
 
 	var resultItems []interface{}
-	itemKeys := map[string]struct{}{}
 	for _, item := range items {
 		key := valueAsString(item.GetAttr("key"))
-		_, exists := itemKeys[key]
-		if exists {
-			return nil, xerrors.Errorf("duplicate metadata key %q", key)
-		}
-		itemKeys[key] = struct{}{}
 		resultItem := map[string]interface{}{
 			"key":       key,
 			"value":     valueAsString(item.GetAttr("value")),
diff --git a/provider/provider_test.go b/provider/provider_test.go
index cd00f9a4..4bf98b32 100644
--- a/provider/provider_test.go
+++ b/provider/provider_test.go
@@ -3,9 +3,12 @@ package provider_test
 import (
 	"testing"
 
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
 	"github.com/stretchr/testify/require"
 
-	"github.com/coder/terraform-provider-coder/provider"
+	"github.com/coder/terraform-provider-coder/v2/provider"
 )
 
 func TestProvider(t *testing.T) {
@@ -14,3 +17,38 @@ func TestProvider(t *testing.T) {
 	err := tfProvider.InternalValidate()
 	require.NoError(t, err)
 }
+
+// TestProviderEmpty ensures that the provider can be configured without
+// any actual input data. This is important for adding new fields
+// with backwards compatibility guarantees.
+func TestProviderEmpty(t *testing.T) {
+	t.Parallel()
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+			provider "coder" {}
+			data "coder_provisioner" "me" {}
+			data "coder_workspace" "me" {}
+			data "coder_workspace_owner" "me" {}
+			data "coder_external_auth" "git" {
+				id = "git"
+			}
+			data "coder_parameter" "param" {
+				name = "hey"
+			}`,
+			Check: func(state *terraform.State) error {
+				return nil
+			},
+		}},
+	})
+}
+
+func coderFactory() map[string]func() (*schema.Provider, error) {
+	return map[string]func() (*schema.Provider, error){
+		"coder": func() (*schema.Provider, error) {
+			return provider.New(), nil
+		},
+	}
+}
diff --git a/provider/provisioner.go b/provider/provisioner.go
index 49d8f401..49a40d21 100644
--- a/provider/provisioner.go
+++ b/provider/provisioner.go
@@ -11,11 +11,17 @@ import (
 
 func provisionerDataSource() *schema.Resource {
 	return &schema.Resource{
+		SchemaVersion: 1,
+
 		Description: "Use this data source to get information about the Coder provisioner.",
 		ReadContext: func(c context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
 			rd.SetId(uuid.NewString())
 			rd.Set("os", runtime.GOOS)
 			rd.Set("arch", runtime.GOARCH)
+			// Fix for #11782: if we're on 32-bit ARM, set arch to armv7.
+			if runtime.GOARCH == "arm" {
+				rd.Set("arch", "armv7")
+			}
 
 			return nil
 		},
@@ -23,12 +29,12 @@ func provisionerDataSource() *schema.Resource {
 			"os": {
 				Type:        schema.TypeString,
 				Computed:    true,
-				Description: "The operating system of the host. This exposes `runtime.GOOS` (see https://pkg.go.dev/runtime#pkg-constants).",
+				Description: "The operating system of the host. This exposes `runtime.GOOS` (see [Go constants](https://pkg.go.dev/runtime#pkg-constants)).",
 			},
 			"arch": {
 				Type:        schema.TypeString,
 				Computed:    true,
-				Description: "The architecture of the host. This exposes `runtime.GOARCH` (see https://pkg.go.dev/runtime#pkg-constants).",
+				Description: "The architecture of the host. This exposes `runtime.GOARCH` (see [Go constants](https://pkg.go.dev/runtime#pkg-constants)).",
 			},
 		},
 	}
diff --git a/provider/provisioner_test.go b/provider/provisioner_test.go
index 777006f7..53295bcd 100644
--- a/provider/provisioner_test.go
+++ b/provider/provisioner_test.go
@@ -4,19 +4,16 @@ import (
 	"runtime"
 	"testing"
 
-	"github.com/coder/terraform-provider-coder/provider"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
 	"github.com/stretchr/testify/require"
 )
 
 func TestProvisioner(t *testing.T) {
+	t.Parallel()
 	resource.Test(t, resource.TestCase{
-		Providers: map[string]*schema.Provider{
-			"coder": provider.New(),
-		},
-		IsUnitTest: true,
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
 		Steps: []resource.TestStep{{
 			Config: `
 			provider "coder" {
@@ -31,7 +28,11 @@ func TestProvisioner(t *testing.T) {
 
 				attribs := resource.Primary.Attributes
 				require.Equal(t, runtime.GOOS, attribs["os"])
-				require.Equal(t, runtime.GOARCH, attribs["arch"])
+				if runtime.GOARCH == "arm" {
+					require.Equal(t, "armv7", attribs["arch"])
+				} else {
+					require.Equal(t, runtime.GOARCH, attribs["arch"])
+				}
 				return nil
 			},
 		}},
diff --git a/provider/script.go b/provider/script.go
index 2db4829f..23ddec6e 100644
--- a/provider/script.go
+++ b/provider/script.go
@@ -3,6 +3,7 @@ package provider
 import (
 	"context"
 	"fmt"
+	"strings"
 
 	"github.com/google/uuid"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
@@ -13,17 +14,49 @@ import (
 
 var ScriptCRONParser = cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.DowOptional | cron.Descriptor)
 
+// ValidateCronExpression validates a cron expression and provides helpful warnings for common mistakes
+func ValidateCronExpression(cronExpr string) (warnings []string, errors []error) {
+	// Check if it looks like a 5-field Unix cron expression
+	fields := strings.Fields(cronExpr)
+	if len(fields) == 5 {
+		// Try to parse as standard Unix cron (without seconds)
+		unixParser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.DowOptional | cron.Descriptor)
+		if _, err := unixParser.Parse(cronExpr); err == nil {
+			// It's a valid 5-field expression, provide a helpful warning
+			warnings = append(warnings, fmt.Sprintf(
+				"The cron expression '%s' appears to be in Unix 5-field format. "+
+					"Coder uses 6-field format (seconds minutes hours day month day-of-week). "+
+					"Consider prefixing with '0 ' to run at the start of each minute: '0 %s'",
+				cronExpr, cronExpr))
+		}
+	}
+
+	// Validate with the actual 6-field parser
+	_, err := ScriptCRONParser.Parse(cronExpr)
+	if err != nil {
+		errors = append(errors, fmt.Errorf("%s is not a valid cron expression: %w", cronExpr, err))
+	}
+
+	return warnings, errors
+}
+
 func scriptResource() *schema.Resource {
 	return &schema.Resource{
-		Description: "Use this resource to run a script from an agent.",
-		CreateContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
+		SchemaVersion: 1,
+
+		Description: "Use this resource to run a script from an agent. When multiple scripts are assigned to the same agent, they are executed in parallel.",
+		CreateContext: func(_ context.Context, rd *schema.ResourceData, _ interface{}) diag.Diagnostics {
 			rd.SetId(uuid.NewString())
 			runOnStart, _ := rd.Get("run_on_start").(bool)
+			startBlocksLogin, _ := rd.Get("start_blocks_login").(bool)
 			runOnStop, _ := rd.Get("run_on_stop").(bool)
 			cron, _ := rd.Get("cron").(string)
 
 			if !runOnStart && !runOnStop && cron == "" {
-				return diag.Errorf("at least one of run_on_start, run_on_stop, or cron must be set")
+				return diag.Errorf(`at least one of "run_on_start", "run_on_stop", or "cron" must be set`)
+			}
+			if !runOnStart && startBlocksLogin {
+				return diag.Errorf(`"start_blocks_login" can only be set if "run_on_start" is "true"`)
 			}
 			return nil
 		},
@@ -32,7 +65,7 @@ func scriptResource() *schema.Resource {
 		Schema: map[string]*schema.Schema{
 			"agent_id": {
 				Type:        schema.TypeString,
-				Description: `The "id" property of a "coder_agent" resource to associate with.`,
+				Description: "The `id` property of a `coder_agent` resource to associate with.",
 				ForceNew:    true,
 				Required:    true,
 			},
@@ -53,30 +86,26 @@ func scriptResource() *schema.Resource {
 				ForceNew: true,
 				Optional: true,
 				Description: "A URL to an icon that will display in the dashboard. View built-in " +
-					"icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a " +
-					"built-in icon with `data.coder_workspace.me.access_url + \"/icon/<path>\"`.",
+					"icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a " +
+					"built-in icon with `\"${data.coder_workspace.me.access_url}/icon/<path>\"`.",
 			},
 			"script": {
 				ForceNew:    true,
 				Type:        schema.TypeString,
 				Required:    true,
-				Description: "The script to run.",
+				Description: "The content of the script that will be run.",
 			},
 			"cron": {
 				ForceNew:    true,
 				Type:        schema.TypeString,
 				Optional:    true,
-				Description: "The cron schedule to run the script on. This is a cron expression.",
-				ValidateFunc: func(i interface{}, s string) ([]string, []error) {
+				Description: "The cron schedule to run the script on. This uses a 6-field cron expression format: `seconds minutes hours day-of-month month day-of-week`. Note that this differs from the standard Unix 5-field format by including seconds as the first field. Examples: `\"0 0 22 * * *\"` (daily at 10 PM), `\"0 */5 * * * *\"` (every 5 minutes), `\"30 0 9 * * 1-5\"` (weekdays at 9:30 AM).",
+				ValidateFunc: func(i interface{}, _ string) ([]string, []error) {
 					v, ok := i.(string)
 					if !ok {
 						return []string{}, []error{fmt.Errorf("got type %T instead of string", i)}
 					}
-					_, err := ScriptCRONParser.Parse(v)
-					if err != nil {
-						return []string{}, []error{fmt.Errorf("%s is not a valid cron expression: %w", v, err)}
-					}
-					return nil, nil
+					return ValidateCronExpression(v)
 				},
 			},
 			"start_blocks_login": {
@@ -84,28 +113,28 @@ func scriptResource() *schema.Resource {
 				Default:     false,
 				ForceNew:    true,
 				Optional:    true,
-				Description: "This option defines whether or not the user can (by default) login to the workspace before this script completes running on start. When enabled, users may see an incomplete workspace when logging in.",
+				Description: "This option determines whether users can log in immediately or must wait for the workspace to finish running this script upon startup. If not enabled, users may encounter an incomplete workspace when logging in. This option only sets the default, the user can still manually override the behavior.",
 			},
 			"run_on_start": {
 				Type:        schema.TypeBool,
 				Default:     false,
 				ForceNew:    true,
 				Optional:    true,
-				Description: "This option defines whether or not the script should run when the agent starts.",
+				Description: "This option defines whether or not the script should run when the agent starts. The script should exit when it is done to signal that the agent is ready.",
 			},
 			"run_on_stop": {
 				Type:        schema.TypeBool,
 				Default:     false,
 				ForceNew:    true,
 				Optional:    true,
-				Description: "This option defines whether or not the script should run when the agent stops.",
+				Description: "This option defines whether or not the script should run when the agent stops. The script should exit when it is done to signal that the workspace can be stopped.",
 			},
 			"timeout": {
 				Type:         schema.TypeInt,
 				Default:      0,
 				ForceNew:     true,
 				Optional:     true,
-				Description:  "Time in seconds until the agent lifecycle status is marked as timed out, this happens when the script has not completed (exited) in the given time.",
+				Description:  "Time in seconds that the script is allowed to run. If the script does not complete within this time, the script is terminated and the agent lifecycle status is marked as timed out. A value of zero (default) means no timeout.",
 				ValidateFunc: validation.IntAtLeast(1),
 			},
 		},
diff --git a/provider/script_test.go b/provider/script_test.go
index 4ecbacb6..64808372 100644
--- a/provider/script_test.go
+++ b/provider/script_test.go
@@ -4,22 +4,20 @@ import (
 	"regexp"
 	"testing"
 
-	"github.com/coder/terraform-provider-coder/provider"
 	"github.com/stretchr/testify/require"
 
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+
+	"github.com/coder/terraform-provider-coder/v2/provider"
 )
 
 func TestScript(t *testing.T) {
 	t.Parallel()
 
 	resource.Test(t, resource.TestCase{
-		Providers: map[string]*schema.Provider{
-			"coder": provider.New(),
-		},
-		IsUnitTest: true,
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
 		Steps: []resource.TestStep{{
 			Config: `
 			provider "coder" {
@@ -55,10 +53,8 @@ func TestScriptNeverRuns(t *testing.T) {
 	t.Parallel()
 
 	resource.Test(t, resource.TestCase{
-		Providers: map[string]*schema.Provider{
-			"coder": provider.New(),
-		},
-		IsUnitTest: true,
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
 		Steps: []resource.TestStep{{
 			Config: `
 			provider "coder" {
@@ -69,7 +65,134 @@ func TestScriptNeverRuns(t *testing.T) {
 				script = "Wow"
 			}
 			`,
-			ExpectError: regexp.MustCompile(`at least one of run_on_start, run_on_stop, or cron must be set`),
+			ExpectError: regexp.MustCompile(`at least one of "run_on_start", "run_on_stop", or "cron" must be set`),
 		}},
 	})
 }
+
+func TestScriptStartBlocksLoginRequiresRunOnStart(t *testing.T) {
+	t.Parallel()
+
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+			provider "coder" {
+			}
+			resource "coder_script" "example" {
+				agent_id = ""
+				display_name = "Hey"
+				script = "Wow"
+				run_on_stop = true
+				start_blocks_login = true
+			}
+			`,
+			ExpectError: regexp.MustCompile(`"start_blocks_login" can only be set if "run_on_start" is "true"`),
+		}},
+	})
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+			provider "coder" {
+			}
+			resource "coder_script" "example" {
+				agent_id = ""
+				display_name = "Hey"
+				script = "Wow"
+				start_blocks_login = true
+				run_on_start = true
+			}
+			`,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				script := state.Modules[0].Resources["coder_script.example"]
+				require.NotNil(t, script)
+				t.Logf("script attributes: %#v", script.Primary.Attributes)
+				for key, expected := range map[string]string{
+					"agent_id":           "",
+					"display_name":       "Hey",
+					"script":             "Wow",
+					"start_blocks_login": "true",
+					"run_on_start":       "true",
+				} {
+					require.Equal(t, expected, script.Primary.Attributes[key])
+				}
+				return nil
+			},
+		}},
+	})
+}
+
+func TestValidateCronExpression(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name           string
+		cronExpr       string
+		expectWarnings bool
+		expectErrors   bool
+		warningContains string
+	}{
+		{
+			name:           "valid 6-field expression",
+			cronExpr:       "0 0 22 * * *",
+			expectWarnings: false,
+			expectErrors:   false,
+		},
+		{
+			name:           "valid 6-field expression with seconds",
+			cronExpr:       "30 0 9 * * 1-5",
+			expectWarnings: false,
+			expectErrors:   false,
+		},
+		{
+			name:            "5-field Unix format - should warn",
+			cronExpr:        "0 22 * * *",
+			expectWarnings:  true,
+			expectErrors:    false,
+			warningContains: "appears to be in Unix 5-field format",
+		},
+		{
+			name:            "5-field every 5 minutes - should warn",
+			cronExpr:        "*/5 * * * *",
+			expectWarnings:  true,
+			expectErrors:    false,
+			warningContains: "Consider prefixing with '0 '",
+		},
+		{
+			name:         "invalid expression",
+			cronExpr:     "invalid",
+			expectErrors: true,
+		},
+		{
+			name:         "too many fields",
+			cronExpr:     "0 0 0 0 0 0 0",
+			expectErrors: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			warnings, errors := provider.ValidateCronExpression(tt.cronExpr)
+
+			if tt.expectWarnings {
+				require.NotEmpty(t, warnings, "Expected warnings but got none")
+				if tt.warningContains != "" {
+					require.Contains(t, warnings[0], tt.warningContains)
+				}
+			} else {
+				require.Empty(t, warnings, "Expected no warnings but got: %v", warnings)
+			}
+
+			if tt.expectErrors {
+				require.NotEmpty(t, errors, "Expected errors but got none")
+			} else {
+				require.Empty(t, errors, "Expected no errors but got: %v", errors)
+			}
+		})
+	}
+}
diff --git a/provider/testdata/parameter_table.md b/provider/testdata/parameter_table.md
new file mode 100644
index 00000000..980a6d19
--- /dev/null
+++ b/provider/testdata/parameter_table.md
@@ -0,0 +1,97 @@
+| Name                 | Type          | Previous | Input     | Default | Options           | Validation | -> | Output | Optional | ErrorCreate     |
+|----------------------|---------------|----------|-----------|---------|-------------------|------------|----|--------|----------|-----------------|
+|                      | Empty Vals    |          |           |         |                   |            |    |        |          |                 |
+| Empty                | string,number |          |           |         |                   |            |    | ""     | false    |                 |
+| EmptyDupeOps         | string,number |          |           |         | 1,1,1             |            |    |        |          | unique          |
+| EmptyList            | list(string)  |          |           |         |                   |            |    | ""     | false    |                 |
+| EmptyListDupeOpts    | list(string)  |          |           |         | ["a"],["a"]       |            |    |        |          | unique          |
+| EmptyMulti           | tag-select    |          |           |         |                   |            |    | ""     | false    |                 |
+| EmptyOpts            | string,number |          |           |         | 1,2,3             |            |    | ""     | false    |                 |
+| EmptyRegex           | string        |          |           |         |                   | world      |    |        |          | regex error     |
+| EmptyMin             | number        |          |           |         |                   | 1-10       |    |        |          | 1 <  < 10       |
+| EmptyMinOpt          | number        |          |           |         | 1,2,3             | 2-5        |    |        |          | valid option    |
+| EmptyRegexOpt        | string        |          |           |         | "hello","goodbye" | goodbye    |    |        |          | valid option    |
+| EmptyRegexOk         | string        |          |           |         |                   | .*         |    | ""     | false    |                 |
+| EmptyInc             | number        | 4        |           |         |                   | increasing |    |        |          | monotonicity    |
+| EmptyDec             | number        | 4        |           |         |                   | decreasing |    |        |          | monotonicity    |
+|                      |               |          |           |         |                   |            |    |        |          |                 |
+|                      | Default Set   |          | No inputs |         |                   |            |    |        |          |                 |
+| NumDef               | number        |          |           | 5       |                   |            |    | 5      | true     |                 |
+| NumDefVal            | number        |          |           | 5       |                   | 3-7        |    | 5      | true     |                 |
+| NumDefInv            | number        |          |           | 5       |                   | 10-        |    |        |          | 10 < 5 < 0      |
+| NumDefOpts           | number        |          |           | 5       | 1,3,5,7           | 2-6        |    | 5      | true     |                 |
+| NumDefNotOpts        | number        |          |           | 5       | 1,3,7,9           | 2-6        |    |        |          | valid option    |
+| NumDefInvOpt         | number        |          |           | 5       | 1,3,5,7           | 6-10       |    |        |          | 6 < 5 < 10      |
+| NumDefNotNum         | number        |          |           | a       |                   |            |    |        |          | type "number"   |
+| NumDefOptsNotNum     | number        |          |           | 1       | 1,a,2             |            |    |        |          | type "number"   |
+| NumDefInc            | number        | 4        |           | 5       |                   | increasing |    | 5      | true     |                 |
+| NumDefIncBad         | number        | 6        |           | 5       |                   | increasing |    |        |          | greater         |
+| NumDefDec            | number        | 6        |           | 5       |                   | decreasing |    | 5      | true     |                 |
+| NumDefDecBad         | number        | 4        |           | 5       |                   | decreasing |    |        |          | lower           |
+| NumDefDecEq          | number        | 5        |           | 5       |                   | decreasing |    | 5      | true     |                 |
+| NumDefIncEq          | number        | 5        |           | 5       |                   | increasing |    | 5      | true     |                 |
+| NumDefIncNaN         | number        | a        |           | 5       |                   | increasing |    | 5      | true     |                 |
+| NumDefDecNaN         | number        | b        |           | 5       |                   | decreasing |    | 5      | true     |                 |
+|                      |               |          |           |         |                   |            |    |        |          |                 |
+| StrDef               | string        |          |           | hello   |                   |            |    | hello  | true     |                 |
+| StrMonotonicity      | string        |          |           | hello   |                   | increasing |    |        |          | monotonic       |
+| StrDefInv            | string        |          |           | hello   |                   | world      |    |        |          | regex error     |
+| StrDefOpts           | string        |          |           | a       | a,b,c             |            |    | a      | true     |                 |
+| StrDefNotOpts        | string        |          |           | a       | b,c,d             |            |    |        |          | valid option    |
+| StrDefValOpts        | string        |          |           | a       | a,b,c,d,e,f       | [a-c]      |    | a      | true     |                 |
+| StrDefInvOpt         | string        |          |           | d       | a,b,c,d,e,f       | [a-c]      |    |        |          | regex error     |
+|                      |               |          |           |         |                   |            |    |        |          |                 |
+| LStrDef              | list(string)  |          |           | ["a"]   |                   |            |    | ["a"]  | true     |                 |
+| LStrDefOpts          | list(string)  |          |           | ["a"]   | ["a"], ["b"]      |            |    | ["a"]  | true     |                 |
+| LStrDefNotOpts       | list(string)  |          |           | ["a"]   | ["b"], ["c"]      |            |    |        |          | valid option    |
+|                      |               |          |           |         |                   |            |    |        |          |                 |
+| MulDef               | tag-select    |          |           | ["a"]   |                   |            |    | ["a"]  | true     |                 |
+| MulDefOpts           | multi-select  |          |           | ["a"]   | a,b               |            |    | ["a"]  | true     |                 |
+| MulDefNotOpts        | multi-select  |          |           | ["a"]   | b,c               |            |    |        |          | valid option    |
+|                      |               |          |           |         |                   |            |    |        |          |                 |
+|                      | Input Vals    |          |           |         |                   |            |    |        |          |                 |
+| NumIns               | number        |          | 3         |         |                   |            |    | 3      | false    |                 |
+| NumInsOptsNaN        | number        |          | 3         | 5       | a,1,2,3,4,5       | 1-3        |    |        |          | type "number"   |
+| NumInsNotNum         | number        |          | a         |         |                   |            |    |        |          | type "number"   |
+| NumInsNotNumInv      | number        |          | a         |         |                   | 1-3        |    |        |          | 1 < a < 3       |
+| NumInsDef            | number        |          | 3         | 5       |                   |            |    | 3      | true     |                 |
+| NumIns/DefInv        | number        |          | 3         | 5       |                   | 1-3        |    | 3      | true     |                 |
+| NumIns=DefInv        | number        |          | 5         | 5       |                   | 1-3        |    |        |          | 1 < 5 < 3       |
+| NumInsOpts           | number        |          | 3         | 5       | 1,2,3,4,5         | 1-3        |    | 3      | true     |                 |
+| NumInsNotOptsVal     | number        |          | 3         | 5       | 1,2,4,5           | 1-3        |    |        |          | valid option    |
+| NumInsNotOptsInv     | number        |          | 3         | 5       | 1,2,4,5           | 1-2        |    |        | true     | valid option    |
+| NumInsNotOpts        | number        |          | 3         | 5       | 1,2,4,5           |            |    |        |          | valid option    |
+| NumInsNotOpts/NoDef  | number        |          | 3         |         | 1,2,4,5           |            |    |        |          | valid option    |
+| NumInsInc            | number        | 4        | 5         | 3       |                   | increasing |    | 5      | true     |                 |
+| NumInsIncBad         | number        | 6        | 5         | 7       |                   | increasing |    |        |          | greater         |
+| NumInsDec            | number        | 6        | 5         | 7       |                   | decreasing |    | 5      | true     |                 |
+| NumInsDecBad         | number        | 4        | 5         | 3       |                   | decreasing |    |        |          | lower           |
+| NumInsDecEq          | number        | 5        | 5         | 5       |                   | decreasing |    | 5      | true     |                 |
+| NumInsIncEq          | number        | 5        | 5         | 5       |                   | increasing |    | 5      | true     |                 |
+|                      |               |          |           |         |                   |            |    |        |          |                 |
+| StrIns               | string        |          | c         |         |                   |            |    | c      | false    |                 |
+| StrInsDupeOpts       | string        |          | c         |         | a,b,c,c           |            |    |        |          | unique          |
+| StrInsDef            | string        |          | c         | e       |                   |            |    | c      | true     |                 |
+| StrIns/DefInv        | string        |          | c         | e       |                   | [a-c]      |    | c      | true     |                 |
+| StrIns=DefInv        | string        |          | e         | e       |                   | [a-c]      |    |        |          | regex error     |
+| StrInsOpts           | string        |          | c         | e       | a,b,c,d,e         | [a-c]      |    | c      | true     |                 |
+| StrInsNotOptsVal     | string        |          | c         | e       | a,b,d,e           | [a-c]      |    |        |          | valid option    |
+| StrInsNotOptsInv     | string        |          | c         | e       | a,b,d,e           | [a-b]      |    |        |          | valid option    |
+| StrInsNotOpts        | string        |          | c         | e       | a,b,d,e           |            |    |        |          | valid option    |
+| StrInsNotOpts/NoDef  | string        |          | c         |         | a,b,d,e           |            |    |        |          | valid option    |
+| StrInsBadVal         | string        |          | c         |         | a,b,c,d,e         | 1-10       |    |        |          | min cannot      |
+|                      |               |          |           |         |                   |            |    |        |          |                 |
+|                      | list(string)  |          |           |         |                   |            |    |        |          |                 |
+| LStrIns              | list(string)  |          | ["c"]     |         |                   |            |    | ["c"]  | false    |                 |
+| LStrInsNotList       | list(string)  |          | c         |         |                   |            |    |        |          | list of strings |
+| LStrInsDef           | list(string)  |          | ["c"]     | ["e"]   |                   |            |    | ["c"]  | true     |                 |
+| LStrIns/DefInv       | list(string)  |          | ["c"]     | ["e"]   |                   | [a-c]      |    |        |          | regex cannot    |
+| LStrInsOpts          | list(string)  |          | ["c"]     | ["e"]   | ["c"],["d"],["e"] |            |    | ["c"]  | true     |                 |
+| LStrInsNotOpts       | list(string)  |          | ["c"]     | ["e"]   | ["d"],["e"]       |            |    |        |          | valid option    |
+| LStrInsNotOpts/NoDef | list(string)  |          | ["c"]     |         | ["d"],["e"]       |            |    |        |          | valid option    |
+|                      |               |          |           |         |                   |            |    |        |          |                 |
+| MulInsOpts           | multi-select  |          | ["c"]     | ["e"]   | c,d,e             |            |    | ["c"]  | true     |                 |
+| MulInsNotListOpts    | multi-select  |          | c         | ["e"]   | c,d,e             |            |    |        |          | json encoded    |
+| MulInsNotOpts        | multi-select  |          | ["c"]     | ["e"]   | d,e               |            |    |        |          | valid option    |
+| MulInsNotOpts/NoDef  | multi-select  |          | ["c"]     |         | d,e               |            |    |        |          | valid option    |
+| MulInsInvOpts        | multi-select  |          | ["c"]     | ["e"]   | c,d,e             | [a-c]      |    |        |          | regex cannot    |
\ No newline at end of file
diff --git a/provider/workspace.go b/provider/workspace.go
index a6ea64f6..58100a88 100644
--- a/provider/workspace.go
+++ b/provider/workspace.go
@@ -2,70 +2,77 @@ package provider
 
 import (
 	"context"
-	"os"
 	"reflect"
 	"strconv"
+	"strings"
 
 	"github.com/google/uuid"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+
+	"github.com/coder/terraform-provider-coder/v2/provider/helpers"
 )
 
 func workspaceDataSource() *schema.Resource {
 	return &schema.Resource{
+		SchemaVersion: 1,
+
 		Description: "Use this data source to get information for the active workspace build.",
 		ReadContext: func(c context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
-			transition := os.Getenv("CODER_WORKSPACE_TRANSITION")
-			if transition == "" {
-				// Default to start!
-				transition = "start"
-			}
+			transition := helpers.OptionalEnvOrDefault("CODER_WORKSPACE_TRANSITION", "start") // Default to start!
 			_ = rd.Set("transition", transition)
+
 			count := 0
 			if transition == "start" {
 				count = 1
 			}
 			_ = rd.Set("start_count", count)
 
-			owner := os.Getenv("CODER_WORKSPACE_OWNER")
-			if owner == "" {
-				owner = "default"
-			}
-			_ = rd.Set("owner", owner)
-
-			ownerEmail := os.Getenv("CODER_WORKSPACE_OWNER_EMAIL")
-			_ = rd.Set("owner_email", ownerEmail)
+			if isPrebuiltWorkspace() {
+				_ = rd.Set("prebuild_count", 1)
+				_ = rd.Set("is_prebuild", true)
 
-			ownerID := os.Getenv("CODER_WORKSPACE_OWNER_ID")
-			if ownerID == "" {
-				ownerID = uuid.Nil.String()
+				// A claim can only take place AFTER a prebuild, so it's not logically consistent to have this set to any other value.
+				_ = rd.Set("is_prebuild_claim", false)
+			} else {
+				_ = rd.Set("prebuild_count", 0)
+				_ = rd.Set("is_prebuild", false)
 			}
-			_ = rd.Set("owner_id", ownerID)
+			if isPrebuiltWorkspaceClaim() {
+				// Indicate that a prebuild claim has taken place.
+				_ = rd.Set("is_prebuild_claim", true)
 
-			ownerOIDCAccessToken := os.Getenv("CODER_WORKSPACE_OWNER_OIDC_ACCESS_TOKEN")
-			_ = rd.Set("owner_oidc_access_token", ownerOIDCAccessToken)
-
-			name := os.Getenv("CODER_WORKSPACE_NAME")
-			if name == "" {
-				name = "default"
+				// A claim can only take place AFTER a prebuild, so it's not logically consistent to have these set to any other values.
+				_ = rd.Set("prebuild_count", 0)
+				_ = rd.Set("is_prebuild", false)
+			} else {
+				_ = rd.Set("is_prebuild_claim", false)
 			}
-			rd.Set("name", name)
 
-			sessionToken := os.Getenv("CODER_WORKSPACE_OWNER_SESSION_TOKEN")
-			_ = rd.Set("owner_session_token", sessionToken)
+			name := helpers.OptionalEnvOrDefault("CODER_WORKSPACE_NAME", "default")
+			rd.Set("name", name)
 
-			id := os.Getenv("CODER_WORKSPACE_ID")
-			if id == "" {
-				id = uuid.NewString()
-			}
+			id := helpers.OptionalEnvOrDefault("CODER_WORKSPACE_ID", uuid.NewString())
 			rd.SetId(id)
 
-			templateID := os.Getenv("CODER_WORKSPACE_TEMPLATE_ID")
+			templateID, err := helpers.RequireEnv("CODER_WORKSPACE_TEMPLATE_ID")
+			if err != nil {
+				return diag.Errorf("template ID is missing: %s", err.Error())
+			}
 			_ = rd.Set("template_id", templateID)
 
-			templateName := os.Getenv("CODER_WORKSPACE_TEMPLATE_NAME")
+			templateName, err := helpers.RequireEnv("CODER_WORKSPACE_TEMPLATE_NAME")
+			if err != nil {
+				return diag.Errorf("template name is missing: %s", err.Error())
+			}
 			_ = rd.Set("template_name", templateName)
 
+			templateVersion, err := helpers.RequireEnv("CODER_WORKSPACE_TEMPLATE_VERSION")
+			if err != nil {
+				return diag.Errorf("template version is missing: %s", err.Error())
+			}
+			_ = rd.Set("template_version", templateVersion)
+
 			config, valid := i.(config)
 			if !valid {
 				return diag.Errorf("config was unexpected type %q", reflect.TypeOf(i).String())
@@ -98,53 +105,41 @@ func workspaceDataSource() *schema.Resource {
 				Computed:    true,
 				Description: "The access port of the Coder deployment provisioning this workspace.",
 			},
-			"start_count": {
+			"prebuild_count": {
 				Type:        schema.TypeInt,
 				Computed:    true,
-				Description: `A computed count based on "transition" state. If "start", count will equal 1.`,
+				Description: "A computed count, equal to 1 if the workspace is a currently unassigned prebuild. Use this to conditionally act on the status of a prebuild. Actions that do not require user identity can be taken when this value is set to 1. Actions that should only be taken once the workspace has been assigned to a user may be taken when this value is set to 0.",
 			},
-			"transition": {
-				Type:        schema.TypeString,
+			"start_count": {
+				Type:        schema.TypeInt,
 				Computed:    true,
-				Description: `Either "start" or "stop". Use this to start/stop resources with "count".`,
+				Description: "A computed count based on `transition` state. If `start`, count will equal 1.",
 			},
-			"owner": {
+			"transition": {
 				Type:        schema.TypeString,
 				Computed:    true,
-				Description: "Username of the workspace owner.",
+				Description: "Either `start` or `stop`. Use this to start/stop resources with `count`.",
 			},
-			"owner_email": {
+			"id": {
 				Type:        schema.TypeString,
 				Computed:    true,
-				Description: "Email address of the workspace owner.",
+				Description: "UUID of the workspace.",
 			},
-			"owner_id": {
-				Type:        schema.TypeString,
+			"is_prebuild": {
+				Type:        schema.TypeBool,
 				Computed:    true,
-				Description: "UUID of the workspace owner.",
-			},
-			"owner_oidc_access_token": {
-				Type:     schema.TypeString,
-				Computed: true,
-				Description: "A valid OpenID Connect access token of the workspace owner. " +
-					"This is only available if the workspace owner authenticated with OpenID Connect. " +
-					"If a valid token cannot be obtained, this value will be an empty string.",
+				Description: "Similar to `prebuild_count`, but a boolean value instead of a count. This is set to true if the workspace is a currently unassigned prebuild. Once the workspace is assigned, this value will be false.",
 			},
-			"id": {
-				Type:        schema.TypeString,
+			"is_prebuild_claim": {
+				Type:        schema.TypeBool,
 				Computed:    true,
-				Description: "UUID of the workspace.",
+				Description: "Indicates whether a prebuilt workspace has just been claimed and this is the first `apply` after that occurrence.",
 			},
 			"name": {
 				Type:        schema.TypeString,
 				Computed:    true,
 				Description: "Name of the workspace.",
 			},
-			"owner_session_token": {
-				Type:        schema.TypeString,
-				Computed:    true,
-				Description: "Session token for authenticating with a Coder deployment. It is regenerated everytime a workspace is started.",
-			},
 			"template_id": {
 				Type:        schema.TypeString,
 				Computed:    true,
@@ -155,6 +150,56 @@ func workspaceDataSource() *schema.Resource {
 				Computed:    true,
 				Description: "Name of the workspace's template.",
 			},
+			"template_version": {
+				Type:        schema.TypeString,
+				Computed:    true,
+				Description: "Version of the workspace's template.",
+			},
 		},
 	}
 }
+
+// isPrebuiltWorkspace returns true if the workspace is an unclaimed prebuilt workspace.
+func isPrebuiltWorkspace() bool {
+	return strings.EqualFold(helpers.OptionalEnv(IsPrebuildEnvironmentVariable()), "true")
+}
+
+// isPrebuiltWorkspaceClaim returns true if the workspace is a prebuilt workspace which has just been claimed.
+func isPrebuiltWorkspaceClaim() bool {
+	return strings.EqualFold(helpers.OptionalEnv(IsPrebuildClaimEnvironmentVariable()), "true")
+}
+
+// IsPrebuildEnvironmentVariable returns the name of the environment variable that
+// indicates whether the workspace is an unclaimed prebuilt workspace.
+//
+// Knowing whether the workspace is an unclaimed prebuilt workspace allows template
+// authors to conditionally execute code in the template based on whether the workspace
+// has been assigned to a user or not. This allows identity specific configuration to
+// be applied only after the workspace is claimed, while the rest of the workspace can
+// be pre-configured.
+//
+// The value of this environment variable should be set to "true" if the workspace is prebuilt
+// and it has not yet been claimed by a user. Any other values, including "false"
+// and "" will be interpreted to mean that the workspace is not prebuilt, or was
+// prebuilt but has since been claimed by a user.
+func IsPrebuildEnvironmentVariable() string {
+	return "CODER_WORKSPACE_IS_PREBUILD"
+}
+
+// IsPrebuildClaimEnvironmentVariable returns the name of the environment variable that
+// indicates whether the workspace is a prebuilt workspace which has just been claimed, and this is the first Terraform
+// apply after that occurrence.
+//
+// Knowing whether the workspace is a claimed prebuilt workspace allows template
+// authors to conditionally execute code in the template based on whether the workspace
+// has been assigned to a user or not. This allows identity specific configuration to
+// be applied only after the workspace is claimed, while the rest of the workspace can
+// be pre-configured.
+//
+// The value of this environment variable should be set to "true" if the workspace is prebuilt
+// and it has just been claimed by a user. Any other values, including "false"
+// and "" will be interpreted to mean that the workspace is not prebuilt, or was
+// prebuilt but has not been claimed by a user.
+func IsPrebuildClaimEnvironmentVariable() string {
+	return "CODER_WORKSPACE_IS_PREBUILD_CLAIM"
+}
diff --git a/provider/workspace_owner.go b/provider/workspace_owner.go
new file mode 100644
index 00000000..109b0b93
--- /dev/null
+++ b/provider/workspace_owner.go
@@ -0,0 +1,152 @@
+package provider
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"strings"
+
+	"github.com/google/uuid"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+)
+
+func workspaceOwnerDataSource() *schema.Resource {
+	return &schema.Resource{
+		Description: "Use this data source to fetch information about the workspace owner.",
+		ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
+			diags := diag.Diagnostics{}
+
+			if idStr := os.Getenv("CODER_WORKSPACE_OWNER_ID"); idStr != "" {
+				rd.SetId(idStr)
+			} else {
+				rd.SetId(uuid.NewString())
+			}
+
+			if username := os.Getenv("CODER_WORKSPACE_OWNER"); username != "" {
+				_ = rd.Set("name", username)
+			} else {
+				_ = rd.Set("name", "default")
+			}
+
+			if fullname := os.Getenv("CODER_WORKSPACE_OWNER_NAME"); fullname != "" {
+				_ = rd.Set("full_name", fullname)
+			} else { // compat: field can be blank, fill in default
+				_ = rd.Set("full_name", "default")
+			}
+
+			if email := os.Getenv("CODER_WORKSPACE_OWNER_EMAIL"); email != "" {
+				_ = rd.Set("email", email)
+			} else {
+				_ = rd.Set("email", "default@example.com")
+			}
+
+			_ = rd.Set("ssh_public_key", os.Getenv("CODER_WORKSPACE_OWNER_SSH_PUBLIC_KEY"))
+			_ = rd.Set("ssh_private_key", os.Getenv("CODER_WORKSPACE_OWNER_SSH_PRIVATE_KEY"))
+
+			var groups []string
+			if groupsRaw, ok := os.LookupEnv("CODER_WORKSPACE_OWNER_GROUPS"); ok {
+				if err := json.NewDecoder(strings.NewReader(groupsRaw)).Decode(&groups); err != nil {
+					return diag.Errorf("invalid user groups: %s", err.Error())
+				}
+			}
+			_ = rd.Set("groups", groups)
+
+			_ = rd.Set("session_token", os.Getenv("CODER_WORKSPACE_OWNER_SESSION_TOKEN"))
+			_ = rd.Set("oidc_access_token", os.Getenv("CODER_WORKSPACE_OWNER_OIDC_ACCESS_TOKEN"))
+
+			if loginType := os.Getenv("CODER_WORKSPACE_OWNER_LOGIN_TYPE"); loginType != "" {
+				_ = rd.Set("login_type", loginType)
+			}
+
+			var rbacRoles []map[string]string
+			if rolesRaw, ok := os.LookupEnv("CODER_WORKSPACE_OWNER_RBAC_ROLES"); ok {
+				if err := json.NewDecoder(strings.NewReader(rolesRaw)).Decode(&rbacRoles); err != nil {
+					return diag.Errorf("invalid user rbac roles: %s", err.Error())
+				}
+			}
+			_ = rd.Set("rbac_roles", rbacRoles)
+
+			return diags
+		},
+		Schema: map[string]*schema.Schema{
+			"id": {
+				Type:        schema.TypeString,
+				Computed:    true,
+				Description: "The UUID of the workspace owner.",
+			},
+			"name": {
+				Type:        schema.TypeString,
+				Computed:    true,
+				Description: "The username of the user.",
+			},
+			"full_name": {
+				Type:        schema.TypeString,
+				Computed:    true,
+				Description: "The full name of the user.",
+			},
+			"email": {
+				Type:        schema.TypeString,
+				Computed:    true,
+				Description: "The email address of the user.",
+			},
+			"ssh_public_key": {
+				Type:        schema.TypeString,
+				Computed:    true,
+				Description: "The user's generated SSH public key.",
+			},
+			"ssh_private_key": {
+				Type:        schema.TypeString,
+				Computed:    true,
+				Description: "The user's generated SSH private key.",
+				Sensitive:   true,
+			},
+			"groups": {
+				Type: schema.TypeList,
+				Elem: &schema.Schema{
+					Type: schema.TypeString,
+				},
+				Computed:    true,
+				Description: "The groups of which the user is a member.",
+			},
+			"session_token": {
+				Type:        schema.TypeString,
+				Computed:    true,
+				Description: "Session token for authenticating with a Coder deployment. It is regenerated every time a workspace is started.",
+				Sensitive:   true,
+			},
+			"oidc_access_token": {
+				Type:     schema.TypeString,
+				Computed: true,
+				Description: "A valid OpenID Connect access token of the workspace owner. " +
+					"This is only available if the workspace owner authenticated with OpenID Connect. " +
+					"If a valid token cannot be obtained, this value will be an empty string.",
+				Sensitive: true,
+			},
+			"login_type": {
+				Type:        schema.TypeString,
+				Computed:    true,
+				Description: "The type of login the user has.",
+			},
+			"rbac_roles": {
+				Type: schema.TypeList,
+				Elem: &schema.Resource{
+					Schema: map[string]*schema.Schema{
+						"name": {
+							Type:        schema.TypeString,
+							Computed:    true,
+							Description: "The name of the RBAC role.",
+						},
+						"org_id": {
+							Type:        schema.TypeString,
+							Computed:    true,
+							Description: "The organization ID associated with the RBAC role.",
+						},
+					},
+				},
+				Computed:    true,
+				Description: "The RBAC roles of which the user is assigned.",
+			},
+		},
+	}
+}
diff --git a/provider/workspace_owner_test.go b/provider/workspace_owner_test.go
new file mode 100644
index 00000000..de23b3e7
--- /dev/null
+++ b/provider/workspace_owner_test.go
@@ -0,0 +1,122 @@
+package provider_test
+
+import (
+	"os"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const (
+	testSSHEd25519PublicKey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJeNcdBMtd4Jo9f2W8RZef0ld7Ypye5zTQEf0vUXa/Eq owner123@host456`
+	// nolint:gosec  // This key was generated specifically for this purpose.
+	testSSHEd25519PrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
+	b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+	QyNTUxOQAAACCXjXHQTLXeCaPX9lvEWXn9JXe2Kcnuc00BH9L1F2vxKgAAAJgp3mfQKd5n
+	0AAAAAtzc2gtZWQyNTUxOQAAACCXjXHQTLXeCaPX9lvEWXn9JXe2Kcnuc00BH9L1F2vxKg
+	AAAEBia7mAQFoLBILlvTJroTkOUomzfcPY9ckpViQOjYFkAZeNcdBMtd4Jo9f2W8RZef0l
+	d7Ypye5zTQEf0vUXa/EqAAAAE3ZzY29kZUAzY2Y4MWY5YmM3MmQBAg==
+	-----END OPENSSH PRIVATE KEY-----`
+)
+
+func TestWorkspaceOwnerDatasource(t *testing.T) {
+	t.Run("OK", func(t *testing.T) {
+		t.Setenv("CODER_WORKSPACE_OWNER_ID", "11111111-1111-1111-1111-111111111111")
+		t.Setenv("CODER_WORKSPACE_OWNER", "owner123")
+		t.Setenv("CODER_WORKSPACE_OWNER_NAME", "Mr Owner")
+		t.Setenv("CODER_WORKSPACE_OWNER_EMAIL", "owner123@example.com")
+		t.Setenv("CODER_WORKSPACE_OWNER_SSH_PUBLIC_KEY", testSSHEd25519PublicKey)
+		t.Setenv("CODER_WORKSPACE_OWNER_SSH_PRIVATE_KEY", testSSHEd25519PrivateKey)
+		t.Setenv("CODER_WORKSPACE_OWNER_GROUPS", `["group1", "group2"]`)
+		t.Setenv("CODER_WORKSPACE_OWNER_SESSION_TOKEN", `supersecret`)
+		t.Setenv("CODER_WORKSPACE_OWNER_OIDC_ACCESS_TOKEN", `alsosupersecret`)
+		t.Setenv("CODER_WORKSPACE_OWNER_LOGIN_TYPE", `github`)
+		t.Setenv("CODER_WORKSPACE_OWNER_RBAC_ROLES", `[{"name":"member","org_id":"00000000-0000-0000-0000-000000000000"}]`)
+
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+			provider "coder" {}
+			data "coder_workspace_owner" "me" {}
+			`,
+				Check: func(s *terraform.State) error {
+					require.Len(t, s.Modules, 1)
+					require.Len(t, s.Modules[0].Resources, 1)
+					resource := s.Modules[0].Resources["data.coder_workspace_owner.me"]
+					require.NotNil(t, resource)
+
+					attrs := resource.Primary.Attributes
+					assert.Equal(t, "11111111-1111-1111-1111-111111111111", attrs["id"])
+					assert.Equal(t, "owner123", attrs["name"])
+					assert.Equal(t, "Mr Owner", attrs["full_name"])
+					assert.Equal(t, "owner123@example.com", attrs["email"])
+					assert.Equal(t, testSSHEd25519PublicKey, attrs["ssh_public_key"])
+					assert.Equal(t, testSSHEd25519PrivateKey, attrs["ssh_private_key"])
+					assert.Equal(t, `group1`, attrs["groups.0"])
+					assert.Equal(t, `group2`, attrs["groups.1"])
+					assert.Equal(t, `supersecret`, attrs["session_token"])
+					assert.Equal(t, `alsosupersecret`, attrs["oidc_access_token"])
+					assert.Equal(t, `github`, attrs["login_type"])
+					assert.Equal(t, `member`, attrs["rbac_roles.0.name"])
+					assert.Equal(t, `00000000-0000-0000-0000-000000000000`, attrs["rbac_roles.0.org_id"])
+					return nil
+				},
+			}},
+		})
+	})
+
+	t.Run("Defaults", func(t *testing.T) {
+		for _, v := range []string{
+			"CODER_WORKSPACE_OWNER",
+			"CODER_WORKSPACE_OWNER_ID",
+			"CODER_WORKSPACE_OWNER_EMAIL",
+			"CODER_WORKSPACE_OWNER_NAME",
+			"CODER_WORKSPACE_OWNER_SESSION_TOKEN",
+			"CODER_WORKSPACE_OWNER_GROUPS",
+			"CODER_WORKSPACE_OWNER_OIDC_ACCESS_TOKEN",
+			"CODER_WORKSPACE_OWNER_SSH_PUBLIC_KEY",
+			"CODER_WORKSPACE_OWNER_SSH_PRIVATE_KEY",
+			"CODER_WORKSPACE_OWNER_LOGIN_TYPE",
+			"CODER_WORKSPACE_OWNER_RBAC_ROLES",
+		} { // https://github.com/golang/go/issues/52817
+			t.Setenv(v, "")
+			os.Unsetenv(v)
+		}
+
+		resource.Test(t, resource.TestCase{
+			ProviderFactories: coderFactory(),
+			IsUnitTest:        true,
+			Steps: []resource.TestStep{{
+				Config: `
+			provider "coder" {}
+			data "coder_workspace_owner" "me" {}
+			`,
+				Check: func(s *terraform.State) error {
+					require.Len(t, s.Modules, 1)
+					require.Len(t, s.Modules[0].Resources, 1)
+					resource := s.Modules[0].Resources["data.coder_workspace_owner.me"]
+					require.NotNil(t, resource)
+
+					attrs := resource.Primary.Attributes
+					assert.NotEmpty(t, attrs["id"])
+					assert.Equal(t, "default", attrs["name"])
+					assert.Equal(t, "default", attrs["full_name"])
+					assert.Equal(t, "default@example.com", attrs["email"])
+					assert.Empty(t, attrs["ssh_public_key"])
+					assert.Empty(t, attrs["ssh_private_key"])
+					assert.Empty(t, attrs["groups.0"])
+					assert.Empty(t, attrs["session_token"])
+					assert.Empty(t, attrs["oidc_access_token"])
+					assert.Empty(t, attrs["login_type"])
+					assert.Empty(t, attrs["rbac_roles.0"])
+					return nil
+				},
+			}},
+		})
+	})
+}
diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go
new file mode 100644
index 00000000..393c7b7f
--- /dev/null
+++ b/provider/workspace_preset.go
@@ -0,0 +1,316 @@
+package provider
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/coder/terraform-provider-coder/v2/provider/helpers"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
+	"github.com/mitchellh/mapstructure"
+	rbcron "github.com/robfig/cron/v3"
+)
+
+var PrebuildsCRONParser = rbcron.NewParser(rbcron.Minute | rbcron.Hour | rbcron.Dom | rbcron.Month | rbcron.Dow)
+
+type WorkspacePreset struct {
+	Name        string            `mapstructure:"name"`
+	Description string            `mapstructure:"description"`
+	Icon        string            `mapstructure:"icon"`
+	Default     bool              `mapstructure:"default"`
+	Parameters  map[string]string `mapstructure:"parameters"`
+	// There should always be only one prebuild block, but Terraform's type system
+	// still parses them as a slice, so we need to handle it as such. We could use
+	// an anonymous type and rd.Get to avoid a slice here, but that would not be possible
+	// for utilities that parse our terraform output using this type. To remain compatible
+	// with those cases, we use a slice here.
+	Prebuilds []WorkspacePrebuild `mapstructure:"prebuilds"`
+}
+
+type WorkspacePrebuild struct {
+	Instances int `mapstructure:"instances"`
+	// There should always be only one expiration_policy block, but Terraform's type system
+	// still parses them as a slice, so we need to handle it as such. We could use
+	// an anonymous type and rd.Get to avoid a slice here, but that would not be possible
+	// for utilities that parse our terraform output using this type. To remain compatible
+	// with those cases, we use a slice here.
+	ExpirationPolicy []ExpirationPolicy `mapstructure:"expiration_policy"`
+	Scheduling       []Scheduling       `mapstructure:"scheduling"`
+}
+
+type ExpirationPolicy struct {
+	TTL int `mapstructure:"ttl"`
+}
+
+type Scheduling struct {
+	Timezone string     `mapstructure:"timezone"`
+	Schedule []Schedule `mapstructure:"schedule"`
+}
+
+type Schedule struct {
+	Cron      string `mapstructure:"cron"`
+	Instances int    `mapstructure:"instances"`
+}
+
+func workspacePresetDataSource() *schema.Resource {
+	return &schema.Resource{
+		SchemaVersion: 1,
+
+		Description: "Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are defined by the template but not defined by the preset will still be configurable when creating a workspace.",
+
+		ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
+			var preset WorkspacePreset
+			err := mapstructure.Decode(struct {
+				Name interface{}
+			}{
+				Name: rd.Get("name"),
+			}, &preset)
+			if err != nil {
+				return diag.Errorf("decode workspace preset: %s", err)
+			}
+
+			// Validate schedule overlaps if scheduling is configured
+			err = validateSchedules(rd)
+			if err != nil {
+				return diag.Errorf("schedules overlap with each other: %s", err)
+			}
+
+			rd.SetId(preset.Name)
+
+			return nil
+		},
+		Schema: map[string]*schema.Schema{
+			"id": {
+				Type:        schema.TypeString,
+				Description: "The preset ID is automatically generated and may change between runs. It is recommended to use the `name` attribute to identify the preset.",
+				Computed:    true,
+			},
+			"name": {
+				Type:         schema.TypeString,
+				Description:  "The name of the workspace preset.",
+				Required:     true,
+				ValidateFunc: validation.StringIsNotEmpty,
+			},
+			"description": {
+				Type:         schema.TypeString,
+				Optional:     true,
+				Description:  "Describe what this preset does.",
+				ValidateFunc: validation.StringLenBetween(0, 128),
+			},
+			"icon": {
+				Type: schema.TypeString,
+				Description: "A URL to an icon that will display in the dashboard. View built-in " +
+					"icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a " +
+					"built-in icon with `\"${data.coder_workspace.me.access_url}/icon/<path>\"`.",
+				ForceNew: true,
+				Optional: true,
+				ValidateFunc: validation.All(
+					helpers.ValidateURL,
+					validation.StringLenBetween(0, 256),
+				),
+			},
+			"default": {
+				Type:        schema.TypeBool,
+				Description: "Whether this preset should be selected by default when creating a workspace. Only one preset per template can be marked as default.",
+				Optional:    true,
+				Default:     false,
+			},
+			"parameters": {
+				Type:        schema.TypeMap,
+				Description: "Workspace parameters that will be set by the workspace preset. For simple templates that only need prebuilds, you may define a preset with zero parameters. Because workspace parameters may change between Coder template versions, preset parameters are allowed to define values for parameters that do not exist in the current template version.",
+				Optional:    true,
+				Elem: &schema.Schema{
+					Type:         schema.TypeString,
+					Required:     true,
+					ValidateFunc: validation.StringIsNotEmpty,
+				},
+			},
+			"prebuilds": {
+				Type:        schema.TypeSet,
+				Description: "Configuration for prebuilt workspaces associated with this preset. Coder will maintain a pool of standby workspaces based on this configuration. When a user creates a workspace using this preset, they are assigned a prebuilt workspace instead of waiting for a new one to build. See prebuilt workspace documentation [here](https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces.md)",
+				Optional:    true,
+				MaxItems:    1,
+				Elem: &schema.Resource{
+					Schema: map[string]*schema.Schema{
+						"instances": {
+							Type:         schema.TypeInt,
+							Description:  "The number of workspaces to keep in reserve for this preset.",
+							Required:     true,
+							ForceNew:     true,
+							ValidateFunc: validation.IntAtLeast(0),
+						},
+						"expiration_policy": {
+							Type:        schema.TypeSet,
+							Description: "Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date.",
+							Optional:    true,
+							MaxItems:    1,
+							Elem: &schema.Resource{
+								Schema: map[string]*schema.Schema{
+									"ttl": {
+										Type:        schema.TypeInt,
+										Description: "Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.",
+										Required:    true,
+										ForceNew:    true,
+										// Ensure TTL is either 0 (to disable expiration) or between 3600 seconds (1 hour) and 31536000 seconds (1 year)
+										ValidateFunc: func(val interface{}, key string) ([]string, []error) {
+											v := val.(int)
+											if v == 0 {
+												return nil, nil
+											}
+											if v < 3600 || v > 31536000 {
+												return nil, []error{fmt.Errorf("%q must be 0 or between 3600 and 31536000, got %d", key, v)}
+											}
+											return nil, nil
+										},
+									},
+								},
+							},
+						},
+						"scheduling": {
+							Type:        schema.TypeList,
+							Description: "Configuration block that defines scheduling behavior for prebuilds. Use this to automatically adjust the number of prebuild instances based on a schedule.",
+							Optional:    true,
+							MaxItems:    1,
+							Elem: &schema.Resource{
+								Schema: map[string]*schema.Schema{
+									"timezone": {
+										Type: schema.TypeString,
+										Description: `The timezone to use for the prebuild schedules (e.g., "UTC", "America/New_York"). 
+Timezone must be a valid timezone in the IANA timezone database. 
+See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a complete list of valid timezone identifiers and https://www.iana.org/time-zones for the official IANA timezone database.`,
+										Required: true,
+										ValidateFunc: func(val interface{}, key string) ([]string, []error) {
+											timezone := val.(string)
+
+											_, err := time.LoadLocation(timezone)
+											if err != nil {
+												return nil, []error{fmt.Errorf("failed to load timezone %q: %w", timezone, err)}
+											}
+
+											return nil, nil
+										},
+									},
+									"schedule": {
+										Type:        schema.TypeList,
+										Description: "One or more schedule blocks that define when to scale the number of prebuild instances.",
+										Required:    true,
+										MinItems:    1,
+										Elem: &schema.Resource{
+											Schema: map[string]*schema.Schema{
+												"cron": {
+													Type:        schema.TypeString,
+													Description: "A cron expression that defines when this schedule should be active. The cron expression must be in the format \"* HOUR DOM MONTH DAY-OF-WEEK\" where HOUR is 0-23, DOM (day-of-month) is 1-31, MONTH is 1-12, and DAY-OF-WEEK is 0-6 (Sunday-Saturday). The minute field must be \"*\" to ensure the schedule covers entire hours rather than specific minute intervals.",
+													Required:    true,
+													ValidateFunc: func(val interface{}, key string) ([]string, []error) {
+														cronSpec := val.(string)
+
+														err := validatePrebuildsCronSpec(cronSpec)
+														if err != nil {
+															return nil, []error{fmt.Errorf("cron spec failed validation: %w", err)}
+														}
+
+														_, err = PrebuildsCRONParser.Parse(cronSpec)
+														if err != nil {
+															return nil, []error{fmt.Errorf("failed to parse cron spec: %w", err)}
+														}
+
+														return nil, nil
+													},
+												},
+												"instances": {
+													Type:        schema.TypeInt,
+													Description: "The number of prebuild instances to maintain during this schedule period.",
+													Required:    true,
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+// validatePrebuildsCronSpec ensures that the minute field is set to *.
+// This is required because prebuild schedules represent continuous time ranges,
+// and we want the schedule to cover entire hours rather than specific minute intervals.
+func validatePrebuildsCronSpec(spec string) error {
+	parts := strings.Fields(spec)
+	if len(parts) != 5 {
+		return fmt.Errorf("cron specification should consist of 5 fields")
+	}
+	if parts[0] != "*" {
+		return fmt.Errorf("minute field should be *")
+	}
+
+	return nil
+}
+
+// validateSchedules checks if any of the configured prebuild schedules overlap with each other.
+// It returns an error if overlaps are found, nil otherwise.
+func validateSchedules(rd *schema.ResourceData) error {
+	// TypeSet from schema definition
+	prebuilds := rd.Get("prebuilds").(*schema.Set)
+	if prebuilds.Len() == 0 {
+		return nil
+	}
+
+	// Each element of TypeSet with Elem: &schema.Resource{} should be map[string]interface{}
+	prebuild, ok := prebuilds.List()[0].(map[string]interface{})
+	if !ok {
+		return fmt.Errorf("invalid prebuild configuration: expected map[string]interface{}")
+	}
+
+	// TypeList from schema definition
+	schedulingBlocks, ok := prebuild["scheduling"].([]interface{})
+	if !ok {
+		return fmt.Errorf("invalid scheduling configuration: expected []interface{}")
+	}
+	if len(schedulingBlocks) == 0 {
+		return nil
+	}
+
+	// Each element of TypeList with Elem: &schema.Resource{} should be map[string]interface{}
+	schedulingBlock, ok := schedulingBlocks[0].(map[string]interface{})
+	if !ok {
+		return fmt.Errorf("invalid scheduling configuration: expected map[string]interface{}")
+	}
+
+	// TypeList from schema definition
+	scheduleBlocks, ok := schedulingBlock["schedule"].([]interface{})
+	if !ok {
+		return fmt.Errorf("invalid schedule configuration: expected []interface{}")
+	}
+	if len(scheduleBlocks) == 0 {
+		return nil
+	}
+
+	cronSpecs := make([]string, len(scheduleBlocks))
+	for i, scheduleBlock := range scheduleBlocks {
+		// Each element of TypeList with Elem: &schema.Resource{} should be map[string]interface{}
+		schedule, ok := scheduleBlock.(map[string]interface{})
+		if !ok {
+			return fmt.Errorf("invalid schedule configuration: expected map[string]interface{}")
+		}
+
+		// TypeString from schema definition
+		cronSpec := schedule["cron"].(string)
+
+		cronSpecs[i] = cronSpec
+	}
+
+	err := helpers.ValidateSchedules(cronSpecs)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go
new file mode 100644
index 00000000..d10b8126
--- /dev/null
+++ b/provider/workspace_preset_test.go
@@ -0,0 +1,683 @@
+package provider_test
+
+import (
+	"regexp"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+	"github.com/stretchr/testify/require"
+)
+
+func TestWorkspacePreset(t *testing.T) {
+	t.Parallel()
+	type testcase struct {
+		Name        string
+		Config      string
+		ExpectError *regexp.Regexp
+		Check       func(state *terraform.State) error
+	}
+	testcases := []testcase{
+		{
+			Name: "Happy Path",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				description = <<-EOT
+					# Select the machine image
+					See the [registry](https://container.registry.blah/namespace) for options.
+					EOT
+				icon = "/icon/region.svg"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+			}`,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
+				require.NotNil(t, resource)
+				attrs := resource.Primary.Attributes
+				require.Equal(t, attrs["name"], "preset_1")
+				require.Equal(t, attrs["description"], "# Select the machine image\nSee the [registry](https://container.registry.blah/namespace) for options.\n")
+				require.Equal(t, attrs["icon"], "/icon/region.svg")
+				require.Equal(t, attrs["parameters.region"], "us-east1-a")
+				return nil
+			},
+		},
+		{
+			Name: "Name field is not provided",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				parameters = {
+					"region" = "us-east1-a"
+				}
+			}`,
+			// This validation is done by Terraform, but it could still break if we misconfigure the schema.
+			// So we test it here to make sure we don't regress.
+			ExpectError: regexp.MustCompile("The argument \"name\" is required, but no definition was found"),
+		},
+		{
+			Name: "Name field is empty",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = ""
+				parameters = {
+					"region" = "us-east1-a"
+				}
+			}`,
+			// This validation is done by Terraform, but it could still break if we misconfigure the schema.
+			// So we test it here to make sure we don't regress.
+			ExpectError: regexp.MustCompile("expected \"name\" to not be an empty string"),
+		},
+		{
+			Name: "Name field is not a string",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = [1, 2, 3]
+				parameters = {
+					"region" = "us-east1-a"
+				}
+			}`,
+			// This validation is done by Terraform, but it could still break if we misconfigure the schema.
+			// So we test it here to make sure we don't regress.
+			ExpectError: regexp.MustCompile("Incorrect attribute value type"),
+		},
+		{
+			Name: "Description field is empty",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				description = ""
+				parameters = {
+					"region" = "us-east1-a"
+				}
+			}`,
+			// This validation is done by Terraform, but it could still break if we misconfigure the schema.
+			// So we test it here to make sure we don't regress.
+			ExpectError: nil,
+		},
+		{
+			Name: "Description field exceeds maximum supported length (128 characters)",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur vehicula leo sit amet mi laoreet, sed ornare velit tincidunt. Proin gravida lacinia blandit."
+				parameters = {
+					"region" = "us-east1-a"
+				}
+			}`,
+			// This validation is done by Terraform, but it could still break if we misconfigure the schema.
+			// So we test it here to make sure we don't regress.
+			ExpectError: regexp.MustCompile(`expected length of description to be in the range \(0 - 128\)`),
+		},
+		{
+			Name: "Icon field is empty",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				icon = ""
+				parameters = {
+					"region" = "us-east1-a"
+				}
+			}`,
+			// This validation is done by Terraform, but it could still break if we misconfigure the schema.
+			// So we test it here to make sure we don't regress.
+			ExpectError: nil,
+		},
+		{
+			Name: "Icon field is an invalid URL",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				icon = "/icon%.svg"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+			}`,
+			// This validation is done by Terraform, but it could still break if we misconfigure the schema.
+			// So we test it here to make sure we don't regress.
+			ExpectError: regexp.MustCompile("invalid URL escape"),
+		},
+		{
+			Name: "Icon field exceeds maximum supported length (256 characters)",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				icon = "https://example.com/path/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.svg"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+			}`,
+			// This validation is done by Terraform, but it could still break if we misconfigure the schema.
+			// So we test it here to make sure we don't regress.
+			ExpectError: regexp.MustCompile(`expected length of icon to be in the range \(0 - 256\)`),
+		},
+		{
+			Name: "Parameters field is not provided",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+			}`,
+			// This validation is done by Terraform, but it could still break if we misconfigure the schema.
+			// So we test it here to make sure we don't regress.
+			ExpectError: nil,
+		},
+		{
+			Name: "Parameters field is empty",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {}
+			}`,
+			// This validation is *not* done by Terraform, because MinItems doesn't work with maps.
+			// We've implemented the validation in ReadContext, so we test it here to make sure we don't regress.
+			ExpectError: nil,
+		},
+		{
+			Name: "Parameters field is not a map",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = "not a map"
+			}`,
+			// This validation is done by Terraform, but it could still break if we misconfigure the schema.
+			// So we test it here to make sure we don't regress.
+			ExpectError: regexp.MustCompile("Inappropriate value for attribute \"parameters\": map of string required"),
+		},
+		{
+			Name: "Prebuilds is set, but not its required fields",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+				prebuilds {}
+			}`,
+			ExpectError: regexp.MustCompile("The argument \"instances\" is required, but no definition was found."),
+		},
+		{
+			Name: "Prebuilds is set, and so are its required fields",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+				prebuilds {
+					instances = 1
+				}
+			}`,
+			ExpectError: nil,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
+				require.NotNil(t, resource)
+				attrs := resource.Primary.Attributes
+				require.Equal(t, attrs["name"], "preset_1")
+				require.Equal(t, attrs["prebuilds.0.instances"], "1")
+				return nil
+			},
+		},
+		{
+			Name: "Prebuilds is set with a expiration_policy field without its required fields",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+				prebuilds {
+					instances = 1
+					expiration_policy {}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`The argument "ttl" is required, but no definition was found.`),
+		},
+		{
+			Name: "Prebuilds is set with a expiration_policy field with its required fields",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+				prebuilds {
+					instances = 1
+					expiration_policy {
+						ttl = 86400
+					}
+				}
+			}`,
+			ExpectError: nil,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
+				require.NotNil(t, resource)
+				attrs := resource.Primary.Attributes
+				require.Equal(t, attrs["name"], "preset_1")
+				require.Equal(t, attrs["prebuilds.0.expiration_policy.0.ttl"], "86400")
+				return nil
+			},
+		},
+		{
+			Name: "Prebuilds block with expiration_policy.ttl set to 0 seconds (disables expiration)",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+				prebuilds {
+					instances = 1
+					expiration_policy {
+						ttl = 0
+					}
+				}
+			}`,
+			ExpectError: nil,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
+				require.NotNil(t, resource)
+				attrs := resource.Primary.Attributes
+				require.Equal(t, attrs["name"], "preset_1")
+				require.Equal(t, attrs["prebuilds.0.expiration_policy.0.ttl"], "0")
+				return nil
+			},
+		},
+		{
+			Name: "Prebuilds block with expiration_policy.ttl set to 30 minutes (below 1 hour limit)",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+				prebuilds {
+					instances = 1
+					expiration_policy {
+						ttl = 1800
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`"prebuilds.0.expiration_policy.0.ttl" must be 0 or between 3600 and 31536000, got 1800`),
+		},
+		{
+			Name: "Prebuilds block with expiration_policy.ttl set to 2 years (exceeds 1 year limit)",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+				prebuilds {
+					instances = 1
+					expiration_policy {
+						ttl = 63072000
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`"prebuilds.0.expiration_policy.0.ttl" must be 0 or between 3600 and 31536000, got 63072000`),
+		},
+		{
+			Name: "Prebuilds is set with a expiration_policy field with its required fields and an unexpected argument",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+				prebuilds {
+					instances = 1
+					expiration_policy {
+						ttl = 86400
+						invalid_argument = "test"
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile("An argument named \"invalid_argument\" is not expected here."),
+		},
+		{
+			Name: "Prebuilds is set with an empty scheduling field",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				prebuilds {
+					instances = 1
+					scheduling {}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`The argument "[^"]+" is required, but no definition was found.`),
+		},
+		{
+			Name: "Prebuilds is set with an scheduling field, but without timezone",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				prebuilds {
+					instances = 1
+					scheduling {
+					  	schedule {
+							cron = "* 8-18 * * 1-5"
+							instances = 3
+					  	}
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`The argument "timezone" is required, but no definition was found.`),
+		},
+		{
+			Name: "Prebuilds is set with an scheduling field, but without schedule",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				prebuilds {
+					instances = 1
+					scheduling {
+						timezone = "UTC"
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`At least 1 "schedule" blocks are required.`),
+		},
+		{
+			Name: "Prebuilds is set with an scheduling.schedule field, but without cron",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				prebuilds {
+					instances = 1
+					scheduling {
+						timezone = "UTC"
+						schedule {
+							instances = 3
+					  	}
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`The argument "cron" is required, but no definition was found.`),
+		},
+		{
+			Name: "Prebuilds is set with an scheduling.schedule field, but without instances",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				prebuilds {
+					instances = 1
+					scheduling {
+						timezone = "UTC"
+						schedule {
+							cron = "* 8-18 * * 1-5"
+					  	}
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`The argument "instances" is required, but no definition was found.`),
+		},
+		{
+			Name: "Prebuilds is set with an scheduling.schedule field, but with invalid type for instances",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				prebuilds {
+					instances = 1
+					scheduling {
+						timezone = "UTC"
+						schedule {
+							cron = "* 8-18 * * 1-5"
+							instances = "not_a_number"
+					  	}
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`Inappropriate value for attribute "instances": a number is required`),
+		},
+		{
+			Name: "Prebuilds is set with an scheduling field with 1 schedule",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				prebuilds {
+					instances = 1
+					scheduling {
+						timezone = "UTC"
+					  	schedule {
+							cron = "* 8-18 * * 1-5"
+							instances = 3
+					  	}
+					}
+				}
+			}`,
+			ExpectError: nil,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
+				require.NotNil(t, resource)
+				attrs := resource.Primary.Attributes
+				require.Equal(t, attrs["name"], "preset_1")
+				require.Equal(t, attrs["prebuilds.0.scheduling.0.timezone"], "UTC")
+				require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.0.cron"], "* 8-18 * * 1-5")
+				require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.0.instances"], "3")
+				return nil
+			},
+		},
+		{
+			Name: "Prebuilds is set with an scheduling field with 2 schedules",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				prebuilds {
+					instances = 1
+					scheduling {
+						timezone = "UTC"
+					  	schedule {
+							cron = "* 8-18 * * 1-5"
+							instances = 3
+					  	}
+						schedule {
+							cron = "* 8-14 * * 6"
+							instances = 1
+						}
+					}
+				}
+			}`,
+			ExpectError: nil,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
+				require.NotNil(t, resource)
+				attrs := resource.Primary.Attributes
+				require.Equal(t, attrs["name"], "preset_1")
+				require.Equal(t, attrs["prebuilds.0.scheduling.0.timezone"], "UTC")
+				require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.0.cron"], "* 8-18 * * 1-5")
+				require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.0.instances"], "3")
+				require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.1.cron"], "* 8-14 * * 6")
+				require.Equal(t, attrs["prebuilds.0.scheduling.0.schedule.1.instances"], "1")
+				return nil
+			},
+		},
+		{
+			Name: "Prebuilds is set with an scheduling.schedule field, but the cron includes a disallowed minute field",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				prebuilds {
+					instances = 1
+					scheduling {
+						timezone = "UTC"
+						schedule {
+							cron = "30 8-18 * * 1-5"
+							instances = "1"
+					  	}
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`cron spec failed validation: minute field should be *`),
+		},
+		{
+			Name: "Prebuilds is set with an scheduling.schedule field, but the cron hour field is invalid",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				prebuilds {
+					instances = 1
+					scheduling {
+						timezone = "UTC"
+						schedule {
+							cron = "* 25-26 * * 1-5"
+							instances = "1"
+					  	}
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`failed to parse cron spec: end of range \(26\) above maximum \(23\): 25-26`),
+		},
+		{
+			Name: "Prebuilds is set with a valid scheduling.timezone field",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				prebuilds {
+					instances = 1
+					scheduling {
+						timezone = "America/Los_Angeles"
+						schedule {
+							cron = "* 8-18 * * 1-5"
+							instances = 3
+						}
+					}
+				}
+			}`,
+			ExpectError: nil,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
+				require.NotNil(t, resource)
+				attrs := resource.Primary.Attributes
+				require.Equal(t, attrs["name"], "preset_1")
+				require.Equal(t, attrs["prebuilds.0.scheduling.0.timezone"], "America/Los_Angeles")
+				return nil
+			},
+		},
+		{
+			Name: "Prebuilds is set with an invalid scheduling.timezone field",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				prebuilds {
+					instances = 1
+					scheduling {
+						timezone = "InvalidLocation"
+						schedule {
+							cron = "* 8-18 * * 1-5"
+							instances = 3
+						}
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`failed to load timezone "InvalidLocation": unknown time zone InvalidLocation`),
+		},
+		{
+			Name: "Prebuilds is set with an scheduling field, with 2 overlapping schedules",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				prebuilds {
+					instances = 1
+					scheduling {
+						timezone = "UTC"
+					  	schedule {
+							cron = "* 8-18 * * 1-5"
+							instances = 3
+					  	}
+						schedule {
+							cron = "* 18-19 * * 5-6"
+							instances = 1
+						}
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`schedules overlap with each other: schedules overlap: \* 8-18 \* \* 1-5 and \* 18-19 \* \* 5-6`),
+		},
+		{
+			Name: "Default field set to true",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				default = true
+				parameters = {
+					"region" = "us-east1-a"
+				}
+			}`,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
+				require.NotNil(t, resource)
+				require.Equal(t, resource.Primary.Attributes["default"], "true")
+				return nil
+			},
+		},
+		{
+			Name: "Default field set to false",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				default = false
+				parameters = {
+					"region" = "us-east1-a"
+				}
+			}`,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
+				require.NotNil(t, resource)
+				require.Equal(t, resource.Primary.Attributes["default"], "false")
+				return nil
+			},
+		},
+		{
+			Name: "Default field not provided (defaults to false)",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+			}`,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
+				require.NotNil(t, resource)
+				require.Equal(t, resource.Primary.Attributes["default"], "false")
+				return nil
+			},
+		},
+	}
+
+	for _, testcase := range testcases {
+		t.Run(testcase.Name, func(t *testing.T) {
+			t.Parallel()
+
+			resource.Test(t, resource.TestCase{
+				ProviderFactories: coderFactory(),
+				IsUnitTest:        true,
+				Steps: []resource.TestStep{{
+					Config:      testcase.Config,
+					ExpectError: testcase.ExpectError,
+					Check:       testcase.Check,
+				}},
+			})
+		})
+	}
+}
diff --git a/provider/workspace_tags.go b/provider/workspace_tags.go
new file mode 100644
index 00000000..09736d02
--- /dev/null
+++ b/provider/workspace_tags.go
@@ -0,0 +1,34 @@
+package provider
+
+import (
+	"context"
+
+	"github.com/google/uuid"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+)
+
+type WorkspaceTags struct {
+	Tags map[string]string
+}
+
+func workspaceTagDataSource() *schema.Resource {
+	return &schema.Resource{
+		SchemaVersion: 1,
+
+		Description: "Use this data source to configure workspace tags to select provisioners.",
+		ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
+			rd.SetId(uuid.NewString())
+			return nil
+		},
+		Schema: map[string]*schema.Schema{
+			"tags": {
+				Type:        schema.TypeMap,
+				Description: `Key-value map with workspace tags`,
+				ForceNew:    true,
+				Optional:    true,
+				Elem:        &schema.Schema{Type: schema.TypeString},
+			},
+		},
+	}
+}
diff --git a/provider/workspace_tags_test.go b/provider/workspace_tags_test.go
new file mode 100644
index 00000000..95949562
--- /dev/null
+++ b/provider/workspace_tags_test.go
@@ -0,0 +1,43 @@
+package provider_test
+
+import (
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+	"github.com/stretchr/testify/require"
+)
+
+func TestWorkspaceTags(t *testing.T) {
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+			provider "coder" {
+			}
+			data "coder_parameter" "animal" {
+				name = "animal"
+				type = "string"
+				default = "chris"
+			}
+			data "coder_workspace_tags" "wt" {
+				tags = {
+					"cat" = "james"
+					"dog" = data.coder_parameter.animal.value
+				}
+			}`,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 2)
+				resource := state.Modules[0].Resources["data.coder_workspace_tags.wt"]
+				require.NotNil(t, resource)
+
+				attribs := resource.Primary.Attributes
+				require.Equal(t, "james", attribs["tags.cat"])
+				require.Equal(t, "chris", attribs["tags.dog"])
+				return nil
+			},
+		}},
+	})
+}
diff --git a/provider/workspace_test.go b/provider/workspace_test.go
index 69c64992..17dfabd2 100644
--- a/provider/workspace_test.go
+++ b/provider/workspace_test.go
@@ -1,28 +1,24 @@
 package provider_test
 
 import (
+	"regexp"
 	"testing"
 
+	"github.com/coder/terraform-provider-coder/v2/provider"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-
-	"github.com/coder/terraform-provider-coder/provider"
 )
 
 func TestWorkspace(t *testing.T) {
-	t.Setenv("CODER_WORKSPACE_OWNER", "owner123")
-	t.Setenv("CODER_WORKSPACE_OWNER_EMAIL", "owner123@example.com")
-	t.Setenv("CODER_WORKSPACE_OWNER_SESSION_TOKEN", "abc123")
 	t.Setenv("CODER_WORKSPACE_TEMPLATE_ID", "templateID")
 	t.Setenv("CODER_WORKSPACE_TEMPLATE_NAME", "template123")
+	t.Setenv("CODER_WORKSPACE_TEMPLATE_VERSION", "v1.2.3")
 
 	resource.Test(t, resource.TestCase{
-		Providers: map[string]*schema.Provider{
-			"coder": provider.New(),
-		},
-		IsUnitTest: true,
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
 		Steps: []resource.TestStep{{
 			Config: `
 			provider "coder" {
@@ -40,21 +36,25 @@ func TestWorkspace(t *testing.T) {
 				value := attribs["transition"]
 				require.NotNil(t, value)
 				t.Log(value)
-				require.Equal(t, "8080", attribs["access_port"])
-				require.Equal(t, "owner123", attribs["owner"])
-				require.Equal(t, "owner123@example.com", attribs["owner_email"])
-				require.Equal(t, "abc123", attribs["owner_session_token"])
-				require.Equal(t, "templateID", attribs["template_id"])
-				require.Equal(t, "template123", attribs["template_name"])
+				assert.Equal(t, "https://example.com:8080", attribs["access_url"])
+				assert.Equal(t, "8080", attribs["access_port"])
+				assert.Equal(t, "templateID", attribs["template_id"])
+				assert.Equal(t, "template123", attribs["template_name"])
+				assert.Equal(t, "v1.2.3", attribs["template_version"])
 				return nil
 			},
 		}},
 	})
+}
+
+func TestWorkspace_UndefinedOwner(t *testing.T) {
+	t.Setenv("CODER_WORKSPACE_TEMPLATE_ID", "templateID")
+	t.Setenv("CODER_WORKSPACE_TEMPLATE_NAME", "template123")
+	t.Setenv("CODER_WORKSPACE_TEMPLATE_VERSION", "v1.2.3")
+
 	resource.Test(t, resource.TestCase{
-		Providers: map[string]*schema.Provider{
-			"coder": provider.New(),
-		},
-		IsUnitTest: true,
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
 		Steps: []resource.TestStep{{
 			Config: `
 			provider "coder" {
@@ -72,13 +72,153 @@ func TestWorkspace(t *testing.T) {
 				value := attribs["transition"]
 				require.NotNil(t, value)
 				t.Log(value)
-				require.Equal(t, "https://example.com:8080", attribs["access_url"])
-				require.Equal(t, "owner123", attribs["owner"])
-				require.Equal(t, "owner123@example.com", attribs["owner_email"])
-				require.Equal(t, "templateID", attribs["template_id"])
-				require.Equal(t, "template123", attribs["template_name"])
+				assert.Equal(t, "templateID", attribs["template_id"])
+				assert.Equal(t, "template123", attribs["template_name"])
+				assert.Equal(t, "v1.2.3", attribs["template_version"])
+				// Skip other asserts
 				return nil
 			},
 		}},
 	})
 }
+
+func TestWorkspace_MissingTemplateName(t *testing.T) {
+	t.Setenv("CODER_WORKSPACE_BUILD_ID", "1") // Let's pretend this is a workspace build
+
+	t.Setenv("CODER_WORKSPACE_TEMPLATE_ID", "templateID")
+	// CODER_WORKSPACE_TEMPLATE_NAME is missing
+	t.Setenv("CODER_WORKSPACE_TEMPLATE_VERSION", "v1.2.3")
+
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: coderFactory(),
+		IsUnitTest:        true,
+		Steps: []resource.TestStep{{
+			Config: `
+			provider "coder" {
+				url = "https://example.com:8080"
+			}
+			data "coder_workspace" "me" {
+			}`,
+			ExpectError: regexp.MustCompile("CODER_WORKSPACE_TEMPLATE_NAME is required"),
+		}},
+	})
+}
+
+// TestWorkspace_PrebuildEnv validates that our handling of input environment variables is correct.
+func TestWorkspace_PrebuildEnv(t *testing.T) {
+	cases := []struct {
+		name  string
+		envs  map[string]string
+		check func(state *terraform.State, resource *terraform.ResourceState) error
+	}{
+		{
+			name: "unused",
+			envs: map[string]string{},
+			check: func(state *terraform.State, resource *terraform.ResourceState) error {
+				attribs := resource.Primary.Attributes
+				assert.Equal(t, "false", attribs["is_prebuild"])
+				assert.Equal(t, "0", attribs["prebuild_count"])
+				assert.Equal(t, "false", attribs["is_prebuild_claim"])
+				return nil
+			},
+		},
+		{
+			name: "prebuild=true",
+			envs: map[string]string{
+				provider.IsPrebuildEnvironmentVariable(): "true",
+			},
+			check: func(state *terraform.State, resource *terraform.ResourceState) error {
+				attribs := resource.Primary.Attributes
+				assert.Equal(t, "true", attribs["is_prebuild"])
+				assert.Equal(t, "1", attribs["prebuild_count"])
+				assert.Equal(t, "false", attribs["is_prebuild_claim"])
+				return nil
+			},
+		},
+		{
+			name: "prebuild=false",
+			envs: map[string]string{
+				provider.IsPrebuildEnvironmentVariable(): "false",
+			},
+			check: func(state *terraform.State, resource *terraform.ResourceState) error {
+				attribs := resource.Primary.Attributes
+				assert.Equal(t, "false", attribs["is_prebuild"])
+				assert.Equal(t, "0", attribs["prebuild_count"])
+				assert.Equal(t, "false", attribs["is_prebuild_claim"])
+				return nil
+			},
+		},
+		{
+			name: "prebuild_claim=true",
+			envs: map[string]string{
+				provider.IsPrebuildClaimEnvironmentVariable(): "true",
+			},
+			check: func(state *terraform.State, resource *terraform.ResourceState) error {
+				attribs := resource.Primary.Attributes
+				assert.Equal(t, "false", attribs["is_prebuild"])
+				assert.Equal(t, "0", attribs["prebuild_count"])
+				assert.Equal(t, "true", attribs["is_prebuild_claim"])
+				return nil
+			},
+		},
+		{
+			name: "prebuild_claim=false",
+			envs: map[string]string{
+				provider.IsPrebuildClaimEnvironmentVariable(): "false",
+			},
+			check: func(state *terraform.State, resource *terraform.ResourceState) error {
+				attribs := resource.Primary.Attributes
+				assert.Equal(t, "false", attribs["is_prebuild"])
+				assert.Equal(t, "0", attribs["prebuild_count"])
+				assert.Equal(t, "false", attribs["is_prebuild_claim"])
+				return nil
+			},
+		},
+		{
+			// Should not ever happen, but let's ensure our defensive check is activated. We can't ever have both flags
+			// being true.
+			name: "prebuild=true,prebuild_claim=true",
+			envs: map[string]string{
+				provider.IsPrebuildEnvironmentVariable():      "true",
+				provider.IsPrebuildClaimEnvironmentVariable(): "true",
+			},
+			check: func(state *terraform.State, resource *terraform.ResourceState) error {
+				attribs := resource.Primary.Attributes
+				assert.Equal(t, "false", attribs["is_prebuild"])
+				assert.Equal(t, "0", attribs["prebuild_count"])
+				assert.Equal(t, "true", attribs["is_prebuild_claim"])
+				return nil
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			for k, v := range tc.envs {
+				t.Setenv(k, v)
+			}
+
+			resource.Test(t, resource.TestCase{
+				ProviderFactories: coderFactory(),
+				IsUnitTest:        true,
+				Steps: []resource.TestStep{{
+					Config: `
+provider "coder" {
+	url = "https://example.com:8080"
+}
+data "coder_workspace" "me" {
+}`,
+					Check: func(state *terraform.State) error {
+						// Baseline checks
+						require.Len(t, state.Modules, 1)
+						require.Len(t, state.Modules[0].Resources, 1)
+						resource := state.Modules[0].Resources["data.coder_workspace.me"]
+						require.NotNil(t, resource)
+
+						return tc.check(state, resource)
+					},
+				}},
+			})
+		})
+	}
+}
diff --git a/scripts/coderversion/main.go b/scripts/coderversion/main.go
new file mode 100644
index 00000000..9b5f4c06
--- /dev/null
+++ b/scripts/coderversion/main.go
@@ -0,0 +1,141 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"os"
+
+	"github.com/masterminds/semver"
+)
+
+func main() {
+	releases := fetchReleases()
+
+	mainlineVer := semver.MustParse("v0.0.0")
+	for _, rel := range releases {
+		if rel == "" {
+			debug("ignoring untagged version %s\n", rel)
+			continue
+		}
+
+		ver, err := semver.NewVersion(rel)
+		if err != nil {
+			debug("skipping invalid version %s\n", rel)
+		}
+
+		if ver.Compare(mainlineVer) > 0 {
+			mainlineVer = ver
+			continue
+		}
+	}
+
+	mainline := fmt.Sprintf("v%d.%d.%d", mainlineVer.Major(), mainlineVer.Minor(), mainlineVer.Patch())
+	_, _ = fmt.Fprintf(os.Stdout, "CODER_MAINLINE_VERSION=%q\n", mainline)
+
+	expectedStableMinor := mainlineVer.Minor() - 1
+	if expectedStableMinor < 0 {
+		expectedStableMinor = 0
+	}
+	debug("expected stable minor: %d\n", expectedStableMinor)
+	stableVer := semver.MustParse("v0.0.0")
+	for _, rel := range releases {
+		debug("check version %s\n", rel)
+		if rel == "" {
+			debug("ignoring untagged version %s\n", rel)
+			continue
+		}
+
+		ver, err := semver.NewVersion(rel)
+		if err != nil {
+			debug("skipping invalid version %s\n", rel)
+		}
+
+		if ver.Minor() != expectedStableMinor {
+			debug("skipping version %s\n", rel)
+			continue
+		}
+
+		if ver.Compare(stableVer) > 0 {
+			stableVer = ver
+			continue
+		}
+	}
+
+	stable := fmt.Sprintf("v%d.%d.%d", stableVer.Major(), stableVer.Minor(), stableVer.Patch())
+	_, _ = fmt.Fprintf(os.Stdout, "CODER_STABLE_VERSION=%q\n", stable)
+
+	expectedOldStableMinor := mainlineVer.Minor() - 2
+	if expectedOldStableMinor < 0 {
+		expectedOldStableMinor = 0
+	}
+	debug("expected old stable minor: %d\n", expectedStableMinor)
+	oldStableVer := semver.MustParse("v0.0.0")
+	for _, rel := range releases {
+		debug("check version %s\n", rel)
+		if rel == "" {
+			debug("ignoring untagged version %s\n", rel)
+			continue
+		}
+
+		ver, err := semver.NewVersion(rel)
+		if err != nil {
+			debug("skipping invalid version %s\n", rel)
+		}
+
+		if ver.Minor() != expectedOldStableMinor {
+			debug("skipping version %s\n", rel)
+			continue
+		}
+
+		if ver.Compare(oldStableVer) > 0 {
+			oldStableVer = ver
+			continue
+		}
+	}
+
+	oldStable := fmt.Sprintf("v%d.%d.%d", oldStableVer.Major(), oldStableVer.Minor(), oldStableVer.Patch())
+	_, _ = fmt.Fprintf(os.Stdout, "CODER_OLDSTABLE_VERSION=%q\n", oldStable)
+}
+
+type release struct {
+	TagName string `json:"tag_name"`
+}
+
+const releasesURL = "https://api.github.com/repos/coder/coder/releases"
+
+// fetchReleases fetches the releases of coder/coder
+// this is done directly via JSON API to avoid pulling in the entire
+// github client
+func fetchReleases() []string {
+	resp, err := http.Get(releasesURL)
+	if err != nil {
+		fatal("get releases: %s", err.Error())
+	}
+	defer resp.Body.Close()
+
+	var releases []release
+	if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
+		fatal("parse releases: %s", err.Error())
+	}
+
+	var ss []string
+	for _, rel := range releases {
+		if rel.TagName != "" {
+			ss = append(ss, rel.TagName)
+
+		}
+	}
+	return ss
+}
+
+func debug(format string, args ...any) {
+	if _, ok := os.LookupEnv("VERBOSE"); ok {
+		_, _ = fmt.Fprintf(os.Stderr, format, args...)
+	}
+}
+
+func fatal(format string, args ...any) {
+	_, _ = fmt.Fprintf(os.Stderr, format, args...)
+	os.Exit(1)
+}
diff --git a/scripts/docsgen/main.go b/scripts/docsgen/main.go
new file mode 100644
index 00000000..53b43ca4
--- /dev/null
+++ b/scripts/docsgen/main.go
@@ -0,0 +1,89 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"log"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/coder/terraform-provider-coder/v2/provider"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+	"golang.org/x/xerrors"
+)
+
+// This script patches Markdown docs generated by `terraform-plugin-docs` to expose the original deprecation message.
+
+const docsDir = "docs" // FIXME expose as flag?
+
+var reDeprecatedProperty = regexp.MustCompile("`([^`]+)` \\(([^,\\)]+), Deprecated\\) ([^\n]+)")
+
+func main() {
+	p := provider.New()
+	err := exposeDeprecationMessage(p)
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+func exposeDeprecationMessage(p *schema.Provider) error {
+	// Patch data-sources
+	for dataSourceName, dataSource := range p.DataSourcesMap {
+		docFile := filepath.Join(docsDir, "data-sources", strings.Replace(dataSourceName, "coder_", "", 1)+".md")
+
+		err := adjustDocFile(docFile, dataSource.Schema)
+		if err != nil {
+			return xerrors.Errorf("unable to adjust data-source doc file (data-source: %s): %w", dataSourceName, err)
+		}
+	}
+
+	// Patch resources
+	for resourceName, resource := range p.ResourcesMap {
+		docFile := filepath.Join(docsDir, "resources", strings.Replace(resourceName, "coder_", "", 1)+".md")
+
+		err := adjustDocFile(docFile, resource.Schema)
+		if err != nil {
+			return xerrors.Errorf("unable to adjust resource doc file (resource: %s): %w", resourceName, err)
+		}
+	}
+
+	// Patch index
+	docFile := filepath.Join(docsDir, "index.md")
+	err := adjustDocFile(docFile, p.Schema)
+	if err != nil {
+		return xerrors.Errorf("unable to adjust index doc file: %w", err)
+	}
+	return nil
+}
+
+func adjustDocFile(docPath string, schemas map[string]*schema.Schema) error {
+	doc, err := os.ReadFile(docPath)
+	if err != nil {
+		return xerrors.Errorf("can't read the source doc file: %w", err)
+	}
+
+	result := writeDeprecationMessage(doc, schemas)
+
+	err = os.WriteFile(docPath, result, 0644)
+	if err != nil {
+		return xerrors.Errorf("can't write modified doc file: %w", err)
+	}
+	return nil
+}
+
+func writeDeprecationMessage(doc []byte, schemas map[string]*schema.Schema) []byte {
+	return reDeprecatedProperty.ReplaceAllFunc(doc, func(m []byte) []byte {
+		matches := reDeprecatedProperty.FindSubmatch(m)
+		propertyName := matches[1]
+		description := matches[3]
+
+		sch := schemas[string(propertyName)]
+		if string(description) != sch.Description {
+			log.Printf("warn: same property name `%s` but description does not match, most likely a different property", propertyName)
+			return m
+		}
+		return bytes.Replace(m, []byte("Deprecated"), []byte(fmt.Sprintf("**Deprecated**: %s", sch.Deprecated)), 1)
+	})
+}
diff --git a/templates/guides/version-2-upgrade.md.tmpl b/templates/guides/version-2-upgrade.md.tmpl
new file mode 100644
index 00000000..9c8063f4
--- /dev/null
+++ b/templates/guides/version-2-upgrade.md.tmpl
@@ -0,0 +1,103 @@
+---
+page_title: "Version 2 Upgrade Guide"
+---
+
+# Version 2 Upgrade Guide
+
+Version 2.0.0 of the Coder provider for Terraform is a major release that introduces some changes that you will need to consider when upgrading.
+This guide is intended to help with the process, and focuses only on the changes from version 1.X to version 2.0.0.
+
+!> Using Version 2.0.0 of the Coder provider requires Coder Server version [`2.18.0`](https://github.com/coder/coder/releases/tag/v2.18.0) or later.
+
+Upgrade topics:
+
+- [Provider Version Configuration](#provider-version-configuration)
+- [Provider Arguments](#provider-arguments)
+- [Data Source: `coder_git_auth`](#data-source-coder_git_auth)
+- [Data Source: `coder_workspace`](#data-source-coder_workspace)
+
+## Provider Version Configuration
+
+-> Before upgrading to version 2.0.0, please first upgrade to the most recent 1.X version and ensure that your environment successfully runs [`terraform plan`](https://developer.hashicorp.com/terraform/cli/commands/plan) without unexpected changes or deprecation notices.
+
+We highly recommend using [version constraints](https://developer.hashicorp.com/terraform/language/providers/requirements#version-constraints) when configuring Terraform providers.
+
+
+For example, given the previous configuration:
+
+```terraform
+terraform {
+  required_providers {
+    coder = {
+      source  = "coder/coder"
+      version = "~> 1.0.0"
+    }
+  }
+}
+
+provider "coder" {
+  feature_use_managed_variables = true
+}
+```
+
+Update to the latest 2.X version:
+
+```terraform
+terraform {
+  required_providers {
+    coder = {
+      source  = "coder/coder"
+      version = "~> 2.0.0"
+    }
+  }
+}
+
+provider "coder" {}
+```
+
+## Provider Arguments
+
+Version 2.0.0 removes the [`feature_use_managed_variables`](https://registry.terraform.io/providers/coder/coder/1.0.4/docs#feature_use_managed_variables-1) argument from the `provider` block.
+
+
+## Data Source: `coder_git_auth`
+
+If you are using the [`coder_git_auth`](https://registry.terraform.io/providers/coder/coder/1.0.4/docs/data-sources/git_auth) data source, you must replace it with the [`coder_external_auth`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/external_auth) data source. The `coder_external_auth` data source is a more generic data source that can be used to create any external authentication provider which supports OAuth2.
+
+For example, given the previous configuration:
+
+```terraform
+data "coder_git_auth" "example" {
+  id = "example"
+}
+```
+
+Update to the new data source:
+
+```terraform
+data "coder_external_auth" "example" {
+  id = "example"
+}
+```
+
+## Data Source: `coder_workspace`
+
+If you are using the `owner` properties of the [`coder_workspace`](https://registry.terraform.io/providers/coder/coder/1.0.4/docs/data-sources/workspace) data source, you must remove them and use the [`coder_workspace_owner`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner) data source instead. The `coder_workspace_owner` data source provides additional properties of the workspace owner.
+
+Update your Terraform configuration to use the `coder_workspace_owner` data source instead and update the following attributes:
+
+```terraform
+
+data "coder_workspace_owner" "me" {}
+
+```
+
+- Remove `owner_id` attribute. Use [`data.coder_workspace_owner.me.id`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#id) instead.
+- Remove `owner` attribute. Use [`data.coder_workspace_owner.me.name`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#name) instead.
+- Remove `owner_name` attribute. Use [`data.coder_workspace_owner.me.full_name`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#full_name) instead.
+- Remove `owner_email` attribute. Use [`data.coder_workspace_owner.me.email`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#email) instead.
+- Remove `owner_groups` attribute. Use [`data.coder_workspace_owner.me.groups`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#groups) instead.
+- Remove `owner_oidc_access_token` attribute. Use [`data.coder_workspace_owner.me.oidc_access_token`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#oidc_access_token) instead.
+- Remove `owner_session_token` attribute. Use [`data.coder_workspace_owner.me.session_token`](https://registry.terraform.io/providers/coder/coder/2.0.0/docs/data-sources/workspace_owner#session_token) instead.
+
+->While we do not anticipate these changes to affect existing resources, we strongly advice reviewing the plan produced by Terraform to ensure no resources are accidentally removed or altered in an undesired way. If you encounter any unexpected behavior, please report it by opening a GitHub [issue](https://github.com/coder/terraform-provider-coder/issues).
\ No newline at end of file
diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl
index 5ca983fd..efdaae96 100644
--- a/templates/index.md.tmpl
+++ b/templates/index.md.tmpl
@@ -3,11 +3,17 @@
 page_title: "Coder Provider"
 subcategory: "Infrastructure"
 description: |-
-  
+    Terraform provider for managing Coder templates, which are the underlying infrastructure for Coder workspaces.
 ---
 
 # Coder Provider
 
+Terraform provider for managing Coder [templates](https://coder.com/docs/admin/templates), which are the underlying infrastructure for Coder [workspaces](https://coder.com/docs/user-guides/workspace-management).
+
+-> Requires Coder [v2.18.0](https://github.com/coder/coder/releases/tag/v2.18.0) or later.
+
+!> [`coder_git_auth`](https://registry.terraform.io/providers/coder/coder/1.0.4/docs/data-sources/git_auth) and owner related fields of [`coder_workspace`](https://registry.terraform.io/providers/coder/coder/1.0.4/docs/data-sources/workspace) data source have been removed. Follow the [Version 2 Upgrade Guide](https://registry.terraform.io/providers/coder/coder/latest/docs/guides/version-2-upgrade) to update your code.
+
 ## Example
 
 {{tffile "examples/provider/provider.tf"}}
