diff --git a/.gitignore b/.gitignore index 946d338..6a79b6e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ search/target/ /ingest/ingest /api/api /cli/sentryctl +/terraform/terraform-provider-sentry /alerting/alerting /hack/windows-fixture/windows-fixture /hack/benchmark-fixture/benchmark-fixture diff --git a/CLAUDE.md b/CLAUDE.md index d9d1b82..e9d7899 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,16 @@ described there without flagging it to me first. schema-on-read fallback for unstructured text. - Every UI action must correspond to a documented REST/gRPC call. No UI-only logic. CLI (`sentryctl`) and Terraform provider are first-class, - not afterthoughts. + not afterthoughts. **Status**: `sentryctl` has been built out phase by + phase since Phase 3. The Terraform provider (`/terraform`) only exists + as of this note -- one resource (`sentry_dashboard`), built on + HashiCorp's `terraform-plugin-framework`, reusing the exact same REST + contract `sentryctl dashboards apply` and web's dashboard export + already use. Alert rules, notification targets, and tenant/RBAC + resources are real, disclosed future work -- see `/terraform/README.md` + for the full accounting of what is and isn't built, and the same + "written but not run against a live stack" verification caveat as + everything else Docker-gated in this repo. ## Tech stack (pinned — do not substitute without discussion) | Component | Language/Tool | diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..c773e81 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,134 @@ +# terraform-provider-sentry + +Sentry's Terraform provider -- `CLAUDE.md`'s "Repo conventions" section +names this a first-class deliverable alongside `sentryctl` +("CLI and Terraform provider are first-class, not afterthoughts"), but +no phase before this one had actually built any of it. This is a first +slice, not a finished provider: one resource (`sentry_dashboard`), built +on [HashiCorp's `terraform-plugin-framework`][framework] (the actively- +developed library, not the legacy SDKv2 -- there's no existing provider +code here to migrate, so there's no reason to start on the framework +HashiCorp itself steers new providers away from). + +[framework]: https://developer.hashicorp.com/terraform/plugin/framework + +## Why `sentry_dashboard` first + +`cli/README.md` already frames the underlying REST contract this way: +`POST /dashboards`, `GET`/`PUT`/`DELETE /dashboards/{id}` are "the seed +of a future Terraform provider: one JSON contract, multiple callers (web +export, CLI apply, eventually a provider)." This provider is that third +caller -- `internal/provider/client.go` talks the exact same JSON shape +`sentryctl dashboards apply` and the web UI's Export JSON button already +use against `api/dashboards.Handler`, not a new contract invented for +Terraform's sake. + +## What's built + +```hcl +terraform { + required_providers { + sentry = { + source = "registry.terraform.io/sentry/sentry" + } + } +} + +provider "sentry" { + endpoint = "http://localhost:8080" # or $SENTRY_API_ENDPOINT + token = var.sentry_api_token # or $SENTRY_API_TOKEN -- optional, only needed once enterprise-auth enforcement is on +} + +resource "sentry_dashboard" "example" { + name = "Checkout Errors" + description = "5xx rate and latency for the checkout service" + # default_earliest/default_latest are optional -- left unset, the API + # itself defaults them ("-1h"/"now"); this resource deliberately + # doesn't hardcode a matching Terraform-side default, so the API stays + # the one source of truth for what "unset" means (see the schema's + # doc comment in internal/provider/dashboard_resource.go). +} +``` + +Supports `terraform import sentry_dashboard.example `. + +**Panels are not managed by this resource.** `api/dashboards.Handler` +exposes panel CRUD as its own endpoints +(`POST`/`PUT`/`DELETE /dashboards/{id}/panels[/{panelId}]`), a +genuinely separate resource shape (a panel belongs to exactly one +dashboard, has its own lifecycle, and the query-language/viz-config +fields deserve their own attribute validation) -- scoped out of this +first pass deliberately, not an oversight. A `sentry_dashboard_panel` +resource (or a panels list block on this one -- an open design question, +not yet decided) is real, disclosed future work. + +**Also not built, all real and disclosed, not attempted here:** +- Alert rules / notification targets (`/alerting`'s REST surface -- + `POST /rules`, etc.) -- a second provider "family" of resources, no + code shared with dashboards beyond this same client-pattern + discipline. +- Tenant/RBAC resources (`enterprise-auth`'s tenant/membership/grant + surface) -- meaningfully different auth model (offline operator flags + today, not a stable REST API a provider could safely drive + idempotently -- see `/enterprise/README.md`'s "Bootstrapping a tenant" + section) and Phase 4 commercial licensing, so this would need its own + design pass, not just "add another resource file." +- A `sentry_dashboard` data source (read-only lookup by ID/name) -- + straightforward given the resource already exists, just not built + yet. +- Publishing to the real Terraform Registry -- `main.go`'s `Address` + (`registry.terraform.io/sentry/sentry`) is the address a real + publication would use, but nothing has actually been published; local + use is via `~/.terraformrc`'s `dev_overrides` (see "Building & + testing" below) or a local provider mirror. + +## Building & testing + +```sh +go build ./... +go vet ./... +go test ./... +``` + +`internal/provider/client_test.go` runs real HTTP round trips against a +`httptest.Server` (same pattern `cli/cmd/sentryctl`'s own tests use +against the same `api/dashboards` endpoints) -- real request +construction (method, path, `Authorization` header, JSON body), real +response parsing, including the 404-vs-other-error distinction +`Read`/`Delete` need to implement Terraform's "resource deleted +out-of-band" convention correctly. +`internal/provider/provider_test.go` validates the provider and +resource schemas are internally well-formed (attribute names, the +`Required`/`Computed` split) without needing a Terraform binary or a +live `api` service at all. + +`internal/provider/dashboard_resource_test.go`'s +`TestAccDashboardResource_basic` is a real acceptance test using +[`terraform-plugin-testing`][testing] -- skipped unless `TF_ACC=1` is +set, that framework's own standard convention, the same shape every +other live-infrastructure test in this repo uses (`docker`-gated env +vars for Postgres/ClickHouse tests elsewhere). Even with `TF_ACC=1` it +also needs a real running `api` service (Postgres + ClickHouse) to apply +against, which this environment has no Docker access to bring up -- +**not run here**, same disclosed gap as every other live-infra test +across this repo (see `/docs/phase-4-runbook.md`'s "Verification +status" section for the project-wide version of this same caveat). "The +test exists and is correct Go" is not the same claim as "this resource +has been applied for real." + +[testing]: https://developer.hashicorp.com/terraform/plugin/testing + +```sh +# local dev override, so `terraform` picks up a locally-built binary +# instead of trying to download from the registry (which nothing has +# been published to -- see "What's built" above) +go build -o terraform-provider-sentry . +cat <<'EOF' >> ~/.terraformrc +provider_installation { + dev_overrides { + "registry.terraform.io/sentry/sentry" = "/absolute/path/to/this/directory" + } + direct {} +} +EOF +``` diff --git a/terraform/examples/provider/provider.tf b/terraform/examples/provider/provider.tf new file mode 100644 index 0000000..315365e --- /dev/null +++ b/terraform/examples/provider/provider.tf @@ -0,0 +1,22 @@ +terraform { + required_providers { + sentry = { + source = "registry.terraform.io/sentry/sentry" + } + } +} + +variable "sentry_api_token" { + type = string + default = null + sensitive = true +} + +provider "sentry" { + # Both optional -- default to $SENTRY_API_ENDPOINT/$SENTRY_API_TOKEN, + # then http://localhost:8080/no token, matching sentryctl's own + # defaults (cli/cmd/sentryctl/main.go). token is only required once a + # deployment turns on enterprise-auth enforcement. + endpoint = "http://localhost:8080" + token = var.sentry_api_token +} diff --git a/terraform/examples/resources/sentry_dashboard/import.sh b/terraform/examples/resources/sentry_dashboard/import.sh new file mode 100644 index 0000000..b4cd4e4 --- /dev/null +++ b/terraform/examples/resources/sentry_dashboard/import.sh @@ -0,0 +1 @@ +terraform import sentry_dashboard.checkout_errors diff --git a/terraform/examples/resources/sentry_dashboard/resource.tf b/terraform/examples/resources/sentry_dashboard/resource.tf new file mode 100644 index 0000000..21de528 --- /dev/null +++ b/terraform/examples/resources/sentry_dashboard/resource.tf @@ -0,0 +1,11 @@ +resource "sentry_dashboard" "checkout_errors" { + name = "Checkout Errors" + description = "5xx rate and latency for the checkout service" + + # Optional -- left unset, the API defaults these to "-1h"/"now" + # server-side (api/dashboards/store.go). Set explicitly here only to + # show the attribute; omit it entirely in real usage unless you want + # something other than the default. + default_earliest = "-24h" + default_latest = "now" +} diff --git a/terraform/go.mod b/terraform/go.mod new file mode 100644 index 0000000..172478d --- /dev/null +++ b/terraform/go.mod @@ -0,0 +1,62 @@ +module github.com/sentry/sentry/terraform + +go 1.25.8 + +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 +) + +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/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.24.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.27.2 // 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/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/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.18.1 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/terraform/go.sum b/terraform/go.sum new file mode 100644 index 0000000..d54160d --- /dev/null +++ b/terraform/go.sum @@ -0,0 +1,242 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +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/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.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= +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.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU= +github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= +github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA= +github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc= +github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8= +github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8= +github.com/hashicorp/terraform-plugin-log v0.11.0 h1:WjhcpZIVqP8YRe83+dIZXncwSgtu4vh27i23G33PUQY= +github.com/hashicorp/terraform-plugin-log v0.11.0/go.mod h1:XygBz8+m5kgwTb73MMyrnUjeNQeVWECEfg+h2opMsj0= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 h1:MKS/2URqeJRwJdbOfcbdsZCq/IRrNkqJNN0GtVIsuGs= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0/go.mod h1:PuG4P97Ju3QXW6c6vRkRadWJbvnEu2Xh+oOuqcYOqX4= +github.com/hashicorp/terraform-plugin-testing v1.16.0 h1:GB97nGnJ1hESpDrCjqZig38RodSF0gdRzxlDupLXP38= +github.com/hashicorp/terraform-plugin-testing v1.16.0/go.mod h1:eQPYAy9xFMV7xtIFX8Y+wJGtUB++HBl329zCF6PBMZk= +github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= +github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= +github.com/hashicorp/terraform-svchost v0.2.1 h1:ubvrTFw3Q7CsoEaX7V06PtCTKG3wu7GyyobAoN4eF3Q= +github.com/hashicorp/terraform-svchost v0.2.1/go.mod h1:zDMheBLvNzu7Q6o9TBvPqiZToJcSuCLXjAXxBslSky4= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/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= +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/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/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/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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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.18.1 h1:yEGE8M4iIZlyKQURZNb2SnEyZlZHUcBCnx6KF81KuwM= +github.com/zclconf/go-cty v1.18.1/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= +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.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= +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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +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-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.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +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/terraform/internal/provider/client.go b/terraform/internal/provider/client.go new file mode 100644 index 0000000..d486fd3 --- /dev/null +++ b/terraform/internal/provider/client.go @@ -0,0 +1,143 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +// client is a thin HTTP client against api/dashboards.Handler's REST +// endpoints -- deliberately hand-rolled, not generated from an OpenAPI +// spec (none exists in this repo yet), the same "boring, well- +// understood" posture cli/cmd/sentryctl's own httpclient.go already +// takes against the same API. Kept separate from that package (not +// reused directly) since this one needs typed request/response +// marshaling for Terraform's plan/state model, where sentryctl only +// ever needs to pretty-print whatever JSON comes back. +type client struct { + baseURL string + token string + http *http.Client +} + +func newClient(baseURL, token string) *client { + return &client{baseURL: baseURL, token: token, http: &http.Client{Timeout: 30 * time.Second}} +} + +// apiError carries the HTTP status code through so callers can +// distinguish "the server rejected this request" from "this specific +// resource doesn't exist" (isNotFound below) -- Read/Delete need that +// distinction to implement Terraform's standard "drop from state, don't +// error the whole apply" convention for a resource deleted out-of-band. +type apiError struct { + StatusCode int + Message string +} + +func (e *apiError) Error() string { + return fmt.Sprintf("sentry api: request failed with status %d: %s", e.StatusCode, e.Message) +} + +func isNotFound(err error) bool { + var apiErr *apiError + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound +} + +// dashboard mirrors api/dashboards.Dashboard's JSON shape -- deliberately +// a local type, not an import of that package (this module has no +// dependency on /api at all, matching every other cross-module boundary +// in this repo: talk over HTTP, not Go imports, to a service that isn't +// yours). Panels are intentionally not modeled here yet -- this +// resource only manages dashboard-level fields; see the provider +// README for why panels are scoped-out future work, not an oversight. +type dashboard struct { + ID string `json:"id,omitempty"` + TenantID string `json:"tenant_id,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + DefaultEarliest string `json:"default_earliest,omitempty"` + DefaultLatest string `json:"default_latest,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +func (c *client) do(ctx context.Context, method, path string, body, out any) error { + var reqBody io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encoding request body: %w", err) + } + reqBody = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody) + if err != nil { + return fmt.Errorf("building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + msg := string(respBody) + var errResp struct { + Error string `json:"error"` + } + if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" { + msg = errResp.Error + } + return &apiError{StatusCode: resp.StatusCode, Message: msg} + } + if out == nil || len(respBody) == 0 { + return nil + } + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + return nil +} + +func (c *client) createDashboard(ctx context.Context, d *dashboard) (*dashboard, error) { + var out dashboard + if err := c.do(ctx, http.MethodPost, "/dashboards", d, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *client) getDashboard(ctx context.Context, id string) (*dashboard, error) { + var out dashboard + if err := c.do(ctx, http.MethodGet, "/dashboards/"+id, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *client) updateDashboard(ctx context.Context, id string, d *dashboard) (*dashboard, error) { + var out dashboard + if err := c.do(ctx, http.MethodPut, "/dashboards/"+id, d, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *client) deleteDashboard(ctx context.Context, id string) error { + return c.do(ctx, http.MethodDelete, "/dashboards/"+id, nil, nil) +} diff --git a/terraform/internal/provider/client_test.go b/terraform/internal/provider/client_test.go new file mode 100644 index 0000000..3ad7bb1 --- /dev/null +++ b/terraform/internal/provider/client_test.go @@ -0,0 +1,152 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestCreateDashboardSendsExpectedRequest is the same "real +// httptest.Server, real HTTP round trip" pattern +// cli/cmd/sentryctl's own tests use against the same api/dashboards +// endpoints -- this client has no fake/mock mode, so its tests exercise +// real request construction and real response parsing throughout. +func TestCreateDashboardSendsExpectedRequest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/dashboards" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("Authorization = %q, want Bearer test-token", got) + } + var body dashboard + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding request body: %v", err) + } + if body.Name != "My Dashboard" { + t.Errorf("request body Name = %q, want My Dashboard", body.Name) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(dashboard{ + ID: "dash-1", TenantID: "acme", Name: body.Name, + DefaultEarliest: "-1h", DefaultLatest: "now", + }) + })) + defer srv.Close() + + c := newClient(srv.URL, "test-token") + out, err := c.createDashboard(context.Background(), &dashboard{Name: "My Dashboard"}) + if err != nil { + t.Fatalf("createDashboard: %v", err) + } + if out.ID != "dash-1" || out.TenantID != "acme" || out.DefaultEarliest != "-1h" { + t.Fatalf("unexpected response: %+v", out) + } +} + +func TestGetDashboardNotFoundIsRecognizable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "dashboard not found"}) + })) + defer srv.Close() + + c := newClient(srv.URL, "") + _, err := c.getDashboard(context.Background(), "does-not-exist") + if err == nil { + t.Fatal("expected an error for a 404 response") + } + if !isNotFound(err) { + t.Fatalf("isNotFound(%v) = false, want true", err) + } +} + +func TestGetDashboardServerErrorIsNotNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + c := newClient(srv.URL, "") + _, err := c.getDashboard(context.Background(), "dash-1") + if err == nil { + t.Fatal("expected an error for a 500 response") + } + if isNotFound(err) { + t.Fatal("isNotFound must be false for a 500 -- only a real 404 means \"this resource is gone\"") + } +} + +func TestUpdateDashboardSendsToCorrectPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/dashboards/dash-1" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(dashboard{ID: "dash-1", Name: "Renamed"}) + })) + defer srv.Close() + + c := newClient(srv.URL, "") + out, err := c.updateDashboard(context.Background(), "dash-1", &dashboard{Name: "Renamed"}) + if err != nil { + t.Fatalf("updateDashboard: %v", err) + } + if out.Name != "Renamed" { + t.Fatalf("Name = %q, want Renamed", out.Name) + } +} + +func TestDeleteDashboardSendsToCorrectPath(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + if r.Method != http.MethodDelete || r.URL.Path != "/dashboards/dash-1" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := newClient(srv.URL, "") + if err := c.deleteDashboard(context.Background(), "dash-1"); err != nil { + t.Fatalf("deleteDashboard: %v", err) + } + if !called { + t.Fatal("expected the server to receive a DELETE request") + } +} + +func TestDoOmitsAuthorizationHeaderWhenNoTokenConfigured(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + t.Errorf("expected no Authorization header, got %q", r.Header.Get("Authorization")) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := newClient(srv.URL, "") + if err := c.do(context.Background(), http.MethodGet, "/dashboards", nil, nil); err != nil { + t.Fatalf("do: %v", err) + } +} + +func TestApiErrorSurfacesPlainTextBodyWhenNotJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("forbidden")) + })) + defer srv.Close() + + c := newClient(srv.URL, "") + _, err := c.getDashboard(context.Background(), "dash-1") + if err == nil || !strings.Contains(err.Error(), "forbidden") { + t.Fatalf("err = %v, want it to surface the plain-text body", err) + } +} diff --git a/terraform/internal/provider/dashboard_resource.go b/terraform/internal/provider/dashboard_resource.go new file mode 100644 index 0000000..a487797 --- /dev/null +++ b/terraform/internal/provider/dashboard_resource.go @@ -0,0 +1,220 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &dashboardResource{} + _ resource.ResourceWithConfigure = &dashboardResource{} + _ resource.ResourceWithImportState = &dashboardResource{} +) + +func newDashboardResource() resource.Resource { + return &dashboardResource{} +} + +// dashboardResource implements sentry_dashboard against +// api/dashboards.Handler's POST/GET/PUT/DELETE /dashboards[/{id}] +// endpoints -- the exact same JSON contract cli/cmd/sentryctl's +// "dashboards apply" and web's Export JSON button already use (see +// cli/README.md's "one JSON contract, multiple callers" framing; this +// is that third caller). Panels are a separate CRUD surface +// (POST/PUT/DELETE /dashboards/{id}/panels[/{panelId}]) not modeled by +// this resource yet -- see the provider README for why that's scoped +// out of this first pass rather than an oversight. +type dashboardResource struct { + client *client +} + +type dashboardResourceModel struct { + ID types.String `tfsdk:"id"` + TenantID types.String `tfsdk:"tenant_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + DefaultEarliest types.String `tfsdk:"default_earliest"` + DefaultLatest types.String `tfsdk:"default_latest"` + CreatedBy types.String `tfsdk:"created_by"` + CreatedAt types.String `tfsdk:"created_at"` + UpdatedAt types.String `tfsdk:"updated_at"` +} + +func (r *dashboardResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_dashboard" +} + +func (r *dashboardResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A Sentry dashboard. Panels aren't managed by this resource yet -- see the provider README.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Description: "Server-generated dashboard ID.", + }, + "tenant_id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Description: "Resolved server-side from the caller's identity -- never settable here, " + + "matching api/dashboards.Handler's tenantID() doc comment (a client-supplied " + + "tenant_id in the request body is always overridden).", + }, + "name": schema.StringAttribute{ + Required: true, + Description: "Dashboard name. The API rejects an empty string.", + }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "default_earliest": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Default earliest time bound for panels that don't set their own override " + + "(a query-language relative offset like \"-1h\" or an absolute timestamp -- see " + + "/docs/query-language-reference.md). Left unset, the server defaults this to " + + "\"-1h\" -- deliberately not hardcoded as a Terraform-side default too, so the API " + + "stays the one source of truth for what \"unset\" means.", + }, + "default_latest": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Default latest time bound. Left unset, the server defaults this to \"now\".", + }, + "created_by": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "created_at": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "updated_at": schema.StringAttribute{ + Computed: true, + Description: "Changes on every update -- deliberately not given UseStateForUnknown, unlike created_at.", + }, + }, + } +} + +func (r *dashboardResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *provider.client, got: %T. This is a provider bug -- please report it.", req.ProviderData), + ) + return + } + r.client = c +} + +func dashboardModelFromAPI(d *dashboard) dashboardResourceModel { + return dashboardResourceModel{ + ID: types.StringValue(d.ID), + TenantID: types.StringValue(d.TenantID), + Name: types.StringValue(d.Name), + Description: types.StringValue(d.Description), + DefaultEarliest: types.StringValue(d.DefaultEarliest), + DefaultLatest: types.StringValue(d.DefaultLatest), + CreatedBy: types.StringValue(d.CreatedBy), + CreatedAt: types.StringValue(d.CreatedAt), + UpdatedAt: types.StringValue(d.UpdatedAt), + } +} + +func (r *dashboardResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan dashboardResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.createDashboard(ctx, &dashboard{ + Name: plan.Name.ValueString(), + Description: plan.Description.ValueString(), + DefaultEarliest: plan.DefaultEarliest.ValueString(), + DefaultLatest: plan.DefaultLatest.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Creating Dashboard", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, dashboardModelFromAPI(out))...) +} + +func (r *dashboardResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state dashboardResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.getDashboard(ctx, state.ID.ValueString()) + if err != nil { + if isNotFound(err) { + // Deleted out-of-band (e.g. via web or sentryctl) -- + // dropping it from state lets the next plan offer to + // recreate it, the standard Terraform convention, rather + // than failing every subsequent plan/apply until someone + // manually edits state. + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Reading Dashboard", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, dashboardModelFromAPI(out))...) +} + +func (r *dashboardResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan dashboardResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.updateDashboard(ctx, plan.ID.ValueString(), &dashboard{ + Name: plan.Name.ValueString(), + Description: plan.Description.ValueString(), + DefaultEarliest: plan.DefaultEarliest.ValueString(), + DefaultLatest: plan.DefaultLatest.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Updating Dashboard", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, dashboardModelFromAPI(out))...) +} + +func (r *dashboardResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state dashboardResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.client.deleteDashboard(ctx, state.ID.ValueString()); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("Deleting Dashboard", err.Error()) + } +} + +func (r *dashboardResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/terraform/internal/provider/dashboard_resource_test.go b/terraform/internal/provider/dashboard_resource_test.go new file mode 100644 index 0000000..e1398ca --- /dev/null +++ b/terraform/internal/provider/dashboard_resource_test.go @@ -0,0 +1,83 @@ +package provider + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// testAccProtoV6ProviderFactories wires this package's own provider +// implementation into terraform-plugin-testing's acceptance-test +// runner -- HashiCorp's standard pattern, one factory reused by every +// acceptance test in this package. +var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ + "sentry": providerserver.NewProtocol6WithError(New("test")()), +} + +// The acceptance test below is gated the same way every other live- +// infrastructure test in this repo is (skip-gated, not deleted or +// faked) -- terraform-plugin-testing's own resource.Test already skips +// unless TF_ACC=1 is set, the framework's standard convention, and it +// additionally needs a real running api service (Docker/Postgres this +// environment doesn't have access to -- see /docs/phase-4-runbook.md's +// "Verification status" section for the same disclosed gap everywhere +// else in this codebase). "The test exists and is correct Go" is not +// the same claim as "this resource has been applied for real," per this +// repo's established honesty discipline. +func TestAccDashboardResource_basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +provider "sentry" { + endpoint = "http://localhost:8080" +} + +resource "sentry_dashboard" "test" { + name = "Acceptance Test Dashboard" + description = "created by TestAccDashboardResource_basic" +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("sentry_dashboard.test", "name", "Acceptance Test Dashboard"), + resource.TestCheckResourceAttr("sentry_dashboard.test", "description", "created by TestAccDashboardResource_basic"), + resource.TestCheckResourceAttrSet("sentry_dashboard.test", "id"), + resource.TestCheckResourceAttrSet("sentry_dashboard.test", "tenant_id"), + // Left unset in config -- must come back as the + // server's own defaults (store.go: "-1h"/"now"), not + // an empty string, proving the Optional+Computed + // schema round-trips the server's default rather + // than fighting it with a Terraform-side one. + resource.TestCheckResourceAttr("sentry_dashboard.test", "default_earliest", "-1h"), + resource.TestCheckResourceAttr("sentry_dashboard.test", "default_latest", "now"), + ), + }, + { + // Update: name change should apply in place, not + // replace (no RequiresReplace plan modifier on name). + Config: ` +provider "sentry" { + endpoint = "http://localhost:8080" +} + +resource "sentry_dashboard" "test" { + name = "Renamed Dashboard" + description = "created by TestAccDashboardResource_basic" +} +`, + Check: resource.TestCheckResourceAttr("sentry_dashboard.test", "name", "Renamed Dashboard"), + }, + { + // Import: re-reads by ID alone and must match what's in + // state, proving Read()'s server round trip agrees with + // what Create()/Update() last wrote. + ResourceName: "sentry_dashboard.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} diff --git a/terraform/internal/provider/provider.go b/terraform/internal/provider/provider.go new file mode 100644 index 0000000..27280ac --- /dev/null +++ b/terraform/internal/provider/provider.go @@ -0,0 +1,105 @@ +// Package provider is Sentry's Terraform provider implementation, +// built on HashiCorp's terraform-plugin-framework (not the legacy +// SDKv2 -- the framework is the actively-developed, currently- +// recommended library for a provider started from scratch, matching +// CLAUDE.md's "prefer boring, well-understood dependencies" read +// forward rather than backward). +package provider + +import ( + "context" + "os" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "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" +) + +var _ provider.Provider = &sentryProvider{} + +// New matches providerserver.Serve's expected constructor shape -- +// version is threaded through from main.go's -ldflags-injected build +// version. +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &sentryProvider{version: version} + } +} + +type sentryProvider struct { + version string +} + +type sentryProviderModel struct { + Endpoint types.String `tfsdk:"endpoint"` + Token types.String `tfsdk:"token"` +} + +func (p *sentryProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "sentry" + resp.Version = p.version +} + +func (p *sentryProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages Sentry log-aggregation-platform resources. Dashboards only for now -- alert rules, notification targets, and tenant/RBAC resources are real, disclosed future work, not built in this pass; see the provider README.", + Attributes: map[string]schema.Attribute{ + "endpoint": schema.StringAttribute{ + Optional: true, + Description: "Base URL of the api service, e.g. \"http://localhost:8080\". Defaults to " + + "$SENTRY_API_ENDPOINT, or \"http://localhost:8080\" if that's unset too -- same " + + "default sentryctl's --api/$SENTRYCTL_API_URL uses (cli/cmd/sentryctl/main.go).", + }, + "token": schema.StringAttribute{ + Optional: true, + Sensitive: true, + Description: "Bearer credential sent as \"Authorization: Bearer \" on every request " + + "-- required once a deployment configures enterprise-auth (see " + + "/docs/phase-4-rbac-design.md), same as sentryctl's $SENTRYCTL_TOKEN. Defaults to " + + "$SENTRY_API_TOKEN if unset. Set via a variable or environment, never a literal in a " + + ".tf file committed to version control.", + }, + }, + } +} + +// Configure resolves endpoint/token the same precedence order +// sentryctl's resolveAPIURL/resolveToken use (explicit config value, +// then an environment variable, then a hardcoded default) so behavior +// stays predictable across both of this project's Sentry API clients. +func (p *sentryProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var config sentryProviderModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + endpoint := config.Endpoint.ValueString() + if endpoint == "" { + endpoint = os.Getenv("SENTRY_API_ENDPOINT") + } + if endpoint == "" { + endpoint = "http://localhost:8080" + } + + token := config.Token.ValueString() + if token == "" { + token = os.Getenv("SENTRY_API_TOKEN") + } + + c := newClient(endpoint, token) + resp.DataSourceData = c + resp.ResourceData = c +} + +func (p *sentryProvider) Resources(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + newDashboardResource, + } +} + +func (p *sentryProvider) DataSources(_ context.Context) []func() datasource.DataSource { + return nil +} diff --git a/terraform/internal/provider/provider_test.go b/terraform/internal/provider/provider_test.go new file mode 100644 index 0000000..1c13784 --- /dev/null +++ b/terraform/internal/provider/provider_test.go @@ -0,0 +1,66 @@ +package provider + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +// TestProviderSchemaValid and TestDashboardResourceSchemaValid don't +// need a Terraform binary or a live api service -- ValidateImplementation +// runs the same internal consistency checks +// terraform-plugin-framework's own protocol layer would (attribute +// names are valid identifiers, no Optional+Required conflicts, etc.), +// catching a broken schema before it ever reaches an acceptance test. +func TestProviderSchemaValid(t *testing.T) { + ctx := context.Background() + req := provider.SchemaRequest{} + resp := &provider.SchemaResponse{} + + New("test")().Schema(ctx, req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("provider schema has errors: %v", resp.Diagnostics) + } + for _, attr := range []string{"endpoint", "token"} { + if _, ok := resp.Schema.Attributes[attr]; !ok { + t.Errorf("provider schema missing expected attribute %q", attr) + } + } +} + +func TestDashboardResourceSchemaValid(t *testing.T) { + ctx := context.Background() + req := resource.SchemaRequest{} + resp := &resource.SchemaResponse{} + + newDashboardResource().Schema(ctx, req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("sentry_dashboard schema has errors: %v", resp.Diagnostics) + } + for _, attr := range []string{ + "id", "tenant_id", "name", "description", + "default_earliest", "default_latest", "created_by", "created_at", "updated_at", + } { + if _, ok := resp.Schema.Attributes[attr]; !ok { + t.Errorf("sentry_dashboard schema missing expected attribute %q", attr) + } + } + if !resp.Schema.Attributes["name"].IsRequired() { + t.Error(`"name" must be Required`) + } + if !resp.Schema.Attributes["id"].IsComputed() { + t.Error(`"id" must be Computed`) + } +} + +func TestDashboardResourceMetadataSetsTypeName(t *testing.T) { + resp := &resource.MetadataResponse{} + newDashboardResource().Metadata(context.Background(), resource.MetadataRequest{ProviderTypeName: "sentry"}, resp) + if resp.TypeName != "sentry_dashboard" { + t.Fatalf("TypeName = %q, want sentry_dashboard", resp.TypeName) + } +} diff --git a/terraform/main.go b/terraform/main.go new file mode 100644 index 0000000..4483e30 --- /dev/null +++ b/terraform/main.go @@ -0,0 +1,50 @@ +// Command terraform-provider-sentry is Sentry's Terraform provider -- +// CLAUDE.md names it a first-class deliverable alongside sentryctl +// ("CLI and Terraform provider are first-class, not afterthoughts"), +// but this is the first phase to actually build any of it. +// +// Scoped deliberately narrow to start: one resource +// (internal/provider.dashboardResource, sentry_dashboard), reusing the +// exact same JSON contract cli/cmd/sentryctl's "apply" subcommand and +// web's dashboard export button already use against +// api/dashboards.Handler -- "one JSON contract, multiple callers" is a +// design decision made back in Phase 3 (see cli/README.md), this +// provider is just a third caller of it, not a new contract. Alert +// rules, notification targets, and tenant/RBAC resources are real, +// disclosed future work, not attempted in this pass -- see README.md. +package main + +import ( + "context" + "flag" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + + "github.com/sentry/sentry/terraform/internal/provider" +) + +// version is overridden at build time via -ldflags, same convention +// HashiCorp's own scaffold and every published provider use -- +// Terraform's registry protocol reports this to users running +// `terraform version`. "dev" is deliberately obvious in output if +// someone runs a local build without setting it. +var version = "dev" + +func main() { + var debug bool + flag.BoolVar(&debug, "debug", false, "run the provider with support for debuggers like delve") + flag.Parse() + + err := providerserver.Serve(context.Background(), provider.New(version), providerserver.ServeOpts{ + // Matches this provider's eventual Terraform Registry address -- + // required by the protocol even before actual registry + // publication, since local dev overrides + // (~/.terraformrc dev_overrides) key on this same address. + Address: "registry.terraform.io/sentry/sentry", + Debug: debug, + }) + if err != nil { + log.Fatal(err.Error()) + } +}