You are an experienced, pragmatic software engineering AI agent. Do not over-engineer a solution when a simple one is possible. Keep edits minimal. If you want an exception to ANY rule, you MUST stop and get permission first.
Terraform provider for managing a Coder deployment (registry.terraform.io/coder/coderd). It wraps the Coder API via the github.com/coder/coder/v2/codersdk client to manage templates, users, groups, organizations, licenses, workspace proxies, provisioner keys, org/group sync, and AI providers.
- Language: Go (toolchain pinned in
go.mod). - Framework: terraform-plugin-framework (v1.x), not the legacy SDKv2. Use framework idioms (
schema.*Attribute,types.*, validators, plan modifiers,ResourceWithValidateConfig). - Key deps:
terraform-plugin-framework-validators,terraform-plugin-docs(docs generation),terraform-plugin-testing(acceptance tests), and the Coder SDK (codersdk). - Source of truth: the Coder server. Prefer the SDK's request/response structs and its
Validate()methods over reimplementing API rules locally.
internal/provider/*_resource.go— one file per resource (model structs, schema, CRUD, validators). Tests live alongside in*_resource_test.go.internal/provider/provider.go—CoderdProvider,Configure(), the resource/data-source registry, andCoderdProviderData(shared client + cached feature entitlements).internal/provider/util.go— shared helpers:isNotFound,stringValueOrNull,memberDiff,computeDirectoryHash,corsPtr,PrintOrNull.internal/provider/uuid.go— customUUIDframework type (Terraform can't produce[]uuid.UUIDfrom a set directly).internal/provider/provider_test.go,provider_headers_test.go— test harness:testAccProtoV6ProviderFactoriesandnewMockServer(...).docs/andexamples/— generated/curated;docs/is produced bymake genand CI fails if it drifts.examples/resources/<type>/feeds the docs.integration/— container-based integration tests (separate from unit/acceptance) that prove several resources work together end-to-end against a real Dockerized Coder. Add one when you need to cover cross-resource behavior the per-resource tests ininternal/providercan't.main.go— provider entrypoint and the//go:generate tfplugindocsdirective.
Each resource implements the framework Resource interface (Metadata/Schema/Configure/Create/Read/Update/Delete), often ResourceWithImportState, and sometimes ResourceWithValidateConfig or ResourceWithModifyPlan.
make build # CGO_ENABLED=0 go build .
make fmt # go fmt ./... && terraform fmt -recursive
make lint # golangci-lint run ./...
make gen # go generate ./... (regenerates docs/ from schema + examples/)
gofmt -l <files> # check specific files are formatted
# Pure Go unit tests (helpers, UUID type, tf-vars parsing, wait-for-job) — no server, no TF_ACC:
go test ./internal/provider -run '^TestReconcileVersionIDs$' -count=1
# TestAcc* funcs are gated by TF_ACC=1 (each skips when it's unset). Schema/ValidateConfig
# ones still need the flag, but assert before Configure() so they need NO server:
TF_ACC=1 go test ./internal/provider -run '^TestAccUserResourceValidateConfig$' -count=1
# Full acceptance suite (TF_ACC=1 + a reachable Coder server/license). Avoid running all of it casually:
make testacc # TF_ACC=1 go test ./... -timeout 120mmake gen requires terraform on PATH. The //go:generate directive must pass --provider-name coderd; otherwise tfplugindocs infers the name from the working-directory/branch and writes wrong doc paths.
Naming. Terraform resource types are prefixed with the Coder feature area they belong to so related resources cluster together in configs and docs. Prefer an existing prefix over inventing a new one:
coderd_agents_*— Coder Agents feature area (coderd_agents_mcp_server,coderd_agents_model,coderd_agents_system_prompt,coderd_default_agents_model).coderd_organization_*— organization-scoped settings (coderd_organization_group_sync,coderd_organization_sync_settings).coderd_oauth2_*,coderd_workspace_*, etc. follow the same rule.
The Go file, the TypeName (req.ProviderTypeName + "_<name>"), the examples/resources/coderd_<name>/ directory, and the generated docs/resources/<name>.md must all use the same <name>. Rename all four together if you change it.
- Create
internal/provider/<name>_resource.go— model struct,Schema,Configure, CRUD, and validators (implement the frameworkResourceinterface). - Register it by adding
New<Name>Resourceto the slice returned byResources(ctx)ininternal/provider/provider.go. - Add an example to
examples/resources/coderd_<name>/resource.tf(plusimport.shif importable) —docs/is generated from these, so a missing example means missing/incorrect docs. - Implement
ResourceWithImportStateif the resource is importable, and document the import ID format (UUID vs name vs composite) in the example/schema. - Gate premium features behind a
Check<X>Entitlements(ctx, features)helper (mirrorCheckGroupEntitlements). - Add
internal/provider/<name>_resource_test.go— anewMockServer(...)unit test plus aTF_ACC=1acceptance test (see Testing patterns). - Run the Definition of Done gate before finishing.
- Secrets via write-only arguments (Terraform >= 1.11). New secret-bearing attributes use
WriteOnly: true+Sensitive: truepaired with a normal*_wo_versiontrigger argument (bump the version to re-send). Read write-only values fromreq.Configonly — never fromreq.Planor state; the framework nullifies them in state regardless. Constraints: write-only attrs cannot beComputed; set attributes cannot be write-only or contain write-only descendants (use a map keyed by a local alias instead); a nested parent of a write-only child must not beComputed. A*_wo_versionbump should resend the corresponding*_wovalue; if the version changes and the write-only value is absent, return a diagnostic rather than sending an empty payload. Treat a null version as unmanaged/preserve unless there is an explicit clear mechanism. - Prefer built-in validators over hand-rolled checks. Use
stringvalidator.{OneOf,LengthAtLeast,RegexMatches,AlsoRequires},resourcevalidator.{RequiredTogether,Conflicting,ExactlyOneOf,...}, andpath.MatchRoot(...).AtName(...)expressions. ReserveValidateConfigfor conditional/cross-field rules built-ins can't express (e.g. discriminator-dependent requirements). - Fail at plan time, not apply time. If an invariant is decidable from config + prior state, enforce it in a validator,
ValidateConfig, or plan modifier instead of returning an error fromCreate()/Update()— a failed apply is strictly worse UX than a failed plan, and by then part of the config may already be applied. Even create-only rules ("required when creating") can be checked at plan time, since a plan modifier sees bothreq.ConfigValueandreq.State. Return early, without erroring, while any needed input is unknown (see Anti-patterns). (#383) - Entitlements are cached and shared.
Configure()fetchesclient.Entitlements()once intoCoderdProviderData(Features()/SetFeatures()are mutex-guarded). Gate premium features with aCheck<X>Entitlements(ctx, features)helper that emits a clear diagnostic (mirrorCheckGroupEntitlements). After a resource changes entitlements at apply time (license create/delete), it must re-fetch andSetFeatures(...)so later resources in the same apply see fresh flags (see Anti-patterns). - Drift / external deletion.
isNotFoundtreats both HTTP 404 and the 400"must be an existing uuid or username"as not-found. Coder tombstones some objects (a deleted user still returns from GET-by-ID), so detect deletion with a secondary lookup (e.g. by username) andresp.State.RemoveResource(ctx)rather than trusting GET-by-ID. - "Unmanaged" via null. A
nullblock/attribute can mean "Terraform does not manage this facet" (e.g.coderd_user.roles = nullskips role read/update so OIDC role-sync doesn't fight the provider). Don't synthesize remote values into state for unmanaged facets. - Use SDK pointer fields for optional updates so an explicit
false/zero is sent rather than omitted, and only send update requests when a value actually changed (avoid spurious PATCHes equal to the default or the server-computed value).
- Required ≠ known. A
Requiredattribute can still be unknown at validate/plan time when sourced from an input variable, module output, or computed reference.ValidateConfigand plan modifiers run during the validate walk where required vars are unknown. Always guard withIsUnknown()and defer (return without error) when a value you depend on is unknown — built-in validators already do this. (#368) - Don't decode unknown collections into native Go slices.
ElementsAsinto[]Tpanics/errors on unknown sets/lists with "Received unknown value, however the target type cannot handle unknown values." Model such attributes astypes.Set/types.Listand only convert to[]Tonce!IsUnknown() && !IsNull(). (#305, #347, #362) - Don't rewrite whole nested collections in plan modifiers — it strips cty sensitivity marks.
types.ListValueFrom(...)reconstructs values and drops Terraform core's sensitivity marks, causing "Provider produced inconsistent final plan: inconsistent values for sensitive attribute". Write only the single field you need viaresp.Plan.SetAttribute(...). (#343) - Only use
UseStateForUnknownfor values stable across config changes. It is useful for server-assigned IDs and stable server defaults, but wrong for computed values derived from mutable config. If an omitted computed field is derived from another attribute (e.g. Bedrockregionfrombase_url), preserving prior state can produce "Provider produced inconsistent result after apply" when the source attribute changes; let the value plan as unknown instead. (#368) - Never
deferinside afor/retry loop. Go runsdeferat function return, not loop-iteration end, so closers accumulate (and historically caused a nil-deref SIGSEGV). Extract the loop body into its own function (e.g.waitForJobOnce). (#308) - Don't assume entitlements from
Configure()stay valid. They're fetched once before any resource is created; acoderd_licenseapplied in the same run leaves later resources seeing stale flags unless entitlements are refreshed. (#306) - Don't default a server-computed field to
""and send it. Some Coder fields are server-computed (e.g. organizationdisplay_namedefaults toname); sending an empty default causes drift/spurious updates. Mirror server behavior or leave itComputed. (#183, #190) - Update can be more permissive than create. An API's update/PATCH path may not re-check the invariants its create path enforces, so a PATCH can clear a required field and leave a resource that create would have rejected. Validate the planned effective state before sending a PATCH; but the server preserves omitted write-only secrets, so don't re-require them on an unchanged
*_wo_version. (#368) - Map the server's
""back to null when building state. Whencodersdktypes an optional field as plainstring(not*string), an absent JSON key decodes to""— absent and empty are indistinguishable — but a config that omits the attribute plans as null. Writingtypes.StringValue("")into state where the plan is null breaks Terraform's contract that final state equals every known planned value — surfacing as "Provider produced inconsistent result after apply", masked as "inconsistent values for sensitive attribute" when the value sits in a nested block with sensitive leaves. UsestringValueOrNullso absence has one representation. (dogfoodagents-bedrockimport incident, #384) - Reject a configured
""onstring,omitemptyattributes withstringvalidator.LengthAtLeast(1). The write direction of the same collapse:omitemptydrops""from the request, nothing is stored, and the readback is null — so even with the state mapping above, apply fails with the mismatch inverted (plan""≠ state null). The validator turns that into a clear validate-time error; omitting the attribute is the only way to say unset. It checks config only (never the computed plan), so it's safe onOptional+Computed— but skip it on*stringfields where""is a real value distinct from unset. (#384) - Detect "is this a create?" with
req.State.Raw.IsNull(). Import populates state without ever runningCreate(), so this is true only for a genuine create and doesn't misfire on the first plan afterterraform import. Pair it withreq.ConfigValue.IsNull()— the attribute is definitely omitted, not merely unknown — to enforce "required on create" at plan time. (#383) - Check a collection's null/unknown-ness explicitly; never rely on
len()alone.len(data.Xs) == 0conflates null ("user omitted it"), unknown ("not decided yet"), and empty ("user wrote[]") — three cases that usually need different handling at plan time. TestIsNull()/IsUnknown()on the framework value first, and only reason about length once the value is known. (#383) - Many stock validators skip null/unknown values. e.g.
listvalidator.SizeAtLeast(1)early-returns on both — which is what makes it compatible withOptional— so it rejects an explicit[]but can't reject an omitted attribute. If null itself must be rejected, handle it explicitly in a plan modifier or custom validator. (#383) - State movers run before
Configure()— they must be fully offline. Terraform Core calls theMoveResourceStateRPC beforeConfigureProvider(hashicorp/terraform#35922), so inside aStateMoverr.datais always nil — any dependence on the client orDefaultOrganizationIDfails deterministically for everymoved-block user. Carry over only what the source state contains, write null for values the mover can't know, and let the first apply adopt the configured value (resolveOrganizationID, adoption-friendlyRequiresReplaceIf, warn-and-preserveRead). (coder/dogfood#453)
- What needs
TF_ACC=1is decided by the test's name, notIsUnitTest. PlainTest*funcs (e.g.TestReconcileVersionIDs,TestUUID*,TestWaitForJob*,TestValidateListUnknownTFVars) are pure Go unit tests that run with no flag and no server. EveryTestAcc*func opens withif os.Getenv("TF_ACC") == "" { t.Skip() }, so it runs only underTF_ACC=1—IsUnitTest: truedoes not exempt it (a missing guard would letresource.Testrun the body anyway, so keep the guard on everyTestAcc*func). - Among
TF_ACC=1tests, only some need a server. Schema/ValidateConfigerrors useresource.TestwithIsUnitTest: trueandExpectError: regexp.MustCompile(...); they fire beforeConfigure(), so no server is needed (theTF_ACC=1flag still is). - Tests that reach plan/apply need a reachable server.
Configure()callsclient.User(ctx, Me)(to resolve the default org) andclient.Entitlements(ctx), so a bogus URL fails with connection-refused even forPlanOnly. UsenewMockServer(nil)(fromprovider_headers_test.go) for plan-only/deferral unit tests. - Deferral tests: inject unknown values with a
terraform_data.x.outputreference, then assert the plan succeeds usingPlanOnly: true+ExpectNonEmptyPlan: true(PlanOnly with a non-empty plan otherwise errors with "The non-refresh plan was not empty"). - Reproduce the "unknown var" class of bug with required (no-default) variables via
TestStep.ConfigVariables: the validate walk evaluates required vars as unknown, which is exactly where the#305family of bugs surfaced. Literal-interpolated configs and vars-with-defaults do not catch it. - Test
movedblocks through the real lifecycle, not by invoking the mover directly. A hand-builtStateMoverrequest with pre-populatedr.dataproves nothing about RPC ordering — exactly how coder/dogfood#453 slipped through. Use a two-stepresource.Test: step 1 persists old-schema state via the in-testlegacyCoderdProvider, step 2 runs the real factories with themovedblock and asserts plan actions and final state. If the mover checks the source provider address, sett.Setenv(resource.EnvTfAccProviderNamespace, "coder")(the harness defaults tohashicorp/; precludest.Parallel()). - Acceptance tests (the server-backed
TestAcc*ones) share one Coder instance and therefore cannot run subtests in parallel — hence golangci'sparalleltest.ignore-missing-subtests: true. Usestatecheck/ConfigPlanChecksto assert plan/state.
- Never hand-edit generated files.
docs/is produced bymake gen— change the schema andexamples/instead, then regenerate. CI fails ifdocs/drifts. - Don't add a dependency without approval. Prefer the standard library and existing helpers — check
internal/provider/util.gofirst. - Touch only what the task requires. No unrelated refactors, renames, or formatting churn outside the files you're changing.
- Git safety: never push to or force-push
main; ask before pushing anything. Don't add AI attribution orCo-Authored-Bytrailers to commits.
Run this gate top-to-bottom before declaring a change complete. The task is not done until it passes — or you report the exact blocker:
make build # compiles
make fmt && git diff --exit-code # CI fails on unformatted code
make gen && git diff --exit-code # CI fails if generated docs drift
make lint
TF_ACC= go test ./internal/provider -run '<focused>' -count=1Before committing, run the Definition of Done gate above and ensure it's clean.
- Commit messages: Conventional Commits —
type(scope): summary(fix:,feat:,chore:,test:; scope likecoderd_userorinternal/provider). Squash-merge appends the PR number, e.g.fix: handle unknown tf_vars at plan time (#362). - PR descriptions: explain the problem (with the failing error/repro) and the fix; reference issues (
Closes #208,Refs #305). Do not hard-wrap body lines. Do not add a "Testing" section that just lists tests you ran — CI covers acceptance testing across the Terraform version matrix. - Docs: when a schema or example changes, regenerate with
make genand commit the updateddocs/andexamples/together with the code. - Terraform version note: the CI acceptance matrix runs TF 1.5–1.14, but write-only (
*_wo) arguments require TF >= 1.11 when configured; document that requirement on any resource that uses them.