From fb3ddccd840bd84dd5e67c9dd66552cf0bb3e636 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 10 Oct 2026 17:37:17 -0400 Subject: [PATCH 01/14] chore: scaffold the stackorder provider Start the provider as an empty Plugin Framework provider served over protocol 6 at registry.terraform.io/stackorder/stackorder, so the client, configuration, data source and ephemeral resource can land as separate changes on a module that already builds, lints and tests. The module targets go 1.26.0 like the Stackorder module it will import and pins the framework, plugin-go and plugin-log releases it is built against; plugin-log stays indirect until the client logs through it. The unit test drives the provider through the protocol 6 server, the same path Terraform uses, and needs no Terraform binary. The protocol carries no provider type name, so the test reads it from Metadata. The lint setup mirrors Stackorder's so both repositories follow one style, and its gofmt step fails on files gofmt cannot parse. Co-Authored-By: Claude Fable 5.1 --- .editorconfig | 18 ++++++ .gitignore | 9 +++ .golangci.yml | 51 ++++++++++++++++ GNUmakefile | 32 ++++++++++ README.md | 49 +++++++++++++++ go.mod | 34 +++++++++++ go.sum | 96 ++++++++++++++++++++++++++++++ internal/provider/provider.go | 52 ++++++++++++++++ internal/provider/provider_test.go | 48 +++++++++++++++ main.go | 29 +++++++++ 10 files changed, 418 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 GNUmakefile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/provider/provider.go create mode 100644 internal/provider/provider_test.go create mode 100644 main.go 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/.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/GNUmakefile b/GNUmakefile new file mode 100644 index 0000000..44e75af --- /dev/null +++ b/GNUmakefile @@ -0,0 +1,32 @@ +SHELL := /bin/bash + +BINARY := terraform-provider-stackorder +GOLANGCI_LINT_VERSION ?= v2.14.0 + +.PHONY: all build test testacc lint fmt generate 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 ./... + +tidy: + go mod tidy diff --git a/README.md b/README.md index ebd987f..54ebce0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,52 @@ # 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 will offer `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. + +## 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. + +## Development + +| 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` | Runs `go generate` | +| `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/go.mod b/go.mod new file mode 100644 index 0000000..27bdbf3 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module github.com/stackorder/terraform-provider-stackorder + +go 1.26.0 + +require ( + github.com/hashicorp/terraform-plugin-framework v1.19.0 + github.com/hashicorp/terraform-plugin-go v0.31.0 + github.com/stretchr/testify v1.12.1 +) + +require ( + github.com/fatih/color v1.18.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/terraform-plugin-log v0.11.0 // indirect + github.com/hashicorp/terraform-registry-address v0.4.0 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f8d5620 --- /dev/null +++ b/go.sum @@ -0,0 +1,96 @@ +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/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/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/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/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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/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-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-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/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-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.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= +github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +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/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/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/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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/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= +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.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +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/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +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-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +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/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..f3dbbef --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,52 @@ +// Package provider implements the stackorder provider on the Terraform Plugin Framework. +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +var ( + _ provider.Provider = (*stackorderProvider)(nil) + _ provider.ProviderWithEphemeralResources = (*stackorderProvider)(nil) +) + +type stackorderProvider struct { + version 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{} +} + +func (p *stackorderProvider) Configure(_ context.Context, _ provider.ConfigureRequest, _ *provider.ConfigureResponse) { +} + +func (p *stackorderProvider) DataSources(_ context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{} +} + +func (p *stackorderProvider) EphemeralResources(_ context.Context) []func() ephemeral.EphemeralResource { + return []func() ephemeral.EphemeralResource{} +} + +func (p *stackorderProvider) Resources(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{} +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..b849057 --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,48 @@ +package provider + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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) + assert.Empty(t, resp.Provider.Block.Attributes) + assert.Empty(t, resp.Provider.Block.BlockTypes) + assert.Empty(t, resp.Provider.Block.Description) + assert.Empty(t, resp.DataSourceSchemas) + assert.Empty(t, resp.EphemeralResourceSchemas) + assert.Empty(t, resp.ResourceSchemas) + }) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..55cd9fb --- /dev/null +++ b/main.go @@ -0,0 +1,29 @@ +// 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" +) + +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) + } +} From 7fdbab83e6dd264d93864ba424e443676efee10f Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 10 Oct 2026 18:16:17 -0400 Subject: [PATCH 02/14] feat(client): read a stack's outputs from the Stackorder server The provider needs one call, GET /v1/repos/{owner}/{repo}/stacks/{key}/outputs, so the client is small and owns the things that are easy to get wrong. Inside GitHub Actions the server accepts each OIDC token only once, so every attempt, retries included, mints a new token; an API key is used only when the Actions token variables are absent, so an admin key never silently wins in CI. The audience defaults to the server URL as configured, trimmed as the stackorder CLI trims it, and when nothing names an audience the source is refused up front instead of sending one the server would answer with a bare 401. An empty run or reader is left out of the query for API keys, which read outside any run. Network failures, a connection lost or timed out while the answer is read included, and 429 and 5xx answers, from the server or from the token endpoint, are retried up to three attempts with backoff, waiting longer when a Retry-After header asks for up to a minute; a longer request ends the call. Every other answer is final and returned as an *APIError carrying the v1.Error code, so the data source can map each refusal to its own diagnostic without retrying a refusal that cannot change. The answer is read whole, capped at 32 MiB, four times the server's cap on an outputs block, before it is decoded, so a cut connection is retried while a malformed body is final. The retry loop is written out rather than taken from go-retryablehttp because the token must be replaced on every attempt. Neither HTTP client follows redirects: Go resends the Authorization header to the same host even from https to http, and a followed redirect would replay a single-use token. The server URL may carry a path prefix but no user info, query or fragment, which would break the endpoint built from it, and a stack key of . or .. is refused because proxies normalise dot segments. The token sources are unexported, so no zero value can send an empty key or panic, and every format verb prints a description without the key or the request token. Tokens never appear in error messages. The wire types come from github.com/stackorder/stackorder/api/v1. go.mod requires v0.3.0, the newest tag, as a placeholder until the release that carries StackOutputs; until then local builds use an untracked go.work that includes the stackorder checkout, as the README explains. go mod tidy ignores go.work, so the tidy target needs no flag; requiring v0.3.0 raises a few indirect versions through minimal version selection. The golden stack_outputs.json from api/v1 is copied into testdata to pin the wire contract on this side. Co-Authored-By: Claude Fable 5.1 --- README.md | 6 + go.mod | 11 +- go.sum | 47 +- internal/client/client.go | 253 ++++++ internal/client/client_test.go | 754 ++++++++++++++++++ internal/client/errors.go | 101 +++ internal/client/testdata/stack_outputs.json | 58 ++ .../client/testdata/stack_outputs_empty.json | 8 + internal/client/token.go | 168 ++++ internal/client/token_test.go | 373 +++++++++ 10 files changed, 1752 insertions(+), 27 deletions(-) create mode 100644 internal/client/client.go create mode 100644 internal/client/client_test.go create mode 100644 internal/client/errors.go create mode 100644 internal/client/testdata/stack_outputs.json create mode 100644 internal/client/testdata/stack_outputs_empty.json create mode 100644 internal/client/token.go create mode 100644 internal/client/token_test.go diff --git a/README.md b/README.md index 54ebce0..7ccabc6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ The provider is under development and not yet published to a registry. It will o ## Development +Until Stackorder tags the release that carries the `api/v1` stack outputs types, `go.mod` requires `github.com/stackorder/stackorder` v0.3.0 as a placeholder, and the provider builds only in a Go workspace that also uses a Stackorder checkout with those types. Create the workspace in the repository root, naming the path of that checkout; git ignores `go.work` and `go.work.sum`: + +```shell +go work init . ../stackorder +``` + | Target | What it does | |---|---| | `make build` | Builds `terraform-provider-stackorder` in the repository root | diff --git a/go.mod b/go.mod index 27bdbf3..b06286b 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( github.com/hashicorp/terraform-plugin-framework v1.19.0 github.com/hashicorp/terraform-plugin-go v0.31.0 + github.com/stackorder/stackorder v0.3.0 github.com/stretchr/testify v1.12.1 ) @@ -25,10 +26,10 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/grpc v1.79.2 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect + google.golang.org/grpc v1.82.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index f8d5620..90d777d 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stackorder/stackorder v0.3.0 h1:hedGEwM6Kfi8LL97TSFMQO7HFc/+7kx8A0vv50KpRhY= +github.com/stackorder/stackorder v0.3.0/go.mod h1:3o5f+ugNRQ5Pig2tpwg6bCa/TKZjmjyGA4fzmJA4apE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= @@ -60,37 +62,38 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 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.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +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.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +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/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= 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-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= -google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +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.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= 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/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..2c8bb6e --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,253 @@ +// 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. +func New(serverURL string, src TokenSource, opts ...Option) (*Client, error) { + u, err := url.Parse(serverURL) + if err != nil { + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + return nil, fmt.Errorf("parsing the Stackorder server URL: %w", err) + } + if (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" { + return nil, fmt.Errorf("the Stackorder server URL %q is not an http or https URL", u.Redacted()) + } + if u.User != nil || strings.ContainsAny(serverURL, "?#") { + return nil, fmt.Errorf("the Stackorder server URL %q carries user info, a query or a fragment", u.Redacted()) + } + 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..f39b750 --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,754 @@ +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: "is not an http or https URL"}, + {name: "no scheme", serverURL: "stackorder.acme.example", wantErr: "is not an http or https URL"}, + {name: "other scheme", serverURL: "ftp://stackorder.acme.example", wantErr: "is not an http or https URL"}, + {name: "no host", serverURL: "https://", wantErr: "is not an http or https URL"}, + {name: "unparsable URL", serverURL: "https://stackorder.acme.example/%zz", wantErr: "parsing the Stackorder server URL"}, + {name: "unparsable URL with user info", serverURL: "https://ci:s3cr3t@stackorder.acme.example/%zz", wantErr: `parsing the Stackorder server URL: invalid URL escape "%zz"`}, + {name: "other scheme with user info", serverURL: "ftp://ci:s3cr3t@stackorder.acme.example", wantErr: "is not an http or https URL"}, + {name: "user info", serverURL: "https://ci:s3cr3t@stackorder.acme.example", wantErr: `"https://ci:xxxxx@stackorder.acme.example" 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: "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.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..e8f6ace --- /dev/null +++ b/internal/client/token.go @@ -0,0 +1,168 @@ +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) { + requestURL := strings.TrimSpace(os.Getenv(EnvOIDCRequestURL)) + requestToken := strings.TrimSpace(os.Getenv(EnvOIDCRequestToken)) + if requestURL != "" && requestToken != "" { + 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 +} + +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..dea64ac --- /dev/null +++ b/internal/client/token_test.go @@ -0,0 +1,373 @@ +package client_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "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 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)) + }) + } +} From bc6e15472b4d6e8381ebc230b02a07989143e4ea Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 10 Oct 2026 18:42:32 -0400 Subject: [PATCH 03/14] feat(provider): configure the server, credentials and repository The provider takes server_url, api_key, repository and oidc_audience, each optional with an environment fallback, so a Stackorder job needs no provider configuration at all: the Stackorder CLI exports the server URL, repository, run id and stack key to Terraform. Unknown values refuse to configure, since a fallback chosen at plan time could differ at apply. Credentials come from the client's token source, which owns the API key and audience fallbacks and prefers GitHub Actions ID tokens whenever the runner offers them. The client now exports that check so the provider can tell when an ID token will be used without re-reading the runner variables. With an ID token the server can only bind the read to a stack of a Stackorder run, so the provider refuses to configure without STACKORDER_RUN_ID and STACKORDER_STACK and says to use an API key outside such a job. Diagnostics carry the source of a bad value, never a key or token. The client's errors for a rejected server URL no longer repeat the URL. Redacted hides only a password that url.Parse finds in user info, so a password in a URL without a scheme, a password read as a port, or a key in the query reached the diagnostic. Tests drive Configure directly and over protocol 6, and check where requests go and with which credentials against local fakes of the server and the Actions token endpoint, including server URLs that carry a password or a key. Co-Authored-By: Claude Fable 5.1 --- README.md | 26 ++ examples/provider/provider.tf | 3 + internal/client/client.go | 13 +- internal/client/client_test.go | 20 +- internal/client/token.go | 18 +- internal/client/token_test.go | 46 +++ internal/provider/provider.go | 155 ++++++- internal/provider/provider_test.go | 635 ++++++++++++++++++++++++++++- 8 files changed, 893 insertions(+), 23 deletions(-) create mode 100644 examples/provider/provider.tf diff --git a/README.md b/README.md index 7ccabc6..95a1471 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,32 @@ The provider is under development and not yet published to a registry. It will o - 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 up to 0.3.0 set `STACKORDER_STACK` but not the other three, so a job needs a later release 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. + ## Development Until Stackorder tags the release that carries the `api/v1` stack outputs types, `go.mod` requires `github.com/stackorder/stackorder` v0.3.0 as a placeholder, and the provider builds only in a Go workspace that also uses a Stackorder checkout with those types. Create the workspace in the repository root, naming the path of that checkout; git ignores `go.work` and `go.work.sum`: 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/internal/client/client.go b/internal/client/client.go index 2c8bb6e..38c874d 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -100,21 +100,18 @@ func WithRetryPolicy(p RetryPolicy) Option { // 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. +// 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 { - var urlErr *url.Error - if errors.As(err, &urlErr) { - err = urlErr.Err - } - return nil, fmt.Errorf("parsing the Stackorder server URL: %w", err) + 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, fmt.Errorf("the Stackorder server URL %q is not an http or https URL", u.Redacted()) + 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, fmt.Errorf("the Stackorder server URL %q carries user info, a query or a fragment", u.Redacted()) + 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") diff --git a/internal/client/client_test.go b/internal/client/client_test.go index f39b750..b00bc9d 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -686,15 +686,18 @@ func TestNewRefusesInvalidSettings(t *testing.T) { noSource bool wantErr string }{ - {name: "empty URL", serverURL: "", wantErr: "is not an http or https URL"}, - {name: "no scheme", serverURL: "stackorder.acme.example", wantErr: "is not an http or https URL"}, - {name: "other scheme", serverURL: "ftp://stackorder.acme.example", wantErr: "is not an http or https URL"}, - {name: "no host", serverURL: "https://", wantErr: "is not an http or https URL"}, - {name: "unparsable URL", serverURL: "https://stackorder.acme.example/%zz", wantErr: "parsing the Stackorder server URL"}, - {name: "unparsable URL with user info", serverURL: "https://ci:s3cr3t@stackorder.acme.example/%zz", wantErr: `parsing the Stackorder server URL: invalid URL escape "%zz"`}, - {name: "other scheme with user info", serverURL: "ftp://ci:s3cr3t@stackorder.acme.example", wantErr: "is not an http or https URL"}, - {name: "user info", serverURL: "https://ci:s3cr3t@stackorder.acme.example", wantErr: `"https://ci:xxxxx@stackorder.acme.example" carries user info, a query or a fragment`}, + {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"}, @@ -709,6 +712,7 @@ func TestNewRefusesInvalidSettings(t *testing.T) { 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) }) } diff --git a/internal/client/token.go b/internal/client/token.go index e8f6ace..c10f395 100644 --- a/internal/client/token.go +++ b/internal/client/token.go @@ -72,9 +72,7 @@ type Credentials struct { // 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) { - requestURL := strings.TrimSpace(os.Getenv(EnvOIDCRequestURL)) - requestToken := strings.TrimSpace(os.Getenv(EnvOIDCRequestToken)) - if requestURL != "" && requestToken != "" { + if requestURL, requestToken, ok := actionsTokenRequest(); ok { audience := firstNonEmpty(creds.OIDCAudience, os.Getenv(EnvOIDCAudience), creds.ServerURL) if audience == "" { return nil, ErrNoAudience @@ -92,6 +90,20 @@ func NewTokenSource(creds Credentials) (TokenSource, error) { 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 diff --git a/internal/client/token_test.go b/internal/client/token_test.go index dea64ac..322b51b 100644 --- a/internal/client/token_test.go +++ b/internal/client/token_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "sync" "sync/atomic" "testing" @@ -210,6 +211,51 @@ func TestAPIKeyOutsideActions(t *testing.T) { } } +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 diff --git a/internal/provider/provider.go b/internal/provider/provider.go index f3dbbef..9ee5787 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -3,12 +3,28 @@ package provider import ( "context" + "errors" + "fmt" + "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 ( @@ -20,6 +36,27 @@ 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 + // 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 { @@ -33,10 +70,103 @@ func (p *stackorderProvider) Metadata(_ context.Context, _ provider.MetadataRequ } func (p *stackorderProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { - resp.Schema = schema.Schema{} + 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(_ context.Context, _ provider.ConfigureRequest, _ *provider.ConfigureResponse) { +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)), + } + if client.ActionsIDTokensAvailable() { + 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 { @@ -50,3 +180,24 @@ func (p *stackorderProvider) EphemeralResources(_ context.Context) []func() ephe 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 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 index b849057..3fe49dd 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -1,15 +1,182 @@ package provider import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "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 @@ -37,12 +204,476 @@ func TestProviderServesProtocol6(t *testing.T) { assert.Empty(t, resp.Diagnostics) require.NotNil(t, resp.Provider) require.NotNil(t, resp.Provider.Block) - assert.Empty(t, resp.Provider.Block.Attributes) + 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.Empty(t, resp.Provider.Block.Description) assert.Empty(t, resp.DataSourceSchemas) assert.Empty(t, 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) + }) + } +} From b01eeb4f3fa36d63f4fda37a0cd129dee1416374 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 10 Oct 2026 18:48:08 -0400 Subject: [PATCH 04/14] feat(provider): turn typed state outputs into dynamic values The data source and the ephemeral resource expose a stack's outputs as one dynamic object each, so every output, sent as its JSON value beside the type Terraform wrote to state, has to become a framework value of exactly that type: a tuple must not turn into a list, numbers keep the 512-bit precision Terraform reads them at, and nulls stay typed. Types are parsed here, including the ones dynamically typed values carry inside the value, and values are walked here too, so tftypes only reads strings, numbers and bools. Its own type parser, deprecated for third parties, keeps the optional attribute markers of object types, and tftypes panics when such a type later meets a value, which would crash the provider. Building tftypes values still recovers from a panic as a backstop. Every type is rebuilt as the type of its contents, as cty computes it, so dynamically typed positions resolve. A list, set or map of dynamic elements takes the type its elements share and turns its null elements into nulls of that type, as cty does; elements of different types are refused, since Terraform cannot build such a value. Scalars are read as Terraform reads JSON, so a number may arrive as a string and the reverse. No outputs give an empty object rather than null. Errors name the output and what is malformed but never quote the value, which may be sensitive, so neither tftypes nor framework error text is passed through for values. Co-Authored-By: Claude Fable 5.1 --- internal/outputs/outputs.go | 344 +++++++++++++++ internal/outputs/outputs_test.go | 717 +++++++++++++++++++++++++++++++ 2 files changed, 1061 insertions(+) create mode 100644 internal/outputs/outputs.go create mode 100644 internal/outputs/outputs_test.go 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) + }) + } +} From cd272f6621693d61636d1d427c00b53a2de8ff93 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 10 Oct 2026 19:20:28 -0400 Subject: [PATCH 05/14] feat(provider): add the stackorder_outputs data source A reading stack names a producer as path, path:instance or owner/repo//key and receives the outputs the server serves as two dynamic objects: values, sensitive as a whole because a provider cannot mark nested paths, and nonsensitive_values for arguments that refuse sensitive values. The repository of the stack is checked as owner/repo before any request, so a malformed key or repository setting is reported on stack and names the setting that holds it. Every refusal code of the server, the run state codes included, becomes its own diagnostic that says who can act on it: the producer, the server's operator or the reader's credentials. None repeats a token, a value or the request URL. An answer without a v1 error code, as from a proxy in front of the server, is reported by status and redirect host only, so its body never reaches Terraform's output. Withheld sensitive outputs raise a warning that names them and how the producer shares them. The provider data now records the server host and whether ID tokens authenticate, so diagnostics name the host only and give credential advice for the mode in use. Acceptance tests run terraform-plugin-testing against an httptest server that serves the client's golden fixture. plugin-testing cannot observe warnings, so the withheld warning is asserted through the protocol 6 server in the unit tests. Co-Authored-By: Claude Fable 5.1 --- README.md | 39 +- .../stackorder_outputs/data-source.tf | 16 + go.mod | 35 +- go.sum | 155 ++- internal/provider/outputs_data_source.go | 386 +++++++ internal/provider/outputs_data_source_test.go | 989 ++++++++++++++++++ internal/provider/provider.go | 21 +- internal/provider/provider_test.go | 4 +- 8 files changed, 1635 insertions(+), 10 deletions(-) create mode 100644 examples/data-sources/stackorder_outputs/data-source.tf create mode 100644 internal/provider/outputs_data_source.go create mode 100644 internal/provider/outputs_data_source_test.go diff --git a/README.md b/README.md index 95a1471..01b1928 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Terraform and OpenTofu provider for [Stackorder](https://github.com/stackorder/s ## Status -The provider is under development and not yet published to a registry. It will offer `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. +The provider is under development and not yet published to a registry. It offers `stackorder_outputs` as a data source, and will offer it as an ephemeral resource too: 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. ## Requirements @@ -38,6 +38,43 @@ 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: read sensitive values with the `stackorder_outputs` ephemeral resource, which keeps them out of both, once it is available. + ## Development Until Stackorder tags the release that carries the `api/v1` stack outputs types, `go.mod` requires `github.com/stackorder/stackorder` v0.3.0 as a placeholder, and the provider builds only in a Go workspace that also uses a Stackorder checkout with those types. Create the workspace in the repository root, naming the path of that checkout; git ignores `go.work` and `go.work.sum`: 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/go.mod b/go.mod index b06286b..f13f121 100644 --- a/go.mod +++ b/go.mod @@ -5,30 +5,61 @@ go 1.26.0 require ( 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.3.0 github.com/stretchr/testify v1.12.1 ) require ( + 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/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/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/go-version v1.9.0 // 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-json v0.28.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.1.1 // indirect + github.com/hashicorp/terraform-svchost v0.2.1 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect - github.com/oklog/run v1.1.0 // 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/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/zclconf/go-cty v1.19.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.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.82.0 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 90d777d..cce1775 100644 --- a/go.sum +++ b/go.sum @@ -1,42 +1,119 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +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/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/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.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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.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-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.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= -github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +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/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 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 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= @@ -45,21 +122,47 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky 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/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/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= -github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +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.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/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/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/stackorder/stackorder v0.3.0 h1:hedGEwM6Kfi8LL97TSFMQO7HFc/+7kx8A0vv50KpRhY= github.com/stackorder/stackorder v0.3.0/go.mod h1:3o5f+ugNRQ5Pig2tpwg6bCa/TKZjmjyGA4fzmJA4apE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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/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.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= @@ -74,26 +177,70 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ 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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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/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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +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.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/provider/outputs_data_source.go b/internal/provider/outputs_data_source.go new file mode 100644 index 0000000..cdc4ff3 --- /dev/null +++ b/internal/provider/outputs_data_source.go @@ -0,0 +1,386 @@ +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 ( + 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" + + "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.\n\n" + + "Values read by a data source are stored in the plan file, which Stackorder uploads as a workflow artifact, and in state. " + + "Read sensitive values with the `stackorder_outputs` ephemeral resource instead, which keeps them out of both.", + Attributes: map[string]schema.Attribute{ + "stack": schema.StringAttribute{ + Required: true, + MarkdownDescription: "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`.", + }, + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Qualified key of the stack, `owner/repo//key`.", + }, + "values": schema.DynamicAttribute{ + Computed: true, + Sensitive: true, + MarkdownDescription: "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.", + }, + "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) { + if req.ProviderData == nil { + return + } + data, ok := req.ProviderData.(*providerData) + if !ok { + resp.Diagnostics.AddError("Unexpected provider data", + fmt.Sprintf("The stackorder_outputs data source received provider data of type %T. Report this to the provider's maintainers.", req.ProviderData)) + 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 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. Shared sensitive outputs are stored in the reader's plan file and state; read them with the stackorder_outputs ephemeral resource 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..6048ea3 --- /dev/null +++ b/internal/provider/outputs_data_source_test.go @@ -0,0 +1,989 @@ +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 readThroughServer(t *testing.T, stack string) (map[string]tftypes.Value, []*tfprotov6.Diagnostic) { + 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) + + 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) + for _, d := range read.Diagnostics { + for _, secret := range []string{testAPIKey, testRequestToken, pskCanary} { + assert.NotContains(t, d.Summary+" "+d.Detail, secret) + } + } + if read.State == nil { + return nil, read.Diagnostics + } + state, err := read.State.Unmarshal(ds.ValueType()) + require.NoError(t, err) + if state.IsNull() { + return nil, read.Diagnostics + } + var attrs map[string]tftypes.Value + require.NoError(t, state.As(&attrs)) + return attrs, 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", "default branch", "plan file", "ephemeral resource"} { + assert.Contains(t, ds.Block.Description, want) + } +} + +func TestOutputsDataSourceRead(t *testing.T) { + tests := []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 + }{ + { + 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"`}, + }, + } + for _, tt := range tests { + 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) + + 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["id"].Equal(str(tt.wantID)), "id %s", attrs["id"]) + assert.True(t, attrs["values"].Equal(tt.wantValues), "values %s", attrs["values"]) + 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", "ephemeral resource") { + assert.Contains(t, diags[0].Detail, want) + } + }) + } +} + +func TestOutputsDataSourceReadErrors(t *testing.T) { + 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}, + }}) + tests := []struct { + name string + env map[string]string + stack string + answers map[string]served + wantRequests int + wantSummary string + wantAttribute bool + wantDetail []string + notDetail []string + }{ + { + 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"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := newOutputsServer(t, tt.answers) + serverEnvironment(t, srv, tt.env) + + attrs, diags := readThroughServer(t, tt.stack) + + assert.Len(t, srv.requests(), tt.wantRequests) + for _, name := range []string{"id", "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 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/provider.go b/internal/provider/provider.go index 9ee5787..58c4f8c 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "net/url" "os" "strings" @@ -46,6 +47,12 @@ type providerModel struct { 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 @@ -145,8 +152,10 @@ func (p *stackorderProvider) Configure(ctx context.Context, req provider.Configu 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 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.", @@ -170,7 +179,7 @@ func (p *stackorderProvider) Configure(ctx context.Context, req provider.Configu } func (p *stackorderProvider) DataSources(_ context.Context) []func() datasource.DataSource { - return []func() datasource.DataSource{} + return []func() datasource.DataSource{NewOutputsDataSource} } func (p *stackorderProvider) EphemeralResources(_ context.Context) []func() ephemeral.EphemeralResource { @@ -193,6 +202,14 @@ func missingJobVariables(data *providerData) string { 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 != "" { diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 3fe49dd..170d935 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -2,9 +2,11 @@ package provider import ( "encoding/json" + "maps" "net/http" "net/http/httptest" "net/url" + "slices" "sync" "testing" @@ -210,7 +212,7 @@ func TestProviderServesProtocol6(t *testing.T) { } 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.Empty(t, resp.DataSourceSchemas) + assert.Equal(t, []string{"stackorder_outputs"}, slices.Sorted(maps.Keys(resp.DataSourceSchemas))) assert.Empty(t, resp.EphemeralResourceSchemas) assert.Empty(t, resp.ResourceSchemas) }) From fc82458a1408e0b029c2ec7e61028ab660943f1f Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 10 Oct 2026 19:45:44 -0400 Subject: [PATCH 06/14] feat(provider): add the stackorder_outputs ephemeral resource Values a data source reads land in the reader's plan file, which Stackorder uploads as a workflow artifact, and in state, so a shared sensitive output had no safe way in. The ephemeral resource reads the same outputs with the same stack, values and nonsensitive_values, minus id, and Terraform keeps its result out of both. It needs Terraform 1.10 or OpenTofu 1.11. Open goes through the data source's read helper, so producer resolution, the request, the conversion, every refusal diagnostic and the withheld warning are the same code; the type check of the provider data is shared too. There is no Renew or Close: the outputs are a copy of state values, not a lease, so nothing expires or needs releasing. The data source description, the withheld warning and the README now point at ephemeral "stackorder_outputs" for sensitive values, and the warning no longer assumes the reader is a data source. The data source's read and error tables now drive both kinds over protocol 6, so the ephemeral result is checked for the exact types, the missing id and every error mapping. The acceptance test passes the result to the echo provider and checks that a shared sensitive output reaches it, that the pre-apply plan holds neither the value nor any resource but echo.x, and that state holds only echo.x and the value nowhere else. Terraform never lists ephemeral resources in plan or state JSON, so the checks name what must be there rather than search for what must not. OpenTofu reports its own version, so the test skips below 1.11 there and below 1.10 on Terraform. Co-Authored-By: Claude Fable 5.1 --- README.md | 22 +- .../stackorder_outputs/ephemeral-resource.tf | 13 + go.mod | 2 +- internal/provider/outputs_data_source.go | 53 ++-- internal/provider/outputs_data_source_test.go | 202 +++++++----- internal/provider/outputs_ephemeral.go | 95 ++++++ internal/provider/outputs_ephemeral_test.go | 297 ++++++++++++++++++ internal/provider/provider.go | 2 +- internal/provider/provider_test.go | 2 +- 9 files changed, 584 insertions(+), 104 deletions(-) create mode 100644 examples/ephemeral-resources/stackorder_outputs/ephemeral-resource.tf create mode 100644 internal/provider/outputs_ephemeral.go create mode 100644 internal/provider/outputs_ephemeral_test.go diff --git a/README.md b/README.md index 01b1928..ced6ebd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Terraform and OpenTofu provider for [Stackorder](https://github.com/stackorder/s ## Status -The provider is under development and not yet published to a registry. It offers `stackorder_outputs` as a data source, and will offer it as an ephemeral resource too: 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. +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. ## Requirements @@ -73,7 +73,25 @@ output "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: read sensitive values with the `stackorder_outputs` ephemeral resource, which keeps them out of both, once it is available. +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 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/go.mod b/go.mod index f13f121..4fc656d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/stackorder/terraform-provider-stackorder go 1.26.0 require ( + 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 @@ -33,7 +34,6 @@ require ( 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-json v0.28.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 diff --git a/internal/provider/outputs_data_source.go b/internal/provider/outputs_data_source.go index cdc4ff3..7b9373f 100644 --- a/internal/provider/outputs_data_source.go +++ b/internal/provider/outputs_data_source.go @@ -24,6 +24,13 @@ import ( "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" @@ -68,25 +75,22 @@ func (d *outputsDataSource) Metadata(_ context.Context, req datasource.MetadataR 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" + - "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.\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. " + - "Read sensitive values with the `stackorder_outputs` ephemeral resource instead, which keeps them out of both.", + "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: "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`.", + 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: "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.", + Computed: true, + Sensitive: true, + MarkdownDescription: valuesDescription, }, "nonsensitive_values": schema.DynamicAttribute{ Computed: true, @@ -99,13 +103,9 @@ func (d *outputsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest // Configure keeps the settings of the configured provider for Read. func (d *outputsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - data, ok := req.ProviderData.(*providerData) - if !ok { - resp.Diagnostics.AddError("Unexpected provider data", - fmt.Sprintf("The stackorder_outputs data source received provider data of type %T. Report this to the provider's maintainers.", req.ProviderData)) + data, problem := providerDataOf(req.ProviderData, "data source") + if problem != nil { + resp.Diagnostics.Append(problem) return } d.data = data @@ -129,6 +129,23 @@ func (d *outputsDataSource) Read(ctx context.Context, req datasource.ReadRequest 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 { @@ -377,7 +394,7 @@ func withheldWarning(producer string, withheld []string) diag.Diagnostic { 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. Shared sensitive outputs are stored in the reader's plan file and state; read them with the stackorder_outputs ephemeral resource to keep them out of both.", + "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, ", "))) } diff --git a/internal/provider/outputs_data_source_test.go b/internal/provider/outputs_data_source_test.go index 6048ea3..ae5dbc5 100644 --- a/internal/provider/outputs_data_source_test.go +++ b/internal/provider/outputs_data_source_test.go @@ -177,7 +177,7 @@ func nullObject(block *tfprotov6.SchemaBlock, set map[string]tftypes.Value) tfty return tftypes.NewValue(block.ValueType(), attrs) } -func readThroughServer(t *testing.T, stack string) (map[string]tftypes.Value, []*tfprotov6.Diagnostic) { +func configuredServer(t *testing.T) (tfprotov6.ProviderServer, *tfprotov6.GetProviderSchemaResponse) { t.Helper() server, err := providerserver.NewProtocol6WithError(New("test")())() require.NoError(t, err) @@ -189,29 +189,44 @@ func readThroughServer(t *testing.T, stack string) (map[string]tftypes.Value, [] 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 +} - 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) - for _, d := range read.Diagnostics { +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) } } - if read.State == nil { - return nil, read.Diagnostics +} + +func objectAttributes(t *testing.T, raw *tfprotov6.DynamicValue, typ tftypes.Type) map[string]tftypes.Value { + t.Helper() + if raw == nil { + return nil } - state, err := read.State.Unmarshal(ds.ValueType()) + v, err := raw.Unmarshal(typ) require.NoError(t, err) - if state.IsNull() { - return nil, read.Diagnostics + if v.IsNull() { + return nil } var attrs map[string]tftypes.Value - require.NoError(t, state.As(&attrs)) - return attrs, read.Diagnostics + 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) { @@ -248,24 +263,27 @@ func TestOutputsDataSourceSchema(t *testing.T) { "values": tftypes.DynamicPseudoType, "nonsensitive_values": tftypes.DynamicPseudoType, }, types) - for _, want := range []string{"outputs.readers", "outputs.sensitive: share", "default branch", "plan file", "ephemeral resource"} { + 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) } } -func TestOutputsDataSourceRead(t *testing.T) { - tests := []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 - }{ +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"}, @@ -340,52 +358,65 @@ func TestOutputsDataSourceRead(t *testing.T) { wantWithheld: []string{`"db_password", "vpn_preshared_key"`}, }, } - for _, tt := range tests { +} + +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) - 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"]) + tt.assertRead(t, srv, attrs, diags) assert.True(t, attrs["id"].Equal(str(tt.wantID)), "id %s", attrs["id"]) - assert.True(t, attrs["values"].Equal(tt.wantValues), "values %s", attrs["values"]) - 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", "ephemeral resource") { - assert.Contains(t, diags[0].Detail, want) - } }) } } -func TestOutputsDataSourceReadErrors(t *testing.T) { +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}, }}) - tests := []struct { - name string - env map[string]string - stack string - answers map[string]served - wantRequests int - wantSummary string - wantAttribute bool - wantDetail []string - notDetail []string - }{ + return []outputsReadErrorCase{ { name: "no repository for a stack of the same repository", stack: vpcStack, @@ -485,32 +516,41 @@ func TestOutputsDataSourceReadErrors(t *testing.T) { wantDetail: []string{`output "vpn_preshared_key"`, "does not conform to its type", "127.0.0.1"}, }, } - for _, tt := range tests { +} + +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) - assert.Len(t, srv.requests(), tt.wantRequests) - for _, name := range []string{"id", "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) - } + tt.assertFailure(t, srv, attrs, diags) + assert.True(t, attrs["id"].IsNull(), "id %s", attrs["id"]) }) } } 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..53c7028 --- /dev/null +++ b/internal/provider/outputs_ephemeral_test.go @@ -0,0 +1,297 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + 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 ephemeralResourcesSupported() tfversion.TerraformVersionCheck { + // tofu version -json reports OpenTofu's own version, and OpenTofu has ephemeral resources only from 1.11. + if strings.Contains(filepath.Base(os.Getenv("TF_ACC_TERRAFORM_PATH")), "tofu") || os.Getenv("TF_ACC_PROVIDER_HOST") == "registry.opentofu.org" { + return tfversion.SkipBelow(tfversion.Version1_11_0) + } + return tfversion.SkipBelow(tfversion.Version1_10_0) +} + +const echoAddress = "echo.x" + +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 { + 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(req.Plan.PlannedValues.RootModule) +} + +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" {} +`, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{planHoldingOnlyEcho{secret: pskCanary}}, + }, + 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 index 58c4f8c..838a0d0 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -183,7 +183,7 @@ func (p *stackorderProvider) DataSources(_ context.Context) []func() datasource. } func (p *stackorderProvider) EphemeralResources(_ context.Context) []func() ephemeral.EphemeralResource { - return []func() ephemeral.EphemeralResource{} + return []func() ephemeral.EphemeralResource{NewOutputsEphemeralResource} } func (p *stackorderProvider) Resources(_ context.Context) []func() resource.Resource { diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 170d935..60b7539 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -213,7 +213,7 @@ func TestProviderServesProtocol6(t *testing.T) { 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.Empty(t, resp.EphemeralResourceSchemas) + assert.Equal(t, []string{"stackorder_outputs"}, slices.Sorted(maps.Keys(resp.EphemeralResourceSchemas))) assert.Empty(t, resp.ResourceSchemas) }) } From a596f7633a039e22ca4b53a32c95c7770d17d4b4 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 10 Oct 2026 20:02:24 -0400 Subject: [PATCH 07/14] docs: document the provider for the registries The Terraform Registry renders a provider's documentation from docs/ at each release tag, so the pages must be in the repository before the first tag. tfplugindocs renders them from templates, the example files and the schema's descriptions, so attribute text lives only in the Go schema and the pages cannot drift from it. The overview page says what the provider is for, how a producer opts in with outputs.readers and outputs.sensitive, which credentials apply inside and outside Stackorder jobs and which Terraform and OpenTofu releases are needed, and links to the Stackorder documentation. The data source and ephemeral resource pages add how values are addressed and how Stackorder orders a reader after its producer. The generator is a tool dependency in go.mod, pinned to terraform-plugin-docs v0.25.0 and run with go tool from go:generate lines in main.go, after terraform fmt on the examples the pages embed. It reads the schema with Terraform 1.14.4, which it downloads, because a terraform older than 1.10 on PATH cannot export the ephemeral resource schema and leaves docs/ half deleted, and a pinned release keeps the pages identical on every machine. Template and page names leave out the stackorder_ prefix because the generator and the registry look them up that way. make docs-check regenerates the pages and fails when docs/ or examples/ differ from HEAD, staged changes included, or docs/ holds an untracked page. CI can run it once go.mod requires the Stackorder release with the api/v1 stack outputs types; until then the provider builds only in a Go workspace with a Stackorder checkout. Co-Authored-By: Claude Fable 5.1 --- GNUmakefile | 7 +- README.md | 15 +++- docs/data-sources/outputs.md | 59 ++++++++++++++ docs/ephemeral-resources/outputs.md | 55 ++++++++++++++ docs/index.md | 76 +++++++++++++++++++ go.mod | 25 ++++++ go.sum | 63 ++++++++++++++- main.go | 3 + templates/data-sources/outputs.md.tmpl | 30 ++++++++ templates/ephemeral-resources/outputs.md.tmpl | 30 ++++++++ templates/index.md.tmpl | 67 ++++++++++++++++ 11 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 docs/data-sources/outputs.md create mode 100644 docs/ephemeral-resources/outputs.md create mode 100644 docs/index.md create mode 100644 templates/data-sources/outputs.md.tmpl create mode 100644 templates/ephemeral-resources/outputs.md.tmpl create mode 100644 templates/index.md.tmpl diff --git a/GNUmakefile b/GNUmakefile index 44e75af..21598fa 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -3,7 +3,7 @@ SHELL := /bin/bash BINARY := terraform-provider-stackorder GOLANGCI_LINT_VERSION ?= v2.14.0 -.PHONY: all build test testacc lint fmt generate tidy +.PHONY: all build test testacc lint fmt generate docs-check tidy all: build @@ -28,5 +28,10 @@ fmt: 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 ced6ebd..6925ba6 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,18 @@ Terraform and OpenTofu provider for [Stackorder](https://github.com/stackorder/s 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. @@ -108,7 +120,8 @@ go work init . ../stackorder | `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` | Runs `go generate` | +| `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: diff --git a/docs/data-sources/outputs.md b/docs/data-sources/outputs.md new file mode 100644 index 0000000..8d394f0 --- /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 in the stack's own directory whose `stack` is a literal string, as it does for `terraform_remote_state`; a stack of another repository becomes an external dependency, as with a cross-repository `depends_on`. A block inside a module the stack calls, or a `stack` built from variables, is not inferred: list the producer under `depends_on` in the reader's `.stackorder.yaml`. + + +## 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..a9bbdbe --- /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 in the stack's own directory whose `stack` is a literal string, as it does for `terraform_remote_state`; a stack of another repository becomes an external dependency, as with a cross-repository `depends_on`. A block inside a module the stack calls, or a `stack` built from variables, is not inferred: list the producer under `depends_on` in the reader's `.stackorder.yaml`. + + +## 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..c23c504 --- /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, a Stackorder CLI release later than 0.3.0, 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/go.mod b/go.mod index 4fc656d..c051d49 100644 --- a/go.mod +++ b/go.mod @@ -12,14 +12,24 @@ require ( ) 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 @@ -34,25 +44,36 @@ require ( 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.54.0 // indirect + golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect @@ -63,4 +84,8 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/grpc v1.82.0 // 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 index cce1775..2c3dbd9 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,15 @@ 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= @@ -11,6 +21,12 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew 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= @@ -28,6 +44,8 @@ github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FM 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= @@ -51,8 +69,11 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw 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= @@ -65,6 +86,7 @@ github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g 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= @@ -86,6 +108,8 @@ github.com/hashicorp/terraform-exec v0.25.1 h1:PRutYRGM8pixV3B8812NYoBK5O+yuf3qc 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= @@ -102,6 +126,11 @@ github.com/hashicorp/terraform-svchost v0.2.1 h1:ubvrTFw3Q7CsoEaX7V06PtCTKG3wu7G 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= @@ -112,8 +141,9 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN 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 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 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= @@ -122,6 +152,9 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky 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= @@ -130,6 +163,7 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ 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= @@ -137,15 +171,26 @@ github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YF 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.3.0 h1:hedGEwM6Kfi8LL97TSFMQO7HFc/+7kx8A0vv50KpRhY= github.com/stackorder/stackorder v0.3.0/go.mod h1:3o5f+ugNRQ5Pig2tpwg6bCa/TKZjmjyGA4fzmJA4apE= 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= @@ -159,10 +204,16 @@ github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV 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= @@ -179,8 +230,11 @@ 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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= @@ -188,6 +242,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn 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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -205,15 +260,18 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc 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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -242,5 +300,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN 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/main.go b/main.go index 55cd9fb..4c5fd77 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,9 @@ import ( "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() { diff --git a/templates/data-sources/outputs.md.tmpl b/templates/data-sources/outputs.md.tmpl new file mode 100644 index 0000000..b7326fe --- /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 in the stack's own directory whose `stack` is a literal string, as it does for `terraform_remote_state`; a stack of another repository becomes an external dependency, as with a cross-repository `depends_on`. A block inside a module the stack calls, or a `stack` built from variables, is not inferred: list the producer under `depends_on` in the reader's `.stackorder.yaml`. + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/ephemeral-resources/outputs.md.tmpl b/templates/ephemeral-resources/outputs.md.tmpl new file mode 100644 index 0000000..8e50c5e --- /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 in the stack's own directory whose `stack` is a literal string, as it does for `terraform_remote_state`; a stack of another repository becomes an external dependency, as with a cross-repository `depends_on`. A block inside a module the stack calls, or a `stack` built from variables, is not inferred: list the producer under `depends_on` in the reader's `.stackorder.yaml`. + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl new file mode 100644 index 0000000..bb05b9c --- /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, a Stackorder CLI release later than 0.3.0, 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 }} From 5239aaec93b5e6e7008dde04a09e01666612e050 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 10 Oct 2026 20:01:43 -0400 Subject: [PATCH 08/14] build(release): publish signed archives for the registries The Terraform and OpenTofu registries ingest a provider from its GitHub releases: one zip per platform holding a binary named terraform-provider-stackorder_v, a SHA256SUMS file covering the zips and the registry manifest, and a binary detached GPG signature of that file. GoReleaser builds these assets in the layout and for the platforms of the HashiCorp scaffolding, which covers the registry's recommended list including linux/s390x. The manifest declares plugin protocol 6, which the Terraform Registry would otherwise record as protocol 5. A pre-release tag becomes a GitHub pre-release. The release workflow runs on v* tags in the release environment, so the signing key can be limited to tag runs, and takes the release notes from the tag's CHANGELOG section as the Stackorder release does, failing when that section is missing. It builds with the newest stable Go and without a restored cache: go-version-file would install go.mod's go 1.26.0 directive as the exact toolchain, and a cache written by other runs could reach the signed binaries. govulncheck stops a release whose code reaches a known vulnerability before the key is imported. Actions are pinned to full commit SHAs and GoReleaser to v2.18.2, and checkout keeps no git credentials because GoReleaser publishes through GITHUB_TOKEN. The workflow builds without the local go.work, so a tag can only be cut once go.mod requires the Stackorder release carrying the api/v1 outputs types; that change also has to raise grpc and x/net past their known vulnerabilities for the govulncheck step to pass. The changelog names Stackorder 0.4.0 as the planned minimum. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 49 ++++++++++++++++++ .goreleaser.yml | 89 ++++++++++++++++++++++++++++++++ CHANGELOG.md | 16 ++++++ terraform-registry-manifest.json | 6 +++ 4 files changed, 160 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yml create mode 100644 CHANGELOG.md create mode 100644 terraform-registry-manifest.json 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/.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..fddf5b3 --- /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 release planned to be the first 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/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"] + } +} From b50490f30cbbfd7e2c5c127f0bb04c5970a488fa Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 10 Oct 2026 20:01:36 -0400 Subject: [PATCH 09/14] ci: build, lint, test against terraform and tofu, check generated docs Run lint, unit tests, a docs check and builds on every push to main and every pull request. The acceptance tests run against Terraform 1.10, the first release with ephemeral resources, and the latest Terraform, and against OpenTofu 1.11, its first release with them, and the latest OpenTofu. For OpenTofu, setup-opentofu's provider_acceptance_tests input points terraform-plugin-testing at the tofu binary and at OpenTofu's registry host, which the reattached provider's address must carry. The docs job runs go generate, which formats examples/ and renders docs/ from them, and fails when either differs from the commit or when it creates pages that were never committed, which git diff alone does not report. Actions are pinned to commit SHAs and checkouts do not keep the token. The module compiles in CI only once go.mod requires a Stackorder release whose api/v1 carries StackOutputs; until then every job stops at compile time, and the build step's name says so. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 122 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..722aa92 --- /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, which needs go.mod to require a Stackorder release whose api/v1 has StackOutputs + 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 . From 431e4244fb4e31c7a717f6a1d7930f5fe35768ae Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 10 Oct 2026 20:25:23 -0400 Subject: [PATCH 10/14] test(provider): accept OpenTofu 1.11 plans in the ephemeral acceptance test OpenTofu 1.11 lists an ephemeral resource in a plan's resource_changes and planned_values, with mode ephemeral and the action open, so the plan check reported ephemeral.stackorder_outputs.vpc as a change beside echo.x. The plan walkers now skip entries whose mode is ephemeral. The state check still rejects everything but echo.x, ephemeral entries included, so the test still proves the ephemeral resource never reaches state, and the canary check still covers the whole plan JSON. OpenTofu 1.11 also reports the open as a change in the plan terraform-plugin-testing makes after apply, so that plan is never empty there. The step sets ExpectNonEmptyPlan only when the CLI is OpenTofu older than 1.12, because plugin-testing v1.16.0 fails a step that sets it when the plan comes back empty, as it does on OpenTofu 1.12 and later and on Terraform. tofu version -json does not name the product, so the CLI counts as OpenTofu when its binary is named tofu or TF_ACC_PROVIDER_HOST is OpenTofu's registry, and only the version is read from that JSON. Both helpers have unit tests. Since ExpectNonEmptyPlan switches off the empty-plan check, both post-apply plans now require echo.x to be a no-op, which keeps catching drift on OpenTofu 1.11. The acceptance tests pass on Terraform 1.10.5, 1.14.4 and 1.16.5 and on OpenTofu 1.11.14, 1.12.6 and 1.13.1. go-version moves from an indirect to a direct requirement at the same version. Co-Authored-By: Claude Fable 5.1 --- go.mod | 2 +- internal/provider/outputs_ephemeral_test.go | 114 +++++++++++++++++++- 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index c051d49..5d727c5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ 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 @@ -39,7 +40,6 @@ require ( 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/go-version v1.9.0 // 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 diff --git a/internal/provider/outputs_ephemeral_test.go b/internal/provider/outputs_ephemeral_test.go index 53c7028..134ce14 100644 --- a/internal/provider/outputs_ephemeral_test.go +++ b/internal/provider/outputs_ephemeral_test.go @@ -7,11 +7,13 @@ import ( "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" @@ -178,15 +180,104 @@ func testAccProtoV6ProviderFactoriesWithEcho() map[string]func() (tfprotov6.Prov 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 strings.Contains(filepath.Base(os.Getenv("TF_ACC_TERRAFORM_PATH")), "tofu") || os.Getenv("TF_ACC_PROVIDER_HOST") == "registry.opentofu.org" { + if cliIsOpenTofu() { return tfversion.SkipBelow(tfversion.Version1_11_0) } return tfversion.SkipBelow(tfversion.Version1_10_0) } -const echoAddress = "echo.x" +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 { @@ -243,6 +334,9 @@ func (c planHoldingOnlyEcho) CheckPlan(_ context.Context, req plancheck.CheckPla } 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}) { @@ -253,7 +347,16 @@ func (c planHoldingOnlyEcho) CheckPlan(_ context.Context, req plancheck.CheckPla resp.Error = fmt.Errorf("the plan has no planned values") return } - resp.Error = holdsOnlyEcho(req.Plan.PlannedValues.RootModule) + 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) { @@ -275,8 +378,11 @@ provider "echo" { resource "echo" "x" {} `, + ExpectNonEmptyPlan: cliPlansEphemeralOpens(t), ConfigPlanChecks: resource.ConfigPlanChecks{ - PreApply: []plancheck.PlanCheck{planHoldingOnlyEcho{secret: pskCanary}}, + 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{ From 35b2294a0aeae9050f2d466b74528f22c612e199 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 10 Oct 2026 22:35:11 -0400 Subject: [PATCH 11/14] docs: describe how the scanner orders stacks that read through modules The Stackorder scanner now reads stackorder_outputs blocks in every local module a stack reaches, not only in the stack's own directory, and resolves a module's var. references from the literal arguments of the module block that calls it, one level up. The Ordering section of the data source and ephemeral resource pages still said a block inside a module is never inferred, which would send readers to depends_on for lookup modules the scanner now orders on its own. The section now states the rule as the scanner applies it: literal stacks in the stack's directory or its local modules, module variables set to literals by the calling module block, and depends_on for any other expression. It names every module the scanner leaves unread, registry, git and other remote modules as well as local sources outside the repository, so readers know a block there also needs depends_on. It keeps terraform_remote_state tied to the stack's own directory, since the scanner does not follow that data source into modules, and it says that depends_on does not silence the warning for a non-literal stack, so readers do not take the remaining warning for a missing dependency. Co-Authored-By: Claude Fable 5.1 --- docs/data-sources/outputs.md | 2 +- docs/ephemeral-resources/outputs.md | 2 +- templates/data-sources/outputs.md.tmpl | 2 +- templates/ephemeral-resources/outputs.md.tmpl | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/data-sources/outputs.md b/docs/data-sources/outputs.md index 8d394f0..8bbd0ce 100644 --- a/docs/data-sources/outputs.md +++ b/docs/data-sources/outputs.md @@ -43,7 +43,7 @@ Each output is an attribute of `values`, and of `nonsensitive_values` unless the ## Ordering -Stackorder plans and applies a reading stack after the stacks it reads. Its scanner infers that dependency from each `stackorder_outputs` block in the stack's own directory whose `stack` is a literal string, as it does for `terraform_remote_state`; a stack of another repository becomes an external dependency, as with a cross-repository `depends_on`. A block inside a module the stack calls, or a `stack` built from variables, is not inferred: list the producer under `depends_on` in the reader's `.stackorder.yaml`. +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 diff --git a/docs/ephemeral-resources/outputs.md b/docs/ephemeral-resources/outputs.md index a9bbdbe..9f6aad3 100644 --- a/docs/ephemeral-resources/outputs.md +++ b/docs/ephemeral-resources/outputs.md @@ -40,7 +40,7 @@ Each output is an attribute of `values`, and of `nonsensitive_values` unless the ## Ordering -Stackorder plans and applies a reading stack after the stacks it reads. Its scanner infers that dependency from each `stackorder_outputs` block in the stack's own directory whose `stack` is a literal string, as it does for `terraform_remote_state`; a stack of another repository becomes an external dependency, as with a cross-repository `depends_on`. A block inside a module the stack calls, or a `stack` built from variables, is not inferred: list the producer under `depends_on` in the reader's `.stackorder.yaml`. +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 diff --git a/templates/data-sources/outputs.md.tmpl b/templates/data-sources/outputs.md.tmpl index b7326fe..3a8f751 100644 --- a/templates/data-sources/outputs.md.tmpl +++ b/templates/data-sources/outputs.md.tmpl @@ -25,6 +25,6 @@ Each output is an attribute of `values`, and of `nonsensitive_values` unless the ## Ordering -Stackorder plans and applies a reading stack after the stacks it reads. Its scanner infers that dependency from each `stackorder_outputs` block in the stack's own directory whose `stack` is a literal string, as it does for `terraform_remote_state`; a stack of another repository becomes an external dependency, as with a cross-repository `depends_on`. A block inside a module the stack calls, or a `stack` built from variables, is not inferred: list the producer under `depends_on` in the reader's `.stackorder.yaml`. +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 index 8e50c5e..658d420 100644 --- a/templates/ephemeral-resources/outputs.md.tmpl +++ b/templates/ephemeral-resources/outputs.md.tmpl @@ -25,6 +25,6 @@ Each output is an attribute of `values`, and of `nonsensitive_values` unless the ## Ordering -Stackorder plans and applies a reading stack after the stacks it reads. Its scanner infers that dependency from each `stackorder_outputs` block in the stack's own directory whose `stack` is a literal string, as it does for `terraform_remote_state`; a stack of another repository becomes an external dependency, as with a cross-repository `depends_on`. A block inside a module the stack calls, or a `stack` built from variables, is not inferred: list the producer under `depends_on` in the reader's `.stackorder.yaml`. +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 }} From a2992cdd097af0d8e7690cbd3f1c4e25f7eccf24 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sun, 11 Oct 2026 03:10:34 -0400 Subject: [PATCH 12/14] build(deps): require stackorder v0.4.0 Stackorder v0.4.0 is the first release whose api/v1 package has the shared-outputs types the provider decodes. Requiring it replaces the v0.3.0 placeholder, so the provider builds and tests from the published module without a Go workspace that points at a local checkout. The CI build step and the README no longer describe that arrangement. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 +- README.md | 6 +----- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 722aa92..721beb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,7 +114,7 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - - name: Build, which needs go.mod to require a Stackorder release whose api/v1 has StackOutputs + - name: Build run: go build ./... - name: Cross-compile for macOS on arm64 and Windows on amd64 run: | diff --git a/README.md b/README.md index 6925ba6..2350540 100644 --- a/README.md +++ b/README.md @@ -107,11 +107,7 @@ The ephemeral resource takes the same `stack` and returns the same `values` and ## Development -Until Stackorder tags the release that carries the `api/v1` stack outputs types, `go.mod` requires `github.com/stackorder/stackorder` v0.3.0 as a placeholder, and the provider builds only in a Go workspace that also uses a Stackorder checkout with those types. Create the workspace in the repository root, naming the path of that checkout; git ignores `go.work` and `go.work.sum`: - -```shell -go work init . ../stackorder -``` +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 | |---|---| diff --git a/go.mod b/go.mod index 5d727c5..ea44229 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( 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.3.0 + github.com/stackorder/stackorder v0.4.0 github.com/stretchr/testify v1.12.1 ) diff --git a/go.sum b/go.sum index 2c3dbd9..2d242f6 100644 --- a/go.sum +++ b/go.sum @@ -185,8 +185,8 @@ github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQ 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.3.0 h1:hedGEwM6Kfi8LL97TSFMQO7HFc/+7kx8A0vv50KpRhY= -github.com/stackorder/stackorder v0.3.0/go.mod h1:3o5f+ugNRQ5Pig2tpwg6bCa/TKZjmjyGA4fzmJA4apE= +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= From 2a199504fc8a096ac24fdf60daea2b347b0e3c17 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sun, 11 Oct 2026 03:11:47 -0400 Subject: [PATCH 13/14] build(deps): upgrade grpc and x/net past their advisories govulncheck reports GO-2026-6061, GO-2026-6348 and GO-2026-6443 in google.golang.org/grpc v1.82.0, and GO-2026-6603, GO-2026-6610, GO-2026-6611, GO-2026-6612 and GO-2026-6617 in golang.org/x/net v0.57.0, each with call paths from the provider's code. grpc v1.83.1 fixes GO-2026-6348 but not GO-2026-6443, which the 1.83 line fixes in v1.83.2, so this requires grpc v1.83.2 and x/net v0.60.0. x/net v0.60.0 raises its own minimums, which tidy carries over: x/crypto v0.57.0, x/mod v0.41.0, x/sync v0.23.0, x/sys v0.48.0, x/text v0.42.0 and x/tools v0.49.0. govulncheck v1.8.0 on Go 1.27.2 then finds no vulnerabilities in the code or in any required module. Co-Authored-By: Claude Fable 5.1 --- go.mod | 16 ++++++++-------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index ea44229..2c3a7f9 100644 --- a/go.mod +++ b/go.mod @@ -72,17 +72,17 @@ require ( 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.54.0 // indirect + golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - golang.org/x/tools v0.47.0 // 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.82.0 // 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 diff --git a/go.sum b/go.sum index 2d242f6..6ee7ad6 100644 --- a/go.sum +++ b/go.sum @@ -222,8 +222,8 @@ go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSY 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.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +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= @@ -231,25 +231,25 @@ 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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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= @@ -262,8 +262,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc 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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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= @@ -272,13 +272,13 @@ 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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +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= @@ -288,8 +288,8 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs 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.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= -google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +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= From 5c09967221208cd06719f8fd0da02282867a2028 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sun, 11 Oct 2026 03:12:37 -0400 Subject: [PATCH 14/14] docs: say the provider needs Stackorder 0.4.0 Stackorder 0.4.0 is now released and is the first CLI release that passes the server URL, the run and the repository to Terraform. The README, the provider page and the changelog named it only as the release after 0.3.0 or as a planned one, so they now name 0.4.0. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- README.md | 2 +- docs/index.md | 2 +- templates/index.md.tmpl | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fddf5b3..3f6842f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this provider are documented here. The format follows [Ke ## [Unreleased] -It needs Stackorder 0.4.0 or later on the server and in the CLI, the release planned to be the first to serve shared outputs. +It needs Stackorder 0.4.0 or later on the server and in the CLI, the first release to serve shared outputs. ### Added diff --git a/README.md b/README.md index 2350540..1b60bb6 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ provider "stackorder" { } ``` -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 up to 0.3.0 set `STACKORDER_STACK` but not the other three, so a job needs a later release to read outputs with its own identity. +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. diff --git a/docs/index.md b/docs/index.md index c23c504..176b661 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,7 +20,7 @@ The [Stackorder documentation](https://docs.stackorder.io) covers the server, th - 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, a Stackorder CLI release later than 0.3.0, which passes the server URL, the run and the repository to Terraform. +- Inside Stackorder jobs, Stackorder CLI 0.4.0 or later, which passes the server URL, the run and the repository to Terraform. ## Sharing outputs diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl index bb05b9c..9ab26f4 100644 --- a/templates/index.md.tmpl +++ b/templates/index.md.tmpl @@ -20,7 +20,7 @@ The [Stackorder documentation](https://docs.stackorder.io) covers the server, th - 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, a Stackorder CLI release later than 0.3.0, which passes the server URL, the run and the repository to Terraform. +- Inside Stackorder jobs, Stackorder CLI 0.4.0 or later, which passes the server URL, the run and the repository to Terraform. ## Sharing outputs