Skip to content

feat(coderd_template): make versions optional#383

Merged
BobbyHo merged 4 commits into
mainfrom
coder-plat-288
Jul 9, 2026
Merged

feat(coderd_template): make versions optional#383
BobbyHo merged 4 commits into
mainfrom
coder-plat-288

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Why

Some teams are moving template version rollout out of Terraform and into a
custom CI pipeline that calls coder templates push directly — this gives
them more control over how new versions are rolled out (e.g. pushing
inactive versions for testing, activating on a separate schedule, deploying
more than once a day). They still want Terraform to manage the template's
other settings: ACL, dormancy TTLs, autostop requirements, etc.

That split wasn't possible before this change, because versions on
coderd_template was Required and had to contain at least one element.
Every coderd_template resource was therefore forced to "own" at least one
version, which caused two problems:

  1. There was no way to configure the resource at all without declaring a
    version — you couldn't manage settings-only.
  2. Even with a throwaway/bootstrap version as a workaround, Read() would
    refresh that version's active flag from the API on every plan. Once the
    external pipeline activated a different version, the next terraform apply — even one only touching a TTL — would see that diff and call
    UpdateActiveTemplateVersion to reactivate Terraform's own (now stale)
    version, silently reverting whatever the pipeline had just rolled out.

What changed

  • versions is now Optional (dropped Required and the
    listvalidator.SizeAtLeast(1) validator on the schema attribute). Null or
    omitted versions now means "Terraform does not create, update, or read
    any template version — it only manages the template's other settings."
    This mirrors the existing acl attribute on this same resource, where
    null already means "don't manage this."
  • Create() now explicitly requires at least one version (with exactly
    one active)
    before doing anything else. This can't be relaxed away:
    Coderd's POST /templates API itself requires a version ID to create a
    template, so versions can only be optional for templates that already
    exist (created previously by Terraform, or by terraform import).
  • Moved the "must have an active version" check from the versions
    plan modifier into Create(). It used to infer "this is a brand-new
    resource" from "there's no prior private state yet," but that heuristic
    breaks for a terraform imported template with versions omitted (no
    private state is ever written for an imported resource, since Create()
    never runs for it) — it would have raised a false "must be active" error
    on the very first plan after import. Create() is unambiguous: it's only
    ever invoked by Terraform Core for a genuine create.
  • The plan modifier now short-circuits on a null/unknown versions
    config
    , before doing any reconciliation. Without this, rebuilding the
    planned list from zero elements would have produced a non-null empty list
    ([]) instead of preserving null, quietly turning "I didn't set this"
    into "I set it to empty."
  • Read() and Update() needed no changes. Both already iterate over
    Versions with a plain Go for range, which is a no-op on a nil/empty
    slice — so "leave version management alone, just reconcile settings" was
    already their behavior once they could ever see an empty list.

How it works

With versions left null, Read() never asks Coderd which version is
active, because it only loops over versions Terraform is already tracking
(zero, in this case) — there is nothing to compare against config, so there
is no drift to react to. Whatever the external pipeline does to the
template's active version is invisible to (and left untouched by)
Terraform. Update() only ever pushes/activates a version when
Versions is non-empty, so it never touches version state either.

Test plan

  • go build ./... / go vet ./... clean
  • New TestAccTemplateResourceOptionalVersions (acceptance, against a
    real Coderd via testcontainers):
    • CreateWithoutVersionsErrors — creating a brand-new template with no
      versions fails with a clear error
    • SettingsOnlyManagementAfterDroppingVersions — create with Terraform,
      drop versions to null, have an external "pipeline" push and
      activate a new version directly against the API, then have Terraform
      update a setting — verifies Terraform never touches versions and the
      pipeline's active version survives
  • Full pre-existing TestAccTemplateResource suite reran — no
    regressions
  • Docs regenerated via tfplugindocs generate

Manual Test

In addition to the automated tests above, I ran this end-to-end against a real
local Coder deployment with the real Terraform CLI (not the acceptance-test
harness), to confirm the actual customer workflow from the linked issue.

Setup: build the provider, point Terraform at it, start a local Coder
# 1. Build and install the modified provider
cd terraform-provider-coderd
go install .
# -> installs $(go env GOPATH)/bin/terraform-provider-coderd

# 2. Point Terraform at the local build (project-scoped, doesn't touch ~/.terraformrc)
cat > /tmp/dev.tfrc <<'EOF'
provider_installation {
  dev_overrides {
    "coder/coderd" = "/Users/<you>/go/bin"
  }
  direct {}
}
EOF
export TF_CLI_CONFIG_FILE=/tmp/dev.tfrc
# With dev_overrides active, skip `terraform init` for this provider.

