Skip to content

feat: the stackorder provider with the stackorder_outputs data source and ephemeral resource - #1

Merged
dortort merged 14 commits into
mainfrom
feat/initial-provider
Oct 11, 2026
Merged

dortort merged 14 commits into
mainfrom
feat/initial-provider

Conversation

@dortort

@dortort dortort commented Oct 11, 2026

Copy link
Copy Markdown
Member

Summary

The initial stackorder provider for Terraform and OpenTofu, at registry.terraform.io/stackorder/stackorder on plugin protocol 6. It reads the outputs another stack shares through the Stackorder server (stackorder/stackorder#5, released in v0.4.0), so a consumer stack needs no access to the producer's state object. It replaces terraform_remote_state for stacks that Stackorder runs.

  • stackorder_outputs data source. stack names the producer as path, path:instance or owner/repo//key. values holds every output the producer serves and is sensitive; nonsensitive_values holds the ones not marked sensitive. A warning names the sensitive outputs the producer withholds. Each refusal of the server (outputs_disabled, outputs_not_shared, not_found, state_location_unknown, state_bucket_not_allowed, state_not_found, state_access_denied, state_encrypted, state_unsupported, state_too_large, state_unreadable) gets its own diagnostic saying what to check or change.
  • stackorder_outputs ephemeral resource. Same stack, values and nonsensitive_values, never written to the plan file or the state. Terraform 1.10 or OpenTofu 1.11 or later.
  • Zero configuration inside a Stackorder job. server_url, api_key, repository and oidc_audience are optional and fall back to STACKORDER_SERVER_URL, STACKORDER_API_KEY, STACKORDER_REPOSITORY (then GITHUB_REPOSITORY) and STACKORDER_OIDC_AUDIENCE, which the Stackorder CLI 0.4.0 sets for the tool.
  • Credentials. In a plan, apply or drift job, a fresh GitHub Actions ID token for every request, bound to the reading stack through STACKORDER_RUN_ID and STACKORDER_STACK. Elsewhere, an API key. Where an ID token can be requested, a configured API key is ignored, so an admin key never replaces the job's own identity. Retries only on network errors and 5xx, with a fresh token each attempt.
  • Wire types come from github.com/stackorder/stackorder/api/v1 at v0.4.0; a golden fixture in that module pins the format.
  • Tooling. Generated docs (go tool tfplugindocs, checked in CI), golangci-lint, acceptance tests against an httptest fake on a matrix of Terraform 1.10.x and latest and OpenTofu 1.11.x and latest, GoReleaser archives with _SHA256SUMS, a GPG detached signature and terraform-registry-manifest.json (protocol 6.0) on v* tags.

Before v0.1.0

  • Repository secrets GPG_PRIVATE_KEY and PASSPHRASE for the release signing step.
  • A ## [0.1.0] section in CHANGELOG.md; the release workflow takes the release notes from it.
  • After the tag: publish on the Terraform Registry with the signing key, and submit the provider and key to the OpenTofu registry.

Verification

  • GOWORK=off: go build, go vet, go test, make lint (0 issues), make docs-check (regenerated docs unchanged), go mod tidy and go mod verify clean.
  • Acceptance tests (TF_ACC=1) pass on Terraform 1.14.4 and OpenTofu 1.12.6 locally; earlier commits were also run on Terraform 1.10.5 and 1.16.5 and OpenTofu 1.11.14 and 1.13.1.
  • govulncheck@v1.8.0 under Go 1.27.2 (today's stable, which the release workflow installs): no vulnerabilities found, after moving grpc to v1.83.2 and x/net to v0.60.0.
  • Every commit was reviewed and independently verified in its own pass before integration.

🤖 Generated with Claude Code

dortort and others added 14 commits October 10, 2026 17:46
Start the provider as an empty Plugin Framework provider served over
protocol 6 at registry.terraform.io/stackorder/stackorder, so the client,
configuration, data source and ephemeral resource can land as separate
changes on a module that already builds, lints and tests. The module
targets go 1.26.0 like the Stackorder module it will import and pins the
framework, plugin-go and plugin-log releases it is built against;
plugin-log stays indirect until the client logs through it.

The unit test drives the provider through the protocol 6 server, the
same path Terraform uses, and needs no Terraform binary. The protocol
carries no provider type name, so the test reads it from Metadata. The
lint setup mirrors Stackorder's so both repositories follow one style,
and its gofmt step fails on files gofmt cannot parse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The provider needs one call, GET /v1/repos/{owner}/{repo}/stacks/{key}/outputs,
so the client is small and owns the things that are easy to get wrong.
Inside GitHub Actions the server accepts each OIDC token only once, so every
attempt, retries included, mints a new token; an API key is used only when
the Actions token variables are absent, so an admin key never silently wins
in CI. The audience defaults to the server URL as configured, trimmed as the
stackorder CLI trims it, and when nothing names an audience the source is
refused up front instead of sending one the server would answer with a bare
401. An empty run or reader is left out of the query for API keys, which
read outside any run.

Network failures, a connection lost or timed out while the answer is read
included, and 429 and 5xx answers, from the server or from the token
endpoint, are retried up to three attempts with backoff, waiting longer when
a Retry-After header asks for up to a minute; a longer request ends the
call. Every other answer is final and returned as an *APIError carrying the
v1.Error code, so the data source can map each refusal to its own diagnostic
without retrying a refusal that cannot change. The answer is read whole,
capped at 32 MiB, four times the server's cap on an outputs block, before it
is decoded, so a cut connection is retried while a malformed body is final.
The retry loop is written out rather than taken from go-retryablehttp
because the token must be replaced on every attempt.

Neither HTTP client follows redirects: Go resends the Authorization header
to the same host even from https to http, and a followed redirect would
replay a single-use token. The server URL may carry a path prefix but no
user info, query or fragment, which would break the endpoint built from it,
and a stack key of . or .. is refused because proxies normalise dot
segments. The token sources are unexported, so no zero value can send an
empty key or panic, and every format verb prints a description without the
key or the request token. Tokens never appear in error messages.

The wire types come from github.com/stackorder/stackorder/api/v1. go.mod
requires v0.3.0, the newest tag, as a placeholder until the release that
carries StackOutputs; until then local builds use an untracked go.work that
includes the stackorder checkout, as the README explains. go mod tidy
ignores go.work, so the tidy target needs no flag; requiring v0.3.0 raises a
few indirect versions through minimal version selection. The golden
stack_outputs.json from api/v1 is copied into testdata to pin the wire
contract on this side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The provider takes server_url, api_key, repository and oidc_audience,
each optional with an environment fallback, so a Stackorder job needs no
provider configuration at all: the Stackorder CLI exports the server URL,
repository, run id and stack key to Terraform. Unknown values refuse to
configure, since a fallback chosen at plan time could differ at apply.

Credentials come from the client's token source, which owns the API key
and audience fallbacks and prefers GitHub Actions ID tokens whenever the
runner offers them. The client now exports that check so the provider
can tell when an ID token will be used without re-reading the runner
variables. With an ID token the server can only bind the read to a stack
of a Stackorder run, so the provider refuses to configure without
STACKORDER_RUN_ID and STACKORDER_STACK and says to use an API key outside
such a job. Diagnostics carry the source of a bad value, never a key or
token.

The client's errors for a rejected server URL no longer repeat the URL.
Redacted hides only a password that url.Parse finds in user info, so a
password in a URL without a scheme, a password read as a port, or a key
in the query reached the diagnostic.

Tests drive Configure directly and over protocol 6, and check where
requests go and with which credentials against local fakes of the server
and the Actions token endpoint, including server URLs that carry a
password or a key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The data source and the ephemeral resource expose a stack's outputs as one
dynamic object each, so every output, sent as its JSON value beside the
type Terraform wrote to state, has to become a framework value of exactly
that type: a tuple must not turn into a list, numbers keep the 512-bit
precision Terraform reads them at, and nulls stay typed.

Types are parsed here, including the ones dynamically typed values carry
inside the value, and values are walked here too, so tftypes only reads
strings, numbers and bools. Its own type parser, deprecated for third
parties, keeps the optional attribute markers of object types, and tftypes
panics when such a type later meets a value, which would crash the
provider. Building tftypes values still recovers from a panic as a
backstop.

Every type is rebuilt as the type of its contents, as cty computes it, so
dynamically typed positions resolve. A list, set or map of dynamic
elements takes the type its elements share and turns its null elements
into nulls of that type, as cty does; elements of different types are
refused, since Terraform cannot build such a value. Scalars are read as
Terraform reads JSON, so a number may arrive as a string and the reverse.
No outputs give an empty object rather than null.

Errors name the output and what is malformed but never quote the value,
which may be sensitive, so neither tftypes nor framework error text is
passed through for values.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A reading stack names a producer as path, path:instance or
owner/repo//key and receives the outputs the server serves as two dynamic
objects: values, sensitive as a whole because a provider cannot mark nested
paths, and nonsensitive_values for arguments that refuse sensitive values.
The repository of the stack is checked as owner/repo before any request,
so a malformed key or repository setting is reported on stack and names
the setting that holds it.

Every refusal code of the server, the run state codes included, becomes
its own diagnostic that says who can act on it: the producer, the server's
operator or the reader's credentials. None repeats a token, a value or the
request URL. An answer without a v1 error code, as from a proxy in front
of the server, is reported by status and redirect host only, so its body
never reaches Terraform's output. Withheld sensitive outputs raise a
warning that names them and how the producer shares them. The provider
data now records the server host and whether ID tokens authenticate, so
diagnostics name the host only and give credential advice for the mode in
use.

Acceptance tests run terraform-plugin-testing against an httptest server
that serves the client's golden fixture. plugin-testing cannot observe
warnings, so the withheld warning is asserted through the protocol 6
server in the unit tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Values a data source reads land in the reader's plan file, which
Stackorder uploads as a workflow artifact, and in state, so a shared
sensitive output had no safe way in. The ephemeral resource reads the
same outputs with the same stack, values and nonsensitive_values, minus
id, and Terraform keeps its result out of both. It needs Terraform 1.10
or OpenTofu 1.11.

Open goes through the data source's read helper, so producer resolution,
the request, the conversion, every refusal diagnostic and the withheld
warning are the same code; the type check of the provider data is shared
too. There is no Renew or Close: the outputs are a copy of state values,
not a lease, so nothing expires or needs releasing. The data source
description, the withheld warning and the README now point at
ephemeral "stackorder_outputs" for sensitive values, and the warning no
longer assumes the reader is a data source.

The data source's read and error tables now drive both kinds over
protocol 6, so the ephemeral result is checked for the exact types, the
missing id and every error mapping. The acceptance test passes the
result to the echo provider and checks that a shared sensitive output
reaches it, that the pre-apply plan holds neither the value nor any
resource but echo.x, and that state holds only echo.x and the value
nowhere else. Terraform never lists ephemeral resources in plan or state
JSON, so the checks name what must be there rather than search for what
must not. OpenTofu reports its own version, so the test skips below 1.11
there and below 1.10 on Terraform.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Terraform Registry renders a provider's documentation from docs/ at
each release tag, so the pages must be in the repository before the
first tag. tfplugindocs renders them from templates, the example files
and the schema's descriptions, so attribute text lives only in the Go
schema and the pages cannot drift from it. The overview page says what
the provider is for, how a producer opts in with outputs.readers and
outputs.sensitive, which credentials apply inside and outside Stackorder
jobs and which Terraform and OpenTofu releases are needed, and links to
the Stackorder documentation. The data source and ephemeral resource
pages add how values are addressed and how Stackorder orders a reader
after its producer.

The generator is a tool dependency in go.mod, pinned to
terraform-plugin-docs v0.25.0 and run with go tool from go:generate
lines in main.go, after terraform fmt on the examples the pages embed.
It reads the schema with Terraform 1.14.4, which it downloads, because
a terraform older than 1.10 on PATH cannot export the ephemeral resource
schema and leaves docs/ half deleted, and a pinned release keeps the
pages identical on every machine. Template and page names leave out the
stackorder_ prefix because the generator and the registry look them up
that way. make docs-check regenerates the pages and fails when docs/ or
examples/ differ from HEAD, staged changes included, or docs/ holds an
untracked page. CI can run it once go.mod requires the Stackorder
release with the api/v1 stack outputs types; until then the provider
builds only in a Go workspace with a Stackorder checkout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Terraform and OpenTofu registries ingest a provider from its GitHub
releases: one zip per platform holding a binary named
terraform-provider-stackorder_v<version>, a SHA256SUMS file covering the
zips and the registry manifest, and a binary detached GPG signature of
that file. GoReleaser builds these assets in the layout and for the
platforms of the HashiCorp scaffolding, which covers the registry's
recommended list including linux/s390x. The manifest declares plugin
protocol 6, which the Terraform Registry would otherwise record as
protocol 5. A pre-release tag becomes a GitHub pre-release.

The release workflow runs on v* tags in the release environment, so the
signing key can be limited to tag runs, and takes the release notes from
the tag's CHANGELOG section as the Stackorder release does, failing when
that section is missing. It builds with the newest stable Go and without
a restored cache: go-version-file would install go.mod's go 1.26.0
directive as the exact toolchain, and a cache written by other runs
could reach the signed binaries. govulncheck stops a release whose code
reaches a known vulnerability before the key is imported. Actions are
pinned to full commit SHAs and GoReleaser to v2.18.2, and checkout keeps
no git credentials because GoReleaser publishes through GITHUB_TOKEN.

The workflow builds without the local go.work, so a tag can only be cut
once go.mod requires the Stackorder release carrying the api/v1 outputs
types; that change also has to raise grpc and x/net past their known
vulnerabilities for the govulncheck step to pass. The changelog names
Stackorder 0.4.0 as the planned minimum.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Run lint, unit tests, a docs check and builds on every push to main and
every pull request. The acceptance tests run against Terraform 1.10, the
first release with ephemeral resources, and the latest Terraform, and
against OpenTofu 1.11, its first release with them, and the latest
OpenTofu. For OpenTofu, setup-opentofu's provider_acceptance_tests input
points terraform-plugin-testing at the tofu binary and at OpenTofu's
registry host, which the reattached provider's address must carry.

The docs job runs go generate, which formats examples/ and renders docs/
from them, and fails when either differs from the commit or when it
creates pages that were never committed, which git diff alone does not
report. Actions are pinned to commit SHAs and checkouts do not keep the
token.

The module compiles in CI only once go.mod requires a Stackorder release
whose api/v1 carries StackOutputs; until then every job stops at compile
time, and the build step's name says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e test

OpenTofu 1.11 lists an ephemeral resource in a plan's resource_changes
and planned_values, with mode ephemeral and the action open, so the plan
check reported ephemeral.stackorder_outputs.vpc as a change beside
echo.x. The plan walkers now skip entries whose mode is ephemeral. The
state check still rejects everything but echo.x, ephemeral entries
included, so the test still proves the ephemeral resource never reaches
state, and the canary check still covers the whole plan JSON.

OpenTofu 1.11 also reports the open as a change in the plan
terraform-plugin-testing makes after apply, so that plan is never empty
there. The step sets ExpectNonEmptyPlan only when the CLI is OpenTofu
older than 1.12, because plugin-testing v1.16.0 fails a step that sets
it when the plan comes back empty, as it does on OpenTofu 1.12 and later
and on Terraform. tofu version -json does not name the product, so the
CLI counts as OpenTofu when its binary is named tofu or
TF_ACC_PROVIDER_HOST is OpenTofu's registry, and only the version is
read from that JSON. Both helpers have unit tests. Since
ExpectNonEmptyPlan switches off the empty-plan check, both post-apply
plans now require echo.x to be a no-op, which keeps catching drift on
OpenTofu 1.11.

The acceptance tests pass on Terraform 1.10.5, 1.14.4 and 1.16.5 and on
OpenTofu 1.11.14, 1.12.6 and 1.13.1. go-version moves from an indirect
to a direct requirement at the same version.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Stackorder scanner now reads stackorder_outputs blocks in every
local module a stack reaches, not only in the stack's own directory, and
resolves a module's var.<name> references from the literal arguments of
the module block that calls it, one level up. The Ordering section of
the data source and ephemeral resource pages still said a block inside a
module is never inferred, which would send readers to depends_on for
lookup modules the scanner now orders on its own.

The section now states the rule as the scanner applies it: literal
stacks in the stack's directory or its local modules, module variables
set to literals by the calling module block, and depends_on for any
other expression. It names every module the scanner leaves unread,
registry, git and other remote modules as well as local sources outside
the repository, so readers know a block there also needs depends_on. It
keeps terraform_remote_state tied to the stack's own directory, since
the scanner does not follow that data source into modules, and it says
that depends_on does not silence the warning for a non-literal stack,
so readers do not take the remaining warning for a missing dependency.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Stackorder v0.4.0 is the first release whose api/v1 package has the
shared-outputs types the provider decodes. Requiring it replaces the
v0.3.0 placeholder, so the provider builds and tests from the published
module without a Go workspace that points at a local checkout. The CI
build step and the README no longer describe that arrangement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
govulncheck reports GO-2026-6061, GO-2026-6348 and GO-2026-6443 in
google.golang.org/grpc v1.82.0, and GO-2026-6603, GO-2026-6610,
GO-2026-6611, GO-2026-6612 and GO-2026-6617 in golang.org/x/net
v0.57.0, each with call paths from the provider's code. grpc v1.83.1
fixes GO-2026-6348 but not GO-2026-6443, which the 1.83 line fixes in
v1.83.2, so this requires grpc v1.83.2 and x/net v0.60.0.

x/net v0.60.0 raises its own minimums, which tidy carries over:
x/crypto v0.57.0, x/mod v0.41.0, x/sync v0.23.0, x/sys v0.48.0,
x/text v0.42.0 and x/tools v0.49.0. govulncheck v1.8.0 on Go 1.27.2
then finds no vulnerabilities in the code or in any required module.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Stackorder 0.4.0 is now released and is the first CLI release that
passes the server URL, the run and the repository to Terraform. The
README, the provider page and the changelog named it only as the
release after 0.3.0 or as a planned one, so they now name 0.4.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dortort
dortort merged commit ea8837c into main Oct 11, 2026
8 checks passed
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.

1 participant