feat(coderd_template): make versions optional#383
Conversation
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).
…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>
|
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:
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. 🙏 |
…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>
|
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! |
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
left a comment
There was a problem hiding this comment.
Sick, thanks! One last comment
PlanOnly ensures the ExpectError check is pinned to terraform plan, matching where the check now runs after 1237d7c/2bee0ca.
Why
Some teams are moving template version rollout out of Terraform and into a
custom CI pipeline that calls
coder templates pushdirectly — this givesthem 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
versionsoncoderd_templatewasRequiredand had to contain at least one element.Every
coderd_templateresource was therefore forced to "own" at least oneversion, which caused two problems:
version — you couldn't manage settings-only.
Read()wouldrefresh that version's
activeflag from the API on every plan. Once theexternal pipeline activated a different version, the next
terraform apply— even one only touching a TTL — would see that diff and callUpdateActiveTemplateVersionto reactivate Terraform's own (now stale)version, silently reverting whatever the pipeline had just rolled out.
What changed
versionsis nowOptional(droppedRequiredand thelistvalidator.SizeAtLeast(1)validator on the schema attribute). Null oromitted
versionsnow means "Terraform does not create, update, or readany template version — it only manages the template's other settings."
This mirrors the existing
aclattribute on this same resource, wherenullalready means "don't manage this."Create()now explicitly requires at least one version (with exactlyone active) before doing anything else. This can't be relaxed away:
Coderd's
POST /templatesAPI itself requires a version ID to create atemplate, so
versionscan only be optional for templates that alreadyexist (created previously by Terraform, or by
terraform import).versionsplan modifier into
Create(). It used to infer "this is a brand-newresource" from "there's no prior private state yet," but that heuristic
breaks for a
terraform imported template withversionsomitted (noprivate 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 onlyever invoked by Terraform Core for a genuine create.
versionsconfig, before doing any reconciliation. Without this, rebuilding the
planned list from zero elements would have produced a non-null empty list
(
[]) instead of preservingnull, quietly turning "I didn't set this"into "I set it to empty."
Read()andUpdate()needed no changes. Both already iterate overVersionswith a plain Gofor range, which is a no-op on a nil/emptyslice — so "leave version management alone, just reconcile settings" was
already their behavior once they could ever see an empty list.
How it works
With
versionsleftnull,Read()never asks Coderd which version isactive, 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 whenVersionsis non-empty, so it never touches version state either.Test plan
go build ./.../go vet ./...cleanTestAccTemplateResourceOptionalVersions(acceptance, against areal Coderd via testcontainers):
CreateWithoutVersionsErrors— creating a brand-new template with noversions fails with a clear error
SettingsOnlyManagementAfterDroppingVersions— create with Terraform,drop
versionstonull, have an external "pipeline" push andactivate 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
TestAccTemplateResourcesuite reran — noregressions
tfplugindocs generateManual 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
Scenario A — creating a new template with
versionsomitted still fails clearly (Coderd requires a version to exist at all):Output:
terraform applyScenario B — create normally, then drop
versionstonull— only theversionsattribute should change, the template and its version stay put:Output: create with one active version, then
versions = nullScenario C — the actual customer scenario: an external pipeline pushes and activates a new version while
versionsisnull—terraform planmust show no drift:Contents of
example-template-2(the "new" version the pipeline pushes)integration/template-test/example-template-2/main.tf:Output:
coder templates push --activateoutside Terraform, thenterraform planScenario D — Terraform updates a setting while
versionsisnull— only the setting changes, the pipeline's active version must survive untouched:Output:
terraform applywith a newdescription, then re-check active versionfrightening_robinson82(the pipeline's version) is stillActive— Terraform never touched it.