# 3. Start a disposable local Coder deployment
docker network create coder-manual-test
docker run -d --name coder-pg --network coder-manual-test \
  -e POSTGRES_USER=coder -e POSTGRES_PASSWORD=coder -e POSTGRES_DB=coder postgres:17
docker run -d --name coder-server --network coder-manual-test -p 3000:3000 \
  -e CODER_PG_CONNECTION_URL="postgres://coder:coder@coder-pg/coder?sslmode=disable" \
  -e CODER_ACCESS_URL="http://localhost:3000" -e CODER_ADDRESS="0.0.0.0:3000" \
  ghcr.io/coder/coder:latest
curl -sf http://localhost:3000/api/v2/buildinfo   # wait until this succeeds

# 4. Bootstrap the first user (use an isolated CODER_CONFIG_DIR, or the web UI,
#    so this doesn't clobber your own default `coder` CLI session)
export CODER_CONFIG_DIR=/tmp/coderv2-manual-test
coder login http://localhost:3000 \
  --first-user-username admin --first-user-email admin@coder.com \
  --first-user-password "SomeSecurePassword123!" --first-user-trial=false
cat "$CODER_CONFIG_DIR/session"   # session token, paste into provider "token"

Scenario A — creating a new template with versions omitted still fails clearly (Coderd requires a version to exist at all):

