Skip to content

feat(coderd_template): accept version contents as files or an archive - #440

Open
michvllni wants to merge 5 commits into
coder:mainfrom
axiansinfoma:feat/template-version-contents
Open

michvllni wants to merge 5 commits into
coder:mainfrom
axiansinfoma:feat/template-version-contents

Conversation

@michvllni

@michvllni michvllni commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Problem

versions[*].directory is the only way to give a coderd_template version its contents, so the contents must exist as a directory on the filesystem of the machine running Terraform, at plan time. That rules out the cases described in #439:

  • templates rendered per-deployment with templatefile() (the local_file + depends_on workaround relies on plan-time/apply-time ordering that isn't guaranteed),
  • templates fetched from another repo, an artifact store, or http/vault, which arrive as strings or base64,
  • remote runs on HCP Terraform / Terraform Enterprise, which only receive the uploaded configuration,
  • contents that are already packaged as an archive, whether checked in, downloaded, or built by data "archive_file".

Fix

A version now takes its contents from exactly one of four attributes, enforced at validate time with stringvalidator.ExactlyOneOf:

  • directory (unchanged) — a path to a directory on disk.

  • files — map(string) of relative path to file content:

    files = {
      "main.tf"          = templatefile("${path.module}/tpl/main.tf.tftpl", { image = "ubuntu:24.04" })
      "build/Dockerfile" = file("${path.module}/tpl/build/Dockerfile")
    }

    The files are written out and then archived through provisionersdk.Tar like any other directory, so a version built from files behaves identically to the same contents on disk: hidden files are excluded, terraform.tfvars/*.auto.tfvars entries are read as variable values rather than uploaded, and the archive size limit applies. Paths must be relative and must not escape the root.

  • archive_base64 — a base64-encoded archive, uploaded as-is, covering binary contents and prebuilt tarballs.

  • archive_file — a path to an archive file, uploaded as-is, for an archive that already exists on disk and would otherwise have to be slurped into the configuration with filebase64():

    archive_file = data.archive_file.template.output_path

Both archive attributes accept an uncompressed tar, a gzipped tar, or a zip archive, detected from the contents rather than from a file extension. Tar and zip are uploaded as-is, since the Coder API accepts both and expands a zip server-side; a gzipped tar is decompressed first, buffering at most provisionersdk.TemplateArchiveLimit so a compression bomb can't be expanded into an unbounded amount of memory. Accepting more than uncompressed tar matters for archive_file in particular: data "archive_file" only produces zip and tar.gz, so tar-only support would have left the most common producer unusable.

Variable values can't be discovered from an archive without extracting it, so tf_vars is the only way to set them for the two archive sources.

directory_hash — the value that decides whether a new template version is created — is now computed from whichever source is set (files hashes sorted path + content, so a rename is a change; the archive sources hash the normalized archive bytes, so re-gzipping identical contents is not a change). When the configured source isn't known yet, the hash plans as unknown instead of erroring, so contents produced by another resource plan as a new version and are resolved during apply.

Following "fail at plan time, not apply time": invalid base64, a missing or unreadable archive_file, an archive that is none of the three formats, an oversized archive, more than one source set (which ExactlyOneOf defers on when one of the four is unknown), and a files map with no root .tf/.tf.json file all fail the plan.

Notes

  • directory becomes Optional instead of Required; existing configurations are unaffected.
  • directory_hash keeps its name (it's in state for every existing template) but now documents itself as the hash of the version contents.
  • Switching an existing version from directory to the equivalent files or archive creates one new template version, since the hashes are computed differently per source.
  • archive_file is read while planning, like directory is, so the archive has to exist at plan time — a data source fits it, a resource that writes the file during apply does not.
  • No new module dependencies: go.mod/go.sum are unchanged, and archive/zip/compress/gzip are standard library packages already linked into the binary transitively.

Closes #439

`versions[*].directory` was the only way to give a template version its
contents, which meant the contents had to exist on the filesystem of the
machine running Terraform. Templates rendered with `templatefile()`,
fetched from another repo or an artifact store, or applied from HCP
Terraform can't be expressed that way, and the `local_file` workaround
depends on plan-time/apply-time ordering that isn't guaranteed.

A version can now take its contents from one of three mutually exclusive
attributes, enforced with `stringvalidator.ExactlyOneOf`:

- `directory`, unchanged.
- `files`, a map of relative path to file content. The files are written
  out and archived exactly like a directory, so hidden files are
  excluded and `terraform.tfvars`/`*.auto.tfvars` entries are read as
  variable values.
- `archive_base64`, a base64-encoded tar archive, uploaded as-is.
  Variable values aren't discovered from it, so `tf_vars` is the only
  way to set them.

`directory_hash` is now computed from whichever source is set, and is
unknown (rather than an error) while the source is unknown, so contents
produced by another resource plan as a new version. Bad base64, a
non-tar archive, an oversized archive, and a `files` map without a root
`.tf` file all fail during the plan instead of the apply.

Closes coder#439
The example template's `${var.name}` was interpolated by the test's own
configuration instead of reaching the provider, so the `files` acceptance
test failed to plan with "Reference to undeclared input variable". The
plan-only test now uses the same contents, so the mock-server test catches
this without a Coder deployment.
`archive_base64` closed the gap for contents that live in the
configuration, but an archive that already exists as a file still had to
be read into the configuration with `filebase64()`, which is wasteful for
anything of size and awkward when the archive is produced by a data
source such as `archive_file`.

A version can now point `archive_file` at an archive on disk instead. It
is the fourth mutually exclusive content attribute, hashed while planning
like `directory` is, so the archive has to exist at plan time.

Both archive attributes now accept an uncompressed tar, a gzipped tar, or
a zip archive, detected from the contents rather than a file extension:
`archive_file` is otherwise unusable with the `archive_file` data source,
which produces zip or tar.gz only. Tar and zip are uploaded as-is since
the API takes both, gzip is decompressed first, and decompression is
bounded by the archive size limit so a compression bomb can't be expanded
into an unbounded amount of memory.

Refs coder#439
…ad helper

`uploadTemplateArchive` returned a `[]codersdk.VariableValue` that was
always nil, to match the shape of `uploadVersionContents`. `unparam`
rejects that, failing the build job's linters, so the helper now returns
just the file ID and its callers supply the nil themselves.
…o's style

The comments added for `files`, `archive_base64`, and `archive_file`
explained why the code is the way it is. The rest of the package states
what a function does and leaves the reasoning to the pull request, so
these now do the same.
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.

feat(coderd_template): accept template version contents directly, not only a directory path

1 participant