diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4fad239 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.go] +indent_style = tab + +[GNUmakefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..721beb9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,122 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - run: make lint + + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - run: make test + + acceptance: + name: acceptance (${{ matrix.cli }} ${{ matrix.version }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - cli: terraform + version: 1.10.x + - cli: terraform + version: latest + - cli: tofu + version: 1.11.x + - cli: tofu + version: latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - if: matrix.cli == 'terraform' + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: ${{ matrix.version }} + terraform_wrapper: false + - name: Point the acceptance tests at the installed Terraform + if: matrix.cli == 'terraform' + run: echo "TF_ACC_TERRAFORM_PATH=$(command -v terraform)" >> "$GITHUB_ENV" + - name: Install OpenTofu and point the acceptance tests at it and its registry host + if: matrix.cli == 'tofu' + uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 + with: + tofu_version: ${{ matrix.version }} + tofu_wrapper: false + provider_acceptance_tests: true + - name: Show the CLI the acceptance tests run + run: '"$TF_ACC_TERRAFORM_PATH" version' + - run: make testacc + + docs: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: 1.14.x + terraform_wrapper: false + - run: go generate ./... + - name: Fail when the generated docs or the formatted examples differ from the committed files + run: | + git diff --exit-code -- docs/ examples/ + untracked="$(git ls-files --others --exclude-standard -- docs/)" + if [ -n "$untracked" ]; then + echo "::error::go generate created docs that are not committed" + echo "$untracked" + exit 1 + fi + + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - name: Build + run: go build ./... + - name: Cross-compile for macOS on arm64 and Windows on amd64 + run: | + GOOS=darwin GOARCH=arm64 go build -o /dev/null . + GOOS=windows GOARCH=amd64 go build -o /dev/null . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4833026 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,49 @@ +name: release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + environment: release + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: false + - name: Take the release notes from the changelog + env: + TAG: ${{ github.ref_name }} + run: | + awk -v version="${TAG#v}" ' + /^## \[/ { printing = ($0 ~ "^## \\[" version "\\]") } + printing && !/^## \[/ && !/^\[[^]]+\]: / { print } + ' CHANGELOG.md | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' > "$RUNNER_TEMP/notes.md" + if [ ! -s "$RUNNER_TEMP/notes.md" ]; then + echo "::error::CHANGELOG.md has no section for $TAG; add a '## [${TAG#v}]' section, also for a pre-release tag" + exit 1 + fi + - name: Check for known vulnerabilities + run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./... + - name: Import the signing key + id: import_gpg + uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.PASSPHRASE }} + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + version: v2.18.2 + args: release --clean --release-notes ${{ runner.temp }}/notes.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..252c3d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/terraform-provider-stackorder +/dist/ +**/.terraform/ +*.tfstate +*.tfstate.* +go.work +go.work.sum +.DS_Store +*.tfrc diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..ddcdd0d --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,51 @@ +version: "2" + +run: + timeout: 10m + +linters: + default: standard + enable: + - bodyclose + - copyloopvar + - errorlint + - gocritic + - gosec + - misspell + - nilerr + - noctx + - prealloc + - revive + - sqlclosecheck + - unconvert + - unparam + - usestdlibvars + settings: + gosec: + excludes: + - G104 + - G304 + revive: + rules: + - name: exported + disabled: true + - name: package-comments + disabled: true + exclusions: + generated: lax + rules: + - path: _test\.go + linters: + - gosec + - noctx + - unparam + - errorlint + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/stackorder/terraform-provider-stackorder diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..3fc3d0c --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,89 @@ +version: 2 + +project_name: terraform-provider-stackorder + +before: + hooks: + - go mod tidy + +builds: + - env: + - CGO_ENABLED=0 + mod_timestamp: "{{ .CommitTimestamp }}" + flags: + - -trimpath + ldflags: + - -s -w -X main.version={{ .Version }} + goos: + - freebsd + - windows + - linux + - darwin + goarch: + - amd64 + - "386" + - arm + - arm64 + - s390x + ignore: + - goos: darwin + goarch: "386" + - goos: darwin + goarch: s390x + - goos: freebsd + goarch: s390x + - goos: windows + goarch: arm + - goos: windows + goarch: s390x + binary: "{{ .ProjectName }}_v{{ .Version }}" + +archives: + - formats: [zip] + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS" + algorithm: sha256 + extra_files: + - glob: terraform-registry-manifest.json + name_template: "{{ .ProjectName }}_{{ .Version }}_manifest.json" + +signs: + - artifacts: checksum + args: + - --batch + - --local-user + - "{{ .Env.GPG_FINGERPRINT }}" + - --output + - ${signature} + - --detach-sign + - ${artifact} + +changelog: + use: github + sort: asc + groups: + - title: Features + regexp: '^feat(\([^)]*\))?:' + order: 0 + - title: Fixes + regexp: '^fix(\([^)]*\))?:' + order: 1 + - title: Other + order: 999 + filters: + exclude: + - '^chore\(deps\)' + - '^ci:' + - '^test' + +release: + github: + owner: stackorder + name: terraform-provider-stackorder + extra_files: + - glob: terraform-registry-manifest.json + name_template: "{{ .ProjectName }}_{{ .Version }}_manifest.json" + draft: false + prerelease: auto diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3f6842f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this provider are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and releases follow [Semantic Versioning](https://semver.org/). Each `vX.Y.Z` tag publishes signed archives for the Terraform and OpenTofu registries. A tag takes its release notes from the section of its version, so a pre-release tag such as `v0.1.0-rc.1` needs a section of its own. + +## [Unreleased] + +It needs Stackorder 0.4.0 or later on the server and in the CLI, the first release to serve shared outputs. + +### Added + +- The `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](https://github.com/stackorder/stackorder) server, so a stack needs no access to the state of the stacks it reads. `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`. +- The `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 outputs not marked sensitive, and a warning names the sensitive outputs the producer withholds. Each refusal of the server gets its own error, which says what to check or change. +- The `stackorder_outputs` ephemeral resource, with the same `stack`, `values` and `nonsensitive_values`, which Terraform never writes to the plan file or state. It needs Terraform 1.10 or OpenTofu 1.11 or later. +- Credentials: inside a Stackorder 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 from `api_key` or `STACKORDER_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. + +[Unreleased]: https://github.com/stackorder/terraform-provider-stackorder/commits/main diff --git a/GNUmakefile b/GNUmakefile new file mode 100644 index 0000000..21598fa --- /dev/null +++ b/GNUmakefile @@ -0,0 +1,37 @@ +SHELL := /bin/bash + +BINARY := terraform-provider-stackorder +GOLANGCI_LINT_VERSION ?= v2.14.0 + +.PHONY: all build test testacc lint fmt generate docs-check tidy + +all: build + +build: + go build -o $(BINARY) + +test: + go test -race -count=1 ./... + +testacc: + TF_ACC=1 go test -count=1 -run 'TestAcc' ./internal/provider/... -timeout 20m + +lint: + @out=$$(gofmt -s -l .) || exit 1; \ + if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi + go vet ./... + go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run + +fmt: + gofmt -s -w . + +generate: + go generate ./... + +docs-check: generate + @git diff --exit-code HEAD -- docs/ examples/ || { echo "docs/ or examples/ differ from the last commit: run make generate and commit the result"; exit 1; } + @untracked=$$(git ls-files --others --exclude-standard docs/); \ + if [ -n "$$untracked" ]; then echo "docs/ has files that are not committed:"; echo "$$untracked"; exit 1; fi + +tidy: + go mod tidy diff --git a/README.md b/README.md index ebd987f..1b60bb6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,148 @@ # terraform-provider-stackorder Terraform and OpenTofu provider for [Stackorder](https://github.com/stackorder/stackorder): read the outputs another stack shares without reading its state. + +## Status + +The provider is under development and not yet published to a registry. It offers `stackorder_outputs` as a data source and as an ephemeral resource: name a stack and receive the root-module outputs its producer shares through the Stackorder server, so the consumer needs no access to the producer's state. + +## Documentation + +The registry documentation lives in [`docs/`](docs), in the [layout the Terraform Registry reads](https://developer.hashicorp.com/terraform/registry/providers/docs) from each release tag: + +| Page | Source | +|---|---| +| [`docs/index.md`](docs/index.md): the provider, sharing outputs, credentials, requirements and the provider configuration | [`templates/index.md.tmpl`](templates/index.md.tmpl), [`examples/provider/`](examples/provider) | +| [`docs/data-sources/outputs.md`](docs/data-sources/outputs.md): the `stackorder_outputs` data source | [`templates/data-sources/outputs.md.tmpl`](templates/data-sources/outputs.md.tmpl), [`examples/data-sources/stackorder_outputs/`](examples/data-sources/stackorder_outputs) | +| [`docs/ephemeral-resources/outputs.md`](docs/ephemeral-resources/outputs.md): the `stackorder_outputs` ephemeral resource | [`templates/ephemeral-resources/outputs.md.tmpl`](templates/ephemeral-resources/outputs.md.tmpl), [`examples/ephemeral-resources/stackorder_outputs/`](examples/ephemeral-resources/stackorder_outputs) | + +[tfplugindocs](https://github.com/hashicorp/terraform-plugin-docs) generates every page from its template, its example files and the descriptions in the provider's schema, so `docs/` is never edited by hand: change the source and run `make generate`, then commit `docs/` with the change. Page and template names leave out the `stackorder_` prefix, as the registry expects; example directories keep it. + +## Requirements + +- Terraform 1.10 or later, or OpenTofu 1.11 or later, for the `stackorder_outputs` ephemeral resource. +- The data source works with any Terraform or OpenTofu release that supports plugin protocol 6 (Terraform 1.0 and later, every OpenTofu release). +- Go 1.26 or later to build the provider. + +## Configuration + +```hcl +provider "stackorder" { + server_url = "https://stackorder.acme.example" +} +``` + +Inside a Stackorder plan, apply or drift job the block can stay empty: the Stackorder CLI sets `STACKORDER_SERVER_URL`, `STACKORDER_REPOSITORY`, `STACKORDER_RUN_ID` and `STACKORDER_STACK` for Terraform, and the job's own GitHub Actions identity authenticates the reads. Stackorder CLI releases before 0.4.0 set `STACKORDER_STACK` but not the other three, so a job needs 0.4.0 or later to read outputs with its own identity. + +Every attribute is optional and falls back to an environment variable. An attribute set to an empty string counts as unset. + +| Attribute | Environment fallback | Meaning | +|---|---|---| +| `server_url` | `STACKORDER_SERVER_URL` | Base URL of the Stackorder server. One of the two is required. | +| `api_key` | `STACKORDER_API_KEY` | Automation API key (`sk_…`), used only where no GitHub Actions ID token can be requested. Sensitive. | +| `repository` | `STACKORDER_REPOSITORY`, then `GITHUB_REPOSITORY` | Repository of the stacks being planned, as `owner/repo`: the repository a stack named without an `owner/repo//` prefix belongs to. | +| `oidc_audience` | `STACKORDER_OIDC_AUDIENCE` | Audience of the GitHub Actions ID tokens, which must be one the server accepts. Defaults to the server URL (`server_url` or `STACKORDER_SERVER_URL`) as configured, trailing slash included. | + +### Credentials + +Where Terraform runs decides which credentials the provider uses: + +- **Job token, inside Stackorder jobs.** Whenever the job can request GitHub Actions ID tokens (`permissions: id-token: write`, which the Stackorder reusable workflows grant), the provider requests a fresh ID token for every request to the server. The server accepts these only from a Stackorder plan, apply or drift job and checks the reading stack against the producer's readers, so the provider also needs `STACKORDER_RUN_ID` and `STACKORDER_STACK`, which the Stackorder CLI sets. Without them the provider refuses to configure. A configured API key is ignored in this case, so an admin key never silently replaces the job's own identity. A GitHub Actions job outside Stackorder that grants `id-token: write`, for example to assume a cloud role, therefore cannot use the provider. +- **API key, elsewhere.** Where no ID token can be requested, such as on a workstation, the provider sends the API key from `api_key` or `STACKORDER_API_KEY`. API keys are admin keys of the Stackorder server and are not bound to the readers a producer lists: keep them out of CI, and prefer `STACKORDER_API_KEY` over writing the key into configuration. + +## Usage + +A stack shares its outputs only with the stacks its producer lists under `outputs.readers` on its default branch, in the stack's `.stackorder.yaml` or in `stackorder.yaml` for every stack of its repository. A reader is a stack key or pattern of the same repository, or `owner/repo//pattern` for stacks of another repository of the same Stackorder installation. Sensitive outputs stay withheld unless the producer also sets `outputs.sensitive: share`: + +```yaml +outputs: + readers: + - stacks/prod/eks + - acme/platform//clusters/** + sensitive: share +``` + +A reading stack then names the producer with the `stackorder_outputs` data source: + +```hcl +data "stackorder_outputs" "vpc" { + stack = "stacks/prod/vpc" +} + +resource "aws_security_group" "cluster" { + name = "eks-cluster" + vpc_id = data.stackorder_outputs.vpc.nonsensitive_values.vpc_id +} + +data "stackorder_outputs" "dns" { + stack = "acme/platform//stacks/prod/dns" +} + +output "zone_id" { + value = data.stackorder_outputs.dns.nonsensitive_values.zone_id +} +``` + +`stack` takes `path` or `path:instance` for a stack of the provider's `repository`, or `owner/repo//key` for a stack of another repository. `values` holds every output the producer serves and is sensitive as a whole; `nonsensitive_values` holds the outputs not marked sensitive, and `id` the qualified key `owner/repo//key`. The data source warns about the sensitive outputs the producer withholds. + +The server serves outputs once its operator enables them with `STACKORDER_STATE_BUCKETS` and an apply or drift job of the producer has reported where its state lives. Values read by a data source are stored in the plan file, which Stackorder uploads as a workflow artifact, and in state. To read sensitive values without storing them, use `ephemeral "stackorder_outputs"`, which Terraform never writes to the plan file or state: + +```hcl +ephemeral "stackorder_outputs" "database" { + stack = "stacks/prod/database" +} + +resource "aws_secretsmanager_secret" "database" { + name = "acme/prod/eks/database" +} + +resource "aws_secretsmanager_secret_version" "database" { + secret_id = aws_secretsmanager_secret.database.id + secret_string_wo = ephemeral.stackorder_outputs.database.values.master_password + secret_string_wo_version = 1 +} +``` + +The ephemeral resource takes the same `stack` and returns the same `values` and `nonsensitive_values`, without `id`, and warns about withheld outputs in the same way. Its result, `nonsensitive_values` included, can only be referenced where Terraform accepts ephemeral values: write-only arguments such as `secret_string_wo` (Terraform 1.11 or later), provider and provisioner blocks, locals, ephemeral input variables, ephemeral outputs of child modules and other ephemeral resources. Terraform reads the outputs again in every plan and apply that needs them. + +## Development + +The provider builds from the published Stackorder module that `go.mod` requires, with no Stackorder checkout or Go workspace. `make test` runs the unit tests and `make testacc` the acceptance tests: + +| Target | What it does | +|---|---| +| `make build` | Builds `terraform-provider-stackorder` in the repository root | +| `make test` | Runs the unit tests with the race detector | +| `make testacc` | Runs the acceptance tests (`TF_ACC=1`) with the binary at `TF_ACC_TERRAFORM_PATH`, else `terraform` on `PATH`, else a downloaded Terraform | +| `make lint` | Checks formatting, runs `go vet` and golangci-lint | +| `make fmt` | Formats the Go sources | +| `make generate` | Formats `examples/` with the `terraform` on `PATH`, then regenerates `docs/` from `templates/`, `examples/` and the provider schema with tfplugindocs, which builds the provider and reads its schema with Terraform 1.14.4, downloaded from releases.hashicorp.com on every run whatever `terraform` is on `PATH` | +| `make docs-check` | Runs `make generate` and fails when `docs/` or `examples/` then differs from the last commit, staged changes included, or `docs/` has a page git does not track | +| `make tidy` | Tidies `go.mod` and `go.sum` | + +The acceptance tests never pick up `tofu` from `PATH`. To run them with OpenTofu, name the binary and OpenTofu's registry host: + +```shell +TF_ACC_TERRAFORM_PATH="$(command -v tofu)" TF_ACC_PROVIDER_HOST=registry.opentofu.org make testacc +``` + +To try a local build, install it with `go install .` (it lands in `$(go env GOPATH)/bin`, usually `~/go/bin`, unless `GOBIN` is set) or build it with `make build`, then point Terraform at that directory with a CLI configuration file: + +```hcl +provider_installation { + dev_overrides { + "stackorder/stackorder" = "/Users/you/go/bin" + } + direct {} +} +``` + +The path must be absolute: use your `~/go/bin`, or this repository's directory after `make build`. Save the block as `dev.tfrc` in the repository root, where git ignores `*.tfrc`, and select it for the current shell only by running this from the repository root: + +```shell +export TF_CLI_CONFIG_FILE="$PWD/dev.tfrc" +``` + +With the override in place, `plan` and `apply` use the local binary and skip version and checksum checks for it. Running `init` is not needed for this provider and can fail while it is unpublished. OpenTofu reads the same `dev_overrides` block. + +`go run . -debug` starts the provider in debug mode and prints a `TF_REATTACH_PROVIDERS` value; export it in the shell that runs `terraform plan` to use that process, for example under a debugger. diff --git a/docs/data-sources/outputs.md b/docs/data-sources/outputs.md new file mode 100644 index 0000000..8bbd0ce --- /dev/null +++ b/docs/data-sources/outputs.md @@ -0,0 +1,59 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackorder_outputs Data Source - Stackorder" +subcategory: "" +description: |- + Reads the root-module outputs a stack shares through the Stackorder server, so a stack can use another stack's outputs without access to its state. +--- + +# stackorder_outputs (Data Source) + +Reads the root-module outputs a stack shares through the Stackorder server, so a stack can use another stack's outputs without access to its state. + +Nothing is shared until the producer opts in on its default branch: its `.stackorder.yaml`, or `stackorder.yaml` for every stack of its repository, lists the stacks that may read its outputs under `outputs.readers`. Sensitive outputs are withheld unless the producer also sets `outputs.sensitive: share`, and the data source warns about the outputs withheld. + +Values read by a data source are stored in the plan file, which Stackorder uploads as a workflow artifact, and in state. To read sensitive values without storing them, use `ephemeral "stackorder_outputs"` instead, which keeps them out of both and needs Terraform 1.10 or later, or OpenTofu 1.11 or later. + +The Stackorder documentation on [shared outputs](https://docs.stackorder.io/configuration/outputs) describes the producer's side, `outputs.readers` and `outputs.sensitive`, in full. + +## Example Usage + +```terraform +data "stackorder_outputs" "vpc" { + stack = "stacks/prod/vpc" +} + +resource "aws_security_group" "cluster" { + name = "eks-cluster" + vpc_id = data.stackorder_outputs.vpc.nonsensitive_values.vpc_id +} + +data "stackorder_outputs" "dns" { + stack = "acme/platform//stacks/prod/dns" +} + +output "zone_id" { + value = data.stackorder_outputs.dns.nonsensitive_values.zone_id +} +``` + +## Reading values + +Each output is an attribute of `values`, and of `nonsensitive_values` unless the producer marks it sensitive, with the type it has in the producer's state: `data.stackorder_outputs.vpc.nonsensitive_values.vpc_id`. A root-module `output` that references `values` must set `sensitive = true`. The data source reads the outputs again in every plan, so a change to a producer's outputs reaches the reader at its next plan. + +## Ordering + +Stackorder plans and applies a reading stack after the stacks it reads. Its scanner infers that dependency from each `stackorder_outputs` block whose `stack` is a literal string, both in the stack's own directory, the only place it reads `terraform_remote_state`, and in every local module inside the repository that the stack calls, directly or through other local modules; a stack of another repository becomes an external dependency, as with a cross-repository `depends_on`. In a module, a `stack` made only of the module's variables and literal text, such as `var.producer` or `"stacks/${var.env}/vpc"`, counts as literal when the `module` block that calls the module sets each of those variables to a literal string, as `producer = "stacks/prod/vpc"` does; a value that block passes on from its own variables does not count. Any other `stack` built from variables or expressions infers nothing and warns. The scanner fetches no remote module and skips a local source outside the repository, so a block inside a registry, git or other remote module, or inside such a local module, infers nothing and gives no warning of its own. In either case, list the producer under `depends_on` in each reader's `.stackorder.yaml`, which orders the stacks but does not silence the warning about a non-literal `stack`. + + +## Schema + +### Required + +- `stack` (String) Key of the producer stack. `path` or `path:instance` names a stack of the provider's `repository`, such as `stacks/prod/vpc` or `stacks/prod/apps:blue`; `owner/repo//key` names a stack of another repository of the same Stackorder installation, such as `acme/platform//stacks/prod/dns`. + +### Read-Only + +- `id` (String) Qualified key of the stack, `owner/repo//key`. +- `nonsensitive_values` (Dynamic) The object of `values` without the outputs marked sensitive. Terraform does not treat it as sensitive, so it can feed arguments that refuse sensitive values, such as `for_each`. +- `values` (Dynamic, Sensitive) Object with one attribute per output the stack serves, named after the output, sensitive outputs included when the producer shares them. Terraform treats the whole object as sensitive. Outputs the producer withholds are absent. diff --git a/docs/ephemeral-resources/outputs.md b/docs/ephemeral-resources/outputs.md new file mode 100644 index 0000000..9f6aad3 --- /dev/null +++ b/docs/ephemeral-resources/outputs.md @@ -0,0 +1,55 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackorder_outputs Ephemeral Resource - Stackorder" +subcategory: "" +description: |- + Reads the root-module outputs a stack shares through the Stackorder server, as the stackorder_outputs data source does, but Terraform never writes them to the plan file or to state. This is the form to use for sensitive outputs. It requires Terraform 1.10 or later, or OpenTofu 1.11 or later. +--- + +# stackorder_outputs (Ephemeral Resource) + +Reads the root-module outputs a stack shares through the Stackorder server, as the `stackorder_outputs` data source does, but Terraform never writes them to the plan file or to state. This is the form to use for sensitive outputs. It requires Terraform 1.10 or later, or OpenTofu 1.11 or later. + +Nothing is shared until the producer opts in on its default branch: its `.stackorder.yaml`, or `stackorder.yaml` for every stack of its repository, lists the stacks that may read its outputs under `outputs.readers`. Sensitive outputs are withheld unless the producer also sets `outputs.sensitive: share`, and the ephemeral resource warns about the outputs withheld. + +Like every ephemeral value, the result can only be referenced where Terraform accepts ephemeral values: write-only arguments of managed resources (Terraform 1.11 or later), provider and provisioner blocks, locals, ephemeral input variables, ephemeral outputs of child modules and other ephemeral resources. Terraform reads the outputs again in every plan and apply that needs them. + +The Stackorder documentation on [shared outputs](https://docs.stackorder.io/configuration/outputs) describes the producer's side, `outputs.readers` and `outputs.sensitive`, in full. + +## Example Usage + +```terraform +ephemeral "stackorder_outputs" "database" { + stack = "stacks/prod/database" +} + +resource "aws_secretsmanager_secret" "database" { + name = "acme/prod/eks/database" +} + +resource "aws_secretsmanager_secret_version" "database" { + secret_id = aws_secretsmanager_secret.database.id + secret_string_wo = ephemeral.stackorder_outputs.database.values.master_password + secret_string_wo_version = 1 +} +``` + +## Reading values + +Each output is an attribute of `values`, and of `nonsensitive_values` unless the producer marks it sensitive, with the type it has in the producer's state: `ephemeral.stackorder_outputs.database.values.master_password`. Unlike the data source, the ephemeral resource has no `id`. + +## Ordering + +Stackorder plans and applies a reading stack after the stacks it reads. Its scanner infers that dependency from each `stackorder_outputs` block whose `stack` is a literal string, both in the stack's own directory, the only place it reads `terraform_remote_state`, and in every local module inside the repository that the stack calls, directly or through other local modules; a stack of another repository becomes an external dependency, as with a cross-repository `depends_on`. In a module, a `stack` made only of the module's variables and literal text, such as `var.producer` or `"stacks/${var.env}/vpc"`, counts as literal when the `module` block that calls the module sets each of those variables to a literal string, as `producer = "stacks/prod/vpc"` does; a value that block passes on from its own variables does not count. Any other `stack` built from variables or expressions infers nothing and warns. The scanner fetches no remote module and skips a local source outside the repository, so a block inside a registry, git or other remote module, or inside such a local module, infers nothing and gives no warning of its own. In either case, list the producer under `depends_on` in each reader's `.stackorder.yaml`, which orders the stacks but does not silence the warning about a non-literal `stack`. + + +## Schema + +### Required + +- `stack` (String) Key of the producer stack. `path` or `path:instance` names a stack of the provider's `repository`, such as `stacks/prod/vpc` or `stacks/prod/apps:blue`; `owner/repo//key` names a stack of another repository of the same Stackorder installation, such as `acme/platform//stacks/prod/dns`. + +### Read-Only + +- `nonsensitive_values` (Dynamic) The object of `values` without the outputs marked sensitive. Terraform does not treat it as sensitive, but it is ephemeral like the rest of the result. +- `values` (Dynamic, Sensitive) Object with one attribute per output the stack serves, named after the output, sensitive outputs included when the producer shares them. Terraform treats the whole object as sensitive. Outputs the producer withholds are absent. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..176b661 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,76 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "Stackorder Provider" +description: |- + Read the outputs another Stackorder stack shares, without access to its state. +--- + +# Stackorder Provider + +Reads the outputs that stacks share through a [Stackorder](https://github.com/stackorder/stackorder) server, so a stack can use another stack's outputs without access to its state. + +Stackorder plans and applies Terraform and OpenTofu stacks in dependency order on GitHub Actions. Without this provider, a stack that needs another stack's values reads them with `terraform_remote_state`, so its credentials must be able to read the producer's whole state object, secrets included. With it, the reading stack names the producer and receives the producer's root-module outputs only: on each read the Stackorder server takes the `outputs` block from the producer's state object with its own AWS credentials and returns it, storing, caching and logging no values. The reader needs no access to the producer's state bucket. + +The provider offers `stackorder_outputs` as a data source and as an ephemeral resource. Both take the same `stack` and return the same `values` and `nonsensitive_values`. The data source stores what it reads in the plan file and in state; the ephemeral resource keeps the values out of both. + +The [Stackorder documentation](https://docs.stackorder.io) covers the server, the CLI and the configuration files, and its [shared outputs](https://docs.stackorder.io/configuration/outputs) page covers the producer's side in full. + +## Requirements + +- A Stackorder server whose operator has enabled shared outputs by setting `STACKORDER_STATE_BUCKETS` to the state buckets, or bucket and prefix pairs, the server may read, and has granted the server read access to them. +- A producer stack that has run at least one apply or drift job with a Stackorder CLI that reports where the stack's state lives, so that the server knows which state object to read. +- Terraform 1.10 or later, or OpenTofu 1.11 or later, for the `stackorder_outputs` ephemeral resource. The data source works with every release that supports plugin protocol 6: Terraform 1.0 and later, and every OpenTofu release. +- Inside Stackorder jobs, Stackorder CLI 0.4.0 or later, which passes the server URL, the run and the repository to Terraform. + +## Sharing outputs + +Nothing is readable until the producer opts in. The producer's default branch lists the stacks that may read its outputs under `outputs.readers`, in the stack's `.stackorder.yaml`, or in `stackorder.yaml` for every stack of the repository: + +```yaml +# acme/infra: stacks/prod/vpc/.stackorder.yaml +outputs: + readers: + - stacks/prod/eks + - stacks/prod/apps:* + - acme/platform//clusters/** + sensitive: share +``` + +- Each reader is a pattern over the stack keys of the producer's repository, such as `stacks/prod/eks`, or `stacks/prod/apps:*` for every instance of a stack. `owner/repo//pattern` names stacks of another repository covered by the same Stackorder installation. +- `outputs` can be set in `stackorder.yaml`, in a stack's `.stackorder.yaml`, and for one instance under the stack's `instances`. The most specific level that sets `readers` replaces the others, and `readers: []` withdraws a broader grant. +- The server reads `outputs` from the default branch only, so a pull request cannot widen the list, and a change takes effect once it is merged. +- Sensitive outputs are withheld unless the producer sets `sensitive: share`. The provider warns about each output withheld. + +## Credentials + +Where Terraform runs decides how the provider authenticates. + +**Inside Stackorder jobs, the job's token.** In a Stackorder plan, apply or drift job, the provider requests a fresh GitHub Actions ID token for every request to the server, which needs `permissions: id-token: write`; the Stackorder reusable workflows grant it. The Stackorder CLI sets `STACKORDER_SERVER_URL`, `STACKORDER_REPOSITORY`, `STACKORDER_RUN_ID` and `STACKORDER_STACK` for Terraform, so the provider block can stay empty: + +```terraform +provider "stackorder" {} +``` + +The server accepts a job's token only while the job's run is in progress, and checks the reading stack, from `STACKORDER_STACK`, against the producer's readers. Whenever the job can request ID tokens the provider uses them and ignores any API key, so an admin key never replaces the job's own identity. For the same reason, a GitHub Actions job outside Stackorder that grants `id-token: write`, for example to assume a cloud role, cannot use the provider. + +**Elsewhere, an API key.** Where no ID token can be requested, such as on a workstation, the provider sends the automation API key from `api_key` or the `STACKORDER_API_KEY` environment variable, which is preferable to writing the key into configuration. API keys are admin keys of the Stackorder server: they read the outputs of any stack whatever its readers, the server audits every read, and they belong outside CI. Sensitive outputs stay withheld unless the producer shares them. + +## Example Usage + +Outside Stackorder jobs, name the server in the provider block and set the API key in `STACKORDER_API_KEY`: + +```terraform +provider "stackorder" { + server_url = "https://stackorder.acme.example" +} +``` + + +## Schema + +### Optional + +- `api_key` (String, Sensitive) Automation API key (`sk_…`) the provider authenticates with outside GitHub Actions. Defaults to the `STACKORDER_API_KEY` environment variable. Ignored whenever the job can request GitHub Actions ID tokens, which take precedence. API keys are admin keys of the server: keep them out of CI. +- `oidc_audience` (String) Audience of the GitHub Actions ID tokens the provider sends, which must be one the server accepts. Defaults to the `STACKORDER_OIDC_AUDIENCE` environment variable, else the server URL (`server_url` or `STACKORDER_SERVER_URL`) as configured, trailing slash included. +- `repository` (String) Repository of the stacks being planned, as `owner/repo`, which a stack named without an `owner/repo//` prefix belongs to. Defaults to the `STACKORDER_REPOSITORY` environment variable, which the Stackorder CLI sets, else `GITHUB_REPOSITORY`. +- `server_url` (String) Base URL of the Stackorder server, such as `https://stackorder.acme.example`. Defaults to the `STACKORDER_SERVER_URL` environment variable, which the Stackorder CLI sets for Terraform in its jobs. One of the two is required. diff --git a/examples/data-sources/stackorder_outputs/data-source.tf b/examples/data-sources/stackorder_outputs/data-source.tf new file mode 100644 index 0000000..ccf35ac --- /dev/null +++ b/examples/data-sources/stackorder_outputs/data-source.tf @@ -0,0 +1,16 @@ +data "stackorder_outputs" "vpc" { + stack = "stacks/prod/vpc" +} + +resource "aws_security_group" "cluster" { + name = "eks-cluster" + vpc_id = data.stackorder_outputs.vpc.nonsensitive_values.vpc_id +} + +data "stackorder_outputs" "dns" { + stack = "acme/platform//stacks/prod/dns" +} + +output "zone_id" { + value = data.stackorder_outputs.dns.nonsensitive_values.zone_id +} diff --git a/examples/ephemeral-resources/stackorder_outputs/ephemeral-resource.tf b/examples/ephemeral-resources/stackorder_outputs/ephemeral-resource.tf new file mode 100644 index 0000000..c76d776 --- /dev/null +++ b/examples/ephemeral-resources/stackorder_outputs/ephemeral-resource.tf @@ -0,0 +1,13 @@ +ephemeral "stackorder_outputs" "database" { + stack = "stacks/prod/database" +} + +resource "aws_secretsmanager_secret" "database" { + name = "acme/prod/eks/database" +} + +resource "aws_secretsmanager_secret_version" "database" { + secret_id = aws_secretsmanager_secret.database.id + secret_string_wo = ephemeral.stackorder_outputs.database.values.master_password + secret_string_wo_version = 1 +} diff --git a/examples/provider/provider.tf b/examples/provider/provider.tf new file mode 100644 index 0000000..9956293 --- /dev/null +++ b/examples/provider/provider.tf @@ -0,0 +1,3 @@ +provider "stackorder" { + server_url = "https://stackorder.acme.example" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2c3a7f9 --- /dev/null +++ b/go.mod @@ -0,0 +1,91 @@ +module github.com/stackorder/terraform-provider-stackorder + +go 1.26.0 + +require ( + github.com/hashicorp/go-version v1.9.0 + github.com/hashicorp/terraform-json v0.28.0 + github.com/hashicorp/terraform-plugin-framework v1.19.0 + github.com/hashicorp/terraform-plugin-go v0.31.0 + github.com/hashicorp/terraform-plugin-testing v1.16.0 + github.com/stackorder/stackorder v0.4.0 + github.com/stretchr/testify v1.12.1 +) + +require ( + github.com/BurntSushi/toml v1.2.1 // indirect + github.com/Kunde21/markdownfmt/v3 v3.1.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.2.0 // indirect + github.com/Masterminds/sprig/v3 v3.2.3 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/agext/levenshtein v1.2.3 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/apparentlymart/go-textseg/v17 v17.0.1 // indirect + github.com/armon/go-radix v1.0.0 // indirect + github.com/bgentry/speakeasy v0.1.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.2 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/cli v1.1.7 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-checkpoint v0.5.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-cty v1.5.0 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/hc-install v0.9.4 // indirect + github.com/hashicorp/hcl/v2 v2.25.0 // indirect + github.com/hashicorp/logutils v1.0.0 // indirect + github.com/hashicorp/terraform-exec v0.25.1 // indirect + github.com/hashicorp/terraform-plugin-docs v0.25.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.11.0 // indirect + github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 // indirect + github.com/hashicorp/terraform-registry-address v0.4.0 // indirect + github.com/hashicorp/terraform-svchost v0.2.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/huandu/xstrings v1.3.3 // indirect + github.com/imdario/mergo v0.3.15 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/oklog/run v1.2.0 // indirect + github.com/posener/complete v1.2.3 // indirect + github.com/shopspring/decimal v1.3.1 // indirect + github.com/spf13/cast v1.5.0 // indirect + github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/yuin/goldmark v1.7.7 // indirect + github.com/yuin/goldmark-meta v1.1.0 // indirect + github.com/zclconf/go-cty v1.19.0 // indirect + go.abhg.dev/goldmark/frontmatter v0.2.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/tools v0.49.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.3.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +tool github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6ee7ad6 --- /dev/null +++ b/go.sum @@ -0,0 +1,307 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/Kunde21/markdownfmt/v3 v3.1.0 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0= +github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/apparentlymart/go-textseg/v17 v17.0.1 h1:bpMXRgQ5cEoRNuQke1a80/Nl6w3G5eoIbWo9f3gXkAs= +github.com/apparentlymart/go-textseg/v17 v17.0.1/go.mod h1:fa8X4jgGeevslICIY6LcdjkSecWnXmYd9Lk34z/VxZs= +github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw= +github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= +github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= +github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/cli v1.1.7 h1:/fZJ+hNdwfTSfsxMBa9WWMlfjUZbX8/LnUxgAd7lCVU= +github.com/hashicorp/cli v1.1.7/go.mod h1:e6Mfpga9OCT1vqzFuoGZiiF/KaG9CbUfO5s3ghU3YgU= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= +github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0= +github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hc-install v0.9.4 h1:KKWOpUG0EqIV63Qk2GGFrZ0s275NVs5lKf9N5vjBNoc= +github.com/hashicorp/hc-install v0.9.4/go.mod h1:4LRYeEN2bMIFfIv57ldMWt9awfuZhvpbRt0vWmv51WU= +github.com/hashicorp/hcl/v2 v2.25.0 h1:HmmQVYRny4MaBo4b20TjmL46wyuUxpnMWkPZ4+NTbWk= +github.com/hashicorp/hcl/v2 v2.25.0/go.mod h1:vR+FKETxoZAmRlHgFfKmuqivj+C4Izm/c66XkmZ3r7M= +github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/terraform-exec v0.25.1 h1:PRutYRGM8pixV3B8812NYoBK5O+yuf3qcB/70KFKGiU= +github.com/hashicorp/terraform-exec v0.25.1/go.mod h1:+izOYrs9sKMQK4OYvGDnrSSJHY/pm4e4eXFqSL2Q5mA= +github.com/hashicorp/terraform-json v0.28.0 h1:dOkJT55rWfU6T1/VklHde51ym4LfNP+9xYR3ZizAJe4= +github.com/hashicorp/terraform-json v0.28.0/go.mod h1:PJIRf+Yzu5iLb52c/xYp1tUOL4jzMzfIAB5gvWWKIWE= +github.com/hashicorp/terraform-plugin-docs v0.25.0 h1:qHs1V257NxVe8tv6HS4UQfNqjaPP5eUlLeDf7jYk85U= +github.com/hashicorp/terraform-plugin-docs v0.25.0/go.mod h1:MQggCmY8zgP7R7E/cC0b0cmTvA9hSj3ZKyrrsDjRbLo= +github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA= +github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc= +github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8= +github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8= +github.com/hashicorp/terraform-plugin-log v0.11.0 h1:WjhcpZIVqP8YRe83+dIZXncwSgtu4vh27i23G33PUQY= +github.com/hashicorp/terraform-plugin-log v0.11.0/go.mod h1:XygBz8+m5kgwTb73MMyrnUjeNQeVWECEfg+h2opMsj0= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 h1:MKS/2URqeJRwJdbOfcbdsZCq/IRrNkqJNN0GtVIsuGs= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0/go.mod h1:PuG4P97Ju3QXW6c6vRkRadWJbvnEu2Xh+oOuqcYOqX4= +github.com/hashicorp/terraform-plugin-testing v1.16.0 h1:GB97nGnJ1hESpDrCjqZig38RodSF0gdRzxlDupLXP38= +github.com/hashicorp/terraform-plugin-testing v1.16.0/go.mod h1:eQPYAy9xFMV7xtIFX8Y+wJGtUB++HBl329zCF6PBMZk= +github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= +github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= +github.com/hashicorp/terraform-svchost v0.2.1 h1:ubvrTFw3Q7CsoEaX7V06PtCTKG3wu7GyyobAoN4eF3Q= +github.com/hashicorp/terraform-svchost v0.2.1/go.mod h1:zDMheBLvNzu7Q6o9TBvPqiZToJcSuCLXjAXxBslSky4= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= +github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= +github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/stackorder/stackorder v0.4.0 h1:tqaEhRB+rThR9YZ7QuMuVGVif+6+Hogf9UJOm5P+nkc= +github.com/stackorder/stackorder v0.4.0/go.mod h1:ZR3UzSjYCWbRha9N6vD3fUwI1tkczIb5a0emXoN9Ruc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.7 h1:5m9rrB1sW3JUMToKFQfb+FGt1U7r57IHu5GrYrG2nqU= +github.com/yuin/goldmark v1.7.7/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= +github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= +github.com/zclconf/go-cty v1.19.0 h1:IV8WdqYZc2c5rLX9bEoLNXKojBAp0MZPBHMIrCoa/s4= +github.com/zclconf/go-cty v1.19.0/go.mod h1:12W89jGn3JCOIQi7infWr9m80rOkb5RNYJqXMZcN4c8= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +go.abhg.dev/goldmark/frontmatter v0.2.0 h1:P8kPG0YkL12+aYk2yU3xHv4tcXzeVnN+gU0tJ5JnxRw= +go.abhg.dev/goldmark/frontmatter v0.2.0/go.mod h1:XqrEkZuM57djk7zrlRUB02x8I5J0px76YjkOzhB4YlU= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..38c874d --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,250 @@ +// Package client reads a stack's shared outputs from the Stackorder server. +// +// Inside GitHub Actions every request attempt carries a new OIDC ID token, +// because the server accepts each token only once; elsewhere it carries an +// automation API key. Network failures, a connection lost while the answer +// is read included, and 429 and 5xx answers are retried; any other answer is +// final and returned as an *APIError whose Code is the server's v1.Error +// code, so the provider can turn each refusal into its own diagnostic. +// Redirects are never followed, so a token only ever reaches the configured +// server. +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + v1 "github.com/stackorder/stackorder/api/v1" +) + +const ( + // DefaultAttempts is how many requests a call sends at most, the first + // included, unless WithRetryPolicy says otherwise. + DefaultAttempts = 3 + // DefaultTimeout bounds each request unless WithHTTPClient replaces the + // HTTP client. + DefaultTimeout = 30 * time.Second + + maxRetryAfter = time.Minute + maxResponseBody = 32 << 20 +) + +// RetryPolicy says how often a call is retried after a network failure, a +// 429 or a 5xx answer, and how long it waits in between. A Retry-After +// header on the answer lengthens the wait to what it asks for; an answer +// that asks for more than a minute is final. +type RetryPolicy struct { + // Attempts is how many requests a call sends at most, the first + // included. Values below 1 mean 1. + Attempts int + // Backoff lists the waits before the second, third and later attempts. + // The last wait repeats; an empty list retries at once. + Backoff []time.Duration +} + +func (p RetryPolicy) wait(retry int) time.Duration { + if len(p.Backoff) == 0 { + return 0 + } + return p.Backoff[min(retry, len(p.Backoff))-1] +} + +// Client reads stack outputs from one Stackorder server. It is safe for +// concurrent use. +type Client struct { + baseURL string + tokens TokenSource + http *http.Client + userAgent string + retry RetryPolicy +} + +// Option configures a Client. +type Option func(*Client) + +// WithHTTPClient sends requests through a copy of hc that never follows +// redirects. A nil hc keeps the client set before. The ID token requests of +// a NewTokenSource source use their own HTTP client. +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) { + if hc != nil { + c.http = withoutRedirects(hc) + } + } +} + +// WithVersion sets the provider version reported in the User-Agent header, +// terraform-provider-stackorder/. The default version is "dev". +func WithVersion(version string) Option { + return func(c *Client) { + if version != "" { + c.userAgent = userAgent(version) + } + } +} + +// WithRetryPolicy replaces the default policy of DefaultAttempts attempts, +// 500ms then 2s apart. +func WithRetryPolicy(p RetryPolicy) Option { + return func(c *Client) { + c.retry = RetryPolicy{Attempts: max(p.Attempts, 1), Backoff: append([]time.Duration(nil), p.Backoff...)} + } +} + +// New returns a Client for the server at serverURL, an http or https URL +// that may carry a path prefix but no user info, query or fragment. src +// supplies the token of every request attempt. The error for a rejected +// URL never repeats the URL, which may carry a secret. +func New(serverURL string, src TokenSource, opts ...Option) (*Client, error) { + u, err := url.Parse(serverURL) + if err != nil { + return nil, errors.New("the Stackorder server URL does not parse as a URL") + } + if (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" { + return nil, errors.New("the Stackorder server URL is not an http or https URL with a host") + } + if u.User != nil || strings.ContainsAny(serverURL, "?#") { + return nil, errors.New("the Stackorder server URL carries user info, a query or a fragment") + } + if src == nil { + return nil, errors.New("no token source for the Stackorder server") + } + c := &Client{ + baseURL: strings.TrimRight(serverURL, "/"), + tokens: src, + http: withoutRedirects(&http.Client{Timeout: DefaultTimeout}), + userAgent: userAgent("dev"), + retry: RetryPolicy{ + Attempts: DefaultAttempts, + Backoff: []time.Duration{500 * time.Millisecond, 2 * time.Second}, + }, + } + for _, opt := range opts { + opt(c) + } + return c, nil +} + +// StackOutputs calls GET /v1/repos/{owner}/{repo}/stacks/{key}/outputs for +// the stack key in repo, "owner/repo". Inside GitHub Actions, runID and +// reader name the caller's run and its stack, which the server checks +// against the producer's readers; an empty runID or reader is left out of +// the query, as for API keys, which read outside any run. +func (c *Client) StackOutputs(ctx context.Context, repo, stack, runID, reader string) (*v1.StackOutputs, error) { + if !validRepo(repo) { + return nil, fmt.Errorf("the repository %q is not owner/repo", repo) + } + if stack == "" { + return nil, errors.New("the stack key is empty") + } + if stack == "." || stack == ".." { + return nil, fmt.Errorf("the stack key %q is not a stack", stack) + } + endpoint := c.baseURL + "/v1/repos/" + repo + "/stacks/" + url.PathEscape(stack) + "/outputs" + query := url.Values{} + if runID != "" { + query.Set("run", runID) + } + if reader != "" { + query.Set("reader", reader) + } + if len(query) > 0 { + endpoint += "?" + query.Encode() + } + qualified := v1.QualifiedStackKey(repo, stack) + body, err := c.get(ctx, endpoint) + if err != nil { + return nil, fmt.Errorf("reading the outputs of %s: %w", qualified, err) + } + var out v1.StackOutputs + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("reading the outputs of %s: decoding the response: %w", qualified, err) + } + return &out, nil +} + +func (c *Client) get(ctx context.Context, endpoint string) ([]byte, error) { + for attempt := 1; ; attempt++ { + body, err := c.attempt(ctx, endpoint) + if err == nil { + return body, nil + } + var r retryableError + if !errors.As(err, &r) || ctx.Err() != nil { + return nil, err + } + if attempt >= c.retry.Attempts { + return nil, fmt.Errorf("giving up after %d attempts: %w", attempt, err) + } + if r.after > maxRetryAfter { + return nil, fmt.Errorf("not retrying, the answer asks to wait %s: %w", r.after, err) + } + timer := time.NewTimer(max(c.retry.wait(attempt), r.after)) + select { + case <-ctx.Done(): + timer.Stop() + return nil, fmt.Errorf("waiting to retry after %w: %w", err, ctx.Err()) + case <-timer.C: + } + } +} + +func (c *Client) attempt(ctx context.Context, endpoint string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return nil, fmt.Errorf("building the request: %w", err) + } + token, err := c.tokens.Token(ctx) + if err != nil { + return nil, fmt.Errorf("obtaining a token: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", c.userAgent) + resp, err := c.http.Do(req) + if err != nil { + return nil, retryable(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + apiErr := decodeError(resp) + if transientStatus(resp.StatusCode) { + return nil, transient(apiErr, resp.Header) + } + return nil, apiErr + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody+1)) + if err != nil { + return nil, retryable(fmt.Errorf("reading the response: %w", err)) + } + if len(body) > maxResponseBody { + return nil, fmt.Errorf("the response is larger than %d MiB", maxResponseBody>>20) + } + return body, nil +} + +func withoutRedirects(hc *http.Client) *http.Client { + cp := *hc + cp.CheckRedirect = noRedirects + return &cp +} + +func userAgent(version string) string { + return "terraform-provider-stackorder/" + version +} + +func validRepo(repo string) bool { + owner, name, ok := strings.Cut(repo, "/") + return ok && validSegment(owner) && validSegment(name) +} + +func validSegment(s string) bool { + return s != "" && s != "." && s != ".." && url.PathEscape(s) == s +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..b00bc9d --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,758 @@ +package client_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/stackorder/stackorder/api/v1" + + "github.com/stackorder/terraform-provider-stackorder/internal/client" +) + +const ( + testRunID = "5b1e3f0a-8c2d-4c1b-9a51-0d7f3e2a9b11" + testAPIKey = "sk_acme_automation" +) + +type reply struct { + status int + header http.Header + body string + drop bool + cut bool + stall bool +} + +type seen struct { + method string + path string + query url.Values + auth string + accept string + userAgent string +} + +type stackorderServer struct { + srv *httptest.Server + mu sync.Mutex + replies []reply + got []seen +} + +func fakeStackorder(t *testing.T, replies ...reply) *stackorderServer { + t.Helper() + s := &stackorderServer{replies: replies} + s.srv = httptest.NewServer(http.HandlerFunc(s.serve)) + t.Cleanup(s.srv.Close) + return s +} + +func (s *stackorderServer) serve(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + s.got = append(s.got, seen{ + method: r.Method, + path: r.URL.EscapedPath(), + query: r.URL.Query(), + auth: r.Header.Get("Authorization"), + accept: r.Header.Get("Accept"), + userAgent: r.Header.Get("User-Agent"), + }) + rep := s.replies[min(len(s.got), len(s.replies))-1] + s.mu.Unlock() + if rep.drop { + if conn, _, err := http.NewResponseController(w).Hijack(); err == nil { + _ = conn.Close() + } + return + } + for name, values := range rep.header { + w.Header()[name] = values + } + w.Header().Set("Content-Type", "application/json") + switch { + case rep.cut: + w.Header().Set("Content-Length", strconv.Itoa(len(rep.body)+64)) + w.WriteHeader(rep.status) + _, _ = w.Write([]byte(rep.body)) + case rep.stall: + w.Header().Set("Content-Length", strconv.Itoa(len(rep.body))) + w.WriteHeader(rep.status) + _, _ = w.Write([]byte(rep.body[:len(rep.body)/2])) + _ = http.NewResponseController(w).Flush() + select { + case <-r.Context().Done(): + case <-time.After(10 * time.Second): + } + default: + w.WriteHeader(rep.status) + _, _ = w.Write([]byte(rep.body)) + } +} + +func (s *stackorderServer) requests() []seen { + s.mu.Lock() + defer s.mu.Unlock() + return append([]seen(nil), s.got...) +} + +func (s *stackorderServer) auths() []string { + got := s.requests() + out := make([]string, 0, len(got)) + for _, r := range got { + out = append(out, r.auth) + } + return out +} + +func golden(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", name)) + require.NoError(t, err) + return string(b) +} + +func outputsReply(t *testing.T) reply { + t.Helper() + return reply{status: http.StatusOK, body: golden(t, "stack_outputs.json")} +} + +func coded(status int, code, message string) reply { + b, _ := json.Marshal(v1.Error{Code: code, Message: message}) + return reply{status: status, body: string(b)} +} + +func newClient(t *testing.T, serverURL string, src client.TokenSource, opts ...client.Option) *client.Client { + t.Helper() + c, err := client.New(serverURL, src, append([]client.Option{ + client.WithVersion("1.2.3"), + client.WithRetryPolicy(client.RetryPolicy{Attempts: client.DefaultAttempts}), + }, opts...)...) + require.NoError(t, err) + return c +} + +func apiKeySource(t *testing.T) client.TokenSource { + t.Helper() + outsideActions(t) + src, err := client.NewTokenSource(client.Credentials{APIKey: testAPIKey}) + require.NoError(t, err) + return src +} + +func oidcSource(t *testing.T, serverURL string) (client.TokenSource, *actionsTokens) { + t.Helper() + actions := fakeActions(t) + t.Setenv(client.EnvAPIKey, "") + src, err := client.NewTokenSource(client.Credentials{ServerURL: serverURL}) + require.NoError(t, err) + return src, actions +} + +func TestOutputsRequestPath(t *testing.T) { + tests := []struct { + name string + prefix string + repo string + stack string + runID string + reader string + wantPath string + want url.Values + }{ + { + name: "stack instance read by a stack of a run", + repo: "acme/infra", + stack: "stacks/prod/apps:blue", + runID: testRunID, + reader: "stacks/prod/eks", + wantPath: "/v1/repos/acme/infra/stacks/stacks%2Fprod%2Fapps:blue/outputs", + want: url.Values{"run": {testRunID}, "reader": {"stacks/prod/eks"}}, + }, + { + name: "stack read by a stack instance", + repo: "acme/infra", + stack: "stacks/prod/vpc", + runID: testRunID, + reader: "stacks/prod/apps:green", + wantPath: "/v1/repos/acme/infra/stacks/stacks%2Fprod%2Fvpc/outputs", + want: url.Values{"run": {testRunID}, "reader": {"stacks/prod/apps:green"}}, + }, + { + name: "API key outside a run", + repo: "acme/network-infra", + stack: "stacks/prod/tgw:eu-west-1", + wantPath: "/v1/repos/acme/network-infra/stacks/stacks%2Fprod%2Ftgw:eu-west-1/outputs", + want: url.Values{}, + }, + { + name: "server URL with a path prefix", + prefix: "/stackorder/", + repo: "acme/infra.live", + stack: "stacks/prod/vpc", + runID: testRunID, + reader: "stacks/prod/eks", + wantPath: "/stackorder/v1/repos/acme/infra.live/stacks/stacks%2Fprod%2Fvpc/outputs", + want: url.Values{"run": {testRunID}, "reader": {"stacks/prod/eks"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := fakeStackorder(t, outputsReply(t)) + c := newClient(t, s.srv.URL+tt.prefix, apiKeySource(t)) + + _, err := c.StackOutputs(t.Context(), tt.repo, tt.stack, tt.runID, tt.reader) + require.NoError(t, err) + + got := s.requests() + require.Len(t, got, 1) + assert.Equal(t, http.MethodGet, got[0].method) + assert.Equal(t, tt.wantPath, got[0].path) + assert.Equal(t, tt.want, got[0].query) + assert.Equal(t, "application/json", got[0].accept) + assert.Equal(t, "Bearer "+testAPIKey, got[0].auth) + assert.Equal(t, "terraform-provider-stackorder/1.2.3", got[0].userAgent) + }) + } +} + +func TestOIDCTokenPerAttempt(t *testing.T) { + tests := []struct { + name string + replies []reply + wantStatus int + }{ + { + name: "every attempt fails", + replies: []reply{ + coded(http.StatusServiceUnavailable, "unavailable", "state read throttled"), + coded(http.StatusServiceUnavailable, "unavailable", "state read throttled"), + coded(http.StatusServiceUnavailable, "unavailable", "state read throttled"), + }, + wantStatus: http.StatusServiceUnavailable, + }, + { + name: "last attempt succeeds", + replies: []reply{ + {status: http.StatusBadGateway, body: "bad gateway"}, + coded(http.StatusTooManyRequests, "rate_limited", "slow down"), + outputsReply(t), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := fakeStackorder(t, tt.replies...) + src, actions := oidcSource(t, s.srv.URL) + c := newClient(t, s.srv.URL, src) + + out, err := c.StackOutputs(t.Context(), "acme/infra", "stacks/prod/vpc", testRunID, "stacks/prod/eks") + + assert.Equal(t, []string{"Bearer oidc-token-1", "Bearer oidc-token-2", "Bearer oidc-token-3"}, s.auths()) + assert.Equal(t, []string{s.srv.URL, s.srv.URL, s.srv.URL}, actions.minted()) + if tt.wantStatus != 0 { + var apiErr *client.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, tt.wantStatus, apiErr.Status) + assert.Contains(t, err.Error(), "giving up after 3 attempts") + assert.NotContains(t, err.Error(), "oidc-token") + return + } + require.NoError(t, err) + assert.Equal(t, "stacks/prod/vpc", out.Stack) + }) + } +} + +func TestRetriesWithFreshToken(t *testing.T) { + tests := []struct { + name string + tokenFailures int + opts []client.Option + replies []reply + wantAuths []string + }{ + { + name: "503 then 200", + replies: []reply{coded(http.StatusServiceUnavailable, "unavailable", "try again"), outputsReply(t)}, + wantAuths: []string{"Bearer oidc-token-1", "Bearer oidc-token-2"}, + }, + { + name: "429 then 200", + replies: []reply{{status: http.StatusTooManyRequests}, outputsReply(t)}, + wantAuths: []string{"Bearer oidc-token-1", "Bearer oidc-token-2"}, + }, + { + name: "dropped connection then 200", + replies: []reply{{drop: true}, outputsReply(t)}, + wantAuths: []string{"Bearer oidc-token-1", "Bearer oidc-token-2"}, + }, + { + name: "connection lost while reading the answer then 200", + replies: []reply{{status: http.StatusOK, body: golden(t, "stack_outputs.json"), cut: true}, outputsReply(t)}, + wantAuths: []string{"Bearer oidc-token-1", "Bearer oidc-token-2"}, + }, + { + name: "timeout while reading the answer then 200", + opts: []client.Option{client.WithHTTPClient(&http.Client{Timeout: 300 * time.Millisecond})}, + replies: []reply{{status: http.StatusOK, body: golden(t, "stack_outputs.json"), stall: true}, outputsReply(t)}, + wantAuths: []string{"Bearer oidc-token-1", "Bearer oidc-token-2"}, + }, + { + name: "ID token endpoint unavailable once", + tokenFailures: 1, + replies: []reply{outputsReply(t)}, + wantAuths: []string{"Bearer oidc-token-1"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := fakeStackorder(t, tt.replies...) + src, actions := oidcSource(t, s.srv.URL) + actions.failNext(tt.tokenFailures) + c := newClient(t, s.srv.URL, src, tt.opts...) + + out, err := c.StackOutputs(t.Context(), "acme/infra", "stacks/prod/vpc", testRunID, "stacks/prod/eks") + require.NoError(t, err) + assert.Equal(t, "stacks/prod/vpc", out.Stack) + assert.Equal(t, tt.wantAuths, s.auths()) + assert.Len(t, actions.minted(), len(tt.wantAuths)) + }) + } +} + +func TestNoRetryOnCodedRefusals(t *testing.T) { + tests := []struct { + name string + reply reply + wantStatus int + wantCode string + wantMessage string + }{ + { + name: "reader not listed", + reply: coded(http.StatusForbidden, v1.ErrCodeOutputsNotShared, "stacks/prod/eks is not a reader of stacks/prod/vpc"), + wantStatus: http.StatusForbidden, + wantCode: v1.ErrCodeOutputsNotShared, + wantMessage: "stacks/prod/eks is not a reader of stacks/prod/vpc", + }, + { + name: "feature off", + reply: coded(http.StatusNotFound, v1.ErrCodeOutputsDisabled, "shared outputs are disabled"), + wantStatus: http.StatusNotFound, + wantCode: v1.ErrCodeOutputsDisabled, + wantMessage: "shared outputs are disabled", + }, + { + name: "another repository", + reply: coded(http.StatusNotFound, "not_found", "stack not found"), + wantStatus: http.StatusNotFound, + wantCode: "not_found", + wantMessage: "stack not found", + }, + { + name: "state location unknown", + reply: coded(http.StatusConflict, v1.ErrCodeStateLocationUnknown, "no apply or drift job has reported the state object"), + wantStatus: http.StatusConflict, + wantCode: v1.ErrCodeStateLocationUnknown, + wantMessage: "no apply or drift job has reported the state object", + }, + { + name: "encrypted state", + reply: coded(http.StatusUnprocessableEntity, v1.ErrCodeStateEncrypted, "the state is encrypted"), + wantStatus: http.StatusUnprocessableEntity, + wantCode: v1.ErrCodeStateEncrypted, + wantMessage: "the state is encrypted", + }, + { + name: "token refused", + reply: coded(http.StatusUnauthorized, "unauthorized", "token already used"), + wantStatus: http.StatusUnauthorized, + wantCode: "unauthorized", + wantMessage: "token already used", + }, + { + name: "code without a message", + reply: coded(http.StatusForbidden, "forbidden", ""), + wantStatus: http.StatusForbidden, + wantCode: "forbidden", + }, + { + name: "proxy page", + reply: reply{status: http.StatusForbidden, body: "\n Forbidden\n\n"}, + wantStatus: http.StatusForbidden, + wantMessage: " Forbidden ", + }, + { + name: "long proxy page cut at a character boundary", + reply: reply{status: http.StatusForbidden, body: "x" + strings.Repeat("é", 150)}, + wantStatus: http.StatusForbidden, + wantMessage: "x" + strings.Repeat("é", 99) + "…", + }, + { + name: "empty body", + reply: reply{status: http.StatusBadRequest}, + wantStatus: http.StatusBadRequest, + wantMessage: "Bad Request", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := fakeStackorder(t, tt.reply, outputsReply(t)) + src, actions := oidcSource(t, s.srv.URL) + c := newClient(t, s.srv.URL, src) + + out, err := c.StackOutputs(t.Context(), "acme/infra", "stacks/prod/vpc", testRunID, "stacks/prod/eks") + assert.Nil(t, out) + + var apiErr *client.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, client.APIError{Status: tt.wantStatus, Code: tt.wantCode, Message: tt.wantMessage}, *apiErr) + assert.Equal(t, "reading the outputs of acme/infra//stacks/prod/vpc: "+apiErr.Error(), err.Error()) + assert.NotContains(t, err.Error(), "oidc-token") + assert.Len(t, s.requests(), 1) + assert.Len(t, actions.minted(), 1) + }) + } +} + +func TestDecodesStackOutputs(t *testing.T) { + tests := []struct { + name string + body string + want *v1.StackOutputs + wantErr string + }{ + { + name: "shared outputs", + body: golden(t, "stack_outputs.json"), + want: &v1.StackOutputs{ + Repo: "acme/infra", + Stack: "stacks/prod/vpc", + Serial: 42, + Lineage: "8d3f6c1e-2b7a-4e9d-9c51-0f4a6b2d7e13", + TerraformVersion: "1.9.8", + Outputs: map[string]v1.StackOutput{ + "vpc_id": { + Value: json.RawMessage(`"vpc-0a1b2c3d4e5f67890"`), + Type: json.RawMessage(`"string"`), + }, + "nat_gateway_count": { + Value: json.RawMessage(`3`), + Type: json.RawMessage(`"number"`), + }, + "private_subnet_ids": { + Value: json.RawMessage(`["subnet-0a1b2c3d4e5f6a7b8","subnet-0b2c3d4e5f6a7b8c9"]`), + Type: json.RawMessage(`["list","string"]`), + }, + "tags": { + Value: json.RawMessage(`{"environment":"prod","team":"platform"}`), + Type: json.RawMessage(`["map","string"]`), + }, + "endpoints": { + Value: json.RawMessage(`{"dynamodb":"vpce-0d4e5f6a7b8c9d0e1","s3":"vpce-0a1b2c3d4e5f6a7b8"}`), + Type: json.RawMessage(`["object",{"dynamodb":"string","s3":"string"}]`), + }, + }, + Withheld: []string{"vpn_preshared_key"}, + }, + }, + { + name: "no outputs", + body: golden(t, "stack_outputs_empty.json"), + want: &v1.StackOutputs{ + Repo: "acme/infra", + Stack: "stacks/prod/dns", + Serial: 3, + Lineage: "5b2e9a74-1c3d-4f60-8a7e-2d9c0b1f6e48", + TerraformVersion: "1.9.8", + Outputs: map[string]v1.StackOutput{}, + }, + }, + { + name: "truncated body", + body: `{"repo":"acme/infra","outputs":{`, + wantErr: "reading the outputs of acme/infra//stacks/prod/vpc: decoding the response: ", + }, + { + name: "field of the wrong type", + body: `{"repo":"acme/infra","stack":"stacks/prod/vpc","serial":"42","outputs":{}}`, + wantErr: "reading the outputs of acme/infra//stacks/prod/vpc: decoding the response: ", + }, + { + name: "body over 32 MiB", + body: `{"repo":"acme/infra","outputs":{}}` + strings.Repeat(" ", 32<<20), + wantErr: "reading the outputs of acme/infra//stacks/prod/vpc: the response is larger than 32 MiB", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := fakeStackorder(t, reply{status: http.StatusOK, body: tt.body}) + c := newClient(t, s.srv.URL, apiKeySource(t)) + + got, err := c.StackOutputs(t.Context(), "acme/infra", "stacks/prod/vpc", "", "") + assert.Len(t, s.requests(), 1) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, got) + return + } + require.NoError(t, err) + for name, out := range got.Outputs { + out.Value, out.Type = compact(t, out.Value), compact(t, out.Type) + got.Outputs[name] = out + } + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRetriesStopWhenTheContextEnds(t *testing.T) { + s := fakeStackorder(t, coded(http.StatusServiceUnavailable, "unavailable", "try again")) + c, err := client.New(s.srv.URL, apiKeySource(t), client.WithRetryPolicy(client.RetryPolicy{ + Attempts: client.DefaultAttempts, + Backoff: []time.Duration{time.Hour}, + })) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + + out, err := c.StackOutputs(ctx, "acme/infra", "stacks/prod/vpc", "", "") + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.Nil(t, out) + assert.Len(t, s.requests(), 1) +} + +func TestRequestsGoThroughTheGivenHTTPClient(t *testing.T) { + tests := []struct { + name string + nilAfter bool + }{ + {name: "given client"}, + {name: "nil keeps the client given before", nilAfter: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := fakeStackorder(t, outputsReply(t)) + var used atomic.Int32 + hc := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + used.Add(1) + return http.DefaultTransport.RoundTrip(r) + })} + opts := []client.Option{client.WithHTTPClient(hc)} + if tt.nilAfter { + opts = append(opts, client.WithHTTPClient(nil)) + } + c, err := client.New(s.srv.URL, apiKeySource(t), opts...) + require.NoError(t, err) + + _, err = c.StackOutputs(t.Context(), "acme/infra", "stacks/prod/vpc", "", "") + require.NoError(t, err) + assert.Equal(t, int32(1), used.Load()) + assert.Len(t, s.requests(), 1) + }) + } +} + +func TestRedirectsAreNotFollowed(t *testing.T) { + tests := []struct { + name string + opts []client.Option + }{ + {name: "default client"}, + {name: "given client", opts: []client.Option{client.WithHTTPClient(&http.Client{})}}, + {name: "nil client", opts: []client.Option{client.WithHTTPClient(nil)}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + elsewhere := fakeStackorder(t, outputsReply(t)) + location := elsewhere.srv.URL + "/v1/repos/acme/infra/stacks/stacks%2Fprod%2Fvpc/outputs" + s := fakeStackorder(t, reply{ + status: http.StatusFound, + header: http.Header{"Location": {location}}, + body: `Found.`, + }) + c := newClient(t, s.srv.URL, apiKeySource(t), tt.opts...) + + out, err := c.StackOutputs(t.Context(), "acme/infra", "stacks/prod/vpc", "", "") + assert.Nil(t, out) + var apiErr *client.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, client.APIError{Status: http.StatusFound, Message: "redirected to " + location + ", which is never followed"}, *apiErr) + assert.Len(t, s.requests(), 1) + assert.Empty(t, elsewhere.requests()) + }) + } +} + +func TestRetryAfter(t *testing.T) { + tests := []struct { + name string + first reply + wantRequests int + wantErr string + }{ + { + name: "seconds waited for", + first: reply{status: http.StatusTooManyRequests, header: http.Header{"Retry-After": {"1"}}}, + wantRequests: 1, + wantErr: "context deadline exceeded", + }, + { + name: "date waited for", + first: reply{status: http.StatusServiceUnavailable, header: http.Header{"Retry-After": {time.Now().Add(3 * time.Second).UTC().Format(http.TimeFormat)}}}, + wantRequests: 1, + wantErr: "context deadline exceeded", + }, + { + name: "zero seconds", + first: reply{status: http.StatusTooManyRequests, header: http.Header{"Retry-After": {"0"}}}, + wantRequests: 2, + }, + { + name: "date in the past", + first: reply{status: http.StatusServiceUnavailable, header: http.Header{"Retry-After": {time.Now().Add(-time.Hour).UTC().Format(http.TimeFormat)}}}, + wantRequests: 2, + }, + { + name: "unparsable header", + first: reply{status: http.StatusTooManyRequests, header: http.Header{"Retry-After": {"soon"}}}, + wantRequests: 2, + }, + { + name: "more than a minute", + first: reply{status: http.StatusTooManyRequests, header: http.Header{"Retry-After": {"3600"}}}, + wantRequests: 1, + wantErr: "not retrying, the answer asks to wait 1h0m0s", + }, + { + name: "more seconds than fit", + first: reply{status: http.StatusTooManyRequests, header: http.Header{"Retry-After": {"99999999999999999999"}}}, + wantRequests: 1, + wantErr: "not retrying, the answer asks to wait", + }, + { + name: "date more than a minute away", + first: reply{status: http.StatusServiceUnavailable, header: http.Header{"Retry-After": {time.Now().Add(time.Hour).UTC().Format(http.TimeFormat)}}}, + wantRequests: 1, + wantErr: "not retrying, the answer asks to wait", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := fakeStackorder(t, tt.first, outputsReply(t)) + c := newClient(t, s.srv.URL, apiKeySource(t)) + ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond) + defer cancel() + + out, err := c.StackOutputs(ctx, "acme/infra", "stacks/prod/vpc", "", "") + assert.Len(t, s.requests(), tt.wantRequests) + if tt.wantErr == "" { + require.NoError(t, err) + assert.Equal(t, "stacks/prod/vpc", out.Stack) + return + } + require.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, out) + var apiErr *client.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, tt.first.status, apiErr.Status) + }) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestNewRefusesInvalidSettings(t *testing.T) { + tests := []struct { + name string + serverURL string + noSource bool + wantErr string + }{ + {name: "empty URL", serverURL: "", wantErr: "the Stackorder server URL is not an http or https URL with a host"}, + {name: "no scheme", serverURL: "stackorder.acme.example", wantErr: "is not an http or https URL with a host"}, + {name: "other scheme", serverURL: "ftp://stackorder.acme.example", wantErr: "is not an http or https URL with a host"}, + {name: "no host", serverURL: "https://", wantErr: "is not an http or https URL with a host"}, + {name: "unparsable URL", serverURL: "https://stackorder.acme.example/%zz", wantErr: "the Stackorder server URL does not parse as a URL"}, + {name: "unparsable URL with user info", serverURL: "https://ci:s3cr3t@stackorder.acme.example/%zz", wantErr: "does not parse as a URL"}, + {name: "password read as a port", serverURL: "https://ci:s3cr3t", wantErr: "does not parse as a URL"}, + {name: "other scheme with user info", serverURL: "ftp://ci:s3cr3t@stackorder.acme.example", wantErr: "is not an http or https URL with a host"}, + {name: "user info without a scheme", serverURL: "ci:s3cr3t@stackorder.acme.example", wantErr: "is not an http or https URL with a host"}, + {name: "user info", serverURL: "https://ci:s3cr3t@stackorder.acme.example", wantErr: "the Stackorder server URL carries user info, a query or a fragment"}, + {name: "query", serverURL: "https://stackorder.acme.example/?tenant=acme", wantErr: "carries user info, a query or a fragment"}, + {name: "secret in the query", serverURL: "https://stackorder.acme.example/?key=s3cr3t", wantErr: "carries user info, a query or a fragment"}, + {name: "empty query", serverURL: "https://stackorder.acme.example/?", wantErr: "carries user info, a query or a fragment"}, + {name: "fragment", serverURL: "https://stackorder.acme.example/#acme", wantErr: "carries user info, a query or a fragment"}, + {name: "empty fragment", serverURL: "https://stackorder.acme.example/#", wantErr: "carries user info, a query or a fragment"}, + {name: "no token source", serverURL: "https://stackorder.acme.example", noSource: true, wantErr: "no token source"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + src := apiKeySource(t) + if tt.noSource { + src = nil + } + c, err := client.New(tt.serverURL, src) + require.ErrorContains(t, err, tt.wantErr) + assert.NotContains(t, err.Error(), "s3cr3t") + assert.NotContains(t, err.Error(), "acme") + assert.Nil(t, c) + }) + } +} + +func TestInvalidArgumentsSendNothing(t *testing.T) { + tests := []struct { + name string + repo string + stack string + wantErr string + }{ + {name: "repository without owner", repo: "infra", stack: "stacks/prod/vpc", wantErr: `the repository "infra" is not owner/repo`}, + {name: "qualified stack key as repository", repo: "acme/infra//stacks/prod/vpc", stack: "stacks/prod/vpc", wantErr: "is not owner/repo"}, + {name: "empty owner", repo: "/infra", stack: "stacks/prod/vpc", wantErr: "is not owner/repo"}, + {name: "empty name", repo: "acme/", stack: "stacks/prod/vpc", wantErr: "is not owner/repo"}, + {name: "dot segment", repo: "acme/..", stack: "stacks/prod/vpc", wantErr: "is not owner/repo"}, + {name: "segment that needs escaping", repo: "acme/in fra", stack: "stacks/prod/vpc", wantErr: "is not owner/repo"}, + {name: "empty stack", repo: "acme/infra", stack: "", wantErr: "the stack key is empty"}, + {name: "dot stack", repo: "acme/infra", stack: ".", wantErr: `the stack key "." is not a stack`}, + {name: "dot-dot stack", repo: "acme/infra", stack: "..", wantErr: `the stack key ".." is not a stack`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := fakeStackorder(t, outputsReply(t)) + src, actions := oidcSource(t, s.srv.URL) + c := newClient(t, s.srv.URL, src) + + out, err := c.StackOutputs(t.Context(), tt.repo, tt.stack, testRunID, "stacks/prod/eks") + require.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, out) + assert.Empty(t, s.requests()) + assert.Empty(t, actions.minted()) + }) + } +} + +func compact(t *testing.T, raw json.RawMessage) json.RawMessage { + t.Helper() + var buf bytes.Buffer + require.NoError(t, json.Compact(&buf, raw)) + return buf.Bytes() +} diff --git a/internal/client/errors.go b/internal/client/errors.go new file mode 100644 index 0000000..7b0937c --- /dev/null +++ b/internal/client/errors.go @@ -0,0 +1,101 @@ +package client + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" + + v1 "github.com/stackorder/stackorder/api/v1" +) + +const maxErrorBody = 1 << 20 + +// APIError is a final non-200 answer of the server. Code and Message come +// from its v1.Error body; when the body is not one, as from a proxy in front +// of the server, Code is empty and Message holds the redirect target, the +// start of the body or the status text. +type APIError struct { + // Status is the HTTP status code. + Status int + // Code is the v1.Error code, such as v1.ErrCodeOutputsNotShared. + Code string + // Message is the server's reason, written for people. + Message string +} + +// Error implements the error interface. +func (e *APIError) Error() string { + msg := "the Stackorder server answered " + strconv.Itoa(e.Status) + if e.Code != "" { + msg += " " + e.Code + } + if e.Message != "" { + msg += ": " + e.Message + } + return msg +} + +func decodeError(resp *http.Response) *APIError { + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody)) + var wire v1.Error + if err := json.Unmarshal(body, &wire); err == nil && wire.Code != "" { + return &APIError{Status: resp.StatusCode, Code: wire.Code, Message: wire.Message} + } + msg := snippet(body) + if loc := resp.Header.Get("Location"); loc != "" && resp.StatusCode >= 300 && resp.StatusCode < 400 { + msg = "redirected to " + loc + ", which is never followed" + } + if msg == "" { + msg = http.StatusText(resp.StatusCode) + } + return &APIError{Status: resp.StatusCode, Message: msg} +} + +func snippet(body []byte) string { + s := strings.Join(strings.Fields(string(body)), " ") + if len(s) <= 200 { + return s + } + n := 200 + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] + "…" +} + +func transientStatus(status int) bool { + return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError +} + +func noRedirects(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + +type retryableError struct { + err error + after time.Duration +} + +func (e retryableError) Error() string { return e.err.Error() } + +func (e retryableError) Unwrap() error { return e.err } + +func retryable(err error) error { return retryableError{err: err} } + +func transient(err error, h http.Header) error { + return retryableError{err: err, after: retryAfter(h, time.Now())} +} + +func retryAfter(h http.Header, now time.Time) time.Duration { + v := strings.TrimSpace(h.Get("Retry-After")) + if secs, err := strconv.ParseUint(v, 10, 32); err == nil || errors.Is(err, strconv.ErrRange) { + return time.Duration(secs) * time.Second + } + if when, err := http.ParseTime(v); err == nil { + return max(when.Sub(now), 0) + } + return 0 +} diff --git a/internal/client/testdata/stack_outputs.json b/internal/client/testdata/stack_outputs.json new file mode 100644 index 0000000..7fe2586 --- /dev/null +++ b/internal/client/testdata/stack_outputs.json @@ -0,0 +1,58 @@ +{ + "repo": "acme/infra", + "stack": "stacks/prod/vpc", + "serial": 42, + "lineage": "8d3f6c1e-2b7a-4e9d-9c51-0f4a6b2d7e13", + "terraform_version": "1.9.8", + "outputs": { + "endpoints": { + "value": { + "dynamodb": "vpce-0d4e5f6a7b8c9d0e1", + "s3": "vpce-0a1b2c3d4e5f6a7b8" + }, + "type": [ + "object", + { + "dynamodb": "string", + "s3": "string" + } + ], + "sensitive": false + }, + "nat_gateway_count": { + "value": 3, + "type": "number", + "sensitive": false + }, + "private_subnet_ids": { + "value": [ + "subnet-0a1b2c3d4e5f6a7b8", + "subnet-0b2c3d4e5f6a7b8c9" + ], + "type": [ + "list", + "string" + ], + "sensitive": false + }, + "tags": { + "value": { + "environment": "prod", + "team": "platform" + }, + "type": [ + "map", + "string" + ], + "sensitive": false + }, + "vpc_id": { + "value": "vpc-0a1b2c3d4e5f67890", + "type": "string", + "sensitive": false + } + }, + "withheld": [ + "vpn_preshared_key" + ] +} diff --git a/internal/client/testdata/stack_outputs_empty.json b/internal/client/testdata/stack_outputs_empty.json new file mode 100644 index 0000000..1ba5a45 --- /dev/null +++ b/internal/client/testdata/stack_outputs_empty.json @@ -0,0 +1,8 @@ +{ + "repo": "acme/infra", + "stack": "stacks/prod/dns", + "serial": 3, + "lineage": "5b2e9a74-1c3d-4f60-8a7e-2d9c0b1f6e48", + "terraform_version": "1.9.8", + "outputs": {} +} diff --git a/internal/client/token.go b/internal/client/token.go new file mode 100644 index 0000000..c10f395 --- /dev/null +++ b/internal/client/token.go @@ -0,0 +1,180 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +const ( + // EnvOIDCRequestURL is the runner variable holding the GitHub Actions ID + // token endpoint. GitHub sets it only for jobs granted + // `permissions: id-token: write`. + EnvOIDCRequestURL = "ACTIONS_ID_TOKEN_REQUEST_URL" + // EnvOIDCRequestToken is the runner variable holding the bearer token for + // the ID token endpoint. + EnvOIDCRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" + // EnvOIDCAudience overrides the audience of the ID tokens when + // Credentials.OIDCAudience is empty. + EnvOIDCAudience = "STACKORDER_OIDC_AUDIENCE" + // EnvAPIKey holds the automation API key used when Credentials.APIKey is + // empty. + EnvAPIKey = "STACKORDER_API_KEY" //nolint:gosec // a variable name, not a secret + + tokenRequestTimeout = 30 * time.Second +) + +var ( + // ErrNoCredentials reports that the provider has neither a GitHub + // Actions ID token nor an API key to authenticate with. + ErrNoCredentials = errors.New("no Stackorder credentials: inside GitHub Actions, grant the job `permissions: id-token: write`; elsewhere, set the provider's api_key or " + EnvAPIKey) + // ErrNoAudience reports that inside GitHub Actions nothing names the + // audience of the ID tokens. + ErrNoAudience = errors.New("no audience for the GitHub Actions ID tokens: set the provider's server_url or oidc_audience, or " + EnvOIDCAudience) +) + +// TokenSource supplies the bearer token of each request attempt. +type TokenSource interface { + // Token returns the token to send with one attempt. + Token(ctx context.Context) (string, error) +} + +// Credentials are the settings NewTokenSource chooses a TokenSource from. +// Every setting is trimmed of surrounding whitespace. +type Credentials struct { + // ServerURL is the server URL as configured. Unless OIDCAudience or + // STACKORDER_OIDC_AUDIENCE is set it is the audience of the ID tokens, + // trailing slash included, and the server's default audience is its + // base URL without one. + ServerURL string + // OIDCAudience is the audience of the ID tokens. + OIDCAudience string + // APIKey is an automation API key (sk_…). When empty, the key comes + // from STACKORDER_API_KEY. + APIKey string +} + +// NewTokenSource returns a source that requests a new GitHub Actions ID +// token for every attempt whenever ACTIONS_ID_TOKEN_REQUEST_URL and +// ACTIONS_ID_TOKEN_REQUEST_TOKEN are set, so that an API key never silently +// takes precedence inside GitHub Actions, and ErrNoAudience when no setting +// names the tokens' audience. Otherwise it returns a source that sends the +// configured API key, or ErrNoCredentials when there is none. The ID token +// requests never follow redirects; network failures, 429 and 5xx answers +// of the token endpoint are retried like those of the server. No format +// verb prints a source's secret. +func NewTokenSource(creds Credentials) (TokenSource, error) { + if requestURL, requestToken, ok := actionsTokenRequest(); ok { + audience := firstNonEmpty(creds.OIDCAudience, os.Getenv(EnvOIDCAudience), creds.ServerURL) + if audience == "" { + return nil, ErrNoAudience + } + return &oidcSource{ + requestURL: requestURL, + requestToken: requestToken, + audience: audience, + http: &http.Client{Timeout: tokenRequestTimeout, CheckRedirect: noRedirects}, + }, nil + } + if key := firstNonEmpty(creds.APIKey, os.Getenv(EnvAPIKey)); key != "" { + return apiKeySource(key), nil + } + return nil, ErrNoCredentials +} + +// ActionsIDTokensAvailable reports whether ACTIONS_ID_TOKEN_REQUEST_URL and +// ACTIONS_ID_TOKEN_REQUEST_TOKEN are both set, which is when NewTokenSource +// returns a source of GitHub Actions ID tokens. +func ActionsIDTokensAvailable() bool { + _, _, ok := actionsTokenRequest() + return ok +} + +func actionsTokenRequest() (requestURL, requestToken string, ok bool) { + requestURL = strings.TrimSpace(os.Getenv(EnvOIDCRequestURL)) + requestToken = strings.TrimSpace(os.Getenv(EnvOIDCRequestToken)) + return requestURL, requestToken, requestURL != "" && requestToken != "" +} + +type oidcSource struct { + requestURL string + requestToken string + audience string + http *http.Client +} + +// Token requests an ID token for the source's audience. +func (s *oidcSource) Token(ctx context.Context) (string, error) { + sep := "?" + if strings.Contains(s.requestURL, "?") { + sep = "&" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.requestURL+sep+"audience="+url.QueryEscape(s.audience), http.NoBody) + if err != nil { + return "", fmt.Errorf("building the GitHub Actions ID token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+s.requestToken) + req.Header.Set("Accept", "application/json") + resp, err := s.http.Do(req) + if err != nil { + return "", retryable(fmt.Errorf("requesting a GitHub Actions ID token: %w", err)) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody)) + if err != nil { + return "", retryable(fmt.Errorf("reading the GitHub Actions ID token: %w", err)) + } + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("the GitHub Actions ID token endpoint answered %d: %s", resp.StatusCode, snippet(body)) + if transientStatus(resp.StatusCode) { + return "", transient(err, resp.Header) + } + return "", err + } + var out struct { + Value string `json:"value"` + } + if err := json.Unmarshal(body, &out); err != nil { + return "", fmt.Errorf("decoding the GitHub Actions ID token response: %w", err) + } + if out.Value == "" { + return "", errors.New("the GitHub Actions ID token endpoint returned no token") + } + return out.Value, nil +} + +// String names the source and its audience, never its request token. +func (s *oidcSource) String() string { + return "GitHub Actions ID tokens for audience " + strconv.Quote(s.audience) +} + +// Format prints String whatever the verb, so no verb shows the request token. +func (s *oidcSource) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, s.String()) } + +type apiKeySource string + +// Token returns the API key. +func (k apiKeySource) Token(context.Context) (string, error) { return string(k), nil } + +// String names the source, never its key. +func (apiKeySource) String() string { return "automation API key" } + +// Format prints String whatever the verb, so no verb shows the key. +func (k apiKeySource) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, k.String()) } + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v = strings.TrimSpace(v); v != "" { + return v + } + } + return "" +} diff --git a/internal/client/token_test.go b/internal/client/token_test.go new file mode 100644 index 0000000..322b51b --- /dev/null +++ b/internal/client/token_test.go @@ -0,0 +1,419 @@ +package client_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackorder/terraform-provider-stackorder/internal/client" +) + +const actionsRequestToken = "actions-request-token" + +type actionsTokens struct { + mu sync.Mutex + failures int + audiences []string +} + +func fakeActions(t *testing.T) *actionsTokens { + t.Helper() + a := &actionsTokens{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.Header.Get("Authorization") != "Bearer "+actionsRequestToken || r.URL.Query().Get("api-version") != "2.0" { + http.Error(w, `{"message":"bad token request"}`, http.StatusUnauthorized) + return + } + a.mu.Lock() + if a.failures > 0 { + a.failures-- + a.mu.Unlock() + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + a.audiences = append(a.audiences, r.URL.Query().Get("audience")) + n := len(a.audiences) + a.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"count": 1, "value": "oidc-token-" + strconv.Itoa(n)}) + })) + t.Cleanup(srv.Close) + t.Setenv(client.EnvOIDCRequestURL, srv.URL+"/idtoken?api-version=2.0") + t.Setenv(client.EnvOIDCRequestToken, actionsRequestToken) + t.Setenv(client.EnvOIDCAudience, "") + return a +} + +func (a *actionsTokens) failNext(n int) { + a.mu.Lock() + defer a.mu.Unlock() + a.failures = n +} + +func (a *actionsTokens) minted() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.audiences...) +} + +func outsideActions(t *testing.T) { + t.Helper() + for _, name := range []string{client.EnvOIDCRequestURL, client.EnvOIDCRequestToken, client.EnvOIDCAudience, client.EnvAPIKey} { + t.Setenv(name, "") + } +} + +func TestOIDCPreferredInActions(t *testing.T) { + tests := []struct { + name string + creds client.Credentials + envAudience string + envAPIKey string + wantAudience string + wantErr error + }{ + { + name: "server URL exactly as given", + creds: client.Credentials{ServerURL: "https://stackorder.acme.example/"}, + wantAudience: "https://stackorder.acme.example/", + }, + { + name: "audience from the environment", + creds: client.Credentials{ServerURL: "https://stackorder.acme.example"}, + envAudience: "stackorder-acme", + wantAudience: "stackorder-acme", + }, + { + name: "configured audience wins over the environment", + creds: client.Credentials{ServerURL: "https://stackorder.acme.example", OIDCAudience: "stackorder-configured"}, + envAudience: "stackorder-acme", + wantAudience: "stackorder-configured", + }, + { + name: "configured API key ignored", + creds: client.Credentials{ServerURL: "https://stackorder.acme.example", APIKey: "sk_configured"}, + wantAudience: "https://stackorder.acme.example", + }, + { + name: "API key from the environment ignored", + creds: client.Credentials{ServerURL: "https://stackorder.acme.example"}, + envAPIKey: "sk_environment", + wantAudience: "https://stackorder.acme.example", + }, + { + name: "surrounding whitespace trimmed", + creds: client.Credentials{ServerURL: " https://stackorder.acme.example\n"}, + wantAudience: "https://stackorder.acme.example", + }, + { + name: "no audience", + creds: client.Credentials{APIKey: "sk_configured"}, + wantErr: client.ErrNoAudience, + }, + { + name: "blank audience settings", + creds: client.Credentials{ServerURL: " ", OIDCAudience: "\t"}, + envAudience: "\n", + wantErr: client.ErrNoAudience, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actions := fakeActions(t) + t.Setenv(client.EnvOIDCAudience, tt.envAudience) + t.Setenv(client.EnvAPIKey, tt.envAPIKey) + + src, err := client.NewTokenSource(tt.creds) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + assert.Nil(t, src) + assert.Contains(t, err.Error(), "server_url") + assert.Contains(t, err.Error(), "oidc_audience") + assert.Contains(t, err.Error(), client.EnvOIDCAudience) + assert.Empty(t, actions.minted()) + return + } + require.NoError(t, err) + + tok, err := src.Token(t.Context()) + require.NoError(t, err) + assert.Equal(t, "oidc-token-1", tok) + assert.Equal(t, []string{tt.wantAudience}, actions.minted()) + }) + } +} + +func TestAPIKeyOutsideActions(t *testing.T) { + tests := []struct { + name string + creds client.Credentials + envAPIKey string + envRequestURL string + envRequestToken string + want string + }{ + { + name: "configured key", + creds: client.Credentials{APIKey: "sk_configured"}, + want: "sk_configured", + }, + { + name: "key from the environment", + envAPIKey: "sk_environment", + want: "sk_environment", + }, + { + name: "configured key wins over the environment", + creds: client.Credentials{APIKey: "sk_configured"}, + envAPIKey: "sk_environment", + want: "sk_configured", + }, + { + name: "surrounding whitespace trimmed", + envAPIKey: " sk_environment\n", + want: "sk_environment", + }, + { + name: "ID token endpoint without its request token", + creds: client.Credentials{APIKey: "sk_configured"}, + envRequestURL: "https://actions.example/idtoken?api-version=2.0", + want: "sk_configured", + }, + { + name: "request token without the ID token endpoint", + creds: client.Credentials{APIKey: "sk_configured"}, + envRequestToken: actionsRequestToken, + want: "sk_configured", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outsideActions(t) + t.Setenv(client.EnvAPIKey, tt.envAPIKey) + t.Setenv(client.EnvOIDCRequestURL, tt.envRequestURL) + t.Setenv(client.EnvOIDCRequestToken, tt.envRequestToken) + + src, err := client.NewTokenSource(tt.creds) + require.NoError(t, err) + + tok, err := src.Token(t.Context()) + require.NoError(t, err) + assert.Equal(t, tt.want, tok) + }) + } +} + +func TestActionsIDTokensAvailable(t *testing.T) { + tests := []struct { + name string + envRequestURL string + envRequestToken string + want bool + }{ + { + name: "endpoint and request token", + envRequestURL: "https://actions.example/idtoken?api-version=2.0", + envRequestToken: actionsRequestToken, + want: true, + }, + { + name: "endpoint only", + envRequestURL: "https://actions.example/idtoken?api-version=2.0", + }, + { + name: "request token only", + envRequestToken: actionsRequestToken, + }, + { + name: "blank variables", + envRequestURL: " ", + envRequestToken: "\n", + }, + { + name: "neither", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outsideActions(t) + t.Setenv(client.EnvOIDCRequestURL, tt.envRequestURL) + t.Setenv(client.EnvOIDCRequestToken, tt.envRequestToken) + + assert.Equal(t, tt.want, client.ActionsIDTokensAvailable()) + + src, err := client.NewTokenSource(client.Credentials{ServerURL: "https://stackorder.acme.example", APIKey: "sk_configured"}) + require.NoError(t, err) + assert.Equal(t, tt.want, strings.HasPrefix(fmt.Sprint(src), "GitHub Actions ID tokens")) + }) + } +} + +func TestNoCredentials(t *testing.T) { + tests := []struct { + name string + creds client.Credentials + envAPIKey string + envRequestURL string + envRequestToken string + }{ + { + name: "nothing configured", + creds: client.Credentials{ServerURL: "https://stackorder.acme.example"}, + }, + { + name: "blank API keys", + creds: client.Credentials{ServerURL: "https://stackorder.acme.example", APIKey: " "}, + envAPIKey: "\n", + }, + { + name: "ID token endpoint without its request token", + envRequestURL: "https://actions.example/idtoken?api-version=2.0", + }, + { + name: "request token without the ID token endpoint", + envRequestToken: actionsRequestToken, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outsideActions(t) + t.Setenv(client.EnvAPIKey, tt.envAPIKey) + t.Setenv(client.EnvOIDCRequestURL, tt.envRequestURL) + t.Setenv(client.EnvOIDCRequestToken, tt.envRequestToken) + + src, err := client.NewTokenSource(tt.creds) + require.ErrorIs(t, err, client.ErrNoCredentials) + assert.Nil(t, src) + assert.Contains(t, err.Error(), "id-token: write") + assert.Contains(t, err.Error(), "api_key") + assert.Contains(t, err.Error(), client.EnvAPIKey) + }) + } +} + +func TestOIDCTokenEndpointFailures(t *testing.T) { + tests := []struct { + name string + status int + redirect bool + body string + wantRequests int + wantErr string + }{ + { + name: "redirected", + status: http.StatusTemporaryRedirect, + redirect: true, + wantRequests: 1, + wantErr: "obtaining a token: the GitHub Actions ID token endpoint answered 307", + }, + { + name: "refused", + status: http.StatusForbidden, + body: `{"message":"Can't issue ID_TOKEN"}`, + wantRequests: 1, + wantErr: `obtaining a token: the GitHub Actions ID token endpoint answered 403: {"message":"Can't issue ID_TOKEN"}`, + }, + { + name: "unavailable", + status: http.StatusServiceUnavailable, + body: "unavailable", + wantRequests: client.DefaultAttempts, + wantErr: "giving up after 3 attempts: obtaining a token: the GitHub Actions ID token endpoint answered 503: unavailable", + }, + { + name: "not JSON", + status: http.StatusOK, + body: "", + wantRequests: 1, + wantErr: "obtaining a token: decoding the GitHub Actions ID token response", + }, + { + name: "no token", + status: http.StatusOK, + body: `{"count":0}`, + wantRequests: 1, + wantErr: "obtaining a token: the GitHub Actions ID token endpoint returned no token", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var requests, redirected atomic.Int32 + elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + redirected.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{"value": "oidc-token-elsewhere"}) + })) + t.Cleanup(elsewhere.Close) + actions := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if tt.redirect { + w.Header().Set("Location", elsewhere.URL+r.URL.RequestURI()) + } + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.body)) + })) + t.Cleanup(actions.Close) + outsideActions(t) + t.Setenv(client.EnvOIDCRequestURL, actions.URL+"/idtoken?api-version=2.0") + t.Setenv(client.EnvOIDCRequestToken, actionsRequestToken) + s := fakeStackorder(t, outputsReply(t)) + src, err := client.NewTokenSource(client.Credentials{ServerURL: s.srv.URL}) + require.NoError(t, err) + c := newClient(t, s.srv.URL, src) + + out, err := c.StackOutputs(t.Context(), "acme/infra", "stacks/prod/vpc", testRunID, "stacks/prod/eks") + require.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, out) + assert.NotContains(t, err.Error(), actionsRequestToken) + assert.Equal(t, int32(tt.wantRequests), requests.Load()) + assert.Zero(t, redirected.Load()) + assert.Empty(t, s.requests()) + }) + } +} + +func TestTokenSourcesHideSecrets(t *testing.T) { + tests := []struct { + name string + inside bool + secret string + want string + }{ + { + name: "ID token source", + inside: true, + secret: actionsRequestToken, + want: `GitHub Actions ID tokens for audience "https://stackorder.acme.example"`, + }, + { + name: "API key source", + secret: testAPIKey, + want: "automation API key", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outsideActions(t) + if tt.inside { + fakeActions(t) + } + src, err := client.NewTokenSource(client.Credentials{ServerURL: "https://stackorder.acme.example", APIKey: testAPIKey}) + require.NoError(t, err) + + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%d", "%T %[1]v"} { + got := fmt.Sprintf(verb, src) + assert.NotContains(t, got, tt.secret, verb) + assert.Contains(t, got, tt.want, verb) + } + assert.Equal(t, tt.want, fmt.Sprint(src)) + }) + } +} diff --git a/internal/outputs/outputs.go b/internal/outputs/outputs.go new file mode 100644 index 0000000..4838b32 --- /dev/null +++ b/internal/outputs/outputs.go @@ -0,0 +1,344 @@ +// Package outputs turns the outputs of a stack, as the Stackorder server +// serves them, into the dynamic values of the stackorder_outputs data source +// and ephemeral resource. Each output arrives as its value in JSON beside its +// type in the JSON syntax Terraform writes to state. The conversion keeps +// that type exactly, so a tuple stays a tuple and an object stays an object, +// reads numbers at the 512-bit precision Terraform itself uses, and keeps +// nulls typed. Errors say what is malformed and never quote a value, which +// may be sensitive. +package outputs + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "iter" + "maps" + "slices" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + + v1 "github.com/stackorder/stackorder/api/v1" +) + +var ( + // ErrMalformedType means an output's type is missing or is not a type in + // Terraform's JSON type syntax. + ErrMalformedType = errors.New("malformed type") + // ErrMalformedValue means an output's value is missing, is not JSON or + // does not conform to its type as Terraform reads JSON values, which also + // takes a number or bool for a string, a string that spells a number or + // bool for one, and a set that repeats an element. + ErrMalformedValue = errors.New("malformed value") +) + +// Value converts one output into a dynamic value whose underlying value has +// the output's type, less the optional attribute markers of its object +// types, which only type constraints carry. A null value converts to a null +// of that type, or to a null dynamic value when the type is "dynamic". A +// value of type "dynamic", at the top or nested, carries its own type as +// {"type": ..., "value": ...}, Terraform's JSON encoding of dynamically +// typed values, and converts to a value of that type. A list, set or map of +// dynamic elements takes the one type its elements have, with null elements +// as nulls of it, as Terraform builds such collections. On error Value +// returns a null dynamic value as well, so only the error tells a failure +// apart from a null output of type "dynamic". +func Value(out v1.StackOutput) (types.Dynamic, error) { + v, err := terraformValue(out) + if err != nil { + return types.DynamicNull(), err + } + return dynamic(v) +} + +// Object converts the outputs include accepts into one dynamic value that +// holds an object with an attribute per output, named after it and +// converted as Value converts it. A nil include accepts every output. When +// include accepts none the object is empty, never null. The first accepted +// output by name that does not convert fails the call with an error that +// names it. +func Object(outputs map[string]v1.StackOutput, include func(name string, out v1.StackOutput) bool) (types.Dynamic, error) { + attrTypes := make(map[string]tftypes.Type, len(outputs)) + attrs := make(map[string]tftypes.Value, len(outputs)) + for _, name := range slices.Sorted(maps.Keys(outputs)) { + out := outputs[name] + if include != nil && !include(name, out) { + continue + } + v, err := terraformValue(out) + if err != nil { + return types.DynamicNull(), fmt.Errorf("output %q: %w", name, err) + } + attrTypes[name] = v.Type() + attrs[name] = v + } + v, err := newValue(tftypes.Object{AttributeTypes: attrTypes}, attrs) + if err != nil { + return types.DynamicNull(), fmt.Errorf("%w: the outputs do not form an object", ErrMalformedValue) + } + return dynamic(v) +} + +func terraformValue(out v1.StackOutput) (tftypes.Value, error) { + if len(out.Type) == 0 { + return tftypes.Value{}, fmt.Errorf("%w: it is missing", ErrMalformedType) + } + typ, err := parseType(out.Type) + if err != nil { + return tftypes.Value{}, fmt.Errorf("%w: %w", ErrMalformedType, err) + } + switch { + case len(out.Value) == 0: + return tftypes.Value{}, fmt.Errorf("%w: it is missing", ErrMalformedValue) + case !json.Valid(out.Value): + return tftypes.Value{}, fmt.Errorf("%w: it is not JSON", ErrMalformedValue) + } + v, err := decode(out.Value, typ) + if err != nil { + return tftypes.Value{}, fmt.Errorf("%w: it does not conform to its type", ErrMalformedValue) + } + return v, nil +} + +func parseType(raw json.RawMessage) (tftypes.Type, error) { + var name string + if json.Unmarshal(raw, &name) == nil { + switch name { + case "string": + return tftypes.String, nil + case "number": + return tftypes.Number, nil + case "bool": + return tftypes.Bool, nil + case "dynamic": + return tftypes.DynamicPseudoType, nil + } + return nil, fmt.Errorf("unknown primitive type %q", name) + } + var parts []json.RawMessage + var kind string + if json.Unmarshal(raw, &parts) != nil || len(parts) < 2 || json.Unmarshal(parts[0], &kind) != nil { + return nil, errors.New("it is neither a primitive type nor a kind with its parameters") + } + switch kind { + case "list", "set", "map": + if len(parts) != 2 { + return nil, fmt.Errorf("a %s type takes one element type", kind) + } + elem, err := parseType(parts[1]) + if err != nil { + return nil, err + } + switch kind { + case "list": + return tftypes.List{ElementType: elem}, nil + case "set": + return tftypes.Set{ElementType: elem}, nil + } + return tftypes.Map{ElementType: elem}, nil + case "tuple": + var elems []json.RawMessage + if len(parts) != 2 || json.Unmarshal(parts[1], &elems) != nil || elems == nil { + return nil, errors.New("a tuple type takes an array of element types") + } + elemTypes := make([]tftypes.Type, len(elems)) + for i, elem := range elems { + t, err := parseType(elem) + if err != nil { + return nil, err + } + elemTypes[i] = t + } + return tftypes.Tuple{ElementTypes: elemTypes}, nil + case "object": + var attrs map[string]json.RawMessage + if len(parts) > 3 || json.Unmarshal(parts[1], &attrs) != nil || attrs == nil { + return nil, errors.New("an object type takes an object of attribute types and an optional array of optional attributes") + } + attrTypes := make(map[string]tftypes.Type, len(attrs)) + for name, attr := range attrs { + t, err := parseType(attr) + if err != nil { + return nil, err + } + attrTypes[name] = t + } + if len(parts) == 3 { + var optional []string + if json.Unmarshal(parts[2], &optional) != nil || optional == nil { + return nil, errors.New("the optional attributes of an object type are not an array of names") + } + for _, name := range optional { + if _, ok := attrs[name]; !ok { + return nil, fmt.Errorf("optional attribute %q is not an attribute of its object type", name) + } + } + } + return tftypes.Object{AttributeTypes: attrTypes}, nil + } + return nil, fmt.Errorf("unknown type kind %q", kind) +} + +func decode(raw json.RawMessage, typ tftypes.Type) (tftypes.Value, error) { + if string(bytes.TrimSpace(raw)) == "null" { + return tftypes.NewValue(typ, nil), nil + } + switch typ := typ.(type) { + case tftypes.List: + elems, elemType, err := decodeElements(raw, typ.ElementType) + if err != nil { + return tftypes.Value{}, err + } + return newValue(tftypes.List{ElementType: elemType}, elems) + case tftypes.Set: + elems, elemType, err := decodeElements(raw, typ.ElementType) + if err != nil { + return tftypes.Value{}, err + } + return newValue(tftypes.Set{ElementType: elemType}, elems) + case tftypes.Map: + var raws map[string]json.RawMessage + if err := json.Unmarshal(raw, &raws); err != nil { + return tftypes.Value{}, err + } + elems := make(map[string]tftypes.Value, len(raws)) + for key, r := range raws { + v, err := decode(r, typ.ElementType) + if err != nil { + return tftypes.Value{}, err + } + elems[key] = v + } + elemType := elementType(typ.ElementType, maps.Values(elems)) + for key, v := range elems { + elems[key] = typedNull(v, elemType) + } + return newValue(tftypes.Map{ElementType: elemType}, elems) + case tftypes.Tuple: + var raws []json.RawMessage + if err := json.Unmarshal(raw, &raws); err != nil { + return tftypes.Value{}, err + } + if len(raws) != len(typ.ElementTypes) { + return tftypes.Value{}, errors.New("the tuple has another number of elements than its type") + } + elemTypes := make([]tftypes.Type, len(raws)) + elems := make([]tftypes.Value, len(raws)) + for i, r := range raws { + v, err := decode(r, typ.ElementTypes[i]) + if err != nil { + return tftypes.Value{}, err + } + elemTypes[i], elems[i] = v.Type(), v + } + return newValue(tftypes.Tuple{ElementTypes: elemTypes}, elems) + case tftypes.Object: + var raws map[string]json.RawMessage + if err := json.Unmarshal(raw, &raws); err != nil { + return tftypes.Value{}, err + } + for name := range raws { + if _, ok := typ.AttributeTypes[name]; !ok { + return tftypes.Value{}, errors.New("the object has an attribute its type lacks") + } + } + attrTypes := make(map[string]tftypes.Type, len(typ.AttributeTypes)) + attrs := make(map[string]tftypes.Value, len(typ.AttributeTypes)) + for name, attrType := range typ.AttributeTypes { + v := tftypes.NewValue(attrType, nil) + if r, ok := raws[name]; ok { + var err error + if v, err = decode(r, attrType); err != nil { + return tftypes.Value{}, err + } + } + attrTypes[name], attrs[name] = v.Type(), v + } + return newValue(tftypes.Object{AttributeTypes: attrTypes}, attrs) + } + if typ.Equal(tftypes.DynamicPseudoType) { + return decodeDynamic(raw) + } + return tftypes.ValueFromJSONWithOpts(raw, typ, tftypes.ValueFromJSONOpts{}) +} + +func decodeDynamic(raw json.RawMessage) (tftypes.Value, error) { + var wrapped map[string]json.RawMessage + if err := json.Unmarshal(raw, &wrapped); err != nil { + return tftypes.Value{}, err + } + rawType, rawValue := wrapped["type"], wrapped["value"] + if len(wrapped) != 2 || rawType == nil || rawValue == nil { + return tftypes.Value{}, errors.New("a dynamic value is not an object of its type and value") + } + typ, err := parseType(rawType) + if err != nil { + return tftypes.Value{}, err + } + return decode(rawValue, typ) +} + +func decodeElements(raw json.RawMessage, declared tftypes.Type) ([]tftypes.Value, tftypes.Type, error) { + var raws []json.RawMessage + if err := json.Unmarshal(raw, &raws); err != nil { + return nil, nil, err + } + elems := make([]tftypes.Value, len(raws)) + for i, r := range raws { + v, err := decode(r, declared) + if err != nil { + return nil, nil, err + } + elems[i] = v + } + elemType := elementType(declared, slices.Values(elems)) + for i, v := range elems { + elems[i] = typedNull(v, elemType) + } + return elems, elemType, nil +} + +func elementType(declared tftypes.Type, elems iter.Seq[tftypes.Value]) tftypes.Type { + for v := range elems { + if !v.Type().Equal(tftypes.DynamicPseudoType) { + return v.Type() + } + } + return declared +} + +func typedNull(v tftypes.Value, typ tftypes.Type) tftypes.Value { + if v.Type().Equal(tftypes.DynamicPseudoType) { + return tftypes.NewValue(typ, nil) + } + return v +} + +func newValue(typ tftypes.Type, val any) (v tftypes.Value, err error) { + defer func() { + // tftypes panics rather than failing on some invalid types, such as objects with optional attributes. + if recover() != nil { + v, err = tftypes.Value{}, errors.New("tftypes cannot build the value") + } + }() + if err = tftypes.ValidateValue(typ, val); err != nil { + return tftypes.Value{}, err + } + return tftypes.NewValue(typ, val), nil +} + +func dynamic(v tftypes.Value) (types.Dynamic, error) { + fv, err := basetypes.DynamicType{}.ValueFromTerraform(context.Background(), v) + if err != nil { + return types.DynamicNull(), fmt.Errorf("%w: the provider framework cannot hold it", ErrMalformedValue) + } + d, ok := fv.(types.Dynamic) + if !ok { + return types.DynamicNull(), fmt.Errorf("%w: the provider framework made a %T of it", ErrMalformedValue, fv) + } + return d, nil +} diff --git a/internal/outputs/outputs_test.go b/internal/outputs/outputs_test.go new file mode 100644 index 0000000..d718a66 --- /dev/null +++ b/internal/outputs/outputs_test.go @@ -0,0 +1,717 @@ +package outputs_test + +import ( + "context" + "encoding/json" + "maps" + "math/big" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/stackorder/stackorder/api/v1" + + "github.com/stackorder/terraform-provider-stackorder/internal/outputs" +) + +const canary = "acme-canary-secret" + +func output(typ, value string) v1.StackOutput { + return v1.StackOutput{Type: json.RawMessage(typ), Value: json.RawMessage(value)} +} + +func str(s string) basetypes.StringValue { return types.StringValue(s) } + +func num(f float64) basetypes.NumberValue { return types.NumberValue(big.NewFloat(f)) } + +var ( + portType = map[string]attr.Type{"id": types.StringType, "port": types.NumberType} + + subnetType = types.ObjectType{AttrTypes: map[string]attr.Type{"id": types.StringType, "zone": types.StringType}} +) + +func assertSameValue(t *testing.T, want, got types.Dynamic) { + t.Helper() + ctx := context.Background() + assert.Truef(t, want.Equal(got), "want %s, got %s", want, got) + wantTF, err := want.ToTerraformValue(ctx) + require.NoError(t, err) + gotTF, err := got.ToTerraformValue(ctx) + require.NoError(t, err) + assert.Truef(t, wantTF.Equal(gotTF), "want %s, got %s", wantTF, gotTF) +} + +func TestValue(t *testing.T) { + tests := []struct { + name string + typ string + value string + want types.Dynamic + }{ + {name: "string", typ: `"string"`, value: `"vpc-0a1b2c3d4e5f67890"`, want: types.DynamicValue(str("vpc-0a1b2c3d4e5f67890"))}, + {name: "empty string", typ: `"string"`, value: `""`, want: types.DynamicValue(str(""))}, + {name: "number", typ: `"number"`, value: `3`, want: types.DynamicValue(num(3))}, + {name: "negative fraction", typ: `"number"`, value: `-2.5`, want: types.DynamicValue(num(-2.5))}, + {name: "bool", typ: `"bool"`, value: `true`, want: types.DynamicValue(types.BoolValue(true))}, + {name: "string from a number", typ: `"string"`, value: `12`, want: types.DynamicValue(str("12"))}, + {name: "string from a bool", typ: `"string"`, value: `true`, want: types.DynamicValue(str("true"))}, + {name: "number from a string", typ: `"number"`, value: `"12.5"`, want: types.DynamicValue(num(12.5))}, + {name: "bool from a string", typ: `"bool"`, value: `"false"`, want: types.DynamicValue(types.BoolValue(false))}, + { + name: "list", typ: `["list","string"]`, value: `["subnet-0a1b","subnet-0b2c"]`, + want: types.DynamicValue(types.ListValueMust(types.StringType, []attr.Value{str("subnet-0a1b"), str("subnet-0b2c")})), + }, + { + name: "empty list", typ: `["list","string"]`, value: `[]`, + want: types.DynamicValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + { + name: "set", typ: `["set","number"]`, value: `[1,2]`, + want: types.DynamicValue(types.SetValueMust(types.NumberType, []attr.Value{num(1), num(2)})), + }, + { + name: "set that repeats an element", typ: `["set","string"]`, value: `["acme","acme"]`, + want: types.DynamicValue(types.SetValueMust(types.StringType, []attr.Value{str("acme"), str("acme")})), + }, + { + name: "map", typ: `["map","bool"]`, value: `{"blue":true,"green":false}`, + want: types.DynamicValue(types.MapValueMust(types.BoolType, map[string]attr.Value{ + "blue": types.BoolValue(true), "green": types.BoolValue(false), + })), + }, + { + name: "empty map", typ: `["map","string"]`, value: `{}`, + want: types.DynamicValue(types.MapValueMust(types.StringType, map[string]attr.Value{})), + }, + { + name: "tuple", typ: `["tuple",["string","number","bool"]]`, value: `["eu-west-1",3,false]`, + want: types.DynamicValue(types.TupleValueMust( + []attr.Type{types.StringType, types.NumberType, types.BoolType}, + []attr.Value{str("eu-west-1"), num(3), types.BoolValue(false)}, + )), + }, + { + name: "empty tuple", typ: `["tuple",[]]`, value: `[]`, + want: types.DynamicValue(types.TupleValueMust([]attr.Type{}, []attr.Value{})), + }, + { + name: "object", typ: `["object",{"id":"string","port":"number"}]`, value: `{"id":"sg-0a1b","port":443}`, + want: types.DynamicValue(types.ObjectValueMust(portType, map[string]attr.Value{"id": str("sg-0a1b"), "port": num(443)})), + }, + { + name: "empty object", typ: `["object",{}]`, value: `{}`, + want: types.DynamicValue(types.ObjectValueMust(map[string]attr.Type{}, map[string]attr.Value{})), + }, + { + name: "object attribute absent from the value", typ: `["object",{"id":"string","port":"number"}]`, value: `{"id":"sg-0a1b"}`, + want: types.DynamicValue(types.ObjectValueMust(portType, map[string]attr.Value{"id": str("sg-0a1b"), "port": types.NumberNull()})), + }, + { + name: "object with optional attributes", typ: `["object",{"id":"string","port":"number"},["port"]]`, value: `{"id":"sg-0a1b"}`, + want: types.DynamicValue(types.ObjectValueMust(portType, map[string]attr.Value{"id": str("sg-0a1b"), "port": types.NumberNull()})), + }, + { + name: "list of objects with optional attributes", typ: `["list",["object",{"id":"string","port":"number"},["port"]]]`, + value: `[{"id":"sg-0a1b","port":443},{"id":"sg-0b2c"}]`, + want: types.DynamicValue(types.ListValueMust(types.ObjectType{AttrTypes: portType}, []attr.Value{ + types.ObjectValueMust(portType, map[string]attr.Value{"id": str("sg-0a1b"), "port": num(443)}), + types.ObjectValueMust(portType, map[string]attr.Value{"id": str("sg-0b2c"), "port": types.NumberNull()}), + })), + }, + { + name: "nested", + typ: `["object",{ + "subnets":["list",["object",{"id":"string","zone":"string"}]], + "routes":["map",["set","string"]], + "pair":["tuple",["string",["object",{"id":"string","port":"number"},["port"]]]] + }]`, + value: `{ + "subnets":[{"id":"subnet-0a1b","zone":"eu-west-1a"},{"id":"subnet-0b2c","zone":"eu-west-1b"}], + "routes":{"private":["10.0.1.0/24","10.0.2.0/24"],"public":[]}, + "pair":["acme",{"id":"sg-0a1b"}] + }`, + want: types.DynamicValue(types.ObjectValueMust( + map[string]attr.Type{ + "subnets": types.ListType{ElemType: subnetType}, + "routes": types.MapType{ElemType: types.SetType{ElemType: types.StringType}}, + "pair": types.TupleType{ElemTypes: []attr.Type{types.StringType, types.ObjectType{AttrTypes: portType}}}, + }, + map[string]attr.Value{ + "subnets": types.ListValueMust(subnetType, []attr.Value{ + types.ObjectValueMust(subnetType.AttrTypes, map[string]attr.Value{"id": str("subnet-0a1b"), "zone": str("eu-west-1a")}), + types.ObjectValueMust(subnetType.AttrTypes, map[string]attr.Value{"id": str("subnet-0b2c"), "zone": str("eu-west-1b")}), + }), + "routes": types.MapValueMust(types.SetType{ElemType: types.StringType}, map[string]attr.Value{ + "private": types.SetValueMust(types.StringType, []attr.Value{str("10.0.1.0/24"), str("10.0.2.0/24")}), + "public": types.SetValueMust(types.StringType, []attr.Value{}), + }), + "pair": types.TupleValueMust( + []attr.Type{types.StringType, types.ObjectType{AttrTypes: portType}}, + []attr.Value{str("acme"), types.ObjectValueMust(portType, map[string]attr.Value{"id": str("sg-0a1b"), "port": types.NumberNull()})}, + ), + }, + )), + }, + {name: "dynamic", typ: `"dynamic"`, value: `{"type":"string","value":"acme"}`, want: types.DynamicValue(str("acme"))}, + { + name: "dynamic object", typ: `"dynamic"`, value: `{"value":{"id":"sg-0a1b","port":443},"type":["object",{"id":"string","port":"number"}]}`, + want: types.DynamicValue(types.ObjectValueMust(portType, map[string]attr.Value{"id": str("sg-0a1b"), "port": num(443)})), + }, + { + name: "dynamic attributes", typ: `["object",{"name":"dynamic","owner":"dynamic"}]`, value: `{"name":{"type":"string","value":"acme"},"owner":null}`, + want: types.DynamicValue(types.ObjectValueMust( + map[string]attr.Type{"name": types.StringType, "owner": types.DynamicType}, + map[string]attr.Value{"name": str("acme"), "owner": types.DynamicNull()}, + )), + }, + { + name: "list of dynamic", typ: `["list","dynamic"]`, value: `[{"type":"number","value":1},{"type":"number","value":2}]`, + want: types.DynamicValue(types.ListValueMust(types.NumberType, []attr.Value{num(1), num(2)})), + }, + { + name: "map of dynamic", typ: `["map","dynamic"]`, value: `{"blue":{"type":"bool","value":true}}`, + want: types.DynamicValue(types.MapValueMust(types.BoolType, map[string]attr.Value{"blue": types.BoolValue(true)})), + }, + { + name: "empty list of dynamic", typ: `["list","dynamic"]`, value: `[]`, + want: types.DynamicValue(types.ListValueMust(types.DynamicType, []attr.Value{})), + }, + { + name: "list of null dynamic", typ: `["list","dynamic"]`, value: `[null]`, + want: types.DynamicValue(types.ListValueMust(types.DynamicType, []attr.Value{types.DynamicNull()})), + }, + { + name: "list of dynamic with a null element", typ: `["list","dynamic"]`, value: `[null,{"type":"string","value":"acme"}]`, + want: types.DynamicValue(types.ListValueMust(types.StringType, []attr.Value{types.StringNull(), str("acme")})), + }, + { + name: "set of dynamic with a null element", typ: `["set","dynamic"]`, value: `[{"type":"number","value":1},null]`, + want: types.DynamicValue(types.SetValueMust(types.NumberType, []attr.Value{num(1), types.NumberNull()})), + }, + { + name: "map of dynamic with a null element", typ: `["map","dynamic"]`, value: `{"blue":null,"green":{"type":"bool","value":true}}`, + want: types.DynamicValue(types.MapValueMust(types.BoolType, map[string]attr.Value{"blue": types.BoolNull(), "green": types.BoolValue(true)})), + }, + {name: "dynamic typed null", typ: `"dynamic"`, value: `{"type":"string","value":null}`, want: types.DynamicValue(types.StringNull())}, + {name: "dynamic in dynamic", typ: `"dynamic"`, value: `{"type":"dynamic","value":{"type":"bool","value":true}}`, want: types.DynamicValue(types.BoolValue(true))}, + { + name: "dynamic object with optional attributes", typ: `"dynamic"`, + value: `{"type":["object",{"id":"string","port":"number"},["port"]],"value":{"id":"sg-0a1b"}}`, + want: types.DynamicValue(types.ObjectValueMust(portType, map[string]attr.Value{"id": str("sg-0a1b"), "port": types.NumberNull()})), + }, + { + name: "dynamic list of objects with optional attributes", typ: `"dynamic"`, + value: `{"type":["list",["object",{"id":"string","port":"number"},["port"]]],"value":[{"id":"sg-0a1b","port":443},{"id":"sg-0b2c"}]}`, + want: types.DynamicValue(types.ListValueMust(types.ObjectType{AttrTypes: portType}, []attr.Value{ + types.ObjectValueMust(portType, map[string]attr.Value{"id": str("sg-0a1b"), "port": num(443)}), + types.ObjectValueMust(portType, map[string]attr.Value{"id": str("sg-0b2c"), "port": types.NumberNull()}), + })), + }, + { + name: "dynamic null object with optional attributes", typ: `"dynamic"`, + value: `{"type":["object",{"id":"string","port":"number"},["port"]],"value":null}`, + want: types.DynamicValue(types.ObjectNull(portType)), + }, + { + name: "dynamic attribute holding a null object with optional attributes", typ: `["object",{"rule":"dynamic"}]`, + value: `{"rule":{"type":["object",{"id":"string","port":"number"},["port"]],"value":null}}`, + want: types.DynamicValue(types.ObjectValueMust( + map[string]attr.Type{"rule": types.ObjectType{AttrTypes: portType}}, + map[string]attr.Value{"rule": types.ObjectNull(portType)}, + )), + }, + { + name: "dynamic empty list of objects with optional attributes", typ: `"dynamic"`, + value: `{"type":["list",["object",{"id":"string","port":"number"},["port"]]],"value":[]}`, + want: types.DynamicValue(types.ListValueMust(types.ObjectType{AttrTypes: portType}, []attr.Value{})), + }, + { + name: "dynamic empty map of objects with optional attributes", typ: `"dynamic"`, + value: `{"type":["map",["object",{"id":"string","port":"number"},["port"]]],"value":{}}`, + want: types.DynamicValue(types.MapValueMust(types.ObjectType{AttrTypes: portType}, map[string]attr.Value{})), + }, + { + name: "dynamic null list of objects with optional attributes", typ: `"dynamic"`, + value: `{"type":["list",["object",{"id":"string","port":"number"},["port"]]],"value":null}`, + want: types.DynamicValue(types.ListNull(types.ObjectType{AttrTypes: portType})), + }, + {name: "null string", typ: `"string"`, value: `null`, want: types.DynamicValue(types.StringNull())}, + {name: "null number", typ: `"number"`, value: `null`, want: types.DynamicValue(types.NumberNull())}, + {name: "null bool", typ: `"bool"`, value: `null`, want: types.DynamicValue(types.BoolNull())}, + {name: "null list", typ: `["list","string"]`, value: `null`, want: types.DynamicValue(types.ListNull(types.StringType))}, + {name: "null set", typ: `["set","number"]`, value: `null`, want: types.DynamicValue(types.SetNull(types.NumberType))}, + {name: "null map", typ: `["map","bool"]`, value: `null`, want: types.DynamicValue(types.MapNull(types.BoolType))}, + { + name: "null tuple", typ: `["tuple",["string","number"]]`, value: `null`, + want: types.DynamicValue(types.TupleNull([]attr.Type{types.StringType, types.NumberType})), + }, + { + name: "null object", typ: `["object",{"id":"string","port":"number"}]`, value: `null`, + want: types.DynamicValue(types.ObjectNull(portType)), + }, + { + name: "null object with optional attributes", typ: `["object",{"id":"string","port":"number"},["port"]]`, value: `null`, + want: types.DynamicValue(types.ObjectNull(portType)), + }, + { + name: "null list of objects with optional attributes", typ: `["list",["object",{"id":"string","port":"number"},["port"]]]`, value: `null`, + want: types.DynamicValue(types.ListNull(types.ObjectType{AttrTypes: portType})), + }, + { + name: "null element of an object type with optional attributes", typ: `["list",["object",{"id":"string","port":"number"},["port"]]]`, value: `[null]`, + want: types.DynamicValue(types.ListValueMust(types.ObjectType{AttrTypes: portType}, []attr.Value{types.ObjectNull(portType)})), + }, + { + name: "null attribute of an object type with optional attributes", typ: `["object",{"rule":["object",{"id":"string","port":"number"},["port"]]}]`, value: `{"rule":null}`, + want: types.DynamicValue(types.ObjectValueMust( + map[string]attr.Type{"rule": types.ObjectType{AttrTypes: portType}}, + map[string]attr.Value{"rule": types.ObjectNull(portType)}, + )), + }, + {name: "null dynamic", typ: `"dynamic"`, value: `null`, want: types.DynamicNull()}, + { + name: "null elements", typ: `["list","string"]`, value: `["acme",null]`, + want: types.DynamicValue(types.ListValueMust(types.StringType, []attr.Value{str("acme"), types.StringNull()})), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := outputs.Value(output(tt.typ, tt.value)) + require.NoError(t, err) + assertSameValue(t, tt.want, got) + }) + } +} + +func TestValueKeepsNumberPrecision(t *testing.T) { + tests := []struct { + in string + want string + }{ + {in: `0.1`, want: "0.1"}, + {in: `1e20`, want: "100000000000000000000"}, + {in: `9007199254740993`, want: "9007199254740993"}, + {in: `-123456789012345678901234567890.123456789`, want: "-123456789012345678901234567890.123456789"}, + {in: `1e-30`, want: "0.000000000000000000000000000001"}, + {in: `3.14159265358979323846264338327950288419716939937510`, want: "3.1415926535897932384626433832795028841971693993751"}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + got, err := outputs.Value(output(`"number"`, tt.in)) + require.NoError(t, err) + n, ok := got.UnderlyingValue().(basetypes.NumberValue) + require.Truef(t, ok, "want a number, got %T", got.UnderlyingValue()) + assert.Equal(t, tt.want, n.ValueBigFloat().Text('f', -1)) + + list, err := outputs.Value(output(`["list","number"]`, "["+tt.in+"]")) + require.NoError(t, err) + l, ok := list.UnderlyingValue().(basetypes.ListValue) + require.Truef(t, ok, "want a list, got %T", list.UnderlyingValue()) + require.Len(t, l.Elements(), 1) + assert.Truef(t, n.Equal(l.Elements()[0]), "want %s, got %s", n, l.Elements()[0]) + }) + } +} + +func TestValueRejectsMalformedOutputs(t *testing.T) { + tests := []struct { + name string + out v1.StackOutput + wantErr error + wantMsg string + }{ + {name: "missing type", out: v1.StackOutput{Value: json.RawMessage(`"` + canary + `"`)}, wantErr: outputs.ErrMalformedType}, + {name: "type not JSON", out: output(`string`, `"`+canary+`"`), wantErr: outputs.ErrMalformedType}, + {name: "unknown primitive", out: output(`"text"`, `"`+canary+`"`), wantErr: outputs.ErrMalformedType, wantMsg: `"text"`}, + {name: "unknown kind", out: output(`["vector","string"]`, `["`+canary+`"]`), wantErr: outputs.ErrMalformedType}, + {name: "extra data in a type", out: output(`["list","string","number"]`, `["`+canary+`"]`), wantErr: outputs.ErrMalformedType}, + {name: "null type", out: output(`null`, `"`+canary+`"`), wantErr: outputs.ErrMalformedType}, + {name: "kind not a string", out: output(`[1,"string"]`, `["`+canary+`"]`), wantErr: outputs.ErrMalformedType}, + {name: "list type without its element type", out: output(`["list"]`, `["`+canary+`"]`), wantErr: outputs.ErrMalformedType}, + {name: "unknown element type", out: output(`["map","text"]`, `{"a":"`+canary+`"}`), wantErr: outputs.ErrMalformedType, wantMsg: `"text"`}, + {name: "tuple type without element types", out: output(`["tuple",null]`, `["`+canary+`"]`), wantErr: outputs.ErrMalformedType}, + {name: "object type without attributes", out: output(`["object"]`, `{"id":"`+canary+`"}`), wantErr: outputs.ErrMalformedType}, + {name: "object type with null attributes", out: output(`["object",null]`, `{"id":"`+canary+`"}`), wantErr: outputs.ErrMalformedType}, + {name: "object type with extra data", out: output(`["object",{"id":"string"},["id"],"x"]`, `{"id":"`+canary+`"}`), wantErr: outputs.ErrMalformedType}, + {name: "optional attributes not an array", out: output(`["object",{"id":"string"},"id"]`, `{"id":"`+canary+`"}`), wantErr: outputs.ErrMalformedType}, + { + name: "optional attribute not in the object type", out: output(`["object",{"id":"string"},["port"]]`, `{"id":"`+canary+`"}`), + wantErr: outputs.ErrMalformedType, wantMsg: `"port"`, + }, + {name: "missing value", out: v1.StackOutput{Type: json.RawMessage(`"string"`)}, wantErr: outputs.ErrMalformedValue}, + {name: "value not JSON", out: output(`"string"`, canary), wantErr: outputs.ErrMalformedValue}, + {name: "data after the value", out: output(`"string"`, `"`+canary+`" "acme"`), wantErr: outputs.ErrMalformedValue}, + {name: "number from text", out: output(`"number"`, `"`+canary+`"`), wantErr: outputs.ErrMalformedValue}, + {name: "bool from text", out: output(`"bool"`, `"`+canary+`"`), wantErr: outputs.ErrMalformedValue}, + {name: "string from an object", out: output(`"string"`, `{"`+canary+`":1}`), wantErr: outputs.ErrMalformedValue}, + {name: "list from an object", out: output(`["list","string"]`, `{"id":"`+canary+`"}`), wantErr: outputs.ErrMalformedValue}, + {name: "undeclared attribute", out: output(`["object",{"id":"string"}]`, `{"id":"acme","`+canary+`":"x"}`), wantErr: outputs.ErrMalformedValue}, + {name: "tuple too long", out: output(`["tuple",["string"]]`, `["`+canary+`","acme"]`), wantErr: outputs.ErrMalformedValue}, + {name: "tuple too short", out: output(`["tuple",["string","string"]]`, `["`+canary+`"]`), wantErr: outputs.ErrMalformedValue}, + { + name: "list of dynamic elements of different types", out: output(`["list","dynamic"]`, `[{"type":"string","value":"`+canary+`"},{"type":"number","value":1}]`), + wantErr: outputs.ErrMalformedValue, + }, + { + name: "map of dynamic elements of different types", out: output(`["map","dynamic"]`, `{"a":{"type":"string","value":"`+canary+`"},"b":{"type":"number","value":1}}`), + wantErr: outputs.ErrMalformedValue, + }, + { + name: "list of objects of different types", out: output(`["list",["object",{"a":"dynamic"}]]`, `[{"a":{"type":"string","value":"`+canary+`"}},{"a":{"type":"number","value":1}}]`), + wantErr: outputs.ErrMalformedValue, + }, + {name: "dynamic without its type", out: output(`"dynamic"`, `"`+canary+`"`), wantErr: outputs.ErrMalformedValue}, + {name: "dynamic with an unknown type", out: output(`"dynamic"`, `{"type":"text","value":"`+canary+`"}`), wantErr: outputs.ErrMalformedValue}, + {name: "dynamic with an extra key", out: output(`"dynamic"`, `{"type":"string","value":"acme","`+canary+`":1}`), wantErr: outputs.ErrMalformedValue}, + {name: "dynamic without its value", out: output(`"dynamic"`, `{"type":"string"}`), wantErr: outputs.ErrMalformedValue}, + {name: "dynamic with a null type", out: output(`"dynamic"`, `{"type":null,"value":"`+canary+`"}`), wantErr: outputs.ErrMalformedValue}, + {name: "dynamic from a list", out: output(`"dynamic"`, `["`+canary+`"]`), wantErr: outputs.ErrMalformedValue}, + { + name: "dynamic with extra data in its type", out: output(`"dynamic"`, `{"type":["list","string","number"],"value":["`+canary+`"]}`), + wantErr: outputs.ErrMalformedValue, + }, + { + name: "dynamic with an optional attribute not in its type", out: output(`"dynamic"`, `{"type":["object",{"id":"string"},["port"]],"value":{"id":"`+canary+`"}}`), + wantErr: outputs.ErrMalformedValue, + }, + { + name: "list of a resolved dynamic attribute and a null object", out: output(`["list",["object",{"a":"dynamic"}]]`, `[{"a":{"type":"string","value":"`+canary+`"}},null]`), + wantErr: outputs.ErrMalformedValue, + }, + { + name: "set of dynamic elements of different types", out: output(`["set","dynamic"]`, `[{"type":"string","value":"`+canary+`"},{"type":"bool","value":true}]`), + wantErr: outputs.ErrMalformedValue, + }, + {name: "object from a list", out: output(`["object",{"id":"string"}]`, `["`+canary+`"]`), wantErr: outputs.ErrMalformedValue}, + {name: "map from a list", out: output(`["map","string"]`, `["`+canary+`"]`), wantErr: outputs.ErrMalformedValue}, + {name: "tuple from an object", out: output(`["tuple",["string"]]`, `{"id":"`+canary+`"}`), wantErr: outputs.ErrMalformedValue}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := outputs.Value(tt.out) + require.ErrorIs(t, err, tt.wantErr) + assert.NotContains(t, err.Error(), canary) + assert.Contains(t, err.Error(), tt.wantMsg) + assert.True(t, got.IsNull()) + }) + } +} + +func goldenOutputs(t *testing.T) map[string]v1.StackOutput { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "client", "testdata", "stack_outputs.json")) + require.NoError(t, err) + var body v1.StackOutputs + require.NoError(t, json.Unmarshal(b, &body)) + return body.Outputs +} + +func with[V any](m map[string]V, key string, value V) map[string]V { + m = maps.Clone(m) + m[key] = value + return m +} + +func TestObjectOfTheGoldenOutputs(t *testing.T) { + endpoints := map[string]attr.Type{"dynamodb": types.StringType, "s3": types.StringType} + attrTypes := map[string]attr.Type{ + "endpoints": types.ObjectType{AttrTypes: endpoints}, + "nat_gateway_count": types.NumberType, + "private_subnet_ids": types.ListType{ElemType: types.StringType}, + "tags": types.MapType{ElemType: types.StringType}, + "vpc_id": types.StringType, + } + attrs := map[string]attr.Value{ + "endpoints": types.ObjectValueMust(endpoints, map[string]attr.Value{ + "dynamodb": str("vpce-0d4e5f6a7b8c9d0e1"), + "s3": str("vpce-0a1b2c3d4e5f6a7b8"), + }), + "nat_gateway_count": num(3), + "private_subnet_ids": types.ListValueMust(types.StringType, []attr.Value{str("subnet-0a1b2c3d4e5f6a7b8"), str("subnet-0b2c3d4e5f6a7b8c9")}), + "tags": types.MapValueMust(types.StringType, map[string]attr.Value{ + "environment": str("prod"), + "team": str("platform"), + }), + "vpc_id": str("vpc-0a1b2c3d4e5f67890"), + } + secret := v1.StackOutput{Type: json.RawMessage(`"string"`), Value: json.RawMessage(`"` + canary + `"`), Sensitive: true} + nonsensitive := func(_ string, out v1.StackOutput) bool { return !out.Sensitive } + + tests := []struct { + name string + outputs map[string]v1.StackOutput + include func(string, v1.StackOutput) bool + want types.Dynamic + }{ + {name: "every output", outputs: goldenOutputs(t), want: types.DynamicValue(types.ObjectValueMust(attrTypes, attrs))}, + { + name: "with a sensitive output", outputs: with(goldenOutputs(t), "db_password", secret), + want: types.DynamicValue(types.ObjectValueMust(with(attrTypes, "db_password", attr.Type(types.StringType)), with(attrs, "db_password", attr.Value(str(canary))))), + }, + { + name: "nonsensitive with a sensitive output", outputs: with(goldenOutputs(t), "db_password", secret), include: nonsensitive, + want: types.DynamicValue(types.ObjectValueMust(attrTypes, attrs)), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := outputs.Object(tt.outputs, tt.include) + require.NoError(t, err) + assertSameValue(t, tt.want, got) + }) + } +} + +func TestObjectInclude(t *testing.T) { + outs := map[string]v1.StackOutput{ + "db_password": {Type: json.RawMessage(`"string"`), Value: json.RawMessage(`"` + canary + `"`), Sensitive: true}, + "vpc_id": output(`"string"`, `"vpc-0a1b2c3d4e5f67890"`), + "zones": output(`["list","string"]`, `["eu-west-1a","eu-west-1b"]`), + } + tests := []struct { + name string + include func(string, v1.StackOutput) bool + want []string + }{ + {name: "nil accepts every output", want: []string{"db_password", "vpc_id", "zones"}}, + {name: "nonsensitive", include: func(_ string, out v1.StackOutput) bool { return !out.Sensitive }, want: []string{"vpc_id", "zones"}}, + {name: "by name", include: func(name string, _ v1.StackOutput) bool { return name == "zones" }, want: []string{"zones"}}, + {name: "none", include: func(string, v1.StackOutput) bool { return false }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := outputs.Object(outs, tt.include) + require.NoError(t, err) + require.False(t, got.IsNull()) + obj, ok := got.UnderlyingValue().(basetypes.ObjectValue) + require.Truef(t, ok, "want an object, got %T", got.UnderlyingValue()) + assert.ElementsMatch(t, tt.want, slices.Collect(maps.Keys(obj.Attributes()))) + for _, name := range tt.want { + want, err := outputs.Value(outs[name]) + require.NoError(t, err) + assertSameValue(t, want, types.DynamicValue(obj.Attributes()[name])) + } + }) + } +} + +func TestObjectIncludeSeesEachOutput(t *testing.T) { + outs := goldenOutputs(t) + seen := map[string]v1.StackOutput{} + _, err := outputs.Object(outs, func(name string, out v1.StackOutput) bool { + seen[name] = out + return true + }) + require.NoError(t, err) + assert.Equal(t, outs, seen) +} + +func TestObjectOfNoOutputs(t *testing.T) { + tests := []struct { + name string + outputs map[string]v1.StackOutput + include func(string, v1.StackOutput) bool + }{ + {name: "nil map"}, + {name: "empty map", outputs: map[string]v1.StackOutput{}}, + {name: "none accepted", outputs: goldenOutputs(t), include: func(string, v1.StackOutput) bool { return false }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := outputs.Object(tt.outputs, tt.include) + require.NoError(t, err) + assert.False(t, got.IsNull()) + assert.False(t, got.IsUnknown()) + assertSameValue(t, types.DynamicValue(types.ObjectValueMust(map[string]attr.Type{}, map[string]attr.Value{})), got) + + tv, err := got.ToTerraformValue(context.Background()) + require.NoError(t, err) + assert.True(t, tv.IsFullyKnown()) + assert.True(t, tv.Type().Equal(tftypes.Object{AttributeTypes: map[string]tftypes.Type{}})) + }) + } +} + +func TestObjectKeepsNullsTyped(t *testing.T) { + got, err := outputs.Object(map[string]v1.StackOutput{ + "name": output(`"string"`, `null`), + "ports": output(`["list","number"]`, `null`), + "rule": output(`["object",{"id":"string","port":"number"},["port"]]`, `null`), + "unknown": output(`"dynamic"`, `null`), + }, nil) + require.NoError(t, err) + assertSameValue(t, types.DynamicValue(types.ObjectValueMust( + map[string]attr.Type{ + "name": types.StringType, + "ports": types.ListType{ElemType: types.NumberType}, + "rule": types.ObjectType{AttrTypes: portType}, + "unknown": types.DynamicType, + }, + map[string]attr.Value{ + "name": types.StringNull(), + "ports": types.ListNull(types.NumberType), + "rule": types.ObjectNull(portType), + "unknown": types.DynamicNull(), + }, + )), got) +} + +func TestObjectNamesTheMalformedOutput(t *testing.T) { + tests := []struct { + name string + broken v1.StackOutput + include func(string, v1.StackOutput) bool + wantErr error + }{ + {name: "malformed type", broken: output(`"text"`, `"`+canary+`"`), wantErr: outputs.ErrMalformedType}, + {name: "malformed value", broken: output(`"number"`, `"`+canary+`"`), wantErr: outputs.ErrMalformedValue}, + { + name: "malformed sensitive value", broken: v1.StackOutput{Type: json.RawMessage(`"bool"`), Value: json.RawMessage(`"` + canary + `"`), Sensitive: true}, + wantErr: outputs.ErrMalformedValue, + }, + { + name: "malformed output left out", broken: output(`"number"`, `"`+canary+`"`), + include: func(name string, _ v1.StackOutput) bool { return name != "broken" }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outs := map[string]v1.StackOutput{ + "broken": tt.broken, + "vpc_id": output(`"string"`, `"vpc-0a1b2c3d4e5f67890"`), + } + got, err := outputs.Object(outs, tt.include) + if tt.wantErr == nil { + require.NoError(t, err) + assertSameValue(t, types.DynamicValue(types.ObjectValueMust( + map[string]attr.Type{"vpc_id": types.StringType}, + map[string]attr.Value{"vpc_id": str("vpc-0a1b2c3d4e5f67890")}, + )), got) + return + } + require.ErrorIs(t, err, tt.wantErr) + assert.Contains(t, err.Error(), `output "broken"`) + assert.NotContains(t, err.Error(), canary) + assert.True(t, got.IsNull()) + }) + } +} + +func typeOfContents(t *testing.T, v tftypes.Value) tftypes.Type { + t.Helper() + if v.IsNull() { + return v.Type() + } + switch typ := v.Type().(type) { + case tftypes.Object: + var attrs map[string]tftypes.Value + require.NoError(t, v.As(&attrs)) + attrTypes := make(map[string]tftypes.Type, len(attrs)) + for name, a := range attrs { + attrTypes[name] = typeOfContents(t, a) + } + return tftypes.Object{AttributeTypes: attrTypes} + case tftypes.Tuple: + var elems []tftypes.Value + require.NoError(t, v.As(&elems)) + elemTypes := make([]tftypes.Type, len(elems)) + for i, e := range elems { + elemTypes[i] = typeOfContents(t, e) + } + return tftypes.Tuple{ElementTypes: elemTypes} + case tftypes.List, tftypes.Set: + var elems []tftypes.Value + require.NoError(t, v.As(&elems)) + if len(elems) == 0 { + return typ + } + if _, ok := typ.(tftypes.Set); ok { + return tftypes.Set{ElementType: typeOfContents(t, elems[0])} + } + return tftypes.List{ElementType: typeOfContents(t, elems[0])} + case tftypes.Map: + var elems map[string]tftypes.Value + require.NoError(t, v.As(&elems)) + for _, e := range elems { + return tftypes.Map{ElementType: typeOfContents(t, e)} + } + return typ + } + return v.Type() +} + +func TestObjectTypeIsTheTypeOfItsAttributes(t *testing.T) { + tests := []struct { + name string + outputs map[string]v1.StackOutput + }{ + {name: "golden", outputs: goldenOutputs(t)}, + {name: "none", outputs: map[string]v1.StackOutput{}}, + {name: "every kind", outputs: map[string]v1.StackOutput{ + "string": output(`"string"`, `"acme"`), + "number": output(`"number"`, `0.1`), + "large": output(`"number"`, `123456789012345678901234567890.123456789`), + "bool": output(`"bool"`, `false`), + "list": output(`["list","string"]`, `["a","b"]`), + "set": output(`["set","number"]`, `[1,2]`), + "map": output(`["map","bool"]`, `{"a":true}`), + "tuple": output(`["tuple",["string","number"]]`, `["a",1]`), + "object": output(`["object",{"id":"string","port":"number"},["port"]]`, `{"id":"sg-0a1b"}`), + "objects": output(`["list",["object",{"id":"string","port":"number"},["port"]]]`, `[{"id":"sg-0a1b"}]`), + "dynamic": output(`"dynamic"`, `{"type":["tuple",["string","bool"]],"value":["a",true]}`), + "attrs": output(`["object",{"name":"dynamic","owner":"dynamic"}]`, `{"name":{"type":"string","value":"acme"},"owner":null}`), + "elements": output(`["map","dynamic"]`, `{"a":{"type":["list","number"],"value":[1]}}`), + "empty": output(`["list","dynamic"]`, `[]`), + "nulls": output(`["list","dynamic"]`, `[null]`), + "null": output(`["object",{"id":"string"}]`, `null`), + "nothing": output(`"dynamic"`, `null`), + "unified": output(`["list","dynamic"]`, `[null,{"type":"string","value":"acme"}]`), + }}, + {name: "optional attributes in dynamic values", outputs: map[string]v1.StackOutput{ + "null_object": output(`"dynamic"`, `{"type":["object",{"a":"string"},["a"]],"value":null}`), + "empty_list": output(`"dynamic"`, `{"type":["list",["object",{"a":"string"},["a"]]],"value":[]}`), + "empty_map": output(`"dynamic"`, `{"type":["map",["object",{"a":"string"},["a"]]],"value":{}}`), + "null_list": output(`"dynamic"`, `{"type":["list",["object",{"a":"string"},["a"]]],"value":null}`), + "list": output(`"dynamic"`, `{"type":["list",["object",{"a":"string"},["a"]]],"value":[{}]}`), + "attribute": output(`["object",{"x":"dynamic"}]`, `{"x":{"type":["object",{"a":"string"},["a"]],"value":null}}`), + }}, + } + schema := tftypes.Object{AttributeTypes: map[string]tftypes.Type{"values": tftypes.DynamicPseudoType}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := outputs.Object(tt.outputs, nil) + require.NoError(t, err) + tv, err := got.ToTerraformValue(context.Background()) + require.NoError(t, err) + require.True(t, tv.IsFullyKnown()) + + obj, ok := tv.Type().(tftypes.Object) + require.Truef(t, ok, "want an object type, got %s", tv.Type()) + var attrs map[string]tftypes.Value + require.NoError(t, tv.As(&attrs)) + attrTypes := make(map[string]tftypes.Type, len(attrs)) + for name, a := range attrs { + attrTypes[name] = a.Type() + } + assert.Truef(t, obj.Equal(tftypes.Object{AttributeTypes: attrTypes}), "object type %s", obj) + assert.Truef(t, obj.Equal(typeOfContents(t, tv)), "object type %s, contents %s", obj, typeOfContents(t, tv)) + assert.Equal(t, slices.Sorted(maps.Keys(tt.outputs)), slices.Sorted(maps.Keys(attrs))) + + state := tftypes.NewValue(schema, map[string]tftypes.Value{"values": tv}) + wire, err := tfprotov6.NewDynamicValue(schema, state) + require.NoError(t, err) + back, err := wire.Unmarshal(schema) + require.NoError(t, err) + assert.Truef(t, state.Equal(back), "sent %s, received %s", state, back) + }) + } +} diff --git a/internal/provider/outputs_data_source.go b/internal/provider/outputs_data_source.go new file mode 100644 index 0000000..7b9373f --- /dev/null +++ b/internal/provider/outputs_data_source.go @@ -0,0 +1,403 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" + + v1 "github.com/stackorder/stackorder/api/v1" + + "github.com/stackorder/terraform-provider-stackorder/internal/client" + "github.com/stackorder/terraform-provider-stackorder/internal/outputs" +) + +const ( + stackDescription = "Key of the producer stack. `path` or `path:instance` names a stack of the provider's `repository`, such as `stacks/prod/vpc` or `stacks/prod/apps:blue`; " + + "`owner/repo//key` names a stack of another repository of the same Stackorder installation, such as `acme/platform//stacks/prod/dns`." + valuesDescription = "Object with one attribute per output the stack serves, named after the output, sensitive outputs included when the producer shares them. " + + "Terraform treats the whole object as sensitive. Outputs the producer withholds are absent." +) + +const ( + codeNotFound = "not_found" + codeConflict = "conflict" + codeSuperseded = "superseded" + codeInvalid = "invalid" +) + +var ( + _ datasource.DataSource = (*outputsDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*outputsDataSource)(nil) +) + +type outputsDataSource struct { + data *providerData +} + +type outputsDataSourceModel struct { + Stack types.String `tfsdk:"stack"` + ID types.String `tfsdk:"id"` + Values types.Dynamic `tfsdk:"values"` + NonsensitiveValues types.Dynamic `tfsdk:"nonsensitive_values"` +} + +type stackOutputs struct { + id string + values types.Dynamic + nonsensitiveValues types.Dynamic +} + +// NewOutputsDataSource returns the stackorder_outputs data source, which +// reads the outputs a stack shares through the Stackorder server. +func NewOutputsDataSource() datasource.DataSource { + return &outputsDataSource{} +} + +// Metadata names the data source stackorder_outputs. +func (d *outputsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_outputs" +} + +// Schema describes the stack the data source names and the values it reads. +func (d *outputsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Reads the root-module outputs a stack shares through the Stackorder server, so a stack can use another stack's outputs without access to its state.\n\n" + + sharingDescription("data source") + "\n\n" + + "Values read by a data source are stored in the plan file, which Stackorder uploads as a workflow artifact, and in state. " + + "To read sensitive values without storing them, use `ephemeral \"stackorder_outputs\"` instead, which keeps them out of both and needs Terraform 1.10 or later, or OpenTofu 1.11 or later.", + Attributes: map[string]schema.Attribute{ + "stack": schema.StringAttribute{ + Required: true, + MarkdownDescription: stackDescription, + }, + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Qualified key of the stack, `owner/repo//key`.", + }, + "values": schema.DynamicAttribute{ + Computed: true, + Sensitive: true, + MarkdownDescription: valuesDescription, + }, + "nonsensitive_values": schema.DynamicAttribute{ + Computed: true, + MarkdownDescription: "The object of `values` without the outputs marked sensitive. " + + "Terraform does not treat it as sensitive, so it can feed arguments that refuse sensitive values, such as `for_each`.", + }, + }, + } +} + +// Configure keeps the settings of the configured provider for Read. +func (d *outputsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + data, problem := providerDataOf(req.ProviderData, "data source") + if problem != nil { + resp.Diagnostics.Append(problem) + return + } + d.data = data +} + +// Read requests the stack's outputs from the Stackorder server. +func (d *outputsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var model outputsDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + read, diags := readStackOutputs(ctx, d.data, model.Stack.ValueString()) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + model.ID = types.StringValue(read.id) + model.Values = read.values + model.NonsensitiveValues = read.nonsensitiveValues + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +func sharingDescription(kind string) string { + return "Nothing is shared until the producer opts in on its default branch: its `.stackorder.yaml`, or `stackorder.yaml` for every stack of its repository, lists the stacks that may read its outputs under `outputs.readers`. " + + "Sensitive outputs are withheld unless the producer also sets `outputs.sensitive: share`, and the " + kind + " warns about the outputs withheld." +} + +func providerDataOf(raw any, kind string) (*providerData, diag.Diagnostic) { + if raw == nil { + return nil, nil + } + data, ok := raw.(*providerData) + if !ok { + return nil, diag.NewErrorDiagnostic("Unexpected provider data", fmt.Sprintf( + "The stackorder_outputs %s received provider data of type %T. Report this to the provider's maintainers.", kind, raw)) + } + return data, nil +} + +func readStackOutputs(ctx context.Context, data *providerData, stack string) (stackOutputs, diag.Diagnostics) { + var diags diag.Diagnostics + if data == nil { + diags.AddError("Unconfigured stackorder provider", "The stackorder provider was not configured before the read. Report this to the provider's maintainers.") + return stackOutputs{}, diags + } + repo, key, problem := producerStack(stack, data.Repository) + if problem != nil { + diags.Append(problem) + return stackOutputs{}, diags + } + id := v1.QualifiedStackKey(repo, key) + out, err := data.Client.StackOutputs(ctx, repo, key, data.RunID, data.Reader) + if err != nil { + diags.Append(readFailure(err, data, repo, key)) + return stackOutputs{}, diags + } + values, err := outputs.Object(out.Outputs, nil) + var nonsensitive types.Dynamic + if err == nil { + nonsensitive, err = outputs.Object(out.Outputs, func(_ string, o v1.StackOutput) bool { return !o.Sensitive }) + } + if err != nil { + diags.AddError("Malformed stack outputs", fmt.Sprintf( + "The Stackorder server at %s served outputs of %s that the provider cannot convert: %s. Report this to the server's operator.", + data.ServerHost, id, err)) + return stackOutputs{}, diags + } + if len(out.Withheld) > 0 { + diags.Append(withheldWarning(id, out.Withheld)) + } + return stackOutputs{id: id, values: values, nonsensitiveValues: nonsensitive}, diags +} + +func producerStack(stack, repository string) (repo, key string, problem diag.Diagnostic) { + if !strings.Contains(stack, "//") { + switch { + case stack == "": + return "", "", stackError("Invalid stack", "The stack is empty. Name the producer stack as path, path:instance or owner/repo//key.") + case !validKey(stack): + return "", "", stackError("Invalid stack", fmt.Sprintf( + "%q is not the key of a stack. Name the producer stack as path, path:instance or owner/repo//key.", stack)) + case repository == "": + return "", "", stackError("Missing Stackorder repository", fmt.Sprintf( + "The stack %q has no owner/repo// prefix, so it is a stack of the repository being planned, and no setting names that repository. Set the provider's repository, %s or %s to owner/repo, or name the stack as owner/repo//%s.", + stack, envRepository, envGitHubRepository, stack)) + case !validRepository(repository): + return "", "", stackError("Invalid Stackorder repository", fmt.Sprintf( + "The stack %q has no owner/repo// prefix, so it belongs to the repository named by the provider's repository, else %s, else %s, and the first of them set holds %q, which is not owner/repo. Set it to owner/repo, such as acme/infra, or name the stack as owner/repo//%s.", + stack, envRepository, envGitHubRepository, repository, stack)) + } + return repository, stack, nil + } + repo, key = v1.SplitQualifiedStackKey(stack) + if !validRepository(repo) || !validKey(key) { + return "", "", stackError("Invalid stack", fmt.Sprintf( + "%q is not the key of a stack of another repository. Write owner/repo//key, such as acme/platform//stacks/prod/dns.", stack)) + } + return repo, key, nil +} + +func validRepository(repo string) bool { + owner, name, ok := strings.Cut(repo, "/") + return ok && validSegment(owner) && validSegment(name) +} + +func validSegment(s string) bool { + return s != "" && s != "." && s != ".." && url.PathEscape(s) == s +} + +func validKey(key string) bool { + return key != "" && key != "." && key != ".." +} + +func readFailure(err error, data *providerData, repo, key string) diag.Diagnostic { + var apiErr *client.APIError + if errors.As(err, &apiErr) { + return refusal(apiErr, data, repo, key) + } + var urlErr *url.Error + if errors.As(err, &urlErr) { + if host := hostOf(urlErr.URL); host != data.ServerHost { + return diag.NewErrorDiagnostic("GitHub Actions ID token unavailable", fmt.Sprintf( + "The provider could not request a GitHub Actions ID token from %s: %s. Retry later.", host, urlErr.Err)) + } + return diag.NewErrorDiagnostic("Stackorder server unreachable", fmt.Sprintf( + "The provider could not reach the Stackorder server at %s: %s. Check the server URL in server_url or %s, and that the server is reachable from where Terraform runs, then retry later.", + data.ServerHost, urlErr.Err, envServerURL)) + } + return diag.NewErrorDiagnostic("Cannot read stack outputs", sentence(err.Error())) +} + +func refusal(e *client.APIError, data *providerData, repo, key string) diag.Diagnostic { + producer := v1.QualifiedStackKey(repo, key) + host := data.ServerHost + stackRefusal := func(summary, detail string) diag.Diagnostic { + return stackError(summary, detail+"\n\n"+answer(e)) + } + refused := func(summary, detail string) diag.Diagnostic { + return diag.NewErrorDiagnostic(summary, detail+"\n\n"+answer(e)) + } + switch e.Code { + case v1.ErrCodeOutputsNotShared: + reader, entry := readerEntry(data) + return stackRefusal("Stack outputs not shared", fmt.Sprintf( + "The stack %s does not share its outputs with %s: either outputs.readers on its default branch does not list it, or that outputs configuration does not resolve, as the server's answer below says. A producer shares its outputs only with the stacks it lists under outputs.readers on its default branch, in the stack's .stackorder.yaml or in stackorder.yaml for every stack of its repository:\n\n"+ + " outputs:\n readers:\n - %s\n\n"+ + "The server reads the list from the default branch only, so a change takes effect once it is merged.", + producer, reader, entry)) + case v1.ErrCodeOutputsDisabled: + return refused("Shared outputs disabled", fmt.Sprintf( + "The Stackorder server at %s does not serve stack outputs: they stay off until the server's operator sets STACKORDER_STATE_BUCKETS to the state buckets, or bucket/prefix pairs, the server may read. Ask the operator to enable them.", + host)) + case v1.ErrCodeStateLocationUnknown: + return stackRefusal("State location unknown", fmt.Sprintf( + "The Stackorder server does not know yet which state object holds the state of %s. It learns the location only from the stack's apply and drift jobs, when they run a Stackorder CLI that reports the state object. Run one apply or drift job of the producer with a current Stackorder CLI, such as the next scheduled drift run, then read again.", + producer)) + case codeNotFound: + detail := fmt.Sprintf( + "The Stackorder server knows no stack %s that this reader may read. Check that the key is exact, path or path:instance as on the producer's default branch, and that the stack exists on the default branch of %s.", + producer, repo) + if !strings.EqualFold(data.Repository, repo) { + detail += " A stack of another repository must belong to the same Stackorder installation and list the reader under outputs.readers: the server answers not found rather than disclose a stack of another repository that does not share with the reader." + } + return stackRefusal("Stack not found", detail+" A server release without shared outputs answers not found as well.") + case v1.ErrCodeStateNotFound: + return stackRefusal("State object not found", fmt.Sprintf( + "The state object the Stackorder server recorded for %s, from its last apply or drift job, does not exist. If the producer moved its state to another bucket, key or workspace, its next apply or drift job records the new location; otherwise ask the server's operator to check the object.", + producer)) + case v1.ErrCodeStateAccessDenied: + return stackRefusal("State object access denied", fmt.Sprintf( + "S3 or KMS denied the Stackorder server read access to the state object of %s. The server's operator must grant the server's role s3:GetObject on the object, s3:ListBucket on its bucket and prefix, without which S3 reports a missing object as denied too, and kms:Decrypt on the state's KMS key through S3.", + producer)) + case v1.ErrCodeStateBucketNotAllowed: + return stackRefusal("State bucket not allowed", fmt.Sprintf( + "The state object of %s lies outside the buckets and prefixes the Stackorder server may read. The server's operator must add its bucket, or bucket/prefix, to STACKORDER_STATE_BUCKETS and grant the server's role read access to it.", + producer)) + case v1.ErrCodeStateEncrypted: + return stackRefusal("State encrypted", fmt.Sprintf( + "The state of %s is encrypted by OpenTofu client-side state encryption, which the Stackorder server never decrypts, so the stack cannot share its outputs through Stackorder.", + producer)) + case v1.ErrCodeStateUnsupported: + return stackRefusal("State format unsupported", fmt.Sprintf( + "The state of %s is in a format version the Stackorder server does not read. The server reads version 4 state, which Terraform 0.12 and later and every OpenTofu release write; an apply of the producer with such a release rewrites the state in it.", + producer)) + case v1.ErrCodeStateTooLarge: + return stackRefusal("State too large", fmt.Sprintf( + "The state object of %s, or its outputs, exceed the size limits of the Stackorder server for the state objects it scans and the outputs it serves. Reduce the outputs of the producer, or split the stack.", + producer)) + case v1.ErrCodeStateUnreadable: + return stackRefusal("State unreadable", fmt.Sprintf( + "The Stackorder server could not decode the state object of %s as Terraform or OpenTofu state. Check that the object at the stack's state location is an intact state file; the next apply of the producer rewrites it.", + producer)) + case codeConflict: + return refused("Stackorder run finished", fmt.Sprintf( + "The Stackorder server at %s serves stack outputs to a job only while the job's run is in progress, and the run in %s has finished. Start a new run, for example by pushing or commenting stackorder plan, and read the outputs in its jobs.", + host, envRunID)) + case codeSuperseded: + return refused("Stackorder run superseded", fmt.Sprintf( + "The run in %s was superseded by a run for a newer commit, and the Stackorder server at %s serves no stack outputs to a superseded run. The jobs of the newer run read them.", + envRunID, host)) + case codeInvalid: + return refused("Invalid read of stack outputs", fmt.Sprintf( + "The Stackorder server at %s rejected the read of %s as malformed. The provider names the run from %s and the reading stack from %s: check that they hold a run id and a stack key as the Stackorder CLI sets them, or leave both unset outside a Stackorder job.", + host, producer, envRunID, envStack)) + } + switch { + case e.Status == http.StatusUnauthorized && data.IDTokens: + return refused("Stackorder credentials rejected", fmt.Sprintf( + "The Stackorder server at %s rejected the job's GitHub Actions ID token. Check that the provider's oidc_audience, else %s, else the server URL, names an audience the server accepts.", + host, client.EnvOIDCAudience)) + case e.Status == http.StatusUnauthorized: + return refused("Stackorder credentials rejected", fmt.Sprintf( + "The Stackorder server at %s rejected the API key from api_key or %s. Check that it is a current automation key of this server that nobody revoked.", + host, client.EnvAPIKey)) + case e.Status == http.StatusForbidden && data.IDTokens: + return refused("Stackorder server refused the read", fmt.Sprintf( + "The Stackorder server at %s accepted the job's GitHub Actions ID token but refused this read. It serves outputs to an ID token only for a reading stack of the job's run whose job has not reported its result yet: the run in %s and the stack in %s, which the Stackorder CLI sets for Terraform in its plan, apply and drift jobs.", + host, envRunID, envStack)) + case e.Status == http.StatusForbidden: + return refused("Stackorder server refused the read", fmt.Sprintf( + "The Stackorder server at %s refused the API key from api_key or %s for this read.", host, client.EnvAPIKey)) + case redirected(e.Status): + return refused("Stackorder server redirected the read", fmt.Sprintf( + "The Stackorder server at %s answered the read of %s with a redirect, which the provider never follows so that its credentials reach only the configured server. Check that server_url or %s names the Stackorder server itself rather than a sign-in page or proxy in front of it.", + host, producer, envServerURL)) + case e.Status == http.StatusTooManyRequests || e.Status >= http.StatusInternalServerError: + return refused("Stackorder server unavailable", fmt.Sprintf( + "The Stackorder server at %s could not serve the outputs of %s. Retry later; if the error persists, the server's operator finds its cause in the server's logs.", + host, producer)) + } + return refused("Unexpected answer from the Stackorder server", fmt.Sprintf( + "The Stackorder server at %s did not serve the outputs of %s.", host, producer)) +} + +func readerEntry(data *providerData) (reader, entry string) { + if data.Reader == "" { + return "the reading stack", "" + } + return "the stack " + data.Reader, data.Reader +} + +func answer(e *client.APIError) string { + s := "The server answered " + strconv.Itoa(e.Status) + if e.Code == "" { + if text := http.StatusText(e.Status); text != "" { + s += " " + text + } + if host := redirectHost(e); host != "" { + s += ", redirecting to " + host + } + return s + "." + } + s += " " + e.Code + if msg := strings.TrimRight(e.Message, ". "); msg != "" { + s += ": " + msg + } + return s + "." +} + +func redirectHost(e *client.APIError) string { + target, ok := strings.CutPrefix(e.Message, "redirected to ") + if !redirected(e.Status) || !ok { + return "" + } + target, _, _ = strings.Cut(target, ", which is never followed") + return hostOf(target) +} + +func redirected(status int) bool { + return status >= http.StatusMultipleChoices && status < http.StatusBadRequest +} + +func sentence(s string) string { + s = strings.TrimRight(s, ". ") + first, size := utf8.DecodeRuneInString(s) + if size == 0 { + return s + } + return string(unicode.ToUpper(first)) + s[size:] + "." +} + +func withheldWarning(producer string, withheld []string) diag.Diagnostic { + names := make([]string, len(withheld)) + for i, name := range slices.Sorted(slices.Values(withheld)) { + names[i] = strconv.Quote(name) + } + return diag.NewWarningDiagnostic("Sensitive outputs withheld", fmt.Sprintf( + "The stack %s withholds its sensitive outputs %s, so they are absent from values. The producer shares them by setting outputs.sensitive: share beside outputs.readers on its default branch. A data \"stackorder_outputs\" block stores shared sensitive outputs in the reader's plan file and state; read them with ephemeral \"stackorder_outputs\" instead to keep them out of both.", + producer, strings.Join(names, ", "))) +} + +func stackError(summary, detail string) diag.Diagnostic { + return diag.NewAttributeErrorDiagnostic(path.Root("stack"), summary, detail) +} diff --git a/internal/provider/outputs_data_source_test.go b/internal/provider/outputs_data_source_test.go new file mode 100644 index 0000000..ae5dbc5 --- /dev/null +++ b/internal/provider/outputs_data_source_test.go @@ -0,0 +1,1029 @@ +package provider + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "sync" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/stackorder/stackorder/api/v1" + + "github.com/stackorder/terraform-provider-stackorder/internal/client" +) + +const ( + vpcStack = "stacks/prod/vpc" + vpcID = "acme/infra//stacks/prod/vpc" + vpcOutputsPath = "/v1/repos/acme/infra/stacks/stacks%2Fprod%2Fvpc/outputs" + clusterStack = "acme/platform//clusters/prod" + clusterPath = "/v1/repos/acme/platform/stacks/clusters%2Fprod/outputs" + pskCanary = "psk-canary-7c1d9e" + testServerHost = "stackorder.acme.example" + outputsAddress = "data.stackorder_outputs.vpc" + outputsTypeName = "stackorder_outputs" + withheldSummary = "Sensitive outputs withheld" + notSharedSummary = "Stack outputs not shared" +) + +type served struct { + status int + body []byte + echo bool +} + +type outputsServer struct { + url string + mu sync.Mutex + answers map[string]served + got []request +} + +func newOutputsServer(t *testing.T, answers map[string]served) *outputsServer { + t.Helper() + s := &outputsServer{answers: maps.Clone(answers)} + if s.answers == nil { + s.answers = map[string]served{} + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + s.got = append(s.got, request{ + path: r.URL.EscapedPath(), + query: r.URL.Query(), + auth: r.Header.Get("Authorization"), + userAgent: r.Header.Get("User-Agent"), + }) + a, ok := s.answers[r.URL.EscapedPath()] + s.mu.Unlock() + if !ok { + a = served{status: http.StatusNotFound, body: refusalBody(codeNotFound, "no such stack")} + } + if a.echo { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(a.status) + _, _ = fmt.Fprintf(w, "bad gateway for %s auth=%s", r.URL.RequestURI(), r.Header.Get("Authorization")) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(a.status) + _, _ = w.Write(a.body) + })) + t.Cleanup(srv.Close) + s.url = srv.URL + return s +} + +func (s *outputsServer) serve(path string, a served) { + s.mu.Lock() + defer s.mu.Unlock() + s.answers[path] = a +} + +func (s *outputsServer) requests() []request { + s.mu.Lock() + defer s.mu.Unlock() + return slices.Clone(s.got) +} + +func okWith(body []byte) served { return served{status: http.StatusOK, body: body} } + +func golden(t *testing.T, name string) []byte { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "client", "testdata", name)) + require.NoError(t, err) + return raw +} + +func encode(t *testing.T, v any) []byte { + t.Helper() + raw, err := json.Marshal(v) + require.NoError(t, err) + return raw +} + +func sharingSensitive(t *testing.T) []byte { + t.Helper() + var out v1.StackOutputs + require.NoError(t, json.Unmarshal(golden(t, "stack_outputs.json"), &out)) + out.Outputs["vpn_preshared_key"] = v1.StackOutput{ + Value: json.RawMessage(strconv.Quote(pskCanary)), + Type: json.RawMessage(`"string"`), + Sensitive: true, + } + out.Withheld = nil + return encode(t, out) +} + +func refusalBody(code, message string) []byte { + raw, _ := json.Marshal(v1.Error{Code: code, Message: message}) + return raw +} + +func str(s string) tftypes.Value { return tftypes.NewValue(tftypes.String, s) } + +func emptyObject() tftypes.Value { + return tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{}}, map[string]tftypes.Value{}) +} + +func vpcValues(extra map[string]tftypes.Value) tftypes.Value { + endpoints := tftypes.Object{AttributeTypes: map[string]tftypes.Type{"dynamodb": tftypes.String, "s3": tftypes.String}} + subnets := tftypes.List{ElementType: tftypes.String} + tags := tftypes.Map{ElementType: tftypes.String} + attrs := map[string]tftypes.Value{ + "endpoints": tftypes.NewValue(endpoints, map[string]tftypes.Value{ + "dynamodb": str("vpce-0d4e5f6a7b8c9d0e1"), + "s3": str("vpce-0a1b2c3d4e5f6a7b8"), + }), + "nat_gateway_count": tftypes.NewValue(tftypes.Number, big.NewFloat(3)), + "private_subnet_ids": tftypes.NewValue(subnets, []tftypes.Value{str("subnet-0a1b2c3d4e5f6a7b8"), str("subnet-0b2c3d4e5f6a7b8c9")}), + "tags": tftypes.NewValue(tags, map[string]tftypes.Value{"environment": str("prod"), "team": str("platform")}), + "vpc_id": str("vpc-0a1b2c3d4e5f67890"), + } + maps.Copy(attrs, extra) + attrTypes := make(map[string]tftypes.Type, len(attrs)) + for name, v := range attrs { + attrTypes[name] = v.Type() + } + return tftypes.NewValue(tftypes.Object{AttributeTypes: attrTypes}, attrs) +} + +func nullObject(block *tfprotov6.SchemaBlock, set map[string]tftypes.Value) tftypes.Value { + attrs := make(map[string]tftypes.Value, len(block.Attributes)) + for _, a := range block.Attributes { + attrs[a.Name] = tftypes.NewValue(a.ValueType(), nil) + } + maps.Copy(attrs, set) + return tftypes.NewValue(block.ValueType(), attrs) +} + +func configuredServer(t *testing.T) (tfprotov6.ProviderServer, *tfprotov6.GetProviderSchemaResponse) { + t.Helper() + server, err := providerserver.NewProtocol6WithError(New("test")())() + require.NoError(t, err) + schemas, err := server.GetProviderSchema(t.Context(), &tfprotov6.GetProviderSchemaRequest{}) + require.NoError(t, err) + + providerConfig, err := tfprotov6.NewDynamicValue(schemas.Provider.ValueType(), nullObject(schemas.Provider.Block, nil)) + require.NoError(t, err) + configured, err := server.ConfigureProvider(t.Context(), &tfprotov6.ConfigureProviderRequest{TerraformVersion: "1.14.4", Config: &providerConfig}) + require.NoError(t, err) + require.Empty(t, configured.Diagnostics) + return server, schemas +} + +func assertNoSecretsServed(t *testing.T, diags []*tfprotov6.Diagnostic) { + t.Helper() + for _, d := range diags { + for _, secret := range []string{testAPIKey, testRequestToken, pskCanary} { + assert.NotContains(t, d.Summary+" "+d.Detail, secret) + } + } +} + +func objectAttributes(t *testing.T, raw *tfprotov6.DynamicValue, typ tftypes.Type) map[string]tftypes.Value { + t.Helper() + if raw == nil { + return nil + } + v, err := raw.Unmarshal(typ) + require.NoError(t, err) + if v.IsNull() { + return nil + } + var attrs map[string]tftypes.Value + require.NoError(t, v.As(&attrs)) + return attrs +} + +func readThroughServer(t *testing.T, stack string) (map[string]tftypes.Value, []*tfprotov6.Diagnostic) { + t.Helper() + server, schemas := configuredServer(t) + ds := schemas.DataSourceSchemas[outputsTypeName] + require.NotNil(t, ds) + config, err := tfprotov6.NewDynamicValue(ds.ValueType(), nullObject(ds.Block, map[string]tftypes.Value{"stack": str(stack)})) + require.NoError(t, err) + read, err := server.ReadDataSource(t.Context(), &tfprotov6.ReadDataSourceRequest{TypeName: outputsTypeName, Config: &config}) + require.NoError(t, err) + assertNoSecretsServed(t, read.Diagnostics) + return objectAttributes(t, read.State, ds.ValueType()), read.Diagnostics +} + +func serverEnvironment(t *testing.T, srv *outputsServer, env map[string]string) { + t.Helper() + all := map[string]string{"STACKORDER_SERVER_URL": srv.url, "STACKORDER_API_KEY": testAPIKey} + maps.Copy(all, env) + cleanEnvironment(t, all) +} + +func TestOutputsDataSourceSchema(t *testing.T) { + server, err := providerserver.NewProtocol6WithError(New("test")())() + require.NoError(t, err) + schemas, err := server.GetProviderSchema(t.Context(), &tfprotov6.GetProviderSchemaRequest{}) + require.NoError(t, err) + ds := schemas.DataSourceSchemas[outputsTypeName] + require.NotNil(t, ds) + + type flags struct{ required, computed, sensitive bool } + got := map[string]flags{} + types := map[string]tftypes.Type{} + for _, a := range ds.Block.Attributes { + got[a.Name] = flags{required: a.Required, computed: a.Computed, sensitive: a.Sensitive} + types[a.Name] = a.Type + } + assert.Equal(t, map[string]flags{ + "stack": {required: true}, + "id": {computed: true}, + "values": {computed: true, sensitive: true}, + "nonsensitive_values": {computed: true}, + }, got) + assert.Equal(t, map[string]tftypes.Type{ + "stack": tftypes.String, + "id": tftypes.String, + "values": tftypes.DynamicPseudoType, + "nonsensitive_values": tftypes.DynamicPseudoType, + }, types) + for _, want := range []string{"outputs.readers", "outputs.sensitive: share", "the data source warns", "default branch", "plan file", `ephemeral "stackorder_outputs"`, "Terraform 1.10", "OpenTofu 1.11"} { + assert.Contains(t, ds.Block.Description, want) + } +} + +type outputsReadCase struct { + name string + env map[string]string + stack string + path string + body []byte + wantQuery url.Values + wantID string + wantValues tftypes.Value + wantNonsensitive tftypes.Value + wantWithheld []string +} + +func outputsReadCases(t *testing.T) []outputsReadCase { + t.Helper() + return []outputsReadCase{ + { + name: "stack of GITHUB_REPOSITORY", + env: map[string]string{"GITHUB_REPOSITORY": "acme/infra"}, + stack: vpcStack, + path: vpcOutputsPath, + body: golden(t, "stack_outputs.json"), + wantQuery: url.Values{}, + wantID: vpcID, + wantValues: vpcValues(nil), + wantNonsensitive: vpcValues(nil), + wantWithheld: []string{`"vpn_preshared_key"`}, + }, + { + name: "run and reader of a Stackorder job", + env: map[string]string{"STACKORDER_REPOSITORY": "acme/infra", "GITHUB_REPOSITORY": "acme/other", "STACKORDER_RUN_ID": testRunID, "STACKORDER_STACK": testReader}, + stack: vpcStack, + path: vpcOutputsPath, + body: golden(t, "stack_outputs.json"), + wantQuery: url.Values{"run": {testRunID}, "reader": {testReader}}, + wantID: vpcID, + wantValues: vpcValues(nil), + wantNonsensitive: vpcValues(nil), + wantWithheld: []string{`"vpn_preshared_key"`}, + }, + { + name: "shared sensitive output", + env: map[string]string{"GITHUB_REPOSITORY": "acme/infra"}, + stack: vpcStack, + path: vpcOutputsPath, + body: sharingSensitive(t), + wantQuery: url.Values{}, + wantID: vpcID, + wantValues: vpcValues(map[string]tftypes.Value{"vpn_preshared_key": str(pskCanary)}), + wantNonsensitive: vpcValues(nil), + }, + { + name: "stack of another repository", + env: map[string]string{"GITHUB_REPOSITORY": "acme/infra"}, + stack: clusterStack, + path: clusterPath, + body: golden(t, "stack_outputs_empty.json"), + wantQuery: url.Values{}, + wantID: clusterStack, + wantValues: emptyObject(), + wantNonsensitive: emptyObject(), + }, + { + name: "instance of a stack", + env: map[string]string{"GITHUB_REPOSITORY": "acme/infra"}, + stack: "stacks/prod/apps:blue", + path: "/v1/repos/acme/infra/stacks/stacks%2Fprod%2Fapps:blue/outputs", + body: golden(t, "stack_outputs_empty.json"), + wantQuery: url.Values{}, + wantID: "acme/infra//stacks/prod/apps:blue", + wantValues: emptyObject(), + wantNonsensitive: emptyObject(), + }, + { + name: "several outputs withheld", + env: map[string]string{"GITHUB_REPOSITORY": "acme/infra"}, + stack: vpcStack, + path: vpcOutputsPath, + body: encode(t, v1.StackOutputs{ + Repo: "acme/infra", Stack: vpcStack, + Outputs: map[string]v1.StackOutput{}, + Withheld: []string{"vpn_preshared_key", "db_password"}, + }), + wantQuery: url.Values{}, + wantID: vpcID, + wantValues: emptyObject(), + wantNonsensitive: emptyObject(), + wantWithheld: []string{`"db_password", "vpn_preshared_key"`}, + }, + } +} + +func (tt outputsReadCase) assertRead(t *testing.T, srv *outputsServer, attrs map[string]tftypes.Value, diags []*tfprotov6.Diagnostic) { + t.Helper() + got := srv.requests() + require.Len(t, got, 1) + assert.Equal(t, tt.path, got[0].path) + assert.Equal(t, tt.wantQuery, got[0].query) + assert.Equal(t, "Bearer "+testAPIKey, got[0].auth) + require.NotNil(t, attrs, "%v", diags) + assert.True(t, attrs["stack"].Equal(str(tt.stack)), "stack %s", attrs["stack"]) + assert.True(t, attrs["values"].Type().Equal(tt.wantValues.Type()), "values type %s", attrs["values"].Type()) + assert.True(t, attrs["values"].Equal(tt.wantValues), "values %s", attrs["values"]) + assert.True(t, attrs["nonsensitive_values"].Type().Equal(tt.wantNonsensitive.Type()), "nonsensitive_values type %s", attrs["nonsensitive_values"].Type()) + assert.True(t, attrs["nonsensitive_values"].Equal(tt.wantNonsensitive), "nonsensitive_values %s", attrs["nonsensitive_values"]) + if tt.wantWithheld == nil { + assert.Empty(t, diags) + return + } + require.Len(t, diags, 1) + assert.Equal(t, tfprotov6.DiagnosticSeverityWarning, diags[0].Severity) + assert.Equal(t, withheldSummary, diags[0].Summary) + for _, want := range append(tt.wantWithheld, tt.wantID, "outputs.sensitive: share", "default branch", `data "stackorder_outputs"`, `ephemeral "stackorder_outputs"`) { + assert.Contains(t, diags[0].Detail, want) + } +} + +func TestOutputsDataSourceRead(t *testing.T) { + for _, tt := range outputsReadCases(t) { + t.Run(tt.name, func(t *testing.T) { + srv := newOutputsServer(t, map[string]served{tt.path: okWith(tt.body)}) + serverEnvironment(t, srv, tt.env) + + attrs, diags := readThroughServer(t, tt.stack) + + tt.assertRead(t, srv, attrs, diags) + assert.True(t, attrs["id"].Equal(str(tt.wantID)), "id %s", attrs["id"]) + }) + } +} + +type outputsReadErrorCase struct { + name string + env map[string]string + stack string + answers map[string]served + wantRequests int + wantSummary string + wantAttribute bool + wantDetail []string + notDetail []string +} + +func outputsReadErrorCases(t *testing.T) []outputsReadErrorCase { + t.Helper() + malformed := encode(t, v1.StackOutputs{Repo: "acme/infra", Stack: vpcStack, Outputs: map[string]v1.StackOutput{ + "vpn_preshared_key": {Value: json.RawMessage(strconv.Quote(pskCanary)), Type: json.RawMessage(`"number"`), Sensitive: true}, + }}) + return []outputsReadErrorCase{ + { + name: "no repository for a stack of the same repository", + stack: vpcStack, + wantSummary: "Missing Stackorder repository", + wantAttribute: true, + wantDetail: []string{"repository", "STACKORDER_REPOSITORY", "GITHUB_REPOSITORY", "owner/repo//stacks/prod/vpc"}, + }, + { + name: "empty stack", + env: map[string]string{"GITHUB_REPOSITORY": "acme/infra"}, + stack: "", + wantSummary: "Invalid stack", + wantAttribute: true, + wantDetail: []string{"path, path:instance or owner/repo//key"}, + }, + { + name: "parent directory as the stack", + env: map[string]string{"GITHUB_REPOSITORY": "acme/infra"}, + stack: "..", + wantSummary: "Invalid stack", + wantAttribute: true, + wantDetail: []string{`".." is not the key of a stack`, "path, path:instance or owner/repo//key"}, + }, + { + name: "repository setting not owner/repo", + env: map[string]string{"GITHUB_REPOSITORY": "acme"}, + stack: vpcStack, + wantSummary: "Invalid Stackorder repository", + wantAttribute: true, + wantDetail: []string{`the first of them set holds "acme", which is not owner/repo`, "repository", "STACKORDER_REPOSITORY", "GITHUB_REPOSITORY", "owner/repo//stacks/prod/vpc"}, + }, + { + name: "repository part with a space", + env: map[string]string{"GITHUB_REPOSITORY": "acme/infra"}, + stack: "acme/in fra//stacks/prod/vpc", + wantSummary: "Invalid stack", + wantAttribute: true, + wantDetail: []string{`"acme/in fra//stacks/prod/vpc"`, "owner/repo//key"}, + }, + { + name: "repository part without an owner", + stack: "infra//stacks/prod/vpc", + wantSummary: "Invalid stack", + wantAttribute: true, + wantDetail: []string{`"infra//stacks/prod/vpc"`, "owner/repo//key"}, + }, + { + name: "repository part with a path", + stack: "acme/infra/stacks//prod/vpc", + wantSummary: "Invalid stack", + wantAttribute: true, + wantDetail: []string{"owner/repo//key"}, + }, + { + name: "no key after the repository", + stack: "acme/platform//", + wantSummary: "Invalid stack", + wantAttribute: true, + wantDetail: []string{"owner/repo//key"}, + }, + { + name: "not shared with the reader", + env: map[string]string{"STACKORDER_REPOSITORY": "acme/infra", "STACKORDER_RUN_ID": testRunID, "STACKORDER_STACK": testReader}, + stack: vpcStack, + answers: map[string]served{vpcOutputsPath: {status: http.StatusForbidden, body: refusalBody(v1.ErrCodeOutputsNotShared, "stacks/prod/vpc does not share its outputs with stacks/prod/eks")}}, + wantRequests: 1, + wantSummary: notSharedSummary, + wantAttribute: true, + wantDetail: []string{vpcID, " - stacks/prod/eks", "outputs.readers", "The server answered 403 outputs_not_shared: stacks/prod/vpc does not share"}, + }, + { + name: "proxy echoing the request", + env: map[string]string{"GITHUB_REPOSITORY": "acme/infra", "STACKORDER_RUN_ID": testRunID, "STACKORDER_STACK": testReader}, + stack: vpcStack, + answers: map[string]served{vpcOutputsPath: {status: http.StatusBadGateway, echo: true}}, + wantRequests: client.DefaultAttempts, + wantSummary: "Stackorder server unavailable", + wantDetail: []string{"127.0.0.1", "Retry later", "The server answered 502 Bad Gateway."}, + notDetail: []string{"/v1/repos", "bad gateway for", "auth=", "Bearer", testRunID}, + }, + { + name: "unknown stack", + env: map[string]string{"GITHUB_REPOSITORY": "acme/infra"}, + stack: "stacks/prod/missing", + wantRequests: 1, + wantSummary: "Stack not found", + wantAttribute: true, + wantDetail: []string{"acme/infra//stacks/prod/missing", "The server answered 404 not_found: no such stack."}, + }, + { + name: "output not of its type", + env: map[string]string{"GITHUB_REPOSITORY": "acme/infra"}, + stack: vpcStack, + answers: map[string]served{vpcOutputsPath: okWith(malformed)}, + wantRequests: 1, + wantSummary: "Malformed stack outputs", + wantDetail: []string{`output "vpn_preshared_key"`, "does not conform to its type", "127.0.0.1"}, + }, + } +} + +func (tt outputsReadErrorCase) assertFailure(t *testing.T, srv *outputsServer, attrs map[string]tftypes.Value, diags []*tfprotov6.Diagnostic) { + t.Helper() + assert.Len(t, srv.requests(), tt.wantRequests) + for _, name := range []string{"values", "nonsensitive_values"} { + assert.True(t, attrs[name].IsNull(), "%s %s", name, attrs[name]) + } + require.Len(t, diags, 1) + d := diags[0] + assert.Equal(t, tfprotov6.DiagnosticSeverityError, d.Severity) + assert.Equal(t, tt.wantSummary, d.Summary) + if tt.wantAttribute { + assert.True(t, d.Attribute.Equal(tftypes.NewAttributePath().WithAttributeName("stack")), "attribute %s", d.Attribute) + } else { + assert.Nil(t, d.Attribute) + } + for _, want := range tt.wantDetail { + assert.Contains(t, d.Detail, want) + } + for _, unwanted := range append(tt.notDetail, testAPIKey) { + assert.NotContains(t, d.Summary+" "+d.Detail, unwanted) + } +} + +func TestOutputsDataSourceReadErrors(t *testing.T) { + for _, tt := range outputsReadErrorCases(t) { + t.Run(tt.name, func(t *testing.T) { + srv := newOutputsServer(t, tt.answers) + serverEnvironment(t, srv, tt.env) + + attrs, diags := readThroughServer(t, tt.stack) + + tt.assertFailure(t, srv, attrs, diags) + assert.True(t, attrs["id"].IsNull(), "id %s", attrs["id"]) + }) + } +} + +func TestReadFailureDiagnostics(t *testing.T) { + refusal := func(status int, code, message string) error { + return fmt.Errorf("reading the outputs of %s: %w", vpcID, &client.APIError{Status: status, Code: code, Message: message}) + } + retried := func(status int, code, message string) error { + return fmt.Errorf("reading the outputs of %s: giving up after 3 attempts: %w", vpcID, &client.APIError{Status: status, Code: code, Message: message}) + } + transport := func(rawURL string) error { + return fmt.Errorf("reading the outputs of %s: giving up after 3 attempts: %w", vpcID, + &url.Error{Op: "Get", URL: rawURL, Err: errors.New("dial tcp 192.0.2.10:443: connect: connection refused")}) + } + serverURL := "https://" + testServerHost + "/v1/repos/acme/infra/stacks/stacks%2Fprod%2Fvpc/outputs?reader=stacks%2Fprod%2Feks&run=" + testRunID + tokenURL := "https://pipelines.actions.githubusercontent.com/acme/idtoken?api-version=2.0&audience=https%3A%2F%2F" + testServerHost + + tests := []struct { + name string + err error + repo string + idTokens bool + reader string + wantSummary string + wantAttribute bool + wantDetail []string + notDetail []string + exactDetail string + }{ + { + name: v1.ErrCodeOutputsNotShared, + err: refusal(http.StatusForbidden, v1.ErrCodeOutputsNotShared, "not shared."), + wantSummary: notSharedSummary, + wantAttribute: true, + wantDetail: []string{ + "The stack " + vpcID + " does not share its outputs with the stack stacks/prod/eks: either outputs.readers on its default branch does not list it, or that outputs configuration does not resolve", + " - stacks/prod/eks\n", "The server answered 403 outputs_not_shared: not shared.", + }, + }, + { + name: v1.ErrCodeOutputsNotShared + " with an outputs configuration that does not resolve", + err: refusal(http.StatusForbidden, v1.ErrCodeOutputsNotShared, "the outputs configuration of stacks/prod/vpc on the default branch does not resolve, so its outputs are not shared: outputs.readers: not a list"), + wantSummary: notSharedSummary, + wantAttribute: true, + wantDetail: []string{"does not resolve, as the server's answer below says", "The server answered 403 outputs_not_shared: the outputs configuration of stacks/prod/vpc on the default branch does not resolve"}, + }, + { + name: v1.ErrCodeOutputsNotShared + " names the bare reader key whatever the provider's repository", + err: refusal(http.StatusForbidden, v1.ErrCodeOutputsNotShared, ""), + repo: "acme/network", + wantSummary: notSharedSummary, + wantAttribute: true, + wantDetail: []string{"does not share its outputs with the stack stacks/prod/eks:", " - stacks/prod/eks\n", "The server answered 403 outputs_not_shared."}, + notDetail: []string{"//stacks/prod/eks"}, + }, + { + name: v1.ErrCodeOutputsNotShared + " without a reader", + err: refusal(http.StatusForbidden, v1.ErrCodeOutputsNotShared, ""), + reader: "-", + wantSummary: notSharedSummary, + wantAttribute: true, + wantDetail: []string{"does not share its outputs with the reading stack:", " - \n"}, + }, + { + name: v1.ErrCodeOutputsDisabled, + err: refusal(http.StatusNotFound, v1.ErrCodeOutputsDisabled, "shared outputs are disabled"), + wantSummary: "Shared outputs disabled", + wantDetail: []string{testServerHost, "STACKORDER_STATE_BUCKETS", "operator"}, + }, + { + name: v1.ErrCodeStateLocationUnknown, + err: refusal(http.StatusConflict, v1.ErrCodeStateLocationUnknown, "no apply or drift job reported the state object"), + wantSummary: "State location unknown", + wantAttribute: true, + wantDetail: []string{vpcID, "apply or drift job", "Stackorder CLI that reports the state object", "The server answered 409 state_location_unknown"}, + }, + { + name: codeNotFound, + err: refusal(http.StatusNotFound, codeNotFound, "no such stack"), + wantSummary: "Stack not found", + wantAttribute: true, + wantDetail: []string{vpcID, "default branch of acme/infra", "without shared outputs"}, + notDetail: []string{"another repository"}, + }, + { + name: codeNotFound + " in another repository", + err: refusal(http.StatusNotFound, codeNotFound, "no such stack"), + repo: "acme/network", + wantSummary: "Stack not found", + wantAttribute: true, + wantDetail: []string{"acme/network//stacks/prod/vpc", "same Stackorder installation", "outputs.readers"}, + }, + { + name: v1.ErrCodeStateNotFound, + err: refusal(http.StatusNotFound, v1.ErrCodeStateNotFound, "the state object does not exist"), + wantSummary: "State object not found", + wantAttribute: true, + wantDetail: []string{vpcID, "moved its state", "operator"}, + }, + { + name: v1.ErrCodeStateAccessDenied, + err: refusal(http.StatusForbidden, v1.ErrCodeStateAccessDenied, "access denied"), + wantSummary: "State object access denied", + wantAttribute: true, + wantDetail: []string{"s3:GetObject", "s3:ListBucket", "kms:Decrypt", "operator"}, + }, + { + name: v1.ErrCodeStateBucketNotAllowed, + err: refusal(http.StatusForbidden, v1.ErrCodeStateBucketNotAllowed, "bucket not allowed"), + wantSummary: "State bucket not allowed", + wantAttribute: true, + wantDetail: []string{"STACKORDER_STATE_BUCKETS", "operator"}, + }, + { + name: v1.ErrCodeStateEncrypted, + err: refusal(http.StatusUnprocessableEntity, v1.ErrCodeStateEncrypted, "encrypted state"), + wantSummary: "State encrypted", + wantAttribute: true, + wantDetail: []string{"OpenTofu client-side state encryption", "never decrypts"}, + }, + { + name: v1.ErrCodeStateUnsupported, + err: refusal(http.StatusUnprocessableEntity, v1.ErrCodeStateUnsupported, "state version 3"), + wantSummary: "State format unsupported", + wantAttribute: true, + wantDetail: []string{"version 4", "The server answered 422 state_unsupported: state version 3."}, + }, + { + name: v1.ErrCodeStateTooLarge, + err: refusal(http.StatusUnprocessableEntity, v1.ErrCodeStateTooLarge, "outputs exceed 8 MiB"), + wantSummary: "State too large", + wantAttribute: true, + wantDetail: []string{"size limits", "outputs exceed 8 MiB"}, + }, + { + name: v1.ErrCodeStateUnreadable, + err: refusal(http.StatusUnprocessableEntity, v1.ErrCodeStateUnreadable, "malformed state at byte 812"), + wantSummary: "State unreadable", + wantAttribute: true, + wantDetail: []string{"could not decode", "at byte 812"}, + }, + { + name: "unauthorized API key", + err: refusal(http.StatusUnauthorized, "unauthorized", "the API key is unknown or revoked"), + wantSummary: "Stackorder credentials rejected", + wantDetail: []string{testServerHost, "api_key", "STACKORDER_API_KEY", "revoked"}, + notDetail: []string{"oidc_audience"}, + }, + { + name: "unauthorized ID token", + err: refusal(http.StatusUnauthorized, "unauthorized", "verify runner token: audience mismatch"), + idTokens: true, + wantSummary: "Stackorder credentials rejected", + wantDetail: []string{"GitHub Actions ID token", "oidc_audience", "STACKORDER_OIDC_AUDIENCE", "audience mismatch"}, + notDetail: []string{"STACKORDER_API_KEY"}, + }, + { + name: "forbidden API key", + err: refusal(http.StatusForbidden, "forbidden", "forbidden"), + wantSummary: "Stackorder server refused the read", + wantDetail: []string{"API key", "STACKORDER_API_KEY"}, + }, + { + name: "forbidden ID token", + err: refusal(http.StatusForbidden, "forbidden", "stacks/prod/eks is not a stack of the run"), + idTokens: true, + wantSummary: "Stackorder server refused the read", + wantDetail: []string{"STACKORDER_RUN_ID", "STACKORDER_STACK", "has not reported its result", "is not a stack of the run"}, + notDetail: []string{"in progress"}, + }, + { + name: "finished run", + err: refusal(http.StatusConflict, codeConflict, "run 5b1e3f0a is applied; a finished run reads no stack outputs"), + idTokens: true, + wantSummary: "Stackorder run finished", + wantDetail: []string{testServerHost, "only while the job's run is in progress", "STACKORDER_RUN_ID", "Start a new run", "The server answered 409 conflict: run 5b1e3f0a is applied"}, + }, + { + name: "superseded run", + err: refusal(http.StatusConflict, codeSuperseded, "run 5b1e3f0a was superseded by a newer commit"), + idTokens: true, + wantSummary: "Stackorder run superseded", + wantDetail: []string{testServerHost, "STACKORDER_RUN_ID", "newer run", "The server answered 409 superseded"}, + }, + { + name: "internal error", + err: retried(http.StatusInternalServerError, "internal", "internal error"), + wantSummary: "Stackorder server unavailable", + wantDetail: []string{testServerHost, "Retry later", "The server answered 500 internal: internal error."}, + }, + { + name: "unavailable from a proxy", + err: retried(http.StatusServiceUnavailable, "", "Service Unavailable"), + wantSummary: "Stackorder server unavailable", + wantDetail: []string{"Retry later", "The server answered 503 Service Unavailable."}, + }, + { + name: "proxy page echoing the request", + err: retried(http.StatusBadGateway, "", "bad gateway for /v1/repos/acme/infra/stacks/stacks%2Fprod%2Fvpc/outputs?run="+testRunID+" auth=Bearer "+testAPIKey+""), + wantSummary: "Stackorder server unavailable", + wantDetail: []string{testServerHost, "The server answered 502 Bad Gateway."}, + notDetail: []string{"/v1/repos", "", "Bearer", testRunID}, + }, + { + name: "forbidden by a proxy", + err: refusal(http.StatusForbidden, "", "Forbidden for "+testRequestToken+""), + wantSummary: "Stackorder server refused the read", + wantDetail: []string{"The server answered 403 Forbidden."}, + notDetail: []string{""}, + }, + { + name: "unknown status from a proxy", + err: refusal(599, "", "upstream"), + wantSummary: "Stackorder server unavailable", + wantDetail: []string{"The server answered 599."}, + notDetail: []string{"upstream"}, + }, + { + name: "rate limited", + err: retried(http.StatusTooManyRequests, "", "Too Many Requests"), + wantSummary: "Stackorder server unavailable", + wantDetail: []string{"Retry later"}, + }, + { + name: "invalid request", + err: refusal(http.StatusBadRequest, "invalid", "run is not a UUID"), + wantSummary: "Invalid read of stack outputs", + wantDetail: []string{testServerHost, vpcID, "STACKORDER_RUN_ID", "STACKORDER_STACK", "The server answered 400 invalid: run is not a UUID."}, + }, + { + name: "unexpected coded answer", + err: refusal(http.StatusGone, "gone", "the route moved"), + wantSummary: "Unexpected answer from the Stackorder server", + wantDetail: []string{testServerHost, vpcID, "The server answered 410 gone: the route moved."}, + }, + { + name: "redirect", + err: refusal(http.StatusFound, "", "redirected to https://login.acme.example/sso?state=abc123, which is never followed"), + wantSummary: "Stackorder server redirected the read", + wantDetail: []string{testServerHost, "never follows", "server_url", "STACKORDER_SERVER_URL", "The server answered 302 Found, redirecting to login.acme.example."}, + notDetail: []string{"/sso", "abc123", "https://"}, + }, + { + name: "redirect to a relative location", + err: refusal(http.StatusMovedPermanently, "", "redirected to /stackorder/v1/repos, which is never followed"), + wantSummary: "Stackorder server redirected the read", + wantDetail: []string{"The server answered 301 Moved Permanently."}, + notDetail: []string{"/stackorder", "redirecting to"}, + }, + { + name: "server unreachable", + err: transport(serverURL), + wantSummary: "Stackorder server unreachable", + wantDetail: []string{"Stackorder server at " + testServerHost + ": dial tcp", "connection refused", "server_url", "STACKORDER_SERVER_URL", "retry later"}, + notDetail: []string{"/v1/repos", "reader=", testRunID, "https://"}, + }, + { + name: "ID token endpoint unreachable", + err: transport(tokenURL), + idTokens: true, + wantSummary: "GitHub Actions ID token unavailable", + wantDetail: []string{"from pipelines.actions.githubusercontent.com: dial tcp", "Retry later"}, + notDetail: []string{"idtoken", "audience=", "https://"}, + }, + { + name: "undecodable answer", + err: fmt.Errorf("reading the outputs of %s: decoding the response: unexpected end of JSON input", vpcID), + wantSummary: "Cannot read stack outputs", + exactDetail: "Reading the outputs of " + vpcID + ": decoding the response: unexpected end of JSON input.", + }, + { + name: "ID token endpoint refusal", + err: fmt.Errorf("reading the outputs of %s: obtaining a token: %w", vpcID, errors.New(`the GitHub Actions ID token endpoint answered 403: {"message":"Can't issue ID_TOKEN for audience"}`)), + idTokens: true, + wantSummary: "Cannot read stack outputs", + exactDetail: "Reading the outputs of " + vpcID + `: obtaining a token: the GitHub Actions ID token endpoint answered 403: {"message":"Can't issue ID_TOKEN for audience"}.`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := &providerData{ServerHost: testServerHost, IDTokens: tt.idTokens, Repository: "acme/infra", RunID: testRunID, Reader: testReader} + switch tt.reader { + case "-": + data.Reader = "" + case "": + default: + data.Reader = tt.reader + } + repo := tt.repo + if repo == "" { + repo = "acme/infra" + } + + d := readFailure(tt.err, data, repo, vpcStack) + + assert.Equal(t, diag.SeverityError, d.Severity()) + assert.Equal(t, tt.wantSummary, d.Summary()) + if tt.wantAttribute { + assert.Equal(t, "stack", diagnosticPath(d)) + } else { + assert.Empty(t, diagnosticPath(d)) + } + if tt.exactDetail != "" { + assert.Equal(t, tt.exactDetail, d.Detail()) + } + for _, want := range tt.wantDetail { + assert.Contains(t, d.Detail(), want) + } + for _, unwanted := range append(tt.notDetail, testAPIKey, testRequestToken) { + assert.NotContains(t, d.Detail(), unwanted) + } + }) + } +} + +func testAccProtoV6ProviderFactories() map[string]func() (tfprotov6.ProviderServer, error) { + return map[string]func() (tfprotov6.ProviderServer, error){ + "stackorder": providerserver.NewProtocol6WithError(New("test")()), + } +} + +func vpcCheck(extra map[string]knownvalue.Check) knownvalue.Check { + checks := map[string]knownvalue.Check{ + "endpoints": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "dynamodb": knownvalue.StringExact("vpce-0d4e5f6a7b8c9d0e1"), + "s3": knownvalue.StringExact("vpce-0a1b2c3d4e5f6a7b8"), + }), + "nat_gateway_count": knownvalue.Int64Exact(3), + "private_subnet_ids": knownvalue.ListExact([]knownvalue.Check{ + knownvalue.StringExact("subnet-0a1b2c3d4e5f6a7b8"), + knownvalue.StringExact("subnet-0b2c3d4e5f6a7b8c9"), + }), + "tags": knownvalue.MapExact(map[string]knownvalue.Check{ + "environment": knownvalue.StringExact("prod"), + "team": knownvalue.StringExact("platform"), + }), + "vpc_id": knownvalue.StringExact("vpc-0a1b2c3d4e5f67890"), + } + maps.Copy(checks, extra) + return knownvalue.ObjectExact(checks) +} + +const vpcConfig = ` +data "stackorder_outputs" "vpc" { + stack = "stacks/prod/vpc" +} + +output "vpc_id" { + value = data.stackorder_outputs.vpc.nonsensitive_values.vpc_id +} + +output "first_private_subnet" { + value = data.stackorder_outputs.vpc.nonsensitive_values.private_subnet_ids[0] +} + +output "s3_endpoint" { + value = data.stackorder_outputs.vpc.values.endpoints.s3 + sensitive = true +} +` + +func TestAccOutputsDataSource(t *testing.T) { + srv := newOutputsServer(t, map[string]served{vpcOutputsPath: okWith(golden(t, "stack_outputs.json"))}) + serverEnvironment(t, srv, map[string]string{"GITHUB_REPOSITORY": "acme/infra"}) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: vpcConfig, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(outputsAddress, tfjsonpath.New("stack"), knownvalue.StringExact(vpcStack)), + statecheck.ExpectKnownValue(outputsAddress, tfjsonpath.New("id"), knownvalue.StringExact(vpcID)), + statecheck.ExpectKnownValue(outputsAddress, tfjsonpath.New("values"), vpcCheck(nil)), + statecheck.ExpectKnownValue(outputsAddress, tfjsonpath.New("nonsensitive_values"), vpcCheck(nil)), + statecheck.ExpectSensitiveValue(outputsAddress, tfjsonpath.New("values")), + statecheck.ExpectKnownOutputValue("vpc_id", knownvalue.StringExact("vpc-0a1b2c3d4e5f67890")), + statecheck.ExpectKnownOutputValue("first_private_subnet", knownvalue.StringExact("subnet-0a1b2c3d4e5f6a7b8")), + statecheck.ExpectKnownOutputValue("s3_endpoint", knownvalue.StringExact("vpce-0a1b2c3d4e5f6a7b8")), + }, + }, + { + PreConfig: func() { srv.serve(vpcOutputsPath, okWith(sharingSensitive(t))) }, + Config: vpcConfig + ` +output "vpn_preshared_key" { + value = data.stackorder_outputs.vpc.values.vpn_preshared_key + sensitive = true +} +`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(outputsAddress, tfjsonpath.New("values"), vpcCheck(map[string]knownvalue.Check{"vpn_preshared_key": knownvalue.StringExact(pskCanary)})), + statecheck.ExpectKnownValue(outputsAddress, tfjsonpath.New("nonsensitive_values"), vpcCheck(nil)), + statecheck.ExpectSensitiveValue(outputsAddress, tfjsonpath.New("values")), + statecheck.ExpectKnownOutputValue("vpn_preshared_key", knownvalue.StringExact(pskCanary)), + }, + }, + }, + }) +} + +func TestAccOutputsDataSourceCrossRepo(t *testing.T) { + srv := newOutputsServer(t, map[string]served{clusterPath: okWith(encode(t, v1.StackOutputs{ + Repo: "acme/platform", + Stack: "clusters/prod", + Outputs: map[string]v1.StackOutput{ + "cluster_name": {Value: json.RawMessage(`"prod"`), Type: json.RawMessage(`"string"`)}, + }, + }))}) + serverEnvironment(t, srv, map[string]string{"GITHUB_REPOSITORY": "acme/infra"}) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(), + Steps: []resource.TestStep{{ + Config: ` +data "stackorder_outputs" "cluster" { + stack = "acme/platform//clusters/prod" +} + +output "cluster_name" { + value = data.stackorder_outputs.cluster.nonsensitive_values.cluster_name +} +`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.stackorder_outputs.cluster", tfjsonpath.New("id"), knownvalue.StringExact(clusterStack)), + statecheck.ExpectKnownValue("data.stackorder_outputs.cluster", tfjsonpath.New("values"), knownvalue.ObjectExact(map[string]knownvalue.Check{"cluster_name": knownvalue.StringExact("prod")})), + statecheck.ExpectKnownOutputValue("cluster_name", knownvalue.StringExact("prod")), + }, + }}, + }) + + got := srv.requests() + require.NotEmpty(t, got) + for _, r := range got { + assert.Equal(t, clusterPath, r.path) + } +} + +func TestAccOutputsDataSourceNotShared(t *testing.T) { + srv := newOutputsServer(t, map[string]served{vpcOutputsPath: { + status: http.StatusForbidden, + body: refusalBody(v1.ErrCodeOutputsNotShared, "stacks/prod/vpc does not share its outputs with stacks/prod/eks"), + }}) + serverEnvironment(t, srv, map[string]string{"STACKORDER_REPOSITORY": "acme/infra", "STACKORDER_RUN_ID": testRunID, "STACKORDER_STACK": testReader}) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(), + Steps: []resource.TestStep{{ + Config: vpcConfig, + ExpectError: regexp.MustCompile(`(?s)` + notSharedSummary + `.*- stacks/prod/eks.*answered\s+403\s+outputs_not_shared:\s+stacks/prod/vpc\s+does\s+not\s+share`), + }}, + }) +} + +// plugin-testing cannot observe warnings, so TestOutputsDataSourceRead asserts the withheld warning. +func TestAccOutputsDataSourceWithheldWarns(t *testing.T) { + srv := newOutputsServer(t, map[string]served{vpcOutputsPath: okWith(golden(t, "stack_outputs.json"))}) + serverEnvironment(t, srv, map[string]string{"GITHUB_REPOSITORY": "acme/infra"}) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(), + Steps: []resource.TestStep{{ + Config: vpcConfig + ` +output "psk_served" { + value = contains(keys(data.stackorder_outputs.vpc.values), "vpn_preshared_key") + sensitive = true +} +`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(outputsAddress, tfjsonpath.New("values"), vpcCheck(nil)), + statecheck.ExpectKnownOutputValue("psk_served", knownvalue.Bool(false)), + statecheck.ExpectKnownOutputValue("vpc_id", knownvalue.StringExact("vpc-0a1b2c3d4e5f67890")), + }, + }}, + }) +} diff --git a/internal/provider/outputs_ephemeral.go b/internal/provider/outputs_ephemeral.go new file mode 100644 index 0000000..1ec339d --- /dev/null +++ b/internal/provider/outputs_ephemeral.go @@ -0,0 +1,95 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ ephemeral.EphemeralResource = (*outputsEphemeralResource)(nil) + _ ephemeral.EphemeralResourceWithConfigure = (*outputsEphemeralResource)(nil) +) + +type outputsEphemeralResource struct { + data *providerData +} + +type outputsEphemeralModel struct { + Stack types.String `tfsdk:"stack"` + Values types.Dynamic `tfsdk:"values"` + NonsensitiveValues types.Dynamic `tfsdk:"nonsensitive_values"` +} + +// NewOutputsEphemeralResource returns the stackorder_outputs ephemeral +// resource, which reads the outputs a stack shares through the Stackorder +// server as the data source does, but which Terraform never writes to the +// plan file or state. It implements neither Renew nor Close: the outputs +// are a copy of values in the producer's state, not a lease or a session on +// the server, so they never expire and leave nothing to release. +func NewOutputsEphemeralResource() ephemeral.EphemeralResource { + return &outputsEphemeralResource{} +} + +// Metadata names the ephemeral resource stackorder_outputs. +func (r *outputsEphemeralResource) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_outputs" +} + +// Schema describes the stack the ephemeral resource names and the values it +// reads, as the data source does but without an id. +func (r *outputsEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Reads the root-module outputs a stack shares through the Stackorder server, as the `stackorder_outputs` data source does, but Terraform never writes them to the plan file or to state. " + + "This is the form to use for sensitive outputs. It requires Terraform 1.10 or later, or OpenTofu 1.11 or later.\n\n" + + sharingDescription("ephemeral resource") + "\n\n" + + "Like every ephemeral value, the result can only be referenced where Terraform accepts ephemeral values: write-only arguments of managed resources (Terraform 1.11 or later), provider and provisioner blocks, locals, ephemeral input variables, ephemeral outputs of child modules and other ephemeral resources. " + + "Terraform reads the outputs again in every plan and apply that needs them.", + Attributes: map[string]schema.Attribute{ + "stack": schema.StringAttribute{ + Required: true, + MarkdownDescription: stackDescription, + }, + "values": schema.DynamicAttribute{ + Computed: true, + Sensitive: true, + MarkdownDescription: valuesDescription, + }, + "nonsensitive_values": schema.DynamicAttribute{ + Computed: true, + MarkdownDescription: "The object of `values` without the outputs marked sensitive. " + + "Terraform does not treat it as sensitive, but it is ephemeral like the rest of the result.", + }, + }, + } +} + +// Configure keeps the settings of the configured provider for Open. +func (r *outputsEphemeralResource) Configure(_ context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + data, problem := providerDataOf(req.ProviderData, "ephemeral resource") + if problem != nil { + resp.Diagnostics.Append(problem) + return + } + r.data = data +} + +// Open requests the stack's outputs from the Stackorder server, exactly as +// the data source's Read does, and returns them with the configured stack. +func (r *outputsEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + var model outputsEphemeralModel + resp.Diagnostics.Append(req.Config.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + read, diags := readStackOutputs(ctx, r.data, model.Stack.ValueString()) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + model.Values = read.values + model.NonsensitiveValues = read.nonsensitiveValues + resp.Diagnostics.Append(resp.Result.Set(ctx, &model)...) +} diff --git a/internal/provider/outputs_ephemeral_test.go b/internal/provider/outputs_ephemeral_test.go new file mode 100644 index 0000000..134ce14 --- /dev/null +++ b/internal/provider/outputs_ephemeral_test.go @@ -0,0 +1,403 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "maps" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/hashicorp/go-version" + tfjson "github.com/hashicorp/terraform-json" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-testing/echoprovider" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func openThroughServer(t *testing.T, stack string) (map[string]tftypes.Value, []*tfprotov6.Diagnostic) { + t.Helper() + server, schemas := configuredServer(t) + es := schemas.EphemeralResourceSchemas[outputsTypeName] + require.NotNil(t, es) + config, err := tfprotov6.NewDynamicValue(es.ValueType(), nullObject(es.Block, map[string]tftypes.Value{"stack": str(stack)})) + require.NoError(t, err) + validated, err := server.ValidateEphemeralResourceConfig(t.Context(), &tfprotov6.ValidateEphemeralResourceConfigRequest{TypeName: outputsTypeName, Config: &config}) + require.NoError(t, err) + require.Empty(t, validated.Diagnostics) + + opened, err := server.OpenEphemeralResource(t.Context(), &tfprotov6.OpenEphemeralResourceRequest{TypeName: outputsTypeName, Config: &config}) + require.NoError(t, err) + assertNoSecretsServed(t, opened.Diagnostics) + assert.True(t, opened.RenewAt.IsZero(), "renew at %s", opened.RenewAt) + assert.Nil(t, opened.Deferred) + return objectAttributes(t, opened.Result, es.ValueType()), opened.Diagnostics +} + +func TestOutputsEphemeralSchema(t *testing.T) { + server, err := providerserver.NewProtocol6WithError(New("test")())() + require.NoError(t, err) + schemas, err := server.GetProviderSchema(t.Context(), &tfprotov6.GetProviderSchemaRequest{}) + require.NoError(t, err) + es := schemas.EphemeralResourceSchemas[outputsTypeName] + require.NotNil(t, es) + ds := schemas.DataSourceSchemas[outputsTypeName] + require.NotNil(t, ds) + + type flags struct{ required, computed, sensitive bool } + got := map[string]flags{} + types := map[string]tftypes.Type{} + descriptions := map[string]string{} + for _, a := range es.Block.Attributes { + got[a.Name] = flags{required: a.Required, computed: a.Computed, sensitive: a.Sensitive} + types[a.Name] = a.Type + descriptions[a.Name] = a.Description + } + assert.Equal(t, map[string]flags{ + "stack": {required: true}, + "values": {computed: true, sensitive: true}, + "nonsensitive_values": {computed: true}, + }, got) + assert.Equal(t, map[string]tftypes.Type{ + "stack": tftypes.String, + "values": tftypes.DynamicPseudoType, + "nonsensitive_values": tftypes.DynamicPseudoType, + }, types) + assert.Empty(t, es.Block.BlockTypes) + for _, a := range ds.Block.Attributes { + if a.Name == "stack" || a.Name == "values" { + assert.Equal(t, a.Description, descriptions[a.Name], "description of %s", a.Name) + } + } + assert.Contains(t, descriptions["nonsensitive_values"], "ephemeral") + for _, want := range []string{"never writes them to the plan file or to state", "sensitive outputs", "Terraform 1.10", "OpenTofu 1.11", "outputs.readers", "outputs.sensitive: share", "the ephemeral resource warns", "default branch", "write-only arguments"} { + assert.Contains(t, es.Block.Description, want) + } +} + +func TestOutputsEphemeralOpen(t *testing.T) { + for _, tt := range outputsReadCases(t) { + t.Run(tt.name, func(t *testing.T) { + srv := newOutputsServer(t, map[string]served{tt.path: okWith(tt.body)}) + serverEnvironment(t, srv, tt.env) + + attrs, diags := openThroughServer(t, tt.stack) + + tt.assertRead(t, srv, attrs, diags) + assert.Equal(t, []string{"nonsensitive_values", "stack", "values"}, slices.Sorted(maps.Keys(attrs))) + }) + } +} + +func TestOutputsEphemeralOpenErrors(t *testing.T) { + for _, tt := range outputsReadErrorCases(t) { + t.Run(tt.name, func(t *testing.T) { + srv := newOutputsServer(t, tt.answers) + serverEnvironment(t, srv, tt.env) + + attrs, diags := openThroughServer(t, tt.stack) + + tt.assertFailure(t, srv, attrs, diags) + assert.NotContains(t, attrs, "id") + }) + } +} + +func TestOutputsConfigure(t *testing.T) { + configured := &providerData{ServerHost: testServerHost, Repository: "acme/infra"} + kinds := []struct { + name string + configure func(t *testing.T, raw any) (*providerData, diag.Diagnostics) + }{ + { + name: "data source", + configure: func(t *testing.T, raw any) (*providerData, diag.Diagnostics) { + d := &outputsDataSource{} + var resp datasource.ConfigureResponse + d.Configure(t.Context(), datasource.ConfigureRequest{ProviderData: raw}, &resp) + return d.data, resp.Diagnostics + }, + }, + { + name: "ephemeral resource", + configure: func(t *testing.T, raw any) (*providerData, diag.Diagnostics) { + r := &outputsEphemeralResource{} + var resp ephemeral.ConfigureResponse + r.Configure(t.Context(), ephemeral.ConfigureRequest{ProviderData: raw}, &resp) + return r.data, resp.Diagnostics + }, + }, + } + tests := []struct { + name string + raw any + want *providerData + wantDetail string + }{ + {name: "before the provider is configured"}, + {name: "configured provider", raw: configured, want: configured}, + {name: "provider data of another type", raw: "acme", wantDetail: "received provider data of type string"}, + } + for _, kind := range kinds { + for _, tt := range tests { + t.Run(kind.name+"/"+tt.name, func(t *testing.T) { + got, diags := kind.configure(t, tt.raw) + + assert.True(t, got == tt.want, "provider data %+v", got) + if tt.wantDetail == "" { + assert.Empty(t, diags) + return + } + require.Len(t, diags, 1) + assert.Equal(t, diag.SeverityError, diags[0].Severity()) + assert.Equal(t, "Unexpected provider data", diags[0].Summary()) + assert.Contains(t, diags[0].Detail(), "The stackorder_outputs "+kind.name+" "+tt.wantDetail) + }) + } + } +} + +func testAccProtoV6ProviderFactoriesWithEcho() map[string]func() (tfprotov6.ProviderServer, error) { + factories := testAccProtoV6ProviderFactories() + factories["echo"] = echoprovider.NewProviderServer() + return factories +} + +func cliIsOpenTofu() bool { + return strings.Contains(filepath.Base(os.Getenv("TF_ACC_TERRAFORM_PATH")), "tofu") || os.Getenv("TF_ACC_PROVIDER_HOST") == "registry.opentofu.org" +} + +func TestCLIIsOpenTofu(t *testing.T) { + tests := []struct { + name string + path string + host string + want bool + }{ + {name: "tofu binary", path: "/opt/bin/tofu", want: true}, + {name: "tofu binary with an extension", path: "/opt/bin/tofu.exe", want: true}, + {name: "terraform binary", path: "/usr/bin/terraform"}, + {name: "terraform binary in a directory named after tofu", path: "/opt/tofu-1.11/terraform"}, + {name: "terraform binary with the OpenTofu registry host", path: "/usr/bin/terraform", host: "registry.opentofu.org", want: true}, + {name: "terraform binary with the Terraform registry host", path: "/usr/bin/terraform", host: "registry.terraform.io"}, + {name: "neither set"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("TF_ACC_TERRAFORM_PATH", tt.path) + t.Setenv("TF_ACC_PROVIDER_HOST", tt.host) + + assert.Equal(t, tt.want, cliIsOpenTofu()) + }) + } +} + +func ephemeralResourcesSupported() tfversion.TerraformVersionCheck { + // tofu version -json reports OpenTofu's own version, and OpenTofu has ephemeral resources only from 1.11. + if cliIsOpenTofu() { + return tfversion.SkipBelow(tfversion.Version1_11_0) + } + return tfversion.SkipBelow(tfversion.Version1_10_0) +} + +func tofuPlansEphemeralOpens(versionJSON []byte) (bool, error) { + var reported struct { + Version string `json:"terraform_version"` + } + if err := json.Unmarshal(versionJSON, &reported); err != nil { + return false, fmt.Errorf("decode the CLI version: %w", err) + } + v, err := version.NewVersion(reported.Version) + if err != nil { + return false, fmt.Errorf("parse the CLI version: %w", err) + } + // OpenTofu 1.11 plans opening an ephemeral resource as a change, so its plan after apply is never empty. + return v.LessThan(tfversion.Version1_12_0), nil +} + +func TestTofuPlansEphemeralOpens(t *testing.T) { + tests := []struct { + name string + versionJSON string + want bool + wantErr string + }{ + {name: "1.11.0", versionJSON: `{"terraform_version":"1.11.0","platform":"linux_amd64","provider_selections":{}}`, want: true}, + {name: "1.11.14", versionJSON: `{"terraform_version":"1.11.14","platform":"darwin_arm64","provider_selections":{}}`, want: true}, + {name: "1.12.0", versionJSON: `{"terraform_version":"1.12.0","platform":"linux_amd64","provider_selections":{}}`}, + {name: "1.12.6", versionJSON: `{"terraform_version":"1.12.6","platform":"darwin_arm64","provider_selections":{}}`}, + {name: "1.13.1", versionJSON: `{"terraform_version":"1.13.1","platform":"darwin_arm64","provider_selections":{}}`}, + {name: "output that is not json", versionJSON: `OpenTofu v1.11.14`, wantErr: "decode the CLI version"}, + {name: "no version", versionJSON: `{"platform":"darwin_arm64"}`, wantErr: "parse the CLI version"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tofuPlansEphemeralOpens([]byte(tt.versionJSON)) + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func cliPlansEphemeralOpens(t *testing.T) bool { + t.Helper() + path := os.Getenv("TF_ACC_TERRAFORM_PATH") + if os.Getenv("TF_ACC") == "" || path == "" || !cliIsOpenTofu() { + return false + } + out, err := exec.CommandContext(t.Context(), path, "version", "-json").Output() + require.NoError(t, err) + opens, err := tofuPlansEphemeralOpens(out) + require.NoError(t, err) + return opens +} + +const ( + echoAddress = "echo.x" + ephemeralMode = tfjson.ResourceMode("ephemeral") +) + +func holdsOnlyEcho(m *tfjson.StateModule) error { + if m == nil { + return fmt.Errorf("no root module, want only %s", echoAddress) + } + addresses := make([]string, 0, len(m.Resources)) + for _, r := range m.Resources { + addresses = append(addresses, r.Address) + } + if !slices.Equal(addresses, []string{echoAddress}) || len(m.ChildModules) > 0 { + return fmt.Errorf("the root module holds %v and %d child modules, want only %s", addresses, len(m.ChildModules), echoAddress) + } + return nil +} + +type stateHoldingOnlyEcho struct{ secret string } + +func (c stateHoldingOnlyEcho) CheckState(_ context.Context, req statecheck.CheckStateRequest, resp *statecheck.CheckStateResponse) { + if req.State == nil || req.State.Values == nil { + resp.Error = fmt.Errorf("no state to check") + return + } + root := req.State.Values.RootModule + if err := holdsOnlyEcho(root); err != nil { + resp.Error = err + return + } + all, err := json.Marshal(req.State) + if err != nil { + resp.Error = err + return + } + echo, err := json.Marshal(root.Resources[0]) + if err != nil { + resp.Error = err + return + } + if bytes.Count(all, []byte(c.secret)) != bytes.Count(echo, []byte(c.secret)) { + resp.Error = fmt.Errorf("state holds the value of a sensitive output outside %s", echoAddress) + } +} + +type planHoldingOnlyEcho struct{ secret string } + +func (c planHoldingOnlyEcho) CheckPlan(_ context.Context, req plancheck.CheckPlanRequest, resp *plancheck.CheckPlanResponse) { + raw, err := json.Marshal(req.Plan) + if err != nil { + resp.Error = err + return + } + if bytes.Contains(raw, []byte(c.secret)) { + resp.Error = fmt.Errorf("the plan holds the value of a sensitive output") + return + } + changed := make([]string, 0, len(req.Plan.ResourceChanges)) + for _, rc := range req.Plan.ResourceChanges { + if rc.Mode == ephemeralMode { + continue + } + changed = append(changed, rc.Address) + } + if !slices.Equal(changed, []string{echoAddress}) { + resp.Error = fmt.Errorf("the plan changes %v, want only %s", changed, echoAddress) + return + } + if req.Plan.PlannedValues == nil { + resp.Error = fmt.Errorf("the plan has no planned values") + return + } + resp.Error = holdsOnlyEcho(withoutEphemeral(req.Plan.PlannedValues.RootModule)) +} + +func withoutEphemeral(m *tfjson.StateModule) *tfjson.StateModule { + if m == nil { + return nil + } + kept := *m + kept.Resources = slices.DeleteFunc(slices.Clone(m.Resources), func(r *tfjson.StateResource) bool { return r.Mode == ephemeralMode }) + return &kept +} + +func TestAccOutputsEphemeral(t *testing.T) { + srv := newOutputsServer(t, map[string]served{vpcOutputsPath: okWith(sharingSensitive(t))}) + serverEnvironment(t, srv, map[string]string{"GITHUB_REPOSITORY": "acme/infra"}) + + resource.Test(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ephemeralResourcesSupported()}, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactoriesWithEcho(), + Steps: []resource.TestStep{{ + Config: ` +ephemeral "stackorder_outputs" "vpc" { + stack = "stacks/prod/vpc" +} + +provider "echo" { + data = ephemeral.stackorder_outputs.vpc +} + +resource "echo" "x" {} +`, + ExpectNonEmptyPlan: cliPlansEphemeralOpens(t), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{planHoldingOnlyEcho{secret: pskCanary}}, + PostApplyPreRefresh: []plancheck.PlanCheck{plancheck.ExpectResourceAction(echoAddress, plancheck.ResourceActionNoop)}, + PostApplyPostRefresh: []plancheck.PlanCheck{plancheck.ExpectResourceAction(echoAddress, plancheck.ResourceActionNoop)}, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(echoAddress, tfjsonpath.New("data"), knownvalue.ObjectExact(map[string]knownvalue.Check{ + "stack": knownvalue.StringExact(vpcStack), + "values": vpcCheck(map[string]knownvalue.Check{"vpn_preshared_key": knownvalue.StringExact(pskCanary)}), + "nonsensitive_values": vpcCheck(nil), + })), + stateHoldingOnlyEcho{secret: pskCanary}, + }, + }}, + }) + + got := srv.requests() + require.NotEmpty(t, got) + for _, r := range got { + assert.Equal(t, vpcOutputsPath, r.path) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..838a0d0 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,220 @@ +// Package provider implements the stackorder provider on the Terraform Plugin Framework. +package provider + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/stackorder/terraform-provider-stackorder/internal/client" +) + +const ( + envServerURL = "STACKORDER_SERVER_URL" + envRepository = "STACKORDER_REPOSITORY" + envGitHubRepository = "GITHUB_REPOSITORY" + envRunID = "STACKORDER_RUN_ID" + envStack = "STACKORDER_STACK" +) + +var ( + _ provider.Provider = (*stackorderProvider)(nil) + _ provider.ProviderWithEphemeralResources = (*stackorderProvider)(nil) +) + +type stackorderProvider struct { + version string +} + +type providerModel struct { + ServerURL types.String `tfsdk:"server_url"` + APIKey types.String `tfsdk:"api_key"` + Repository types.String `tfsdk:"repository"` + OIDCAudience types.String `tfsdk:"oidc_audience"` +} + +type providerData struct { + // Client reads stack outputs from the configured server. + Client *client.Client + // ServerHost is the host of the server URL, the only part of it that + // diagnostics name. + ServerHost string + // IDTokens reports whether the provider authenticates with GitHub + // Actions ID tokens rather than an API key. + IDTokens bool + // Repository is the "owner/repo" of the stacks being planned, empty when + // no setting names it. + Repository string + // RunID is the Stackorder run of the job Terraform runs in, empty outside + // a Stackorder job. + RunID string + // Reader is the key of the stack the job plans or applies, empty outside + // a Stackorder job. + Reader string +} + +// New returns a constructor of the stackorder provider reporting version. +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &stackorderProvider{version: version} + } +} + +func (p *stackorderProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "stackorder" + resp.Version = p.version +} + +func (p *stackorderProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Reads the outputs that stacks share through a [Stackorder](https://github.com/stackorder/stackorder) server, so a stack can use another stack's outputs without access to its state.", + Attributes: map[string]schema.Attribute{ + "server_url": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Base URL of the Stackorder server, such as `https://stackorder.acme.example`. Defaults to the `" + envServerURL + "` environment variable, which the Stackorder CLI sets for Terraform in its jobs. One of the two is required.", + }, + "api_key": schema.StringAttribute{ + Optional: true, + Sensitive: true, + MarkdownDescription: "Automation API key (`sk_…`) the provider authenticates with outside GitHub Actions. Defaults to the `" + client.EnvAPIKey + "` environment variable. Ignored whenever the job can request GitHub Actions ID tokens, which take precedence. API keys are admin keys of the server: keep them out of CI.", + }, + "repository": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Repository of the stacks being planned, as `owner/repo`, which a stack named without an `owner/repo//` prefix belongs to. Defaults to the `" + envRepository + "` environment variable, which the Stackorder CLI sets, else `" + envGitHubRepository + "`.", + }, + "oidc_audience": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Audience of the GitHub Actions ID tokens the provider sends, which must be one the server accepts. Defaults to the `" + client.EnvOIDCAudience + "` environment variable, else the server URL (`server_url` or `" + envServerURL + "`) as configured, trailing slash included.", + }, + }, + } +} + +func (p *stackorderProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var cfg providerModel + resp.Diagnostics.Append(req.Config.Get(ctx, &cfg)...) + if resp.Diagnostics.HasError() { + return + } + for _, a := range []struct { + name, label, env string + value types.String + }{ + {name: "server_url", label: "server URL", env: envServerURL, value: cfg.ServerURL}, + {name: "api_key", label: "API key", env: client.EnvAPIKey, value: cfg.APIKey}, + {name: "repository", label: "repository", env: envRepository, value: cfg.Repository}, + {name: "oidc_audience", label: "OIDC audience", env: client.EnvOIDCAudience, value: cfg.OIDCAudience}, + } { + if a.value.IsUnknown() { + resp.Diagnostics.AddAttributeError(path.Root(a.name), "Unknown Stackorder "+a.label, + fmt.Sprintf("The provider cannot be configured while %s is unknown. Set it to a value known at plan time, apply the resources it depends on first, or leave it unset and set %s instead.", a.name, a.env)) + } + } + if resp.Diagnostics.HasError() { + return + } + + serverURL, serverURLSource := strings.TrimSpace(cfg.ServerURL.ValueString()), "server_url" + if serverURL == "" { + serverURL, serverURLSource = strings.TrimSpace(os.Getenv(envServerURL)), envServerURL + } + if serverURL == "" { + resp.Diagnostics.AddAttributeError(path.Root("server_url"), "Missing Stackorder server URL", + "Set the provider's server_url or "+envServerURL+" to the base URL of the Stackorder server. Inside a Stackorder job the Stackorder CLI sets "+envServerURL+".") + return + } + tokens, err := client.NewTokenSource(client.Credentials{ + ServerURL: serverURL, + OIDCAudience: cfg.OIDCAudience.ValueString(), + APIKey: cfg.APIKey.ValueString(), + }) + switch { + case errors.Is(err, client.ErrNoCredentials): + resp.Diagnostics.AddAttributeError(path.Root("api_key"), "Missing Stackorder credentials", err.Error()) + return + case err != nil: + resp.Diagnostics.AddError("Invalid Stackorder credentials", err.Error()) + return + } + + data := &providerData{ + Repository: firstNonEmpty(cfg.Repository.ValueString(), os.Getenv(envRepository), os.Getenv(envGitHubRepository)), + RunID: strings.TrimSpace(os.Getenv(envRunID)), + Reader: strings.TrimSpace(os.Getenv(envStack)), + ServerHost: hostOf(serverURL), + IDTokens: client.ActionsIDTokensAvailable(), + } + if data.IDTokens { + if missing := missingJobVariables(data); missing != "" { + resp.Diagnostics.AddError("Not inside a Stackorder job", fmt.Sprintf( + "%s. This job can request GitHub Actions ID tokens, so the provider authenticates with them, and reads with an ID token work only inside a Stackorder plan, apply or drift job, where the Stackorder CLI sets %s and %s for Terraform. Outside one, configure api_key or %s in an environment without ID tokens, since they take precedence over an API key.", + missing, envRunID, envStack, client.EnvAPIKey)) + } + } + data.Client, err = client.New(serverURL, tokens, client.WithVersion(p.version)) + if err != nil { + detail := fmt.Sprintf("The URL in %s is not usable: %s.", serverURLSource, err) + if serverURLSource == envServerURL { + resp.Diagnostics.AddError("Invalid Stackorder server URL", detail) + } else { + resp.Diagnostics.AddAttributeError(path.Root("server_url"), "Invalid Stackorder server URL", detail) + } + } + if resp.Diagnostics.HasError() { + return + } + resp.DataSourceData = data + resp.EphemeralResourceData = data +} + +func (p *stackorderProvider) DataSources(_ context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{NewOutputsDataSource} +} + +func (p *stackorderProvider) EphemeralResources(_ context.Context) []func() ephemeral.EphemeralResource { + return []func() ephemeral.EphemeralResource{NewOutputsEphemeralResource} +} + +func (p *stackorderProvider) Resources(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{} +} + +func missingJobVariables(data *providerData) string { + switch { + case data.RunID == "" && data.Reader == "": + return envRunID + " and " + envStack + " are not set" + case data.RunID == "": + return envRunID + " is not set" + case data.Reader == "": + return envStack + " is not set" + } + return "" +} + +func hostOf(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + return u.Host +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v = strings.TrimSpace(v); v != "" { + return v + } + } + return "" +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..60b7539 --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,681 @@ +package provider + +import ( + "encoding/json" + "maps" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "sync" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/stackorder/stackorder/api/v1" + + "github.com/stackorder/terraform-provider-stackorder/internal/client" +) + +const ( + testAPIKey = "sk_acme_automation" + testRequestToken = "actions-request-token" + testURLPassword = "hunter2" + testRunID = "5b1e3f0a-8c2d-4c1b-9a51-0d7f3e2a9b11" + testReader = "stacks/prod/eks" + testOutputsPath = "/v1/repos/acme/network/stacks/stacks%2Fprod%2Fvpc/outputs" +) + +type request struct { + path string + query url.Values + auth string + userAgent string +} + +type fakeStackorder struct { + url string + mu sync.Mutex + got []request +} + +func newFakeStackorder(t *testing.T) *fakeStackorder { + t.Helper() + f := &fakeStackorder{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.got = append(f.got, request{ + path: r.URL.EscapedPath(), + query: r.URL.Query(), + auth: r.Header.Get("Authorization"), + userAgent: r.Header.Get("User-Agent"), + }) + f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v1.StackOutputs{Repo: "acme/network", Stack: "stacks/prod/vpc", Outputs: map[string]v1.StackOutput{}}) + })) + t.Cleanup(srv.Close) + f.url = srv.URL + return f +} + +func (f *fakeStackorder) read(t *testing.T, data *providerData) request { + t.Helper() + f.mu.Lock() + before := len(f.got) + f.mu.Unlock() + out, err := data.Client.StackOutputs(t.Context(), "acme/network", "stacks/prod/vpc", data.RunID, data.Reader) + require.NoError(t, err) + assert.Equal(t, "stacks/prod/vpc", out.Stack) + f.mu.Lock() + defer f.mu.Unlock() + require.Len(t, f.got, before+1) + return f.got[before] +} + +func fakeActions(t *testing.T) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+testRequestToken { + http.Error(w, `{"message":"bad token request"}`, http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"value": "id-token-for:" + r.URL.Query().Get("audience")}) + })) + t.Cleanup(srv.Close) + t.Setenv(client.EnvOIDCRequestURL, srv.URL+"/idtoken?api-version=2.0") + t.Setenv(client.EnvOIDCRequestToken, testRequestToken) +} + +func cleanEnvironment(t *testing.T, env map[string]string) { + t.Helper() + for _, name := range []string{ + envServerURL, envRepository, envGitHubRepository, envRunID, envStack, + client.EnvAPIKey, client.EnvOIDCAudience, client.EnvOIDCRequestURL, client.EnvOIDCRequestToken, + } { + t.Setenv(name, "") + } + for name, value := range env { + t.Setenv(name, value) + } +} + +func known(s string) tftypes.Value { return tftypes.NewValue(tftypes.String, s) } + +func unknown() tftypes.Value { return tftypes.NewValue(tftypes.String, tftypes.UnknownValue) } + +func providerSchema(t *testing.T) schema.Schema { + t.Helper() + var resp provider.SchemaResponse + New("test")().Schema(t.Context(), provider.SchemaRequest{}, &resp) + require.False(t, resp.Diagnostics.HasError(), "%v", resp.Diagnostics) + return resp.Schema +} + +func configure(t *testing.T, config map[string]tftypes.Value) provider.ConfigureResponse { + t.Helper() + s := providerSchema(t) + values := make(map[string]tftypes.Value, len(s.Attributes)) + for name := range s.Attributes { + values[name] = tftypes.NewValue(tftypes.String, nil) + } + for name, v := range config { + require.Contains(t, values, name) + values[name] = v + } + var resp provider.ConfigureResponse + New("test")().Configure(t.Context(), provider.ConfigureRequest{ + TerraformVersion: "1.16.5", + Config: tfsdk.Config{Schema: s, Raw: tftypes.NewValue(s.Type().TerraformType(t.Context()), values)}, + }, &resp) + assertNoSecrets(t, resp.Diagnostics) + return resp +} + +func configured(t *testing.T, resp provider.ConfigureResponse) *providerData { + t.Helper() + require.False(t, resp.Diagnostics.HasError(), "%v", resp.Diagnostics) + assert.Empty(t, resp.Diagnostics) + data, ok := resp.DataSourceData.(*providerData) + require.True(t, ok, "DataSourceData is %T", resp.DataSourceData) + assert.Same(t, data, resp.EphemeralResourceData) + require.NotNil(t, data.Client) + return data +} + +func requireError(t *testing.T, resp provider.ConfigureResponse, attribute, summary string) diag.Diagnostic { + t.Helper() + assert.Nil(t, resp.DataSourceData) + assert.Nil(t, resp.EphemeralResourceData) + require.Len(t, resp.Diagnostics, 1, "%v", resp.Diagnostics) + d := resp.Diagnostics[0] + assert.Equal(t, diag.SeverityError, d.Severity()) + assert.Equal(t, summary, d.Summary()) + assert.Equal(t, attribute, diagnosticPath(d)) + return d +} + +func diagnosticPath(d diag.Diagnostic) string { + if wp, ok := d.(diag.DiagnosticWithPath); ok { + return wp.Path().String() + } + return "" +} + +func assertNoSecrets(t *testing.T, diags diag.Diagnostics) { + t.Helper() + for _, d := range diags { + for _, secret := range []string{testAPIKey, testRequestToken, testURLPassword} { + assert.NotContains(t, d.Summary()+" "+d.Detail(), secret) + } + } +} + +func TestProviderServesProtocol6(t *testing.T) { + tests := []struct { + name string + version string + }{ + {name: "local build", version: "dev"}, + {name: "test build", version: "test"}, + {name: "release", version: "0.1.0"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := New(tt.version)() + + var meta provider.MetadataResponse + p.Metadata(t.Context(), provider.MetadataRequest{}, &meta) + assert.Equal(t, "stackorder", meta.TypeName) + assert.Equal(t, tt.version, meta.Version) + + server, err := providerserver.NewProtocol6WithError(p)() + require.NoError(t, err) + + resp, err := server.GetProviderSchema(t.Context(), &tfprotov6.GetProviderSchemaRequest{}) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Empty(t, resp.Diagnostics) + require.NotNil(t, resp.Provider) + require.NotNil(t, resp.Provider.Block) + attributes := map[string]bool{} + for _, a := range resp.Provider.Block.Attributes { + attributes[a.Name] = a.Sensitive + } + assert.Equal(t, map[string]bool{"server_url": false, "api_key": true, "repository": false, "oidc_audience": false}, attributes) + assert.Empty(t, resp.Provider.Block.BlockTypes) + assert.Equal(t, []string{"stackorder_outputs"}, slices.Sorted(maps.Keys(resp.DataSourceSchemas))) + assert.Equal(t, []string{"stackorder_outputs"}, slices.Sorted(maps.Keys(resp.EphemeralResourceSchemas))) + assert.Empty(t, resp.ResourceSchemas) + }) + } +} + +func TestProviderSchema(t *testing.T) { + attributes := providerSchema(t).Attributes + tests := []struct { + name string + sensitive bool + envs []string + }{ + {name: "server_url", envs: []string{"STACKORDER_SERVER_URL"}}, + {name: "api_key", sensitive: true, envs: []string{"STACKORDER_API_KEY"}}, + {name: "repository", envs: []string{"STACKORDER_REPOSITORY", "GITHUB_REPOSITORY"}}, + {name: "oidc_audience", envs: []string{"STACKORDER_OIDC_AUDIENCE"}}, + } + require.Len(t, attributes, len(tests)) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Contains(t, attributes, tt.name) + a, ok := attributes[tt.name].(schema.StringAttribute) + require.True(t, ok, "%s is %T", tt.name, attributes[tt.name]) + assert.True(t, a.IsOptional()) + assert.False(t, a.IsRequired()) + assert.False(t, a.IsComputed()) + assert.Equal(t, tt.sensitive, a.IsSensitive()) + for _, env := range tt.envs { + assert.Contains(t, a.MarkdownDescription, "`"+env+"`") + } + }) + } +} + +func TestConfigureServerURL(t *testing.T) { + srv := newFakeStackorder(t) + tests := []struct { + name string + config map[string]tftypes.Value + env map[string]string + wantPrefix string + wantAttribute string + wantSummary string + wantDetail string + }{ + { + name: "from the attribute", + config: map[string]tftypes.Value{"server_url": known(srv.url + "/attribute")}, + wantPrefix: "/attribute", + }, + { + name: "from STACKORDER_SERVER_URL", + env: map[string]string{"STACKORDER_SERVER_URL": srv.url + "/environment"}, + wantPrefix: "/environment", + }, + { + name: "attribute over the environment", + config: map[string]tftypes.Value{"server_url": known(srv.url + "/attribute")}, + env: map[string]string{"STACKORDER_SERVER_URL": srv.url + "/environment"}, + wantPrefix: "/attribute", + }, + { + name: "blank attribute falls back to the environment", + config: map[string]tftypes.Value{"server_url": known(" ")}, + env: map[string]string{"STACKORDER_SERVER_URL": srv.url + "/environment"}, + wantPrefix: "/environment", + }, + { + name: "surrounding whitespace trimmed", + config: map[string]tftypes.Value{"server_url": known(" " + srv.url + "/attribute/\n")}, + wantPrefix: "/attribute", + }, + { + name: "missing", + wantAttribute: "server_url", + wantSummary: "Missing Stackorder server URL", + wantDetail: "STACKORDER_SERVER_URL", + }, + { + name: "not an http URL", + config: map[string]tftypes.Value{"server_url": known("ftp://stackorder.acme.example")}, + wantAttribute: "server_url", + wantSummary: "Invalid Stackorder server URL", + wantDetail: "The URL in server_url is not usable: the Stackorder server URL is not an http or https URL with a host.", + }, + { + name: "user info", + config: map[string]tftypes.Value{"server_url": known("https://admin:" + testURLPassword + "@stackorder.acme.example")}, + wantAttribute: "server_url", + wantSummary: "Invalid Stackorder server URL", + wantDetail: "The URL in server_url is not usable: the Stackorder server URL carries user info, a query or a fragment.", + }, + { + name: "user info without a scheme", + config: map[string]tftypes.Value{"server_url": known("admin:" + testURLPassword + "@stackorder.acme.example")}, + wantAttribute: "server_url", + wantSummary: "Invalid Stackorder server URL", + wantDetail: "The URL in server_url is not usable: the Stackorder server URL is not an http or https URL with a host.", + }, + { + name: "key in the query", + config: map[string]tftypes.Value{"server_url": known("https://stackorder.acme.example/?key=" + testAPIKey)}, + wantAttribute: "server_url", + wantSummary: "Invalid Stackorder server URL", + wantDetail: "The URL in server_url is not usable: the Stackorder server URL carries user info, a query or a fragment.", + }, + { + name: "invalid STACKORDER_SERVER_URL", + env: map[string]string{"STACKORDER_SERVER_URL": "stackorder.acme.example"}, + wantSummary: "Invalid Stackorder server URL", + wantDetail: "The URL in STACKORDER_SERVER_URL is not usable: the Stackorder server URL is not an http or https URL with a host.", + }, + { + name: "password read as a port in STACKORDER_SERVER_URL", + env: map[string]string{"STACKORDER_SERVER_URL": "https://admin:" + testURLPassword}, + wantSummary: "Invalid Stackorder server URL", + wantDetail: "The URL in STACKORDER_SERVER_URL is not usable: the Stackorder server URL does not parse as a URL.", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanEnvironment(t, tt.env) + t.Setenv("STACKORDER_API_KEY", testAPIKey) + + resp := configure(t, tt.config) + if tt.wantSummary != "" { + d := requireError(t, resp, tt.wantAttribute, tt.wantSummary) + assert.Contains(t, d.Detail(), tt.wantDetail) + return + } + got := srv.read(t, configured(t, resp)) + assert.Equal(t, tt.wantPrefix+testOutputsPath, got.path) + assert.Equal(t, "Bearer "+testAPIKey, got.auth) + assert.Equal(t, "terraform-provider-stackorder/test", got.userAgent) + }) + } +} + +func TestConfigureCredentials(t *testing.T) { + srv := newFakeStackorder(t) + job := map[string]string{"STACKORDER_RUN_ID": testRunID, "STACKORDER_STACK": testReader} + tests := []struct { + name string + actions bool + config map[string]tftypes.Value + env map[string]string + wantAuth string + wantRunID string + wantReader string + }{ + { + name: "API key from the attribute", + config: map[string]tftypes.Value{"api_key": known(testAPIKey)}, + wantAuth: "Bearer " + testAPIKey, + }, + { + name: "API key from STACKORDER_API_KEY", + env: map[string]string{"STACKORDER_API_KEY": testAPIKey}, + wantAuth: "Bearer " + testAPIKey, + }, + { + name: "attribute key over the environment", + config: map[string]tftypes.Value{"api_key": known("sk_acme_attribute")}, + env: map[string]string{"STACKORDER_API_KEY": testAPIKey}, + wantAuth: "Bearer sk_acme_attribute", + }, + { + name: "API key in a Stackorder job outside GitHub Actions", + env: map[string]string{"STACKORDER_API_KEY": testAPIKey, "STACKORDER_RUN_ID": testRunID, "STACKORDER_STACK": testReader}, + wantAuth: "Bearer " + testAPIKey, + wantRunID: testRunID, + wantReader: testReader, + }, + { + name: "ID token in a Stackorder job", + actions: true, + env: job, + wantAuth: "Bearer id-token-for:" + srv.url, + wantRunID: testRunID, + wantReader: testReader, + }, + { + name: "ID token audience from oidc_audience", + actions: true, + config: map[string]tftypes.Value{"oidc_audience": known("stackorder-acme")}, + env: map[string]string{"STACKORDER_OIDC_AUDIENCE": "stackorder-environment", "STACKORDER_RUN_ID": testRunID, "STACKORDER_STACK": testReader}, + wantAuth: "Bearer id-token-for:stackorder-acme", + wantRunID: testRunID, + wantReader: testReader, + }, + { + name: "ID token audience from STACKORDER_OIDC_AUDIENCE", + actions: true, + env: map[string]string{"STACKORDER_OIDC_AUDIENCE": "stackorder-environment", "STACKORDER_RUN_ID": testRunID, "STACKORDER_STACK": testReader}, + wantAuth: "Bearer id-token-for:stackorder-environment", + wantRunID: testRunID, + wantReader: testReader, + }, + { + name: "ID token audience from STACKORDER_SERVER_URL as set", + actions: true, + config: map[string]tftypes.Value{"server_url": tftypes.NewValue(tftypes.String, nil)}, + env: map[string]string{"STACKORDER_SERVER_URL": srv.url + "/", "STACKORDER_RUN_ID": testRunID, "STACKORDER_STACK": testReader}, + wantAuth: "Bearer id-token-for:" + srv.url + "/", + wantRunID: testRunID, + wantReader: testReader, + }, + { + name: "ID token over a configured API key", + actions: true, + config: map[string]tftypes.Value{"api_key": known("sk_acme_attribute")}, + env: map[string]string{"STACKORDER_API_KEY": testAPIKey, "STACKORDER_RUN_ID": testRunID, "STACKORDER_STACK": testReader}, + wantAuth: "Bearer id-token-for:" + srv.url, + wantRunID: testRunID, + wantReader: testReader, + }, + { + name: "run and stack trimmed", + actions: true, + env: map[string]string{"STACKORDER_RUN_ID": " " + testRunID + "\n", "STACKORDER_STACK": "\t" + testReader + " "}, + wantAuth: "Bearer id-token-for:" + srv.url, + wantRunID: testRunID, + wantReader: testReader, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanEnvironment(t, tt.env) + if tt.actions { + fakeActions(t) + } + config := map[string]tftypes.Value{"server_url": known(srv.url)} + for name, v := range tt.config { + config[name] = v + } + + data := configured(t, configure(t, config)) + assert.Equal(t, tt.wantRunID, data.RunID) + assert.Equal(t, tt.wantReader, data.Reader) + + got := srv.read(t, data) + assert.Equal(t, testOutputsPath, got.path) + assert.Equal(t, tt.wantAuth, got.auth) + wantQuery := url.Values{} + if tt.wantRunID != "" { + wantQuery.Set("run", tt.wantRunID) + wantQuery.Set("reader", tt.wantReader) + } + assert.Equal(t, wantQuery, got.query) + }) + } +} + +func TestConfigureCredentialErrors(t *testing.T) { + tests := []struct { + name string + actions bool + config map[string]tftypes.Value + env map[string]string + wantAttribute string + wantSummary string + wantDetail []string + }{ + { + name: "no API key outside GitHub Actions", + env: map[string]string{"STACKORDER_RUN_ID": testRunID, "STACKORDER_STACK": testReader}, + wantAttribute: "api_key", + wantSummary: "Missing Stackorder credentials", + wantDetail: []string{"api_key", "STACKORDER_API_KEY", "id-token: write"}, + }, + { + name: "ID token without STACKORDER_RUN_ID", + actions: true, + env: map[string]string{"STACKORDER_STACK": testReader}, + wantSummary: "Not inside a Stackorder job", + wantDetail: []string{"STACKORDER_RUN_ID is not set.", "plan, apply or drift job", "api_key", "STACKORDER_API_KEY"}, + }, + { + name: "ID token without STACKORDER_STACK", + actions: true, + env: map[string]string{"STACKORDER_RUN_ID": testRunID}, + wantSummary: "Not inside a Stackorder job", + wantDetail: []string{"STACKORDER_STACK is not set.", "plan, apply or drift job", "api_key"}, + }, + { + name: "ID token outside any Stackorder job", + actions: true, + wantSummary: "Not inside a Stackorder job", + wantDetail: []string{"STACKORDER_RUN_ID and STACKORDER_STACK are not set.", "plan, apply or drift job", "api_key"}, + }, + { + name: "ID token outside a Stackorder job despite an API key", + actions: true, + config: map[string]tftypes.Value{"api_key": known(testAPIKey)}, + env: map[string]string{"STACKORDER_API_KEY": testAPIKey, "STACKORDER_RUN_ID": " "}, + wantSummary: "Not inside a Stackorder job", + wantDetail: []string{"STACKORDER_RUN_ID and STACKORDER_STACK are not set.", "take precedence over an API key"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanEnvironment(t, tt.env) + if tt.actions { + fakeActions(t) + } + config := map[string]tftypes.Value{"server_url": known("https://stackorder.acme.example")} + for name, v := range tt.config { + config[name] = v + } + + d := requireError(t, configure(t, config), tt.wantAttribute, tt.wantSummary) + for _, want := range tt.wantDetail { + assert.Contains(t, d.Detail(), want) + } + }) + } +} + +func TestConfigureRepository(t *testing.T) { + tests := []struct { + name string + config map[string]tftypes.Value + env map[string]string + want string + }{ + { + name: "attribute over both variables", + config: map[string]tftypes.Value{"repository": known("acme/platform")}, + env: map[string]string{"STACKORDER_REPOSITORY": "acme/network", "GITHUB_REPOSITORY": "acme/infra"}, + want: "acme/platform", + }, + { + name: "STACKORDER_REPOSITORY over GITHUB_REPOSITORY", + env: map[string]string{"STACKORDER_REPOSITORY": "acme/network", "GITHUB_REPOSITORY": "acme/infra"}, + want: "acme/network", + }, + { + name: "GITHUB_REPOSITORY last", + env: map[string]string{"GITHUB_REPOSITORY": "acme/infra"}, + want: "acme/infra", + }, + { + name: "blank attribute falls back to the environment", + config: map[string]tftypes.Value{"repository": known("")}, + env: map[string]string{"STACKORDER_REPOSITORY": " ", "GITHUB_REPOSITORY": "acme/infra"}, + want: "acme/infra", + }, + { + name: "surrounding whitespace trimmed", + config: map[string]tftypes.Value{"repository": known(" acme/platform\n")}, + want: "acme/platform", + }, + { + name: "unnamed", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanEnvironment(t, tt.env) + t.Setenv("STACKORDER_SERVER_URL", "https://stackorder.acme.example") + t.Setenv("STACKORDER_API_KEY", testAPIKey) + + data := configured(t, configure(t, tt.config)) + assert.Equal(t, tt.want, data.Repository) + }) + } +} + +func TestConfigureRefusesUnknownValues(t *testing.T) { + tests := []struct { + name string + unknown []string + want map[string]string + }{ + {name: "server_url", unknown: []string{"server_url"}, want: map[string]string{"server_url": "STACKORDER_SERVER_URL"}}, + {name: "api_key", unknown: []string{"api_key"}, want: map[string]string{"api_key": "STACKORDER_API_KEY"}}, + {name: "repository", unknown: []string{"repository"}, want: map[string]string{"repository": "STACKORDER_REPOSITORY"}}, + {name: "oidc_audience", unknown: []string{"oidc_audience"}, want: map[string]string{"oidc_audience": "STACKORDER_OIDC_AUDIENCE"}}, + { + name: "every attribute", + unknown: []string{"server_url", "api_key", "repository", "oidc_audience"}, + want: map[string]string{ + "server_url": "STACKORDER_SERVER_URL", + "api_key": "STACKORDER_API_KEY", + "repository": "STACKORDER_REPOSITORY", + "oidc_audience": "STACKORDER_OIDC_AUDIENCE", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanEnvironment(t, map[string]string{ + "STACKORDER_SERVER_URL": "https://stackorder.acme.example", + "STACKORDER_API_KEY": testAPIKey, + }) + config := map[string]tftypes.Value{} + for _, name := range tt.unknown { + config[name] = unknown() + } + + resp := configure(t, config) + assert.Nil(t, resp.DataSourceData) + assert.Nil(t, resp.EphemeralResourceData) + got := map[string]string{} + for _, d := range resp.Diagnostics { + assert.Equal(t, diag.SeverityError, d.Severity()) + assert.Contains(t, d.Summary(), "Unknown Stackorder") + attribute := diagnosticPath(d) + assert.Contains(t, d.Detail(), attribute+" is unknown") + assert.Contains(t, d.Detail(), "known at plan time") + if env := tt.want[attribute]; env != "" { + assert.Contains(t, d.Detail(), env) + } + got[attribute] = tt.want[attribute] + } + assert.Equal(t, tt.want, got) + }) + } +} + +func TestConfigureProviderOverProtocol6(t *testing.T) { + configType := tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "server_url": tftypes.String, + "api_key": tftypes.String, + "repository": tftypes.String, + "oidc_audience": tftypes.String, + }} + tests := []struct { + name string + serverURL tftypes.Value + wantAttribute string + wantSummary string + }{ + {name: "configured", serverURL: known("https://stackorder.acme.example")}, + {name: "missing server URL", serverURL: tftypes.NewValue(tftypes.String, nil), wantAttribute: "server_url", wantSummary: "Missing Stackorder server URL"}, + {name: "unknown server URL", serverURL: unknown(), wantAttribute: "server_url", wantSummary: "Unknown Stackorder server URL"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanEnvironment(t, nil) + server, err := providerserver.NewProtocol6WithError(New("test")())() + require.NoError(t, err) + config, err := tfprotov6.NewDynamicValue(configType, tftypes.NewValue(configType, map[string]tftypes.Value{ + "server_url": tt.serverURL, + "api_key": known(testAPIKey), + "repository": known("acme/platform"), + "oidc_audience": tftypes.NewValue(tftypes.String, nil), + })) + require.NoError(t, err) + + resp, err := server.ConfigureProvider(t.Context(), &tfprotov6.ConfigureProviderRequest{TerraformVersion: "1.16.5", Config: &config}) + require.NoError(t, err) + if tt.wantSummary == "" { + assert.Empty(t, resp.Diagnostics) + return + } + require.Len(t, resp.Diagnostics, 1) + d := resp.Diagnostics[0] + assert.Equal(t, tfprotov6.DiagnosticSeverityError, d.Severity) + assert.Equal(t, tt.wantSummary, d.Summary) + assert.True(t, d.Attribute.Equal(tftypes.NewAttributePath().WithAttributeName(tt.wantAttribute)), "attribute %s", d.Attribute) + assert.NotContains(t, d.Detail, testAPIKey) + }) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4c5fd77 --- /dev/null +++ b/main.go @@ -0,0 +1,32 @@ +// Command terraform-provider-stackorder serves the Stackorder provider to Terraform and OpenTofu. +package main + +import ( + "context" + "flag" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + + "github.com/stackorder/terraform-provider-stackorder/internal/provider" +) + +//go:generate terraform fmt -recursive ./examples/ +//go:generate go tool tfplugindocs generate --provider-name stackorder --rendered-provider-name Stackorder --tf-version 1.14.4 + +var version = "dev" + +func main() { + var debug bool + flag.BoolVar(&debug, "debug", false, "start the provider in debug mode and print the reattach configuration for Terraform") + flag.Parse() + + err := providerserver.Serve(context.Background(), provider.New(version), providerserver.ServeOpts{ + Address: "registry.terraform.io/stackorder/stackorder", + Debug: debug, + ProtocolVersion: 6, + }) + if err != nil { + log.Fatal(err) + } +} diff --git a/templates/data-sources/outputs.md.tmpl b/templates/data-sources/outputs.md.tmpl new file mode 100644 index 0000000..3a8f751 --- /dev/null +++ b/templates/data-sources/outputs.md.tmpl @@ -0,0 +1,30 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "{{ .Name }} {{ .Type }} - {{ .RenderedProviderName }}" +subcategory: "" +description: |- +{{ index (split .Description "\n\n") 0 | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{ .Name }} ({{ .Type }}) + +{{ .Description | trimspace }} + +The Stackorder documentation on [shared outputs](https://docs.stackorder.io/configuration/outputs) describes the producer's side, `outputs.readers` and `outputs.sensitive`, in full. + +## Example Usage + +{{- range .ExampleFiles }} + +{{ tffile . }} +{{- end }} + +## Reading values + +Each output is an attribute of `values`, and of `nonsensitive_values` unless the producer marks it sensitive, with the type it has in the producer's state: `data.stackorder_outputs.vpc.nonsensitive_values.vpc_id`. A root-module `output` that references `values` must set `sensitive = true`. The data source reads the outputs again in every plan, so a change to a producer's outputs reaches the reader at its next plan. + +## Ordering + +Stackorder plans and applies a reading stack after the stacks it reads. Its scanner infers that dependency from each `stackorder_outputs` block whose `stack` is a literal string, both in the stack's own directory, the only place it reads `terraform_remote_state`, and in every local module inside the repository that the stack calls, directly or through other local modules; a stack of another repository becomes an external dependency, as with a cross-repository `depends_on`. In a module, a `stack` made only of the module's variables and literal text, such as `var.producer` or `"stacks/${var.env}/vpc"`, counts as literal when the `module` block that calls the module sets each of those variables to a literal string, as `producer = "stacks/prod/vpc"` does; a value that block passes on from its own variables does not count. Any other `stack` built from variables or expressions infers nothing and warns. The scanner fetches no remote module and skips a local source outside the repository, so a block inside a registry, git or other remote module, or inside such a local module, infers nothing and gives no warning of its own. In either case, list the producer under `depends_on` in each reader's `.stackorder.yaml`, which orders the stacks but does not silence the warning about a non-literal `stack`. + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/ephemeral-resources/outputs.md.tmpl b/templates/ephemeral-resources/outputs.md.tmpl new file mode 100644 index 0000000..658d420 --- /dev/null +++ b/templates/ephemeral-resources/outputs.md.tmpl @@ -0,0 +1,30 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "{{ .Name }} {{ .Type }} - {{ .RenderedProviderName }}" +subcategory: "" +description: |- +{{ index (split .Description "\n\n") 0 | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{ .Name }} ({{ .Type }}) + +{{ .Description | trimspace }} + +The Stackorder documentation on [shared outputs](https://docs.stackorder.io/configuration/outputs) describes the producer's side, `outputs.readers` and `outputs.sensitive`, in full. + +## Example Usage + +{{- range .ExampleFiles }} + +{{ tffile . }} +{{- end }} + +## Reading values + +Each output is an attribute of `values`, and of `nonsensitive_values` unless the producer marks it sensitive, with the type it has in the producer's state: `ephemeral.stackorder_outputs.database.values.master_password`. Unlike the data source, the ephemeral resource has no `id`. + +## Ordering + +Stackorder plans and applies a reading stack after the stacks it reads. Its scanner infers that dependency from each `stackorder_outputs` block whose `stack` is a literal string, both in the stack's own directory, the only place it reads `terraform_remote_state`, and in every local module inside the repository that the stack calls, directly or through other local modules; a stack of another repository becomes an external dependency, as with a cross-repository `depends_on`. In a module, a `stack` made only of the module's variables and literal text, such as `var.producer` or `"stacks/${var.env}/vpc"`, counts as literal when the `module` block that calls the module sets each of those variables to a literal string, as `producer = "stacks/prod/vpc"` does; a value that block passes on from its own variables does not count. Any other `stack` built from variables or expressions infers nothing and warns. The scanner fetches no remote module and skips a local source outside the repository, so a block inside a registry, git or other remote module, or inside such a local module, infers nothing and gives no warning of its own. In either case, list the producer under `depends_on` in each reader's `.stackorder.yaml`, which orders the stacks but does not silence the warning about a non-literal `stack`. + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl new file mode 100644 index 0000000..9ab26f4 --- /dev/null +++ b/templates/index.md.tmpl @@ -0,0 +1,67 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "{{ .RenderedProviderName }} Provider" +description: |- + Read the outputs another Stackorder stack shares, without access to its state. +--- + +# {{ .RenderedProviderName }} Provider + +{{ .Description | trimspace }} + +Stackorder plans and applies Terraform and OpenTofu stacks in dependency order on GitHub Actions. Without this provider, a stack that needs another stack's values reads them with `terraform_remote_state`, so its credentials must be able to read the producer's whole state object, secrets included. With it, the reading stack names the producer and receives the producer's root-module outputs only: on each read the Stackorder server takes the `outputs` block from the producer's state object with its own AWS credentials and returns it, storing, caching and logging no values. The reader needs no access to the producer's state bucket. + +The provider offers `stackorder_outputs` as a data source and as an ephemeral resource. Both take the same `stack` and return the same `values` and `nonsensitive_values`. The data source stores what it reads in the plan file and in state; the ephemeral resource keeps the values out of both. + +The [Stackorder documentation](https://docs.stackorder.io) covers the server, the CLI and the configuration files, and its [shared outputs](https://docs.stackorder.io/configuration/outputs) page covers the producer's side in full. + +## Requirements + +- A Stackorder server whose operator has enabled shared outputs by setting `STACKORDER_STATE_BUCKETS` to the state buckets, or bucket and prefix pairs, the server may read, and has granted the server read access to them. +- A producer stack that has run at least one apply or drift job with a Stackorder CLI that reports where the stack's state lives, so that the server knows which state object to read. +- Terraform 1.10 or later, or OpenTofu 1.11 or later, for the `stackorder_outputs` ephemeral resource. The data source works with every release that supports plugin protocol 6: Terraform 1.0 and later, and every OpenTofu release. +- Inside Stackorder jobs, Stackorder CLI 0.4.0 or later, which passes the server URL, the run and the repository to Terraform. + +## Sharing outputs + +Nothing is readable until the producer opts in. The producer's default branch lists the stacks that may read its outputs under `outputs.readers`, in the stack's `.stackorder.yaml`, or in `stackorder.yaml` for every stack of the repository: + +```yaml +# acme/infra: stacks/prod/vpc/.stackorder.yaml +outputs: + readers: + - stacks/prod/eks + - stacks/prod/apps:* + - acme/platform//clusters/** + sensitive: share +``` + +- Each reader is a pattern over the stack keys of the producer's repository, such as `stacks/prod/eks`, or `stacks/prod/apps:*` for every instance of a stack. `owner/repo//pattern` names stacks of another repository covered by the same Stackorder installation. +- `outputs` can be set in `stackorder.yaml`, in a stack's `.stackorder.yaml`, and for one instance under the stack's `instances`. The most specific level that sets `readers` replaces the others, and `readers: []` withdraws a broader grant. +- The server reads `outputs` from the default branch only, so a pull request cannot widen the list, and a change takes effect once it is merged. +- Sensitive outputs are withheld unless the producer sets `sensitive: share`. The provider warns about each output withheld. + +## Credentials + +Where Terraform runs decides how the provider authenticates. + +**Inside Stackorder jobs, the job's token.** In a Stackorder plan, apply or drift job, the provider requests a fresh GitHub Actions ID token for every request to the server, which needs `permissions: id-token: write`; the Stackorder reusable workflows grant it. The Stackorder CLI sets `STACKORDER_SERVER_URL`, `STACKORDER_REPOSITORY`, `STACKORDER_RUN_ID` and `STACKORDER_STACK` for Terraform, so the provider block can stay empty: + +```terraform +provider "stackorder" {} +``` + +The server accepts a job's token only while the job's run is in progress, and checks the reading stack, from `STACKORDER_STACK`, against the producer's readers. Whenever the job can request ID tokens the provider uses them and ignores any API key, so an admin key never replaces the job's own identity. For the same reason, a GitHub Actions job outside Stackorder that grants `id-token: write`, for example to assume a cloud role, cannot use the provider. + +**Elsewhere, an API key.** Where no ID token can be requested, such as on a workstation, the provider sends the automation API key from `api_key` or the `STACKORDER_API_KEY` environment variable, which is preferable to writing the key into configuration. API keys are admin keys of the Stackorder server: they read the outputs of any stack whatever its readers, the server audits every read, and they belong outside CI. Sensitive outputs stay withheld unless the producer shares them. + +## Example Usage + +Outside Stackorder jobs, name the server in the provider block and set the API key in `STACKORDER_API_KEY`: + +{{- range .ExampleFiles }} + +{{ tffile . }} +{{- end }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/terraform-registry-manifest.json b/terraform-registry-manifest.json new file mode 100644 index 0000000..295001a --- /dev/null +++ b/terraform-registry-manifest.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "metadata": { + "protocol_versions": ["6.0"] + } +}