resource "coderd_template" "test" {
  name = "settings-only-template"
  # no `versions` block
}
Output: terraform apply
Plan: 1 to add, 0 to change, 0 to destroy.
coderd_template.test: Creating...
╷
│ Error: Client Error
│
│   with coderd_template.test,
│   on main.tf line 14, in resource "coderd_template" "test":
│   14: resource "coderd_template" "test" {
│
│ At least one template version (with `active = true`) is required when
│ creating a new `coderd_template` resource, since Coder templates cannot
│ exist without a version.
│ To manage an existing template without Terraform-managed versions, use
│ `terraform import` instead.
╵

Scenario B — create normally, then drop versions to null — only the versions attribute should change, the template and its version stay put:

Output: create with one active version, then versions = null
# terraform apply (with versions = [{ directory = "...", active = true }])
Plan: 1 to add, 0 to change, 0 to destroy.
coderd_template.test: Creating...
coderd_template.test: Creation complete after 2s [id=2490ed95-d47e-45cb-b033-a265479c046b]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

# edit main.tf: versions = null, then terraform apply
  ~ resource "coderd_template" "test" {
        name                              = "settings-only-template"
      - versions                          = [
          - {
              - active         = true -> null
              - directory      = "/.../example-template" -> null
              - directory_hash = "f2a1566b..." -> null
              - id             = "50526510-8042-494a-b932-8c35639fb69c" -> null
              - name           = "gleaming_yang68" -> null
            },
        ] -> null
        # (17 unchanged attributes hidden)
    }
Plan: 0 to add, 1 to change, 0 to destroy.
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

# coder templates versions list settings-only-template
NAME             CREATED AT            CREATED BY  STATUS     ACTIVE
gleaming_yang68  2026-07-06T21:29:26Z  admin       Succeeded  Active

Scenario C — the actual customer scenario: an external pipeline pushes and activates a new version while versions is nullterraform plan must show no drift:

Contents of example-template-2 (the "new" version the pipeline pushes)

integration/template-test/example-template-2/main.tf:

variable "name" {
  type = string
}

resource "local_file" "a" {
  filename = "${path.module}/a.txt"
  content  = "hello ${var.name}"
}

output "a" {
  value = local_file.a.content
}
Output: coder templates push --activate outside Terraform, then terraform plan
$ coder templates push settings-only-template -d ./example-template-2 --activate --yes
Updated version at Jul  6 14:30:25!

$ coder templates versions list settings-only-template
NAME                     CREATED AT            CREATED BY  STATUS     ACTIVE
frightening_robinson82  2026-07-06T21:30:24Z  admin       Succeeded  Active
gleaming_yang68          2026-07-06T21:29:26Z  admin       Succeeded

$ terraform plan
coderd_template.test: Refreshing state... [id=2490ed95-d47e-45cb-b033-a265479c046b]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

Scenario D — Terraform updates a setting while versions is null — only the setting changes, the pipeline's active version must survive untouched:

Output: terraform apply with a new description, then re-check active version
$ terraform apply   # description = "now managed by an external pipeline" added
  ~ resource "coderd_template" "test" {
      ~ description                       = "" -> "now managed by an external pipeline"
        id                                = "2490ed95-d47e-45cb-b033-a265479c046b"
        name                              = "settings-only-template"
        # (16 unchanged attributes hidden)
    }
Plan: 0 to add, 1 to change, 0 to destroy.
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

$ coder templates versions list settings-only-template
NAME                     CREATED AT            CREATED BY  STATUS     ACTIVE
frightening_robinson82  2026-07-06T21:30:24Z  admin       Succeeded  Active
gleaming_yang68          2026-07-06T21:29:26Z  admin       Succeeded

frightening_robinson82 (the pipeline's version) is still Active — Terraform never touched it.

Teams that push template versions via a separate CI pipeline (e.g. `coder
templates push`) still want Terraform to manage a template's other settings
(ACL, dormancy, TTLs, etc.). `versions` was previously required and
non-empty, forcing awkward stub-template workarounds. Coder templates still
require at least one version to exist at all, so that invariant is now
enforced in Create() directly, rather than via the previous unreliable
private-state-is-nil heuristic in the plan modifier (which broke for
imported templates with no tracked versions).
@linear-code

linear-code Bot commented Jul 6, 2026

Copy link
Copy Markdown

PLAT-288

@BobbyHo BobbyHo marked this pull request as ready for review July 6, 2026 21:42
@ethanndickson ethanndickson self-requested a review July 7, 2026 01:38
Comment thread internal/provider/template_resource.go
Comment thread internal/provider/template_resource.go Outdated
…zeAtLeast(1)

Address PR #383 review comments on `versions` being made optional:

- Keep `listvalidator.SizeAtLeast(1)` alongside `Optional: true` — it already
  no-ops on null/unknown, so it only rejects an explicit `versions = []`
  without conflicting with omitting the attribute entirely.
- Move the "at least one active version" check for template creation back
  into the `versions` plan modifier, gated on `req.State.Raw.IsNull()`
  instead of the private-state-is-nil heuristic. Private state is also nil
  right after a `terraform import` (Create never ran), which wrongly made
  imported resources look like a Create; req.State.Raw.IsNull() correctly
  distinguishes the two, and catches the error at plan-time instead of apply.

Adds acceptance test coverage for both: PlanOnly on the existing
no-active-version test to pin the plan-time behavior, and a new import
regression test that reproduces the original bug and would fail against the
old heuristic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@BobbyHo

BobbyHo commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, Ethan. I really appreciate you taking the time, especially for catching the import implication in your second comment.

You’re right that req.State.Raw.IsNull() is the correct signal here. I hadn’t considered that an imported Terraform resource would hit the same “no prior private state” heuristic as a genuine Create, which could have caused the active-version check to silently misfire during import. Good catch.

I’ve updated the code based on both comments:

  • Restored listvalidator.SizeAtLeast(1) alongside Optional: true on versions.
  • Moved the active-version check back into the versions plan modifier, gated on req.State.Raw.IsNull() instead of the private-state heuristic, so it’s caught at plan time again and no longer misfires on import.

I also added acceptance test coverage for both cases, including a regression test that reproduces the import scenario and fails against the old heuristic. I re-ran the full manual test flow — plan/apply/import/destroy — against a real Coder deployment to confirm nothing broke.

Whenever you get a chance, I’d appreciate another look. 🙏

@BobbyHo BobbyHo requested a review from ethanndickson July 7, 2026 18:24
Comment thread internal/provider/template_resource.go Outdated
Comment thread internal/provider/template_resource.go
Comment thread internal/provider/template_resource_test.go Outdated
Comment thread internal/provider/template_resource_test.go Outdated
Comment thread internal/provider/template_resource_test.go Outdated
…n time

Addresses PR review feedback on #383: move the "at least one version
required when creating" check fully into versionsPlanModifier using
req.ConfigValue.IsNull() && req.State.Raw.IsNull(), removing the
now-redundant duplicate check from Create(). Also converts the test
config's Versions field to *[]testAccTemplateVersionConfig (matching
the existing AutostartRequirement pattern) instead of a separate
VersionsNull bool, and drops two comments that only restated the
adjacent code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@BobbyHo

BobbyHo commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Thank you @ethanndickson for your suggestions again. I've updated the code and would you mind taking another look when you have a moment? Thanks!

ethanndickson added a commit that referenced this pull request Jul 9, 2026
Distills the plan-time enforcement footguns from the #383 review into
reusable AGENTS.md guidance (fail at plan time not apply time; detect a
create with `req.State.Raw.IsNull()`; check a collection's
null/unknown-ness before `len()`; note that many stock validators skip
null/unknown). Also adds `CLAUDE.md` as a symlink to `AGENTS.md` so
Claude Code sees it(?)

Refs #383

@ethanndickson ethanndickson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sick, thanks! One last comment

Comment thread internal/provider/template_resource_test.go
PlanOnly ensures the ExpectError check is pinned to terraform plan,
matching where the check now runs after 1237d7c/2bee0ca.
@BobbyHo BobbyHo merged commit 2d5632b into main Jul 9, 2026
13 checks passed
@BobbyHo BobbyHo deleted the coder-plat-288 branch July 9, 2026 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants