diff --git a/.github/workflows/acc-tests.yml b/.github/workflows/acc-tests.yml index 86221bfc3..a5917a6e8 100644 --- a/.github/workflows/acc-tests.yml +++ b/.github/workflows/acc-tests.yml @@ -16,22 +16,24 @@ jobs: integration: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: '1.21' - - uses: hashicorp/setup-terraform@v3 + go-version-file: go.mod + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 - run: make integration-test cloudinstance: - concurrency: cloud-instance + concurrency: + group: cloud-instance + cancel-in-progress: false runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: '1.21' - - uses: hashicorp/setup-terraform@v3 + go-version-file: go.mod + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 - name: Get Secrets uses: grafana/shared-workflows/actions/get-vault-secrets@main with: @@ -41,12 +43,16 @@ jobs: GRAFANA_SM_ACCESS_TOKEN=cloud-instance-tests:sm-token GRAFANA_SM_URL=cloud-instance-tests:sm-url GRAFANA_URL=cloud-instance-tests:url - - uses: iFaxity/wait-on-action@v1.2.1 + - uses: iFaxity/wait-on-action@a7d13170ec542bdca4ef8ac4b15e9c6aa00a6866 # v1.2.1 with: resource: ${{ env.GRAFANA_URL }} interval: 2000 # 2s timeout: 30000 # 30s - - run: make testacc-cloud-instance + - uses: nick-fields/retry@7152eba30c6575329ac0576536151aca5a72780e # v3.0.0 + with: + timeout_minutes: 30 + max_attempts: 3 # Try 3 times to make sure we don't report failures on flaky tests + command: make testacc-cloud-instance local: strategy: @@ -55,7 +61,7 @@ jobs: # OSS tests, run on all versions version: ['11.0.0', '10.4.3', '9.5.18'] type: ['oss'] - subset: ['basic', 'other', 'long', 'generate'] + subset: ['basic', 'other', 'long'] include: - version: '11.0.0' type: 'oss' @@ -74,21 +80,28 @@ jobs: # Enterprise tests - version: '11.0.0' type: 'enterprise' - subset: 'all' + subset: 'enterprise' - version: '10.4.3' type: 'enterprise' - subset: 'all' + subset: 'enterprise' - version: '9.5.18' type: 'enterprise' - subset: 'all' + subset: 'enterprise' + # Generate tests + - version: '11.0.0' + type: 'enterprise' + subset: 'generate' + - version: '10.4.3' + type: 'enterprise' + subset: 'generate' name: ${{ matrix.version }} - ${{ matrix.type }} - ${{ matrix.subset }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: '1.21' - - uses: hashicorp/setup-terraform@v3 + go-version-file: go.mod + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 - name: Get Enterprise License uses: grafana/shared-workflows/actions/get-vault-secrets@main if: matrix.type == 'enterprise' @@ -96,14 +109,18 @@ jobs: repo_secrets: | GF_ENTERPRISE_LICENSE_TEXT=enterprise:license - name: Cache Docker image - uses: ScribeMD/docker-cache@0.5.0 + uses: ScribeMD/docker-cache@fb28c93772363301b8d0a6072ce850224b73f74e # v0.5.0 with: key: docker-${{ runner.os }}-${{ matrix.type == 'enterprise' && 'enterprise' || 'oss' }}-${{ matrix.version }} - - run: make testacc-${{ matrix.type }}-docker + - uses: nick-fields/retry@7152eba30c6575329ac0576536151aca5a72780e # v3.0.0 + with: + timeout_minutes: 30 + max_attempts: 3 # Try 3 times to make sure we don't report failures on flaky tests + command: make testacc-${{ matrix.type }}-docker env: GRAFANA_VERSION: ${{ matrix.version }} TESTARGS: >- - ${{ matrix.subset == 'all' && '-parallel 2' || '' }} + ${{ matrix.subset == 'enterprise' && '-skip="TestAccGenerate" -parallel 2' || '' }} ${{ matrix.subset == 'basic' && '-run=".*_basic" -short -parallel 2' || '' }} ${{ matrix.subset == 'other' && '-skip=".*_basic" -short -parallel 2' || '' }} ${{ matrix.subset == 'long' && '-run=".*longtest" -parallel 1' || '' }} diff --git a/.github/workflows/cloud-acc-tests.yml b/.github/workflows/cloud-acc-tests.yml index 305177c76..56ee06ed4 100644 --- a/.github/workflows/cloud-acc-tests.yml +++ b/.github/workflows/cloud-acc-tests.yml @@ -23,11 +23,11 @@ jobs: concurrency: cloud-api runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: '1.21' - - uses: hashicorp/setup-terraform@v3 + go-version-file: go.mod + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 - name: Get Secrets uses: grafana/shared-workflows/actions/get-vault-secrets@main with: diff --git a/.github/workflows/comment-on-pr.yml b/.github/workflows/comment-on-pr.yml index b4865d9ae..e344a47aa 100644 --- a/.github/workflows/comment-on-pr.yml +++ b/.github/workflows/comment-on-pr.yml @@ -9,7 +9,7 @@ jobs: permissions: pull-requests: write steps: - - uses: mshick/add-pr-comment@v2 + - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 with: message: | In order to lower resource usage and have a faster runtime, PRs will not run Cloud tests automatically. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25e8403c6..00c0af5d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,21 +24,21 @@ jobs: - run-cloud-tests steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Unshallow run: git fetch --prune --unshallow - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: '1.21' + go-version-file: go.mod - name: Import GPG key id: import_gpg - uses: crazy-max/ghaction-import-gpg@v6.1.0 + uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0 with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.PASSPHRASE }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 # v6.0.0 with: version: latest args: release --clean diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 1d1ed89d2..03fb3762d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -10,33 +10,33 @@ jobs: name: golangci-lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - run: make golangci-lint # Using the makefile to have the same command in CI and locally terraform_fmt: name: terraform fmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: hashicorp/setup-terraform@v3 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 - name: terraform fmt - run: terraform fmt -recursive -check || (echo "Terraform files aren't formatted. Run 'terraform fmt -recursive && go generate'"; exit 1;) + run: terraform fmt -recursive -check || (echo "Terraform files aren't formatted. Run 'terraform fmt -recursive && go generate ./...'"; exit 1;) docs: name: docs runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: '1.21' + go-version-file: go.mod - name: generate docs run: | - go generate + go generate ./... gitstatus="$(git status --porcelain)" if [ -n "$gitstatus" ]; then echo "$gitstatus" - echo 'docs are out of sync, run "go generate"' + echo 'docs are out of sync, run "go generate ./..."' exit 1 fi - run: make linkcheck @@ -45,9 +45,9 @@ jobs: name: unit tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: '1.21' - - uses: hashicorp/setup-terraform@v3 + go-version-file: go.mod + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 - run: go test ./... diff --git a/.gitignore b/.gitignore index 799e59db7..f49d01686 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ website/vendor testdata/*.crt testdata/*.key testdata/integration/* +testdata/plugins !testdata/integration/main.tf !testdata/integration/test.sh diff --git a/.goreleaser.yml b/.goreleaser.yml index 13ba7bceb..306fe661c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,5 +1,6 @@ # Visit https://goreleaser.com for documentation on how to customize this # behavior. +version: 2 before: hooks: # this is just an example and not a requirement for provider building/publishing diff --git a/GNUmakefile b/GNUmakefile index f239fae2a..39b6d7117 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -2,6 +2,7 @@ GRAFANA_VERSION ?= 11.0.0 DOCKER_COMPOSE_ARGS ?= --force-recreate --detach --remove-orphans --wait --renew-anon-volumes testacc: + go build -o testdata/plugins/registry.terraform.io/grafana/grafana/999.999.999/$$(go env GOOS)_$$(go env GOARCH)/terraform-provider-grafana_v999.999.999_$$(go env GOOS)_$$(go env GOARCH) . TF_ACC=1 go test ./... -v $(TESTARGS) -timeout 120m # Test OSS features diff --git a/cmd/generate/main.go b/cmd/generate/main.go index 64018e26e..2eb2758d8 100644 --- a/cmd/generate/main.go +++ b/cmd/generate/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "log" "os" @@ -9,6 +10,7 @@ import ( "github.com/grafana/terraform-provider-grafana/v3/pkg/generate" "github.com/fatih/color" + goVersion "github.com/hashicorp/go-version" "github.com/urfave/cli/v2" ) @@ -68,6 +70,24 @@ This supports a glob format. Examples: EnvVars: []string{"TFGEN_INCLUDE_RESOURCES"}, Required: false, }, + &cli.BoolFlag{ + Name: "output-credentials", + Usage: "Output credentials in the generated resources", + EnvVars: []string{"TFGEN_OUTPUT_CREDENTIALS"}, + Value: false, + }, + &cli.StringFlag{ + Name: "terraform-install-dir", + Usage: `Directory to install Terraform to. If not set, a temporary directory will be created.`, + EnvVars: []string{"TFGEN_TERRAFORM_INSTALL_DIR"}, + Required: false, + }, + &cli.StringFlag{ + Name: "terraform-install-version", + Usage: `Version of Terraform to install. If not set, the latest version _tested in this tool_ will be installed.`, + EnvVars: []string{"TFGEN_TERRAFORM_INSTALL_VERSION"}, + Required: false, + }, // Grafana OSS flags &cli.StringFlag{ @@ -148,7 +168,8 @@ This supports a glob format. Examples: if err != nil { return fmt.Errorf("failed to parse flags: %w", err) } - return generate.Generate(ctx.Context, cfg) + result := generate.Generate(ctx.Context, cfg) + return errors.Join(result.Errors...) }, } @@ -157,10 +178,11 @@ This supports a glob format. Examples: func parseFlags(ctx *cli.Context) (*generate.Config, error) { config := &generate.Config{ - OutputDir: ctx.String("output-dir"), - Clobber: ctx.Bool("clobber"), - Format: generate.OutputFormat(ctx.String("output-format")), - ProviderVersion: ctx.String("terraform-provider-version"), + OutputDir: ctx.String("output-dir"), + Clobber: ctx.Bool("clobber"), + Format: generate.OutputFormat(ctx.String("output-format")), + ProviderVersion: ctx.String("terraform-provider-version"), + OutputCredentials: ctx.Bool("output-credentials"), Grafana: &generate.GrafanaConfig{ URL: ctx.String("grafana-url"), Auth: ctx.String("grafana-auth"), @@ -177,6 +199,16 @@ func parseFlags(ctx *cli.Context) (*generate.Config, error) { StackServiceAccountName: ctx.String("cloud-stack-service-account-name"), }, IncludeResources: ctx.StringSlice("include-resources"), + TerraformInstallConfig: generate.TerraformInstallConfig{ + InstallDir: ctx.String("terraform-install-dir"), + }, + } + var err error + if tfVersion := ctx.String("terraform-install-version"); tfVersion != "" { + config.TerraformInstallConfig.Version, err = goVersion.NewVersion(ctx.String("terraform-install-version")) + if err != nil { + return nil, fmt.Errorf("terraform-install-version must be a valid version: %w", err) + } } if config.ProviderVersion == "" { @@ -184,7 +216,7 @@ func parseFlags(ctx *cli.Context) (*generate.Config, error) { } // Validate flags - err := newFlagValidations(). + err = newFlagValidations(). atLeastOne("grafana-url", "cloud-access-policy-token"). conflicting( []string{"grafana-url", "grafana-auth", "synthetic-monitoring-url", "synthetic-monitoring-access-token", "oncall-url", "oncall-access-token"}, diff --git a/cmd/without-lister/main.go b/cmd/without-lister/main.go new file mode 100644 index 000000000..59929e402 --- /dev/null +++ b/cmd/without-lister/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" + + "github.com/grafana/terraform-provider-grafana/v3/pkg/provider" +) + +func main() { + fmt.Println("Listing resources without lister functions:") + for _, r := range provider.Resources() { + if r.ListIDsFunc == nil { + fmt.Println(r.Name) + } + } +} diff --git a/docs/data-sources/cloud_access_policies.md b/docs/data-sources/cloud_access_policies.md new file mode 100644 index 000000000..2e583b0cd --- /dev/null +++ b/docs/data-sources/cloud_access_policies.md @@ -0,0 +1,47 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "grafana_cloud_access_policies Data Source - terraform-provider-grafana" +subcategory: "Cloud" +description: |- + Fetches access policies from Grafana Cloud. + Official documentation https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/API documentation https://grafana.com/docs/grafana-cloud/developer-resources/api-reference/cloud-api/#list-access-policies + Required access policy scopes: + accesspolicies:read +--- + +# grafana_cloud_access_policies (Data Source) + +Fetches access policies from Grafana Cloud. + +* [Official documentation](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) +* [API documentation](https://grafana.com/docs/grafana-cloud/developer-resources/api-reference/cloud-api/#list-access-policies) + +Required access policy scopes: + +* accesspolicies:read + + + + +## Schema + +### Optional + +- `name_filter` (String) If set, only access policies with the specified name will be returned. This is faster than filtering in Terraform. +- `region_filter` (String) If set, only access policies in the specified region will be returned. This is faster than filtering in Terraform. + +### Read-Only + +- `access_policies` (Set of Object) (see [below for nested schema](#nestedatt--access_policies)) +- `id` (String) The ID of this datasource. This is an internal identifier used by the provider to track this datasource. + + +### Nested Schema for `access_policies` + +Read-Only: + +- `display_name` (String) +- `id` (String) +- `name` (String) +- `region` (String) +- `status` (String) diff --git a/docs/data-sources/library_panels.md b/docs/data-sources/library_panels.md new file mode 100644 index 000000000..8525b159c --- /dev/null +++ b/docs/data-sources/library_panels.md @@ -0,0 +1,73 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "grafana_library_panels Data Source - terraform-provider-grafana" +subcategory: "Grafana OSS" +description: |- + +--- + +# grafana_library_panels (Data Source) + + + +## Example Usage + +```terraform +resource "grafana_library_panel" "test" { + name = "panelname" + model_json = jsonencode({ + title = "test name" + type = "text" + version = 0 + description = "test description" + }) +} + +resource "grafana_folder" "test" { + title = "Panel Folder" + uid = "panelname-folder" +} + +resource "grafana_library_panel" "folder" { + name = "panelname In Folder" + folder_uid = grafana_folder.test.uid + model_json = jsonencode({ + gridPos = { + x = 0 + y = 0 + h = 10 + w = 10 + } + title = "panel" + type = "text" + version = 0 + }) +} + +data "grafana_library_panels" "all" { + depends_on = [grafana_library_panel.folder, grafana_library_panel.test] +} +``` + + +## Schema + +### Optional + +- `org_id` (String) The Organization ID. If not set, the default organization is used for basic authentication, or the one that owns your service account for token authentication. + +### Read-Only + +- `id` (String) The ID of this resource. +- `panels` (Set of Object) (see [below for nested schema](#nestedatt--panels)) + + +### Nested Schema for `panels` + +Read-Only: + +- `description` (String) +- `folder_uid` (String) +- `model_json` (String) +- `name` (String) +- `uid` (String) diff --git a/docs/data-sources/oncall_user.md b/docs/data-sources/oncall_user.md index 1e244a857..0941bccbb 100644 --- a/docs/data-sources/oncall_user.md +++ b/docs/data-sources/oncall_user.md @@ -28,5 +28,5 @@ data "grafana_oncall_user" "alex" { ### Read-Only - `email` (String) The email of the user. -- `id` (String) The ID of this resource. +- `id` (String) The ID of the user. - `role` (String) The role of the user. diff --git a/docs/data-sources/oncall_users.md b/docs/data-sources/oncall_users.md new file mode 100644 index 000000000..7a78f87dd --- /dev/null +++ b/docs/data-sources/oncall_users.md @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "grafana_oncall_users Data Source - terraform-provider-grafana" +subcategory: "OnCall" +description: |- + HTTP API https://grafana.com/docs/oncall/latest/oncall-api-reference/users/ +--- + +# grafana_oncall_users (Data Source) + +* [HTTP API](https://grafana.com/docs/oncall/latest/oncall-api-reference/users/) + + + + +## Schema + +### Read-Only + +- `id` (String) The ID of this resource. +- `users` (List of Object) (see [below for nested schema](#nestedatt--users)) + + +### Nested Schema for `users` + +Read-Only: + +- `email` (String) +- `id` (String) +- `role` (String) +- `username` (String) diff --git a/docs/data-sources/slos.md b/docs/data-sources/slos.md index 2cbab00e3..c37a8d5a2 100644 --- a/docs/data-sources/slos.md +++ b/docs/data-sources/slos.md @@ -87,6 +87,7 @@ Read-Only: - `name` (String) - `objectives` (List of Object) (see [below for nested schema](#nestedobjatt--slos--objectives)) - `query` (List of Object) (see [below for nested schema](#nestedobjatt--slos--query)) +- `search_expression` (String) - `uuid` (String) diff --git a/docs/data-sources/user.md b/docs/data-sources/user.md index bd40ddc6b..079f0804b 100644 --- a/docs/data-sources/user.md +++ b/docs/data-sources/user.md @@ -6,6 +6,7 @@ description: |- Official documentation https://grafana.com/docs/grafana/latest/administration/user-management/server-user-management/HTTP API https://grafana.com/docs/grafana/latest/developers/http_api/user/ This data source uses Grafana's admin APIs for reading users which does not currently work with API Tokens. You must use basic auth. + This data source is also not compatible with Grafana Cloud, as it does not allow basic auth. --- # grafana_user (Data Source) @@ -15,6 +16,7 @@ description: |- This data source uses Grafana's admin APIs for reading users which does not currently work with API Tokens. You must use basic auth. +This data source is also not compatible with Grafana Cloud, as it does not allow basic auth. ## Example Usage diff --git a/docs/data-sources/users.md b/docs/data-sources/users.md index d29bd96b7..3d8a86251 100644 --- a/docs/data-sources/users.md +++ b/docs/data-sources/users.md @@ -6,6 +6,7 @@ description: |- Official documentation https://grafana.com/docs/grafana/latest/administration/user-management/server-user-management/HTTP API https://grafana.com/docs/grafana/latest/developers/http_api/user/ This data source uses Grafana's admin APIs for reading users which does not currently work with API Tokens. You must use basic auth. + This data source is also not compatible with Grafana Cloud, as it does not allow basic auth. --- # grafana_users (Data Source) @@ -15,6 +16,7 @@ description: |- This data source uses Grafana's admin APIs for reading users which does not currently work with API Tokens. You must use basic auth. +This data source is also not compatible with Grafana Cloud, as it does not allow basic auth. ## Example Usage diff --git a/docs/index.md b/docs/index.md index 1a46414f7..2b4e1b4de 100644 --- a/docs/index.md +++ b/docs/index.md @@ -231,11 +231,11 @@ One, or many, of the following authentication settings must be set. Each authent ### `auth` This can be a Grafana API key, basic auth `username:password`, or a -[Grafana Service Account token](https://grafana.com/docs/grafana/latest/developers/http_api/create-api-tokens-for-org/). +[Grafana Service Account token](https://grafana.com/docs/grafana/latest/developers/http_api/examples/create-api-tokens-for-org/). ### `cloud_access_policy_token` -An access policy token created on the [Grafana Cloud Portal](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/create-api-key/). +An access policy token created on the [Grafana Cloud Portal](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/authorize-services/). ### `sm_access_token` diff --git a/docs/resources/contact_point.md b/docs/resources/contact_point.md index 896687b10..058b2f72a 100644 --- a/docs/resources/contact_point.md +++ b/docs/resources/contact_point.md @@ -4,7 +4,7 @@ page_title: "grafana_contact_point Resource - terraform-provider-grafana" subcategory: "Alerting" description: |- Manages Grafana Alerting contact points. - Official documentation https://grafana.com/docs/grafana/next/alerting/fundamentals/notifications/contact-points/HTTP API https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#contact-points + Official documentation https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/HTTP API https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#contact-points This resource requires Grafana 9.1.0 or later. --- @@ -12,7 +12,7 @@ description: |- Manages Grafana Alerting contact points. -* [Official documentation](https://grafana.com/docs/grafana/next/alerting/fundamentals/notifications/contact-points/) +* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/) * [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#contact-points) This resource requires Grafana 9.1.0 or later. diff --git a/docs/resources/dashboard_permission_item.md b/docs/resources/dashboard_permission_item.md index 75d56e9ba..c1dd837ab 100644 --- a/docs/resources/dashboard_permission_item.md +++ b/docs/resources/dashboard_permission_item.md @@ -59,7 +59,7 @@ resource "grafana_dashboard_permission_item" "team" { ### Optional -- `org_id` (String) The Organization ID. If not set, the Org ID defined in the provider block will be used. +- `org_id` (String) The Organization ID. If not set, the default organization is used for basic authentication, or the one that owns your service account for token authentication. - `role` (String) the role onto which the permission is to be assigned - `team` (String) the team onto which the permission is to be assigned - `user` (String) the user or service account onto which the permission is to be assigned diff --git a/docs/resources/data_source.md b/docs/resources/data_source.md index 7d4cbf5cd..e30129101 100644 --- a/docs/resources/data_source.md +++ b/docs/resources/data_source.md @@ -64,6 +64,21 @@ resource "grafana_data_source" "cloudwatch" { }) } +resource "grafana_data_source" "cloudwatch_assumeARN" { + type = "cloudwatch" + name = "cw-assumeARN-example" + + # Requires `assume_role_enabled` feature flag to be enabled + # OSS: use authType = "default" on OSS + # Cloud: use authType = "grafana_assume_role" which is in private preview on Cloud: + # https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/aws-authentication/#use-grafana-assume-role + json_data_encoded = jsonencode({ + defaultRegion = "us-east-1" + authType = "grafana_assume_role" + assumeRoleArn = "arn:aws:iam::123456789012:root" + }) +} + resource "grafana_data_source" "prometheus" { type = "prometheus" name = "mimir" diff --git a/docs/resources/data_source_permission_item.md b/docs/resources/data_source_permission_item.md index 301399a68..c18e7d671 100644 --- a/docs/resources/data_source_permission_item.md +++ b/docs/resources/data_source_permission_item.md @@ -79,7 +79,7 @@ resource "grafana_data_source_permission_item" "service_account" { ### Optional -- `org_id` (String) The Organization ID. If not set, the Org ID defined in the provider block will be used. +- `org_id` (String) The Organization ID. If not set, the default organization is used for basic authentication, or the one that owns your service account for token authentication. - `role` (String) the role onto which the permission is to be assigned - `team` (String) the team onto which the permission is to be assigned - `user` (String) the user or service account onto which the permission is to be assigned diff --git a/docs/resources/folder_permission_item.md b/docs/resources/folder_permission_item.md index 750b7233e..40e2d9fac 100644 --- a/docs/resources/folder_permission_item.md +++ b/docs/resources/folder_permission_item.md @@ -60,7 +60,7 @@ resource "grafana_folder_permission_item" "on_user" { ### Optional -- `org_id` (String) The Organization ID. If not set, the Org ID defined in the provider block will be used. +- `org_id` (String) The Organization ID. If not set, the default organization is used for basic authentication, or the one that owns your service account for token authentication. - `role` (String) the role onto which the permission is to be assigned - `team` (String) the team onto which the permission is to be assigned - `user` (String) the user or service account onto which the permission is to be assigned diff --git a/docs/resources/machine_learning_alert.md b/docs/resources/machine_learning_alert.md new file mode 100644 index 000000000..0f8366680 --- /dev/null +++ b/docs/resources/machine_learning_alert.md @@ -0,0 +1,77 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "grafana_machine_learning_alert Resource - terraform-provider-grafana" +subcategory: "Machine Learning" +description: |- + +--- + +# grafana_machine_learning_alert (Resource) + + + +## Example Usage + +### Forecast Alert + +This alert uses a forecast. + +```terraform +resource "grafana_machine_learning_job" "test_alert_job" { + name = "Test Job" + metric = "tf_test_alert_job" + datasource_type = "prometheus" + datasource_uid = "abcd12345" + query_params = { + expr = "grafanacloud_grafana_instance_active_user_count" + } +} + +resource "grafana_machine_learning_alert" "test_job_alert" { + job_id = grafana_machine_learning_job.test_alert_job.id + title = "Test Alert" + anomaly_condition = "any" + threshold = ">0.8" + window = "15m" +} +``` + +### Outlier Alert + +This alert uses an outlier detector. + +```terraform +resource "grafana_machine_learning_outlier_detector" "test_alert_outlier_detector" { + name = "Test Outlier" + + metric = "tf_test_alert_outlier" + datasource_type = "prometheus" + datasource_uid = "AbCd12345" + query_params = { + expr = "grafanacloud_grafana_instance_active_user_count" + } + interval = 300 + + algorithm { + name = "dbscan" + sensitivity = 0.5 + config { + epsilon = 1.0 + } + } +} + +resource "grafana_machine_learning_alert" "test_outlier_alert" { + outlier_id = grafana_machine_learning_outlier_detector.test_alert_outlier_detector.id + title = "Test Alert" + window = "1h" +} +``` + +## Import + +Import is supported using the following syntax: + +```shell +terraform import grafana_machine_learning_alert.name "{{ id }}" +``` diff --git a/docs/resources/machine_learning_holiday.md b/docs/resources/machine_learning_holiday.md index 3abe8f78f..fab2e9957 100644 --- a/docs/resources/machine_learning_holiday.md +++ b/docs/resources/machine_learning_holiday.md @@ -29,37 +29,43 @@ resource "grafana_machine_learning_job" "test_job" { } ``` +## Example Usage +### iCal Holiday - -## Schema +This holiday uses an iCal file to define the holidays. -### Required - -- `name` (String) The name of the holiday. - -### Optional - -- `custom_periods` (Block List) A list of custom periods for the holiday. (see [below for nested schema](#nestedblock--custom_periods)) -- `description` (String) A description of the holiday. -- `ical_timezone` (String) The timezone to use for events in the iCal file pointed to by ical_url. -- `ical_url` (String) A URL to an iCal file containing all occurrences of the holiday. - -### Read-Only - -- `id` (String) The ID of the holiday. - - -### Nested Schema for `custom_periods` +```terraform +resource "grafana_machine_learning_holiday" "ical" { + name = "My iCal holiday" + description = "My Holiday" -Required: + ical_url = "https://calendar.google.com/calendar/ical/en.uk%23holiday%40group.v.calendar.google.com/public/basic.ics" + ical_timezone = "Europe/London" +} +``` -- `end_time` (String) -- `start_time` (String) +### Custom Periods Holiday -Optional: +This holiday uses custom periods to define the holidays. -- `name` (String) The name of the custom period. +```terraform +resource "grafana_machine_learning_holiday" "custom_periods" { + name = "My custom periods holiday" + description = "My Holiday" + + custom_periods { + name = "First of January" + start_time = "2023-01-01T00:00:00Z" + end_time = "2023-01-02T00:00:00Z" + } + custom_periods { + name = "First of Feburary" + start_time = "2023-02-01T00:00:00Z" + end_time = "2023-02-02T00:00:00Z" + } +} +``` ## Import diff --git a/docs/resources/machine_learning_job.md b/docs/resources/machine_learning_job.md index 8eb186959..b9ad6c5f8 100644 --- a/docs/resources/machine_learning_job.md +++ b/docs/resources/machine_learning_job.md @@ -10,31 +10,133 @@ description: |- A job defines the queries and model parameters for a machine learning task. +## Example Usage +### Basic Forecast - -## Schema +This forecast uses a Prometheus datasource, where the source query is defined in the `expr` field of the `query_params` attribute. -### Required +Other datasources are supported, but the structure `query_params` may differ. -- `datasource_type` (String) The type of datasource being queried. Currently allowed values are prometheus, graphite, loki, postgres, and datadog. -- `datasource_uid` (String) The uid of the datasource to query. -- `metric` (String) The metric used to query the job results. -- `name` (String) The name of the job. -- `query_params` (Map of String) An object representing the query params to query Grafana with. +```terraform +resource "grafana_data_source" "foo" { + type = "prometheus" + name = "prometheus-ds-test" + uid = "prometheus-ds-test-uid" + url = "https://my-instance.com" + basic_auth_enabled = true + basic_auth_username = "username" -### Optional + json_data_encoded = jsonencode({ + httpMethod = "POST" + prometheusType = "Mimir" + prometheusVersion = "2.4.0" + }) -- `custom_labels` (Map of String) An object representing the custom labels added on the forecast. -- `description` (String) A description of the job. -- `holidays` (List of String) A list of holiday IDs or names to take into account when training the model. -- `hyper_params` (Map of String) The hyperparameters used to fine tune the algorithm. See https://grafana.com/docs/grafana-cloud/machine-learning/models/ for the full list of available hyperparameters. Defaults to `map[]`. -- `interval` (Number) The data interval in seconds to train the data on. Defaults to `300`. -- `training_window` (Number) The data interval in seconds to train the data on. Defaults to `7776000`. + secure_json_data_encoded = jsonencode({ + basicAuthPassword = "password" + }) +} -### Read-Only +resource "grafana_machine_learning_job" "test_job" { + name = "Test Job" + metric = "tf_test_job" + datasource_type = "prometheus" + datasource_uid = grafana_data_source.foo.uid + query_params = { + expr = "grafanacloud_grafana_instance_active_user_count" + } +} +``` + +### Tuned Forecast + +This forecast has tuned hyperparameters to improve the accuracy of the model. + +```terraform +resource "grafana_data_source" "foo" { + type = "prometheus" + name = "prometheus-ds-test" + uid = "prometheus-ds-test-uid" + url = "https://my-instance.com" + basic_auth_enabled = true + basic_auth_username = "username" + + json_data_encoded = jsonencode({ + httpMethod = "POST" + prometheusType = "Mimir" + prometheusVersion = "2.4.0" + }) + + secure_json_data_encoded = jsonencode({ + basicAuthPassword = "password" + }) +} + +resource "grafana_machine_learning_job" "test_job" { + name = "Test Job" + metric = "tf_test_job" + datasource_type = "prometheus" + datasource_uid = grafana_data_source.foo.uid + query_params = { + expr = "grafanacloud_grafana_instance_active_user_count" + } + hyper_params = { + daily_seasonality = 15 + weekly_seasonality = 10 + } + custom_labels = { + example_label = "example_value" + } +} +``` -- `id` (String) The ID of the job. +### Forecast with Holidays + +This forecast has holidays which will be taken into account when training the model. + +```terraform +resource "grafana_data_source" "foo" { + type = "prometheus" + name = "prometheus-ds-test" + uid = "prometheus-ds-test-uid" + url = "https://my-instance.com" + basic_auth_enabled = true + basic_auth_username = "username" + + json_data_encoded = jsonencode({ + httpMethod = "POST" + prometheusType = "Mimir" + prometheusVersion = "2.4.0" + }) + + secure_json_data_encoded = jsonencode({ + basicAuthPassword = "password" + }) +} + +resource "grafana_machine_learning_holiday" "test_holiday" { + name = "Test Holiday" + custom_periods { + name = "First of January" + start_time = "2023-01-01T00:00:00Z" + end_time = "2023-01-02T00:00:00Z" + } +} + +resource "grafana_machine_learning_job" "test_job" { + name = "Test Job" + metric = "tf_test_job" + datasource_type = "prometheus" + datasource_uid = grafana_data_source.foo.uid + query_params = { + expr = "grafanacloud_grafana_instance_active_user_count" + } + holidays = [ + grafana_machine_learning_holiday.test_holiday.id + ] +} +``` ## Import diff --git a/docs/resources/machine_learning_outlier_detector.md b/docs/resources/machine_learning_outlier_detector.md index 6da145ebb..c8a96e960 100644 --- a/docs/resources/machine_learning_outlier_detector.md +++ b/docs/resources/machine_learning_outlier_detector.md @@ -16,47 +16,58 @@ The normal band is configured by choice of algorithm, its sensitivity and other Visit https://grafana.com/docs/grafana-cloud/machine-learning/outlier-detection/ for more details. +## Example Usage + +### DBSCAN Outlier Detector + +This outlier detector uses the DBSCAN algorithm to detect outliers. + +```terraform +resource "grafana_machine_learning_outlier_detector" "my_dbscan_outlier_detector" { + name = "My DBSCAN outlier detector" + description = "My DBSCAN Outlier Detector" + + metric = "tf_test_dbscan_job" + datasource_type = "prometheus" + datasource_uid = "AbCd12345" + query_params = { + expr = "grafanacloud_grafana_instance_active_user_count" + } + interval = 300 + + algorithm { + name = "dbscan" + sensitivity = 0.5 + config { + epsilon = 1.0 + } + } +} +``` +### MAD Outlier Detector - -## Schema - -### Required - -- `algorithm` (Block Set, Min: 1, Max: 1) The algorithm to use and its configuration. See https://grafana.com/docs/grafana-cloud/machine-learning/outlier-detection/ for details. (see [below for nested schema](#nestedblock--algorithm)) -- `datasource_type` (String) The type of datasource being queried. Currently allowed values are prometheus, graphite, loki, postgres, and datadog. -- `datasource_uid` (String) The uid of the datasource to query. -- `metric` (String) The metric used to query the outlier detector results. -- `name` (String) The name of the outlier detector. -- `query_params` (Map of String) An object representing the query params to query Grafana with. - -### Optional - -- `description` (String) A description of the outlier detector. -- `interval` (Number) The data interval in seconds to monitor. Defaults to `300`. - -### Read-Only - -- `id` (String) The ID of the outlier detector. - - -### Nested Schema for `algorithm` - -Required: - -- `name` (String) The name of the algorithm to use ('mad' or 'dbscan'). -- `sensitivity` (Number) Specify the sensitivity of the detector (in range [0,1]). - -Optional: - -- `config` (Block Set, Max: 1) For DBSCAN only, specify the configuration map (see [below for nested schema](#nestedblock--algorithm--config)) +This outlier detector uses the Median Absolute Deviation (MAD) algorithm to detect outliers. - -### Nested Schema for `algorithm.config` +```terraform +resource "grafana_machine_learning_outlier_detector" "my_mad_outlier_detector" { + name = "My MAD outlier detector" + description = "My MAD Outlier Detector" -Required: + metric = "tf_test_mad_job" + datasource_type = "prometheus" + datasource_uid = "AbCd12345" + query_params = { + expr = "grafanacloud_grafana_instance_active_user_count" + } + interval = 300 -- `epsilon` (Number) Specify the epsilon parameter (positive float) + algorithm { + name = "mad" + sensitivity = 0.7 + } +} +``` ## Import diff --git a/docs/resources/message_template.md b/docs/resources/message_template.md index ae51c6686..474efdadb 100644 --- a/docs/resources/message_template.md +++ b/docs/resources/message_template.md @@ -4,7 +4,7 @@ page_title: "grafana_message_template Resource - terraform-provider-grafana" subcategory: "Alerting" description: |- Manages Grafana Alerting message templates. - Official documentation https://grafana.com/docs/grafana/latest/alerting/configure-notifications/template-notifications/create-notification-templates/HTTP API https://grafana.com/docs/grafana/next/developers/http_api/alerting_provisioning/#templates + Official documentation https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/HTTP API https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#templates This resource requires Grafana 9.1.0 or later. --- @@ -12,8 +12,8 @@ description: |- Manages Grafana Alerting message templates. -* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/template-notifications/create-notification-templates/) -* [HTTP API](https://grafana.com/docs/grafana/next/developers/http_api/alerting_provisioning/#templates) +* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/) +* [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#templates) This resource requires Grafana 9.1.0 or later. diff --git a/docs/resources/mute_timing.md b/docs/resources/mute_timing.md index e8faaefc4..63153663b 100644 --- a/docs/resources/mute_timing.md +++ b/docs/resources/mute_timing.md @@ -4,7 +4,7 @@ page_title: "grafana_mute_timing Resource - terraform-provider-grafana" subcategory: "Alerting" description: |- Manages Grafana Alerting mute timings. - Official documentation https://grafana.com/docs/grafana/latest/alerting/configure-notifications/mute-timings/HTTP API https://grafana.com/docs/grafana/next/developers/http_api/alerting_provisioning/#mute-timings + Official documentation https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/HTTP API https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#mute-timings This resource requires Grafana 9.1.0 or later. --- @@ -12,8 +12,8 @@ description: |- Manages Grafana Alerting mute timings. -* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/mute-timings/) -* [HTTP API](https://grafana.com/docs/grafana/next/developers/http_api/alerting_provisioning/#mute-timings) +* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/) +* [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#mute-timings) This resource requires Grafana 9.1.0 or later. diff --git a/docs/resources/notification_policy.md b/docs/resources/notification_policy.md index bc675de7f..a4b1b4a12 100644 --- a/docs/resources/notification_policy.md +++ b/docs/resources/notification_policy.md @@ -5,7 +5,7 @@ subcategory: "Alerting" description: |- Sets the global notification policy for Grafana. !> This resource manages the entire notification policy tree, and will overwrite any existing policies. - Official documentation https://grafana.com/docs/grafana/latest/alerting/configure-notifications/HTTP API https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/ + Official documentation https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/HTTP API https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#notification-policies This resource requires Grafana 9.1.0 or later. --- @@ -15,8 +15,8 @@ Sets the global notification policy for Grafana. !> This resource manages the entire notification policy tree, and will overwrite any existing policies. -* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/) -* [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/) +* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/) +* [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#notification-policies) This resource requires Grafana 9.1.0 or later. diff --git a/docs/resources/oncall_integration.md b/docs/resources/oncall_integration.md index 984a1c642..c56dfc45a 100644 --- a/docs/resources/oncall_integration.md +++ b/docs/resources/oncall_integration.md @@ -3,12 +3,12 @@ page_title: "grafana_oncall_integration Resource - terraform-provider-grafana" subcategory: "OnCall" description: |- - Official documentation https://grafana.com/docs/oncall/latest/integrations/HTTP API https://grafana.com/docs/oncall/latest/oncall-api-reference/ + Official documentation https://grafana.com/docs/oncall/latest/configure/integrations/HTTP API https://grafana.com/docs/oncall/latest/oncall-api-reference/ --- # grafana_oncall_integration (Resource) -* [Official documentation](https://grafana.com/docs/oncall/latest/integrations/) +* [Official documentation](https://grafana.com/docs/oncall/latest/configure/integrations/) * [HTTP API](https://grafana.com/docs/oncall/latest/oncall-api-reference/) ## Example Usage diff --git a/docs/resources/organization.md b/docs/resources/organization.md index f8cb69d0d..a2208444b 100644 --- a/docs/resources/organization.md +++ b/docs/resources/organization.md @@ -7,6 +7,7 @@ description: |- This resource represents an instance-scoped resource and uses Grafana's admin APIs. It does not work with API tokens or service accounts which are org-scoped. You must use basic auth. + This resource is also not compatible with Grafana Cloud, as it does not allow basic auth. --- # grafana_organization (Resource) @@ -16,7 +17,8 @@ description: |- This resource represents an instance-scoped resource and uses Grafana's admin APIs. It does not work with API tokens or service accounts which are org-scoped. -You must use basic auth. +You must use basic auth. +This resource is also not compatible with Grafana Cloud, as it does not allow basic auth. ## Example Usage diff --git a/docs/resources/role_assignment_item.md b/docs/resources/role_assignment_item.md index a46c1c68b..ddcc52171 100644 --- a/docs/resources/role_assignment_item.md +++ b/docs/resources/role_assignment_item.md @@ -65,7 +65,7 @@ resource "grafana_role_assignment_item" "service_account" { ### Optional -- `org_id` (String) The Organization ID. If not set, the Org ID defined in the provider block will be used. +- `org_id` (String) The Organization ID. If not set, the default organization is used for basic authentication, or the one that owns your service account for token authentication. - `service_account_id` (String) the service account onto which the role is to be assigned - `team_id` (String) the team onto which the role is to be assigned - `user_id` (String) the user onto which the role is to be assigned diff --git a/docs/resources/rule_group.md b/docs/resources/rule_group.md index 8f9a059ad..c523f1c25 100644 --- a/docs/resources/rule_group.md +++ b/docs/resources/rule_group.md @@ -4,7 +4,7 @@ page_title: "grafana_rule_group Resource - terraform-provider-grafana" subcategory: "Alerting" description: |- Manages Grafana Alerting rule groups. - Official documentation https://grafana.com/docs/grafana/latest/alerting/alerting-rules/HTTP API https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#alert-rules + Official documentation https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/HTTP API https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#alert-rules This resource requires Grafana 9.1.0 or later. --- @@ -12,7 +12,7 @@ description: |- Manages Grafana Alerting rule groups. -* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/alerting-rules/) +* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/) * [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#alert-rules) This resource requires Grafana 9.1.0 or later. diff --git a/docs/resources/service_account_permission_item.md b/docs/resources/service_account_permission_item.md index a0a32fded..4411d3e95 100644 --- a/docs/resources/service_account_permission_item.md +++ b/docs/resources/service_account_permission_item.md @@ -54,7 +54,7 @@ resource "grafana_service_account_permission_item" "on_user" { ### Optional -- `org_id` (String) The Organization ID. If not set, the Org ID defined in the provider block will be used. +- `org_id` (String) The Organization ID. If not set, the default organization is used for basic authentication, or the one that owns your service account for token authentication. - `team` (String) the team onto which the permission is to be assigned - `user` (String) the user or service account onto which the permission is to be assigned diff --git a/docs/resources/slo.md b/docs/resources/slo.md index cb6c8f35c..5c57628cc 100644 --- a/docs/resources/slo.md +++ b/docs/resources/slo.md @@ -145,6 +145,7 @@ resource "grafana_slo" "test" { - `destination_datasource` (Block List, Max: 1) Destination Datasource sets the datasource defined for an SLO (see [below for nested schema](#nestedblock--destination_datasource)) - `folder_uid` (String) UID for the SLO folder - `label` (Block List) Additional labels that will be attached to all metrics generated from the query. These labels are useful for grouping SLOs in dashboard views that you create by hand. Labels must adhere to Prometheus label name schema - "^[a-zA-Z_][a-zA-Z0-9_]*$" (see [below for nested schema](#nestedblock--label)) +- `search_expression` (String) The name of a search expression in Grafana Asserts. This is used in the SLO UI to open the Asserts RCA workbench and in alerts to link to the RCA workbench. ### Read-Only @@ -217,8 +218,8 @@ Optional: Required: -- `key` (String) -- `value` (String) +- `key` (String) Key for filtering and identification +- `value` (String) Templatable value @@ -234,8 +235,8 @@ Optional: Required: -- `key` (String) -- `value` (String) +- `key` (String) Key for filtering and identification +- `value` (String) Templatable value @@ -243,8 +244,8 @@ Required: Required: -- `key` (String) -- `value` (String) +- `key` (String) Key for filtering and identification +- `value` (String) Templatable value @@ -253,8 +254,8 @@ Required: Required: -- `key` (String) -- `value` (String) +- `key` (String) Key for filtering and identification +- `value` (String) Templatable value @@ -270,8 +271,8 @@ Optional: Required: -- `key` (String) -- `value` (String) +- `key` (String) Key for filtering and identification +- `value` (String) Templatable value @@ -279,8 +280,8 @@ Required: Required: -- `key` (String) -- `value` (String) +- `key` (String) Key for filtering and identification +- `value` (String) Templatable value @@ -298,8 +299,8 @@ Optional: Required: -- `key` (String) -- `value` (String) +- `key` (String) Key for filtering and identification +- `value` (String) Templatable value ## Import diff --git a/docs/resources/sso_settings.md b/docs/resources/sso_settings.md index c414d6b41..022370830 100644 --- a/docs/resources/sso_settings.md +++ b/docs/resources/sso_settings.md @@ -3,13 +3,13 @@ page_title: "grafana_sso_settings Resource - terraform-provider-grafana" subcategory: "Grafana OSS" description: |- - Manages Grafana SSO Settings for OAuth2 and SAML. Support for SAML is currently in preview, it will be available in Grafana Enterprise starting with v11.1. + Manages Grafana SSO Settings for OAuth2, SAML and LDAP. Support for LDAP is currently in preview, it will be available in Grafana starting with v11.3. Official documentation https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication/HTTP API https://grafana.com/docs/grafana/latest/developers/http_api/sso-settings/ --- # grafana_sso_settings (Resource) -Manages Grafana SSO Settings for OAuth2 and SAML. Support for SAML is currently in preview, it will be available in Grafana Enterprise starting with v11.1. +Manages Grafana SSO Settings for OAuth2, SAML and LDAP. Support for LDAP is currently in preview, it will be available in Grafana starting with v11.3. * [Official documentation](https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication/) * [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/sso-settings/) @@ -65,6 +65,48 @@ resource "grafana_sso_settings" "saml_sso_settings" { name_id_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" } } + +# Configure SSO using LDAP +resource "grafana_sso_settings" "ldap_sso_settings" { + provider_name = "ldap" + + ldap_settings { + enabled = "true" + config { + servers { + host = "127.0.0.1" + port = 389 + search_filter = "(cn=%s)" + bind_dn = "cn=admin,dc=grafana,dc=org" + bind_password = "grafana" + search_base_dns = [ + "dc=grafana,dc=org", + ] + attributes = { + name = "givenName" + surname = "sn" + username = "cn" + member_of = "memberOf" + email = "email" + } + group_mappings { + group_dn = "cn=superadmins,dc=grafana,dc=org" + org_role = "Admin" + org_id = 1 + grafana_admin = true + } + group_mappings { + group_dn = "cn=users,dc=grafana,dc=org" + org_role = "Editor" + } + group_mappings { + group_dn = "*" + org_role = "Viewer" + } + } + } + } +} ``` @@ -72,10 +114,11 @@ resource "grafana_sso_settings" "saml_sso_settings" { ### Required -- `provider_name` (String) The name of the SSO provider. Supported values: github, gitlab, google, azuread, okta, generic_oauth, saml. +- `provider_name` (String) The name of the SSO provider. Supported values: github, gitlab, google, azuread, okta, generic_oauth, saml, ldap. ### Optional +- `ldap_settings` (Block Set, Max: 1) The LDAP settings set. Required for the ldap provider. (see [below for nested schema](#nestedblock--ldap_settings)) - `oauth2_settings` (Block Set, Max: 1) The OAuth2 settings set. Required for github, gitlab, google, azuread, okta, generic_oauth providers. (see [below for nested schema](#nestedblock--oauth2_settings)) - `saml_settings` (Block Set, Max: 1) The SAML settings set. Required for the saml provider. (see [below for nested schema](#nestedblock--saml_settings)) @@ -83,6 +126,75 @@ resource "grafana_sso_settings" "saml_sso_settings" { - `id` (String) The ID of this resource. + +### Nested Schema for `ldap_settings` + +Required: + +- `config` (Block List, Min: 1, Max: 1) The LDAP configuration. (see [below for nested schema](#nestedblock--ldap_settings--config)) + +Optional: + +- `allow_sign_up` (Boolean) Whether to allow new Grafana user creation through LDAP login. If set to false, then only existing Grafana users can log in with LDAP. +- `enabled` (Boolean) Define whether this configuration is enabled for LDAP. Defaults to `true`. +- `skip_org_role_sync` (Boolean) Prevent synchronizing users’ organization roles from LDAP. + + +### Nested Schema for `ldap_settings.config` + +Required: + +- `servers` (Block List, Min: 1) The LDAP servers configuration. (see [below for nested schema](#nestedblock--ldap_settings--config--servers)) + + +### Nested Schema for `ldap_settings.config.servers` + +Required: + +- `host` (String) The LDAP server host. +- `search_base_dns` (List of String) An array of base DNs to search through. +- `search_filter` (String) The user search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)". + +Optional: + +- `attributes` (Map of String) The LDAP server attributes. The following attributes can be configured: email, member_of, name, surname, username. +- `bind_dn` (String) The search user bind DN. +- `bind_password` (String, Sensitive) The search user bind password. +- `client_cert` (String) The path to the client certificate. +- `client_cert_value` (String) The Base64 encoded value of the client certificate. +- `client_key` (String, Sensitive) The path to the client private key. +- `client_key_value` (String, Sensitive) The Base64 encoded value of the client private key. +- `group_mappings` (Block List) For mapping an LDAP group to a Grafana organization and role. (see [below for nested schema](#nestedblock--ldap_settings--config--servers--group_mappings)) +- `group_search_base_dns` (List of String) An array of the base DNs to search through for groups. Typically uses ou=groups. +- `group_search_filter` (String) Group search filter, to retrieve the groups of which the user is a member (only set if memberOf attribute is not available). +- `group_search_filter_user_attribute` (String) The %s in the search filter will be replaced with the attribute defined in this field. +- `min_tls_version` (String) Minimum TLS version allowed. Accepted values are: TLS1.2, TLS1.3. +- `port` (Number) The LDAP server port. +- `root_ca_cert` (String) The path to the root CA certificate. +- `root_ca_cert_value` (List of String) The Base64 encoded values of the root CA certificates. +- `ssl_skip_verify` (Boolean) If set to true, the SSL cert validation will be skipped. +- `start_tls` (Boolean) If set to true, use LDAP with STARTTLS instead of LDAPS. +- `timeout` (Number) The timeout in seconds for connecting to the LDAP host. +- `tls_ciphers` (List of String) Accepted TLS ciphers. For a complete list of supported ciphers, refer to: https://go.dev/src/crypto/tls/cipher_suites.go. +- `use_ssl` (Boolean) Set to true if LDAP server should use an encrypted TLS connection (either with STARTTLS or LDAPS). + + +### Nested Schema for `ldap_settings.config.servers.group_mappings` + +Required: + +- `group_dn` (String) LDAP distinguished name (DN) of LDAP group. If you want to match all (or no LDAP groups) then you can use wildcard ("*"). +- `org_role` (String) Assign users of group_dn the organization role Admin, Editor, or Viewer. + +Optional: + +- `grafana_admin` (Boolean) If set to true, it makes the user of group_dn Grafana server admin. +- `org_id` (Number) The Grafana organization database id. + + + + + ### Nested Schema for `oauth2_settings` @@ -114,6 +226,8 @@ Optional: - `login_attribute_path` (String) JMESPath expression to use for user login lookup from the user ID token. Only applicable to Generic OAuth. - `name` (String) Helpful if you use more than one identity providers or SSO protocols. - `name_attribute_path` (String) JMESPath expression to use for user name lookup from the user ID token. This name will be used as the user’s display name. Only applicable to Generic OAuth. +- `org_attribute_path` (String) JMESPath expression to use for the organization mapping lookup from the user ID token. The extracted list will be used for the organization mapping (to match "Organization" in the "org_mapping"). Only applicable to Generic OAuth and Okta. +- `org_mapping` (String) List of comma- or space-separated Organization:OrgIdOrOrgName:Role mappings. Organization can be * meaning “All users”. Role is optional and can have the following values: None, Viewer, Editor or Admin. - `role_attribute_path` (String) JMESPath expression to use for Grafana role lookup. - `role_attribute_strict` (Boolean) If enabled, denies user login if the Grafana role cannot be extracted using Role attribute path. - `scopes` (String) List of comma- or space-separated OAuth2 scopes. @@ -148,7 +262,10 @@ Optional: - `auto_login` (Boolean) Whether SAML auto login is enabled. - `certificate` (String, Sensitive) Base64-encoded string for the SP X.509 certificate. - `certificate_path` (String) Path for the SP X.509 certificate. +- `client_id` (String) The client Id of your OAuth2 app. +- `client_secret` (String) The client secret of your OAuth2 app. - `enabled` (Boolean) Define whether this configuration is enabled for SAML. Defaults to `true`. +- `force_use_graph_api` (Boolean) If enabled, Grafana will fetch groups from Microsoft Graph API instead of using the groups claim from the ID token. - `idp_metadata` (String) Base64-encoded string for the IdP SAML metadata XML. - `idp_metadata_path` (String) Path for the IdP SAML metadata XML. - `idp_metadata_url` (String) URL for the IdP SAML metadata XML. @@ -168,6 +285,7 @@ Optional: - `signature_algorithm` (String) Signature algorithm used for signing requests to the IdP. Supported values are rsa-sha1, rsa-sha256, rsa-sha512. - `single_logout` (Boolean) Whether SAML Single Logout is enabled. - `skip_org_role_sync` (Boolean) Prevent synchronizing users’ organization roles from your IdP. +- `token_url` (String) The token endpoint of your OAuth2 provider. Required for Azure AD providers. ## Import diff --git a/docs/resources/user.md b/docs/resources/user.md index 144401275..6d48d15d5 100644 --- a/docs/resources/user.md +++ b/docs/resources/user.md @@ -7,6 +7,7 @@ description: |- This resource represents an instance-scoped resource and uses Grafana's admin APIs. It does not work with API tokens or service accounts which are org-scoped. You must use basic auth. + This resource is also not compatible with Grafana Cloud, as it does not allow basic auth. --- # grafana_user (Resource) @@ -17,6 +18,7 @@ description: |- This resource represents an instance-scoped resource and uses Grafana's admin APIs. It does not work with API tokens or service accounts which are org-scoped. You must use basic auth. +This resource is also not compatible with Grafana Cloud, as it does not allow basic auth. ## Example Usage diff --git a/examples/data-sources/grafana_library_panels/data-source.tf b/examples/data-sources/grafana_library_panels/data-source.tf new file mode 100644 index 000000000..8713236cc --- /dev/null +++ b/examples/data-sources/grafana_library_panels/data-source.tf @@ -0,0 +1,34 @@ +resource "grafana_library_panel" "test" { + name = "panelname" + model_json = jsonencode({ + title = "test name" + type = "text" + version = 0 + description = "test description" + }) +} + +resource "grafana_folder" "test" { + title = "Panel Folder" + uid = "panelname-folder" +} + +resource "grafana_library_panel" "folder" { + name = "panelname In Folder" + folder_uid = grafana_folder.test.uid + model_json = jsonencode({ + gridPos = { + x = 0 + y = 0 + h = 10 + w = 10 + } + title = "panel" + type = "text" + version = 0 + }) +} + +data "grafana_library_panels" "all" { + depends_on = [grafana_library_panel.folder, grafana_library_panel.test] +} diff --git a/examples/resources/grafana_data_source/resource.tf b/examples/resources/grafana_data_source/resource.tf index 1a2fe9613..fb65df0e7 100644 --- a/examples/resources/grafana_data_source/resource.tf +++ b/examples/resources/grafana_data_source/resource.tf @@ -43,6 +43,21 @@ resource "grafana_data_source" "cloudwatch" { }) } +resource "grafana_data_source" "cloudwatch_assumeARN" { + type = "cloudwatch" + name = "cw-assumeARN-example" + + # Requires `assume_role_enabled` feature flag to be enabled + # OSS: use authType = "default" on OSS + # Cloud: use authType = "grafana_assume_role" which is in private preview on Cloud: + # https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/aws-authentication/#use-grafana-assume-role + json_data_encoded = jsonencode({ + defaultRegion = "us-east-1" + authType = "grafana_assume_role" + assumeRoleArn = "arn:aws:iam::123456789012:root" + }) +} + resource "grafana_data_source" "prometheus" { type = "prometheus" name = "mimir" diff --git a/examples/resources/grafana_machine_learning_alert/forecast_alert.tf b/examples/resources/grafana_machine_learning_alert/forecast_alert.tf new file mode 100644 index 000000000..83efd4376 --- /dev/null +++ b/examples/resources/grafana_machine_learning_alert/forecast_alert.tf @@ -0,0 +1,17 @@ +resource "grafana_machine_learning_job" "test_alert_job" { + name = "Test Job" + metric = "tf_test_alert_job" + datasource_type = "prometheus" + datasource_uid = "abcd12345" + query_params = { + expr = "grafanacloud_grafana_instance_active_user_count" + } +} + +resource "grafana_machine_learning_alert" "test_job_alert" { + job_id = grafana_machine_learning_job.test_alert_job.id + title = "Test Alert" + anomaly_condition = "any" + threshold = ">0.8" + window = "15m" +} diff --git a/examples/resources/grafana_machine_learning_alert/import.sh b/examples/resources/grafana_machine_learning_alert/import.sh new file mode 100644 index 000000000..5ed876422 --- /dev/null +++ b/examples/resources/grafana_machine_learning_alert/import.sh @@ -0,0 +1 @@ +terraform import grafana_machine_learning_alert.name "{{ id }}" diff --git a/examples/resources/grafana_machine_learning_alert/outlier_alert.tf b/examples/resources/grafana_machine_learning_alert/outlier_alert.tf new file mode 100644 index 000000000..5b65970d8 --- /dev/null +++ b/examples/resources/grafana_machine_learning_alert/outlier_alert.tf @@ -0,0 +1,25 @@ +resource "grafana_machine_learning_outlier_detector" "test_alert_outlier_detector" { + name = "Test Outlier" + + metric = "tf_test_alert_outlier" + datasource_type = "prometheus" + datasource_uid = "AbCd12345" + query_params = { + expr = "grafanacloud_grafana_instance_active_user_count" + } + interval = 300 + + algorithm { + name = "dbscan" + sensitivity = 0.5 + config { + epsilon = 1.0 + } + } +} + +resource "grafana_machine_learning_alert" "test_outlier_alert" { + outlier_id = grafana_machine_learning_outlier_detector.test_alert_outlier_detector.id + title = "Test Alert" + window = "1h" +} diff --git a/examples/resources/grafana_slo/resource_search_expression.tf b/examples/resources/grafana_slo/resource_search_expression.tf new file mode 100644 index 000000000..4debc699e --- /dev/null +++ b/examples/resources/grafana_slo/resource_search_expression.tf @@ -0,0 +1,23 @@ +resource "grafana_slo" "search_expression" { + name = "Terraform Testing - Entity Search Expression" + description = "Terraform Description - Entity Search Expression" + query { + freeform { + query = "sum(rate(apiserver_request_total{code!=\"500\"}[$__rate_interval])) / sum(rate(apiserver_request_total[$__rate_interval]))" + } + type = "freeform" + } + objectives { + value = 0.995 + window = "30d" + } + destination_datasource { + uid = "grafanacloud-prom" + } + label { + key = "slo" + value = "terraform" + } + + search_expression = "Entity Search for RCA Workbench" +} diff --git a/examples/resources/grafana_sso_settings/resource.tf b/examples/resources/grafana_sso_settings/resource.tf index dbe381934..ba1bf526f 100644 --- a/examples/resources/grafana_sso_settings/resource.tf +++ b/examples/resources/grafana_sso_settings/resource.tf @@ -46,3 +46,45 @@ resource "grafana_sso_settings" "saml_sso_settings" { name_id_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" } } + +# Configure SSO using LDAP +resource "grafana_sso_settings" "ldap_sso_settings" { + provider_name = "ldap" + + ldap_settings { + enabled = "true" + config { + servers { + host = "127.0.0.1" + port = 389 + search_filter = "(cn=%s)" + bind_dn = "cn=admin,dc=grafana,dc=org" + bind_password = "grafana" + search_base_dns = [ + "dc=grafana,dc=org", + ] + attributes = { + name = "givenName" + surname = "sn" + username = "cn" + member_of = "memberOf" + email = "email" + } + group_mappings { + group_dn = "cn=superadmins,dc=grafana,dc=org" + org_role = "Admin" + org_id = 1 + grafana_admin = true + } + group_mappings { + group_dn = "cn=users,dc=grafana,dc=org" + org_role = "Editor" + } + group_mappings { + group_dn = "*" + org_role = "Viewer" + } + } + } + } +} diff --git a/go.mod b/go.mod index ecf342a3d..60564f4be 100644 --- a/go.mod +++ b/go.mod @@ -9,33 +9,34 @@ require ( github.com/fatih/color v1.17.0 github.com/go-openapi/runtime v0.28.0 github.com/go-openapi/strfmt v0.23.0 - github.com/grafana/amixr-api-go-client v0.0.13 // main branch - github.com/grafana/grafana-com-public-clients/go/gcom v0.0.0-20240322153219-42c6a1d2bcab - github.com/grafana/grafana-openapi-client-go v0.0.0-20240523010106-657d101fcbd9 - github.com/grafana/machine-learning-go-client v0.7.0 - github.com/grafana/slo-openapi-client/go v0.0.0-20240626093634-e6741482b090 - github.com/grafana/synthetic-monitoring-agent v0.24.3 + github.com/grafana/amixr-api-go-client v0.0.12 // main branch + github.com/grafana/grafana-com-public-clients/go/gcom v0.0.0-20240807172819-ac10800522a3 + github.com/grafana/grafana-openapi-client-go v0.0.0-20240723170622-ae2c94b7c9a3 + github.com/grafana/machine-learning-go-client v0.8.1 + github.com/grafana/slo-openapi-client/go/slo v0.0.0-20240807172758-1b7d00838fc7 + github.com/grafana/synthetic-monitoring-agent v0.25.2 github.com/grafana/synthetic-monitoring-api-go-client v0.8.0 github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.7.0 - github.com/hashicorp/hc-install v0.7.0 - github.com/hashicorp/hcl/v2 v2.21.0 + github.com/hashicorp/hc-install v0.8.0 + github.com/hashicorp/hcl/v2 v2.22.0 github.com/hashicorp/terraform-exec v0.21.0 github.com/hashicorp/terraform-json v0.22.1 github.com/hashicorp/terraform-plugin-docs v0.19.4 - github.com/hashicorp/terraform-plugin-framework v1.9.0 - github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 + github.com/hashicorp/terraform-plugin-framework v1.11.0 + github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 github.com/hashicorp/terraform-plugin-go v0.23.0 github.com/hashicorp/terraform-plugin-mux v0.16.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0 + github.com/prometheus/common v0.55.0 github.com/stretchr/testify v1.9.0 - github.com/tmccombs/hcl2json v0.6.3 - github.com/urfave/cli/v2 v2.27.2 - github.com/zclconf/go-cty v1.14.4 - golang.org/x/exp v0.0.0-20240119083558-1b970713d09a - golang.org/x/text v0.16.0 + github.com/tmccombs/hcl2json v0.6.4 + github.com/urfave/cli/v2 v2.27.4 + github.com/zclconf/go-cty v1.15.0 + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 + golang.org/x/text v0.17.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -82,7 +83,7 @@ require ( github.com/gorilla/mux v1.8.1 // indirect github.com/grafana/grafana-plugin-sdk-go v0.235.0 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect - github.com/grafana/pyroscope-go/godeltaprof v0.1.7 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect @@ -103,7 +104,7 @@ require ( github.com/invopop/yaml v0.2.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.3 // indirect + github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/magefile/mage v1.15.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -119,6 +120,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/run v1.1.0 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect @@ -129,8 +131,7 @@ require ( github.com/posener/complete v1.2.3 // indirect github.com/prometheus/client_golang v1.19.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.53.0 // indirect - github.com/prometheus/procfs v0.14.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/cast v1.5.0 // indirect @@ -141,7 +142,7 @@ require ( 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/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/yuin/goldmark v1.7.1 // indirect github.com/yuin/goldmark-meta v1.1.0 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect @@ -158,20 +159,21 @@ require ( go.opentelemetry.io/otel/sdk v1.26.0 // indirect go.opentelemetry.io/otel/trace v1.26.0 // indirect go.opentelemetry.io/proto/otlp v1.2.0 // indirect - golang.org/x/crypto v0.24.0 // indirect - golang.org/x/mod v0.18.0 // indirect - golang.org/x/net v0.26.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.21.0 // indirect + golang.org/x/crypto v0.25.0 // indirect + golang.org/x/mod v0.19.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.22.0 // indirect golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect - golang.org/x/tools v0.22.0 // indirect + golang.org/x/tools v0.23.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be // indirect - google.golang.org/grpc v1.64.0 // indirect - google.golang.org/protobuf v1.34.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect + google.golang.org/grpc v1.65.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect + gopkg.in/validator.v2 v2.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d8c9d9931..19eb5288c 100644 --- a/go.sum +++ b/go.sum @@ -134,26 +134,26 @@ github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1 github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grafana/amixr-api-go-client v0.0.12-0.20240410110211-c9f68db085c4 h1:e7cZfDiNodjQn63be9m8zfnvMEQAMqHVFswjcbdlspk= -github.com/grafana/amixr-api-go-client v0.0.12-0.20240410110211-c9f68db085c4/go.mod h1:N6x26XUrM5zGtK5zL5vNJnAn2JFMxLFPPLTw/6pDkFE= github.com/grafana/amixr-api-go-client v0.0.12 h1:oEHZTBhxoZ35EsfeccZBJGPKhZUVOmdSir3WWnSJMLc= github.com/grafana/amixr-api-go-client v0.0.12/go.mod h1:N6x26XUrM5zGtK5zL5vNJnAn2JFMxLFPPLTw/6pDkFE= -github.com/grafana/grafana-com-public-clients/go/gcom v0.0.0-20240322153219-42c6a1d2bcab h1:/5R8NO996/keDkZqKXEkU3/QgFs1wzChKYkakjsBpRk= -github.com/grafana/grafana-com-public-clients/go/gcom v0.0.0-20240322153219-42c6a1d2bcab/go.mod h1:6sYY1qgwYfSDNQhKQA0tar8Oc38cIGfyqwejhxoOsPs= -github.com/grafana/grafana-openapi-client-go v0.0.0-20240523010106-657d101fcbd9 h1:lOumw0RmkvKsTRMm6e5x2x6EbtyTeIKhy8ZJaK1KW9w= -github.com/grafana/grafana-openapi-client-go v0.0.0-20240523010106-657d101fcbd9/go.mod h1:hiZnMmXc9KXNUlvkV2BKFsiWuIFF/fF4wGgYWEjBitI= +github.com/grafana/grafana-com-public-clients/go/gcom v0.0.0-20240807172819-ac10800522a3 h1:CVLTffnWgBGvVaXfUUcSgFrZbiMzvj0/Hpi909zdeG0= +github.com/grafana/grafana-com-public-clients/go/gcom v0.0.0-20240807172819-ac10800522a3/go.mod h1:u9d0BESoKlztYm93CpoRleQjMbYBcZ+JOLHHP2nN6Wg= +github.com/grafana/grafana-openapi-client-go v0.0.0-20240723170622-ae2c94b7c9a3 h1:W35ScJIkeyLuDlOo3F+u1JSRRvmoIYYf/ghA/17Y18Q= +github.com/grafana/grafana-openapi-client-go v0.0.0-20240723170622-ae2c94b7c9a3/go.mod h1:hiZnMmXc9KXNUlvkV2BKFsiWuIFF/fF4wGgYWEjBitI= github.com/grafana/grafana-plugin-sdk-go v0.235.0 h1:UnZ/iBDvCkfDgwR94opi8trAWJXv4V8Qr1ocJKRRmqA= github.com/grafana/grafana-plugin-sdk-go v0.235.0/go.mod h1:6n9LbrjGL3xAATntYVNcIi90G9BVHRJjzHKz5FXVfWw= -github.com/grafana/machine-learning-go-client v0.7.0 h1:yiRBg8rCNbHh9BURa+vtZ8ItRYvabbdYAtsAOfxoFPI= -github.com/grafana/machine-learning-go-client v0.7.0/go.mod h1:bKsLSJTreH7HXaL2FJnnrliMuP0L8XwMkXte6AgwFFg= +github.com/grafana/machine-learning-go-client v0.8.0 h1:N8+0f5aFM/umVJWvlJkJy9McVIp9MIBUtuNruug94II= +github.com/grafana/machine-learning-go-client v0.8.0/go.mod h1:9xRIoH6Y6RubuCPNjLfpckE/fLVe9dazg3HSLI1ARAU= +github.com/grafana/machine-learning-go-client v0.8.1 h1:cCVb2FQuMMto2qvhmVcjHM4eFjAey3AiTLYVUq3wYVI= +github.com/grafana/machine-learning-go-client v0.8.1/go.mod h1:9xRIoH6Y6RubuCPNjLfpckE/fLVe9dazg3HSLI1ARAU= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= -github.com/grafana/pyroscope-go/godeltaprof v0.1.7 h1:C11j63y7gymiW8VugJ9ZW0pWfxTZugdSJyC48olk5KY= -github.com/grafana/pyroscope-go/godeltaprof v0.1.7/go.mod h1:Tk376Nbldo4Cha9RgiU7ik8WKFkNpfds98aUzS8omLE= -github.com/grafana/slo-openapi-client/go v0.0.0-20240626093634-e6741482b090 h1:gDkJPpTL84zx+UkSY6a1pPlUm9aDEVBzPlVOkUbXmgM= -github.com/grafana/slo-openapi-client/go v0.0.0-20240626093634-e6741482b090/go.mod h1:HgbbeH2gFfCk2XZCrCly39DB13WkwWyQ+Ww+HTxePCs= -github.com/grafana/synthetic-monitoring-agent v0.24.3 h1:+xscAsGZtWTNTNDxdYqqcz4w1tG6QPaOIgCONsVMoO8= -github.com/grafana/synthetic-monitoring-agent v0.24.3/go.mod h1:CJQmPtKRcJMjb/sDe6fDA4vyS2qFPElu0szI33nKlzk= +github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg= +github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/grafana/slo-openapi-client/go/slo v0.0.0-20240807172758-1b7d00838fc7 h1:t7zAFX0rMu868n85zRHLgmAjLJgWbkxUekGquZmovjA= +github.com/grafana/slo-openapi-client/go/slo v0.0.0-20240807172758-1b7d00838fc7/go.mod h1:MVsmQi3lkhNnRExmke6Ug6HFG4Dycd+oRgzC3Rz+vOs= +github.com/grafana/synthetic-monitoring-agent v0.25.2 h1:9D81dzJJBnfy00dZ6wOwD8c0jaOuzYQLBDcHI9m8sGU= +github.com/grafana/synthetic-monitoring-agent v0.25.2/go.mod h1:MFjd+uEvUWLnJj4qIUMVCKV3dWh2PisVnBgEOsf/ftw= github.com/grafana/synthetic-monitoring-api-go-client v0.8.0 h1:Tm4MtwwYmPNInGfnj66l6j6KOshMkNV4emIVKJdlXMg= github.com/grafana/synthetic-monitoring-api-go-client v0.8.0/go.mod h1:TGaywTdL2Z+PJhpWzJEmJFRF5K55vKz2f39mWY/GvV8= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpSQwGEnkcRpTqNOIR6bJbR0gAorgP9CSALpRcKoAA= @@ -191,20 +191,20 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/hc-install v0.7.0 h1:Uu9edVqjKQxxuD28mR5TikkKDd/p55S8vzPC1659aBk= -github.com/hashicorp/hc-install v0.7.0/go.mod h1:ELmmzZlGnEcqoUMKUuykHaPCIR1sYLYX+KSggWSKZuA= -github.com/hashicorp/hcl/v2 v2.21.0 h1:lve4q/o/2rqwYOgUg3y3V2YPyD1/zkCLGjIV74Jit14= -github.com/hashicorp/hcl/v2 v2.21.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/hashicorp/hc-install v0.8.0 h1:LdpZeXkZYMQhoKPCecJHlKvUkQFixN/nvyR1CdfOLjI= +github.com/hashicorp/hc-install v0.8.0/go.mod h1:+MwJYjDfCruSD/udvBmRB22Nlkwwkwf5sAB6uTIhSaU= +github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M= +github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= 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-json v0.22.1 h1:xft84GZR0QzjPVWs4lRUwvTcPnegqlyS7orfb5Ltvec= github.com/hashicorp/terraform-json v0.22.1/go.mod h1:JbWSQCLFSXFFhg42T7l9iJwdGXBYV8fmmD6o/ML4p3A= github.com/hashicorp/terraform-plugin-docs v0.19.4 h1:G3Bgo7J22OMtegIgn8Cd/CaSeyEljqjH3G39w28JK4c= github.com/hashicorp/terraform-plugin-docs v0.19.4/go.mod h1:4pLASsatTmRynVzsjEhbXZ6s7xBlUw/2Kt0zfrq8HxA= -github.com/hashicorp/terraform-plugin-framework v1.9.0 h1:caLcDoxiRucNi2hk8+j3kJwkKfvHznubyFsJMWfZqKU= -github.com/hashicorp/terraform-plugin-framework v1.9.0/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM= -github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 h1:HOjBuMbOEzl7snOdOoUfE2Jgeto6JOjLVQ39Ls2nksc= -github.com/hashicorp/terraform-plugin-framework-validators v0.12.0/go.mod h1:jfHGE/gzjxYz6XoUwi/aYiiKrJDeutQNUtGQXkaHklg= +github.com/hashicorp/terraform-plugin-framework v1.11.0 h1:M7+9zBArexHFXDx/pKTxjE6n/2UCXY6b8FIq9ZYhwfE= +github.com/hashicorp/terraform-plugin-framework v1.11.0/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM= +github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 h1:bxZfGo9DIUoLLtHMElsu+zwqI4IsMZQBRRy4iLzZJ8E= +github.com/hashicorp/terraform-plugin-framework-validators v0.13.0/go.mod h1:wGeI02gEhj9nPANU62F2jCaHjXulejm/X+af4PdZaNo= github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= @@ -242,8 +242,8 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA= -github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -289,6 +289,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= @@ -312,10 +314,10 @@ github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQ github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= -github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= -github.com/prometheus/procfs v0.14.0 h1:Lw4VdGGoKEZilJsayHf0B+9YgLGREba2C6xr+Fdfq6s= -github.com/prometheus/procfs v0.14.0/go.mod h1:XL+Iwz8k8ZabyZfMFHPiilCniixqQarAy5Mu67pHlNQ= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= @@ -351,8 +353,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tmccombs/hcl2json v0.6.3 h1:yfZO7FYuWxSBAkxN1Dw+O9bjnK12vdwCDtSJDzw7haw= -github.com/tmccombs/hcl2json v0.6.3/go.mod h1:VaIUbPyWiGThEKOsVZis0QHfMCnHLqD3IEbggSvQ8eY= +github.com/tmccombs/hcl2json v0.6.4 h1:/FWnzS9JCuyZ4MNwrG4vMrFrzRgsWEOVi+1AyYUVLGw= +github.com/tmccombs/hcl2json v0.6.4/go.mod h1:+ppKlIW3H5nsAsZddXPy2iMyvld3SHxyjswOZhavRDk= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 h1:aVGB3YnaS/JNfOW3tiHIlmNmTDg618va+eT0mVomgyI= @@ -364,8 +366,8 @@ github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3/go.mod h1:1xEUf2abjfP9 github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.15 h1:nuqt+pdC/KqswQKhETJjo7pvn/k4xMUxgW6liI7XpnM= github.com/urfave/cli v1.22.15/go.mod h1:wSan1hmo5zeyLGBjRJbzRTNk8gwoYa2B9n4q9dmRIc0= -github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= -github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= +github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= +github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ= 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= @@ -375,8 +377,8 @@ 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= 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/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= -github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -384,8 +386,8 @@ github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U= github.com/yuin/goldmark v1.7.1/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.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= -github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ= +github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= 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= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= @@ -431,15 +433,15 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 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.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= -golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= -golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -447,15 +449,15 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY 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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= 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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/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.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191020152052-9984515f0562/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -474,21 +476,21 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 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.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s= golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -496,8 +498,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -509,22 +511,24 @@ gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY 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/api v0.0.0-20240415180920-8c6c420018be h1:Zz7rLWqp0ApfsR/l7+zSHhY3PMiH2xqgxlfYfAfNpoU= -google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be/go.mod h1:dvdCTIoAGbkWbcIKBniID56/7XHTt6WfxXNMxuziJ+w= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be h1:LG9vZxsWGOmUKieR8wPAUR3u3MpnYFQZROPIMaXh7/A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= -google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= -google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw= +google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= 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.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= -google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 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/fsnotify/fsnotify.v1 v1.4.7 h1:XNNYLJHt73EyYiCZi6+xjupS9CpvmiDgjPTAjrBlQbo= gopkg.in/fsnotify/fsnotify.v1 v1.4.7/go.mod h1:Fyux9zXlo4rWoMSIzpn9fDAYjalPqJ/K1qJ27s+7ltE= +gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY= +gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8= 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= diff --git a/internal/common/client.go b/internal/common/client.go index 19c88e5a4..f8d0268ba 100644 --- a/internal/common/client.go +++ b/internal/common/client.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana-com-public-clients/go/gcom" goapi "github.com/grafana/grafana-openapi-client-go/client" "github.com/grafana/machine-learning-go-client/mlapi" - slo "github.com/grafana/slo-openapi-client/go" + "github.com/grafana/slo-openapi-client/go/slo" SMAPI "github.com/grafana/synthetic-monitoring-api-go-client" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" diff --git a/internal/common/errcheck.go b/internal/common/errcheck.go index 3f7539b2d..86693f565 100644 --- a/internal/common/errcheck.go +++ b/internal/common/errcheck.go @@ -10,7 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -const NotFoundError = "status: 404" +const NotFoundError = "404" // CheckReadError checks for common cases on resource read/delete paths: // - If the resource no longer exists and 404s, it should be removed from state and return nil, to stop processing the read. diff --git a/internal/common/resource.go b/internal/common/resource.go index b6effba78..cb7abbb18 100644 --- a/internal/common/resource.go +++ b/internal/common/resource.go @@ -66,8 +66,11 @@ type ResourceListIDsFunc func(ctx context.Context, client *Client, data any) ([] type Resource struct { ResourceCommon IDType *ResourceID - ListIDsFunc ResourceListIDsFunc PluginFrameworkSchema resource.ResourceWithConfigure + + // Generation configuration + ListIDsFunc ResourceListIDsFunc + PreferredResourceNameField string // This field will be used as the resource name instead of the ID. This is useful if the ID is not ideal for humans (ex: UUID or numeric). The field value should uniquely identify the resource. } func NewLegacySDKResource(category ResourceCategory, name string, idType *ResourceID, schema *schema.Resource) *Resource { @@ -99,6 +102,11 @@ func (r *Resource) WithLister(lister ResourceListIDsFunc) *Resource { return r } +func (r *Resource) WithPreferredResourceNameField(fieldName string) *Resource { + r.PreferredResourceNameField = fieldName + return r +} + func (r *Resource) ImportExample() string { exampleFromFields := func(fields []ResourceIDField) string { fieldTemplates := make([]string, len(fields)) diff --git a/internal/resources/cloud/common.go b/internal/resources/cloud/common.go index 34160e71c..e2696fd6b 100644 --- a/internal/resources/cloud/common.go +++ b/internal/resources/cloud/common.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana-com-public-clients/go/gcom" "github.com/grafana/terraform-provider-grafana/v3/internal/common" "github.com/hashicorp/go-uuid" + "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -49,6 +50,30 @@ func apiError(err error) diag.Diagnostics { } } +type basePluginFrameworkDataSource struct { + client *gcom.APIClient +} + +func (r *basePluginFrameworkDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Configure is called multiple times (sometimes when ProviderData is not yet available), we only want to configure once + if req.ProviderData == nil || r.client != nil { + return + } + + client, ok := req.ProviderData.(*common.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *common.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client.GrafanaCloudAPI +} + type basePluginFrameworkResource struct { client *gcom.APIClient } diff --git a/internal/resources/cloud/data_source_cloud_access_policies.go b/internal/resources/cloud/data_source_cloud_access_policies.go new file mode 100644 index 000000000..804a3f459 --- /dev/null +++ b/internal/resources/cloud/data_source_cloud_access_policies.go @@ -0,0 +1,130 @@ +package cloud + +import ( + "context" + + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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/types" +) + +var dataSourceAccessPoliciesName = "grafana_cloud_access_policies" + +func datasourceAccessPolicies() *common.DataSource { + return common.NewDataSource( + common.CategoryCloud, + dataSourceAccessPoliciesName, + &AccessPoliciesDataSource{}, + ) +} + +type AccessPoliciesDataSource struct { + basePluginFrameworkDataSource +} + +func (r *AccessPoliciesDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = dataSourceAccessPoliciesName +} + +func (r *AccessPoliciesDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: ` +Fetches access policies from Grafana Cloud. + +* [Official documentation](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) +* [API documentation](https://grafana.com/docs/grafana-cloud/developer-resources/api-reference/cloud-api/#list-access-policies) + +Required access policy scopes: + +* accesspolicies:read`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "The ID of this datasource. This is an internal identifier used by the provider to track this datasource.", + }, + "region_filter": schema.StringAttribute{ + Optional: true, + Description: "If set, only access policies in the specified region will be returned. This is faster than filtering in Terraform.", + }, + "name_filter": schema.StringAttribute{ + Optional: true, + Description: "If set, only access policies with the specified name will be returned. This is faster than filtering in Terraform.", + }, + "access_policies": schema.SetAttribute{ + Computed: true, + ElementType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "region": types.StringType, + "name": types.StringType, + "display_name": types.StringType, + "status": types.StringType, + }, + }, + }, + }, + } +} + +type AccessPoliciesDataSourcePolicyModel struct { + ID types.String `tfsdk:"id"` + Region types.String `tfsdk:"region"` + Name types.String `tfsdk:"name"` + DisplayName types.String `tfsdk:"display_name"` + Status types.String `tfsdk:"status"` +} + +type AccessPoliciesDataSourceModel struct { + ID types.String `tfsdk:"id"` + NameFilter types.String `tfsdk:"name_filter"` + RegionFilter types.String `tfsdk:"region_filter"` + AccessPolicies []AccessPoliciesDataSourcePolicyModel `tfsdk:"access_policies"` +} + +func (r *AccessPoliciesDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + // Read Terraform state data into the model + var data AccessPoliciesDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + var regions []string + if data.RegionFilter.ValueString() != "" { + regions = append(regions, data.RegionFilter.ValueString()) + } else { + apiResp, _, err := r.client.StackRegionsAPI.GetStackRegions(ctx).Execute() + if err != nil { + resp.Diagnostics = diag.Diagnostics{diag.NewErrorDiagnostic("Failed to get stack regions", err.Error())} + return + } + for _, region := range apiResp.Items { + regions = append(regions, region.FormattedApiStackRegionAnyOf.Slug) + } + } + + data.AccessPolicies = []AccessPoliciesDataSourcePolicyModel{} + for _, region := range regions { + apiResp, _, err := r.client.AccesspoliciesAPI.GetAccessPolicies(ctx).Region(region).Execute() + if err != nil { + resp.Diagnostics = diag.Diagnostics{diag.NewErrorDiagnostic("Failed to get access policies", err.Error())} + return + } + for _, policy := range apiResp.Items { + if data.NameFilter.ValueString() != "" && data.NameFilter.ValueString() != policy.Name { + continue + } + data.AccessPolicies = append(data.AccessPolicies, AccessPoliciesDataSourcePolicyModel{ + ID: types.StringValue(*policy.Id), + Region: types.StringValue(region), + Name: types.StringValue(policy.Name), + DisplayName: types.StringValue(*policy.DisplayName), + Status: types.StringValue(*policy.Status), + }) + } + } + data.ID = types.StringValue(data.RegionFilter.ValueString() + "-" + data.NameFilter.ValueString()) // Unique ID + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, data)...) +} diff --git a/internal/resources/cloud/data_source_cloud_access_policies_test.go b/internal/resources/cloud/data_source_cloud_access_policies_test.go new file mode 100644 index 000000000..fcd781d4c --- /dev/null +++ b/internal/resources/cloud/data_source_cloud_access_policies_test.go @@ -0,0 +1,127 @@ +package cloud_test + +import ( + "fmt" + "time" + + "testing" + + "github.com/grafana/grafana-com-public-clients/go/gcom" + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/grafana/terraform-provider-grafana/v3/internal/testutils" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestDataSourceAccessPolicy_Basic(t *testing.T) { + testutils.CheckCloudAPITestsEnabled(t) + + var policy gcom.AuthAccessPolicy + + expiresAt := time.Now().Add(time.Hour * 24).UTC().Format(time.RFC3339) + randomName := acctest.RandStringFromCharSet(6, acctest.CharSetAlpha) + scopes := []string{ + "metrics:read", + "logs:write", + "accesspolicies:read", + "accesspolicies:write", + "accesspolicies:delete", + "datadog:validate", + } + + accessPolicyConfig := testAccCloudAccessPolicyTokenConfigBasic(randomName, randomName+"display", "us", scopes, expiresAt) + setItemMatcher := func(s *terraform.State) error { + return resource.TestCheckTypeSetElemNestedAttrs("data.grafana_cloud_access_policies.test", "access_policies.*", map[string]string{ + "id": *policy.Id, + "region": "us", + "name": randomName, + "display_name": randomName + "display", + "status": *policy.Status, + })(s) + } + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + CheckDestroy: testAccCloudAccessPolicyCheckDestroy("us", &policy), + Steps: []resource.TestStep{ + // Test without filters + { + Config: accessPolicyConfig + testAccDataSourceAccessPoliciesConfigBasic(nil, nil), + Check: resource.ComposeTestCheckFunc( + testAccCloudAccessPolicyCheckExists("grafana_cloud_access_policy.test", &policy), + + resource.TestCheckNoResourceAttr("data.grafana_cloud_access_policies.test", "name_filter"), + resource.TestCheckNoResourceAttr("data.grafana_cloud_access_policies.test", "region_filter"), + setItemMatcher, + ), + }, + // Test with name filter + { + Config: accessPolicyConfig + testAccDataSourceAccessPoliciesConfigBasic(&randomName, nil), + Check: resource.ComposeTestCheckFunc( + testAccCloudAccessPolicyCheckExists("grafana_cloud_access_policy.test", &policy), + + resource.TestCheckResourceAttr("data.grafana_cloud_access_policies.test", "name_filter", randomName), + resource.TestCheckNoResourceAttr("data.grafana_cloud_access_policies.test", "region_filter"), + resource.TestCheckResourceAttr("data.grafana_cloud_access_policies.test", "access_policies.#", "1"), + setItemMatcher, + ), + }, + // Test with region filter + { + Config: accessPolicyConfig + testAccDataSourceAccessPoliciesConfigBasic(nil, common.Ref("us")), + Check: resource.ComposeTestCheckFunc( + testAccCloudAccessPolicyCheckExists("grafana_cloud_access_policy.test", &policy), + + resource.TestCheckNoResourceAttr("data.grafana_cloud_access_policies.test", "name_filter"), + resource.TestCheckResourceAttr("data.grafana_cloud_access_policies.test", "region_filter", "us"), + setItemMatcher, + ), + }, + // Test with name and region filter + { + Config: accessPolicyConfig + testAccDataSourceAccessPoliciesConfigBasic(&randomName, common.Ref("us")), + Check: resource.ComposeTestCheckFunc( + testAccCloudAccessPolicyCheckExists("grafana_cloud_access_policy.test", &policy), + + resource.TestCheckResourceAttr("data.grafana_cloud_access_policies.test", "name_filter", randomName), + resource.TestCheckResourceAttr("data.grafana_cloud_access_policies.test", "region_filter", "us"), + resource.TestCheckResourceAttr("data.grafana_cloud_access_policies.test", "access_policies.#", "1"), + setItemMatcher, + ), + }, + // Test with non-matching name filter + { + Config: accessPolicyConfig + testAccDataSourceAccessPoliciesConfigBasic(common.Ref("nonexistent"), nil), + Check: resource.ComposeTestCheckFunc( + testAccCloudAccessPolicyCheckExists("grafana_cloud_access_policy.test", &policy), + + resource.TestCheckResourceAttr("data.grafana_cloud_access_policies.test", "name_filter", "nonexistent"), + resource.TestCheckNoResourceAttr("data.grafana_cloud_access_policies.test", "region_filter"), + resource.TestCheckResourceAttr("data.grafana_cloud_access_policies.test", "access_policies.#", "0"), + ), + }, + }, + }) +} + +func testAccDataSourceAccessPoliciesConfigBasic(name *string, region *string) string { + regionAttr := "" + if region != nil { + regionAttr = fmt.Sprintf("region_filter = %q", *region) + } + + nameAttr := "" + if name != nil { + nameAttr = fmt.Sprintf("name_filter = %q", *name) + } + + return fmt.Sprintf(` +data "grafana_cloud_access_policies" "test" { + depends_on = [grafana_cloud_access_policy.test] + %s + %s +} +`, regionAttr, nameAttr) +} diff --git a/internal/resources/cloud/resource_cloud_access_policy.go b/internal/resources/cloud/resource_cloud_access_policy.go index 84b236b0e..3f919add0 100644 --- a/internal/resources/cloud/resource_cloud_access_policy.go +++ b/internal/resources/cloud/resource_cloud_access_policy.go @@ -135,7 +135,9 @@ Required access policy scopes: "grafana_cloud_access_policy", resourceAccessPolicyID, schema, - ).WithLister(cloudListerFunction(listAccessPolicies)) + ). + WithLister(cloudListerFunction(listAccessPolicies)). + WithPreferredResourceNameField("name") } func listAccessPolicies(ctx context.Context, client *gcom.APIClient, data *ListerData) ([]string, error) { diff --git a/internal/resources/cloud/resource_cloud_access_policy_token_test.go b/internal/resources/cloud/resource_cloud_access_policy_token_test.go index 54e9ffa3a..4c963fe1b 100644 --- a/internal/resources/cloud/resource_cloud_access_policy_token_test.go +++ b/internal/resources/cloud/resource_cloud_access_policy_token_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strconv" "strings" "time" @@ -13,6 +14,7 @@ import ( "github.com/grafana/terraform-provider-grafana/v3/internal/common" "github.com/grafana/terraform-provider-grafana/v3/internal/resources/cloud" "github.com/grafana/terraform-provider-grafana/v3/internal/testutils" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -38,6 +40,11 @@ func TestResourceAccessPolicyToken_Basic(t *testing.T) { "metrics:write", } + randomName := acctest.RandStringFromCharSet(6, acctest.CharSetAlpha) + initialName := fmt.Sprintf("initial-%s", randomName) + initialToken := fmt.Sprintf("token-%s", initialName) + updatedName := fmt.Sprintf("updated-%s", randomName) + resource.Test(t, resource.TestCase{ ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, CheckDestroy: resource.ComposeTestCheckFunc( @@ -46,13 +53,13 @@ func TestResourceAccessPolicyToken_Basic(t *testing.T) { ), Steps: []resource.TestStep{ { - Config: testAccCloudAccessPolicyTokenConfigBasic("initial", "", "us", initialScopes, expiresAt), + Config: testAccCloudAccessPolicyTokenConfigBasic(initialName, "", "us", initialScopes, expiresAt), Check: resource.ComposeTestCheckFunc( testAccCloudAccessPolicyCheckExists("grafana_cloud_access_policy.test", &policy), testAccCloudAccessPolicyTokenCheckExists("grafana_cloud_access_policy_token.test", &policyToken), - resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "name", "initial"), - resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "display_name", "initial"), + resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "name", initialName), + resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "display_name", initialName), resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "scopes.#", "6"), resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "scopes.0", "accesspolicies:delete"), resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "scopes.1", "accesspolicies:read"), @@ -63,37 +70,54 @@ func TestResourceAccessPolicyToken_Basic(t *testing.T) { resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "realm.#", "1"), resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "realm.0.type", "org"), - resource.TestCheckResourceAttr("grafana_cloud_access_policy_token.test", "name", "token-initial"), - resource.TestCheckResourceAttr("grafana_cloud_access_policy_token.test", "display_name", "token-initial"), + resource.TestCheckResourceAttr("grafana_cloud_access_policy_token.test", "name", initialToken), + resource.TestCheckResourceAttr("grafana_cloud_access_policy_token.test", "display_name", initialToken), resource.TestCheckResourceAttr("grafana_cloud_access_policy_token.test", "expires_at", expiresAt), ), }, { - Config: testAccCloudAccessPolicyTokenConfigBasic("initial", "updated", "us", updatedScopes, expiresAt), + Config: testAccCloudAccessPolicyTokenConfigBasic(initialName, "", "us", initialScopes, expiresAt), + PreConfig: func() { + orgID, err := strconv.ParseInt(*policy.OrgId, 10, 32) + if err != nil { + t.Fatal(err) + } + client := testutils.Provider.Meta().(*common.Client).GrafanaCloudAPI + _, _, err = client.TokensAPI.DeleteToken(context.Background(), *policyToken.Id). + Region("us"). + OrgId(int32(orgID)). + XRequestId("deleting-token").Execute() + if err != nil { + t.Fatalf("error getting cloud access policy: %s", err) + } + }, + }, + { + Config: testAccCloudAccessPolicyTokenConfigBasic(initialName, "updated", "us", updatedScopes, expiresAt), Check: resource.ComposeTestCheckFunc( testAccCloudAccessPolicyCheckExists("grafana_cloud_access_policy.test", &policy), testAccCloudAccessPolicyTokenCheckExists("grafana_cloud_access_policy_token.test", &policyToken), - resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "name", "initial"), + resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "name", initialName), resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "display_name", "updated"), resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "scopes.#", "1"), resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "scopes.0", "metrics:write"), resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "realm.#", "1"), resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "realm.0.type", "org"), - resource.TestCheckResourceAttr("grafana_cloud_access_policy_token.test", "name", "token-initial"), + resource.TestCheckResourceAttr("grafana_cloud_access_policy_token.test", "name", initialToken), resource.TestCheckResourceAttr("grafana_cloud_access_policy_token.test", "display_name", "updated"), resource.TestCheckResourceAttr("grafana_cloud_access_policy_token.test", "expires_at", expiresAt), ), }, // Recreate { - Config: testAccCloudAccessPolicyTokenConfigBasic("updated", "updated", "us", updatedScopes, expiresAt), + Config: testAccCloudAccessPolicyTokenConfigBasic(updatedName, "updated", "us", updatedScopes, expiresAt), Check: resource.ComposeTestCheckFunc( testAccCloudAccessPolicyCheckExists("grafana_cloud_access_policy.test", &policy), testAccCloudAccessPolicyTokenCheckExists("grafana_cloud_access_policy_token.test", &policyToken), - resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "name", "updated"), + resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "name", updatedName), resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "display_name", "updated"), resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "scopes.#", "1"), resource.TestCheckResourceAttr("grafana_cloud_access_policy.test", "scopes.0", "metrics:write"), @@ -123,11 +147,12 @@ func TestResourceAccessPolicyToken_NoExpiration(t *testing.T) { var policy gcom.AuthAccessPolicy var policyToken gcom.AuthToken + randomName := fmt.Sprintf("initial-no-expiration-%s", acctest.RandStringFromCharSet(6, acctest.CharSetAlpha)) resource.Test(t, resource.TestCase{ ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, Steps: []resource.TestStep{ { - Config: testAccCloudAccessPolicyTokenConfigBasic("initial-no-expiration", "", "us", []string{"metrics:read"}, ""), + Config: testAccCloudAccessPolicyTokenConfigBasic(randomName, "", "us", []string{"metrics:read"}, ""), Check: resource.ComposeTestCheckFunc( testAccCloudAccessPolicyCheckExists("grafana_cloud_access_policy.test", &policy), testAccCloudAccessPolicyTokenCheckExists("grafana_cloud_access_policy_token.test", &policyToken), diff --git a/internal/resources/cloud/resource_cloud_stack.go b/internal/resources/cloud/resource_cloud_stack.go index 7acef49d7..dfc4e9849 100644 --- a/internal/resources/cloud/resource_cloud_stack.go +++ b/internal/resources/cloud/resource_cloud_stack.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "log" "net/http" "net/url" "regexp" @@ -205,7 +204,9 @@ Required access policy scopes: "grafana_cloud_stack", resourceStackID, schema, - ).WithLister(cloudListerFunction(listStacks)) + ). + WithLister(cloudListerFunction(listStacks)). + WithPreferredResourceNameField("name") } func listStacks(ctx context.Context, client *gcom.APIClient, data *ListerData) ([]string, error) { @@ -338,9 +339,7 @@ func readStack(ctx context.Context, d *schema.ResourceData, client *gcom.APIClie } if stack.Status == "deleted" { - log.Printf("[WARN] removing stack %s from state because it was deleted outside of Terraform", stack.Name) - d.SetId("") - return nil + return common.WarnMissing("stack", d) } connectionsReq := client.InstancesAPI.GetConnections(ctx, id.(string)) @@ -448,25 +447,37 @@ func waitForStackReadiness(ctx context.Context, timeout time.Duration, stackURL return diag.FromErr(joinErr) } err := retry.RetryContext(ctx, timeout, func() *retry.RetryError { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + // Query the instance URL directly. This makes the stack wake-up if it has been paused. + // The health endpoint is helpful to check that the stack is ready, but it doesn't wake up the stack. + stackReq, err := http.NewRequestWithContext(ctx, http.MethodGet, stackURL, nil) + if err != nil { + return retry.NonRetryableError(err) + } + stackResp, err := http.DefaultClient.Do(stackReq) + if err != nil { + return retry.RetryableError(err) + } + defer stackResp.Body.Close() + + healthReq, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) if err != nil { return retry.NonRetryableError(err) } - resp, err := http.DefaultClient.Do(req) + healthResp, err := http.DefaultClient.Do(healthReq) if err != nil { return retry.RetryableError(err) } - defer resp.Body.Close() - if resp.StatusCode != 200 { + defer healthResp.Body.Close() + if healthResp.StatusCode != 200 { buf := new(bytes.Buffer) body := "" - _, err = buf.ReadFrom(resp.Body) + _, err = buf.ReadFrom(healthResp.Body) if err != nil { body = "unable to read response body, error: " + err.Error() } else { body = buf.String() } - return retry.RetryableError(fmt.Errorf("stack was not ready in %s. Status code: %d, Body: %s", timeout, resp.StatusCode, body)) + return retry.RetryableError(fmt.Errorf("stack was not ready in %s. Status code: %d, Body: %s", timeout, healthResp.StatusCode, body)) } return nil diff --git a/internal/resources/cloud/resource_cloud_stack_service_account.go b/internal/resources/cloud/resource_cloud_stack_service_account.go index 8797fb120..cc639eb27 100644 --- a/internal/resources/cloud/resource_cloud_stack_service_account.go +++ b/internal/resources/cloud/resource_cloud_stack_service_account.go @@ -124,8 +124,7 @@ func readStackServiceAccount(ctx context.Context, d *schema.ResourceData, cloudC resp, httpResp, err := cloudClient.InstancesAPI.GetInstanceServiceAccount(ctx, stackSlug, strconv.FormatInt(serviceAccountID, 10)).Execute() if httpResp != nil && httpResp.StatusCode == 404 { - d.SetId("") - return nil + return common.WarnMissing("stack service account", d) } if err != nil { return diag.FromErr(err) diff --git a/internal/resources/cloud/resource_cloud_stack_service_account_token.go b/internal/resources/cloud/resource_cloud_stack_service_account_token.go index d27825047..01bb0169e 100644 --- a/internal/resources/cloud/resource_cloud_stack_service_account_token.go +++ b/internal/resources/cloud/resource_cloud_stack_service_account_token.go @@ -3,7 +3,6 @@ package cloud import ( "context" "fmt" - "log" "strconv" "time" @@ -155,10 +154,7 @@ func stackServiceAccountTokenRead(ctx context.Context, d *schema.ResourceData, c } } - log.Printf("[WARN] removing service account token %d from state because it no longer exists in grafana", id) - d.SetId("") - - return nil + return common.WarnMissing("stack service account token", d) } func stackServiceAccountTokenDelete(ctx context.Context, d *schema.ResourceData, cloudClient *gcom.APIClient) diag.Diagnostics { diff --git a/internal/resources/cloud/resource_synthetic_monitoring_installation.go b/internal/resources/cloud/resource_synthetic_monitoring_installation.go index e825d7dd0..6d29d9ede 100644 --- a/internal/resources/cloud/resource_synthetic_monitoring_installation.go +++ b/internal/resources/cloud/resource_synthetic_monitoring_installation.go @@ -3,7 +3,6 @@ package cloud import ( "context" "fmt" - "log" "strings" "github.com/grafana/grafana-com-public-clients/go/gcom" @@ -114,8 +113,7 @@ func resourceInstallationRead(ctx context.Context, d *schema.ResourceData, meta apiURL := strings.Split(d.Id(), ";")[0] tempClient := SMAPI.NewClient(apiURL, d.Get("sm_access_token").(string), nil) if err := tempClient.ValidateToken(ctx); err != nil { - log.Printf("[WARN] removing SM installation from state because it is no longer valid") - d.SetId("") + return common.WarnMissing("synthetic monitoring installation", d) } return nil @@ -124,9 +122,6 @@ func resourceInstallationRead(ctx context.Context, d *schema.ResourceData, meta func resourceInstallationDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { apiURL := strings.Split(d.Id(), ";")[0] tempClient := SMAPI.NewClient(apiURL, d.Get("sm_access_token").(string), nil) - if err := tempClient.DeleteToken(ctx); err != nil { - return diag.FromErr(err) - } - d.SetId("") - return nil + err := tempClient.DeleteToken(ctx) + return diag.FromErr(err) } diff --git a/internal/resources/cloud/resources.go b/internal/resources/cloud/resources.go index 7dbc1f578..4129fc1e0 100644 --- a/internal/resources/cloud/resources.go +++ b/internal/resources/cloud/resources.go @@ -5,6 +5,7 @@ import ( ) var DataSources = []*common.DataSource{ + datasourceAccessPolicies(), datasourceIPs(), datasourceOrganization(), datasourceStack(), diff --git a/internal/resources/grafana/common_lister.go b/internal/resources/grafana/common_lister.go index 2d5739395..08922539e 100644 --- a/internal/resources/grafana/common_lister.go +++ b/internal/resources/grafana/common_lister.go @@ -3,6 +3,7 @@ package grafana import ( "context" "fmt" + "strings" "sync" goapi "github.com/grafana/grafana-openapi-client-go/client" @@ -12,14 +13,16 @@ import ( // ListerData is used as the data arg in "ListIDs" functions. It allows getting data common to multiple resources. type ListerData struct { - singleOrg bool - orgIDs []int64 - orgsInit sync.Once + omitSingleOrgID bool + singleOrg bool + orgIDs []int64 + orgsInit sync.Once } -func NewListerData(singleOrg bool) *ListerData { +func NewListerData(singleOrg, omitSingleOrgID bool) *ListerData { return &ListerData{ - singleOrg: singleOrg, + singleOrg: singleOrg, + omitSingleOrgID: omitSingleOrgID, } } @@ -54,8 +57,11 @@ func (ld *ListerData) OrgIDs(client *goapi.GrafanaHTTPAPI) ([]int64, error) { return ld.orgIDs, nil } +type grafanaListerFunc func(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) +type grafanaOrgResourceListerFunc func(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) + // listerFunction is a helper function that wraps a lister function be used more easily in grafana resources. -func listerFunction(listerFunc func(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error)) common.ResourceListIDsFunc { +func listerFunction(listerFunc grafanaListerFunc) common.ResourceListIDsFunc { return func(ctx context.Context, client *common.Client, data any) ([]string, error) { lm, ok := data.(*ListerData) if !ok { @@ -67,3 +73,31 @@ func listerFunction(listerFunc func(ctx context.Context, client *goapi.GrafanaHT return listerFunc(ctx, client.GrafanaAPI, lm) } } + +func listerFunctionOrgResource(listerFunc grafanaOrgResourceListerFunc) common.ResourceListIDsFunc { + return listerFunction(func(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { + orgIDs, err := data.OrgIDs(client) + if err != nil { + return nil, err + } + + var ids []string + for _, orgID := range orgIDs { + idsInOrg, err := listerFunc(ctx, client.Clone().WithOrgID(orgID), orgID) + if err != nil { + return nil, err + } + + // Trim org ID from IDs if there is only one org and it's the default org + if len(orgIDs) == 1 && (orgID <= 1) && data.omitSingleOrgID { + for _, id := range idsInOrg { + ids = append(ids, strings.TrimPrefix(id, fmt.Sprintf("%d:", orgID))) + } + } else { + ids = append(ids, idsInOrg...) + } + } + + return ids, nil + }) +} diff --git a/internal/resources/grafana/common_plugin_framework.go b/internal/resources/grafana/common_plugin_framework.go index 778a85b58..95a1461e2 100644 --- a/internal/resources/grafana/common_plugin_framework.go +++ b/internal/resources/grafana/common_plugin_framework.go @@ -7,6 +7,7 @@ import ( goapi "github.com/grafana/grafana-openapi-client-go/client" "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" frameworkSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -15,6 +16,49 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) +type basePluginFrameworkDataSource struct { + client *goapi.GrafanaHTTPAPI + config *goapi.TransportConfig +} + +func (r *basePluginFrameworkDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Configure is called multiple times (sometimes when ProviderData is not yet available), we only want to configure once + if req.ProviderData == nil || r.client != nil { + return + } + + client, ok := req.ProviderData.(*common.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *common.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client.GrafanaAPI + r.config = client.GrafanaAPIConfig +} + +// clientFromNewOrgResource creates an OpenAPI client from the `org_id` attribute of a resource +// This client is meant to be used in `Create` functions when the ID hasn't already been baked into the resource ID +func (r *basePluginFrameworkDataSource) clientFromNewOrgResource(orgIDStr string) (*goapi.GrafanaHTTPAPI, int64, error) { + if r.client == nil { + return nil, 0, fmt.Errorf("client not configured") + } + + client := r.client.Clone() + orgID, _ := strconv.ParseInt(orgIDStr, 10, 64) + if orgID == 0 { + orgID = client.OrgID() + } else if orgID > 0 { + client = client.WithOrgID(orgID) + } + return client, orgID, nil +} + type basePluginFrameworkResource struct { client *goapi.GrafanaHTTPAPI config *goapi.TransportConfig @@ -98,7 +142,7 @@ func pluginFrameworkOrgIDAttribute() frameworkSchema.Attribute { return frameworkSchema.StringAttribute{ Optional: true, Computed: true, - Description: "The Organization ID. If not set, the Org ID defined in the provider block will be used.", + Description: "The Organization ID. If not set, the default organization is used for basic authentication, or the one that owns your service account for token authentication.", PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), &orgIDAttributePlanModifier{}, diff --git a/internal/resources/grafana/data_source_data_source.go b/internal/resources/grafana/data_source_data_source.go index c9988b491..e63324264 100644 --- a/internal/resources/grafana/data_source_data_source.go +++ b/internal/resources/grafana/data_source_data_source.go @@ -44,11 +44,17 @@ func datasourceDatasourceRead(ctx context.Context, d *schema.ResourceData, meta resp, err = client.Datasources.GetDataSourceByName(name.(string)) } else if uid, ok := d.GetOk("uid"); ok { resp, err = client.Datasources.GetDataSourceByUID(uid.(string)) + } else { + return diag.Errorf("name or uid must be set") } if err != nil { return diag.FromErr(err) } + if resp == nil { + return diag.Errorf("unexpected state, API response is nil") + } + return datasourceToState(d, resp.GetPayload()) } diff --git a/internal/resources/grafana/data_source_library_panels.go b/internal/resources/grafana/data_source_library_panels.go new file mode 100644 index 000000000..8223da30f --- /dev/null +++ b/internal/resources/grafana/data_source_library_panels.go @@ -0,0 +1,106 @@ +package grafana + +import ( + "context" + "encoding/json" + + "github.com/grafana/grafana-openapi-client-go/client/library_elements" + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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/types" +) + +var dataSourceLibraryPanelsName = "grafana_library_panels" + +func datasourceLibraryPanels() *common.DataSource { + return common.NewDataSource( + common.CategoryGrafanaOSS, + dataSourceLibraryPanelsName, + &libraryPanelsDataSource{}, + ) +} + +type libraryPanelsDataSource struct { + basePluginFrameworkDataSource +} + +func (r *libraryPanelsDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = dataSourceLibraryPanelsName +} + +func (r *libraryPanelsDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "org_id": pluginFrameworkOrgIDAttribute(), + "panels": schema.SetAttribute{ + Computed: true, + ElementType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "name": types.StringType, + "uid": types.StringType, + "description": types.StringType, + "folder_uid": types.StringType, + "model_json": types.StringType, + }, + }, + }, + }, + } +} + +type libraryPanelsDataSourcePanelModel struct { + Name types.String `tfsdk:"name"` + UID types.String `tfsdk:"uid"` + Description types.String `tfsdk:"description"` + FolderUID types.String `tfsdk:"folder_uid"` + ModelJSON types.String `tfsdk:"model_json"` +} + +type libraryPanelsDataSourceModel struct { + ID types.String `tfsdk:"id"` + OrgID types.String `tfsdk:"org_id"` + Panels []libraryPanelsDataSourcePanelModel `tfsdk:"panels"` +} + +func (r *libraryPanelsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + // Read Terraform state data into the model + var data libraryPanelsDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + // Read from API + client, _, err := r.clientFromNewOrgResource(data.OrgID.ValueString()) + if err != nil { + resp.Diagnostics = diag.Diagnostics{diag.NewErrorDiagnostic("Failed to create client", err.Error())} + return + } + params := library_elements.NewGetLibraryElementsParams().WithKind(common.Ref(libraryPanelKind)) + apiResp, err := client.LibraryElements.GetLibraryElements(params) + if err != nil { + resp.Diagnostics = diag.Diagnostics{diag.NewErrorDiagnostic("Failed to get library panels", err.Error())} + return + } + for _, panel := range apiResp.Payload.Result.Elements { + modelJSONBytes, err := json.Marshal(panel.Model) + if err != nil { + resp.Diagnostics = diag.Diagnostics{diag.NewErrorDiagnostic("Failed to get library panel JSON", err.Error())} + return + } + data.Panels = append(data.Panels, libraryPanelsDataSourcePanelModel{ + Name: types.StringValue(panel.Name), + UID: types.StringValue(panel.UID), + Description: types.StringValue(panel.Description), + FolderUID: types.StringValue(panel.Meta.FolderUID), + ModelJSON: types.StringValue(string(modelJSONBytes)), + }) + } + data.ID = types.StringValue(data.OrgID.ValueString()) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, data)...) +} diff --git a/internal/resources/grafana/data_source_library_panels_test.go b/internal/resources/grafana/data_source_library_panels_test.go new file mode 100644 index 000000000..38a0c3555 --- /dev/null +++ b/internal/resources/grafana/data_source_library_panels_test.go @@ -0,0 +1,38 @@ +package grafana_test + +import ( + "testing" + + "github.com/grafana/terraform-provider-grafana/v3/internal/testutils" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccDatasourceLibraryPanels_basic(t *testing.T) { + testutils.CheckOSSTestsEnabled(t, ">=8.0.0") + + randomName := acctest.RandString(10) + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testutils.TestAccExampleWithReplace(t, "data-sources/grafana_library_panels/data-source.tf", map[string]string{ + "panelname": randomName, + }), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckTypeSetElemNestedAttrs("data.grafana_library_panels.all", "panels.*", map[string]string{ + "description": "test description", + "folder_uid": "", + "panels.0.name": randomName, + }), + resource.TestCheckTypeSetElemNestedAttrs("data.grafana_library_panels.all", "panels.*", map[string]string{ + "description": "", + "folder_uid": randomName + "-folder", + "panels.0.name": randomName + " In Folder", + }), + ), + }, + }, + }) +} diff --git a/internal/resources/grafana/data_source_user.go b/internal/resources/grafana/data_source_user.go index 2b1897bd8..55813ce98 100644 --- a/internal/resources/grafana/data_source_user.go +++ b/internal/resources/grafana/data_source_user.go @@ -18,6 +18,7 @@ func datasourceUser() *common.DataSource { This data source uses Grafana's admin APIs for reading users which does not currently work with API Tokens. You must use basic auth. +This data source is also not compatible with Grafana Cloud, as it does not allow basic auth. `, ReadContext: dataSourceUserRead, Schema: map[string]*schema.Schema{ diff --git a/internal/resources/grafana/data_source_users.go b/internal/resources/grafana/data_source_users.go index 996778b87..93200aedf 100644 --- a/internal/resources/grafana/data_source_users.go +++ b/internal/resources/grafana/data_source_users.go @@ -24,6 +24,7 @@ func datasourceUsers() *common.DataSource { This data source uses Grafana's admin APIs for reading users which does not currently work with API Tokens. You must use basic auth. +This data source is also not compatible with Grafana Cloud, as it does not allow basic auth. `, Schema: map[string]*schema.Schema{ diff --git a/internal/resources/grafana/resource_alerting_contact_point.go b/internal/resources/grafana/resource_alerting_contact_point.go index ff7aa644c..9f43d236c 100644 --- a/internal/resources/grafana/resource_alerting_contact_point.go +++ b/internal/resources/grafana/resource_alerting_contact_point.go @@ -8,6 +8,7 @@ import ( "time" "github.com/go-openapi/runtime" + goapi "github.com/grafana/grafana-openapi-client-go/client" "github.com/grafana/grafana-openapi-client-go/client/provisioning" "github.com/grafana/grafana-openapi-client-go/models" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -49,7 +50,7 @@ func resourceContactPoint() *common.Resource { Description: ` Manages Grafana Alerting contact points. -* [Official documentation](https://grafana.com/docs/grafana/next/alerting/fundamentals/notifications/contact-points/) +* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/) * [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#contact-points) This resource requires Grafana 9.1.0 or later. @@ -103,48 +104,37 @@ This resource requires Grafana 9.1.0 or later. "grafana_contact_point", orgResourceIDString("name"), resource, - ) + ).WithLister(listerFunctionOrgResource(listContactPoints)) } -// TODO: Fix contact points lister. Terraform doesn't read any of the sensitive fields (or their container) -// It outputs an empty `email {}` block for example, which is not valid. -// func listContactPoints(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { -// orgIDs, err := data.OrgIDs(client) -// if err != nil { -// return nil, err -// } - -// idMap := map[string]bool{} -// for _, orgID := range orgIDs { -// client = client.Clone().WithOrgID(orgID) - -// // Retry if the API returns 500 because it may be that the alertmanager is not ready in the org yet. -// // The alertmanager is provisioned asynchronously when the org is created. -// if err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { -// resp, err := client.Provisioning.GetContactpoints(provisioning.NewGetContactpointsParams()) -// if err != nil { -// if orgID > 1 && (err.(*runtime.APIError).IsCode(500) || err.(*runtime.APIError).IsCode(403)) { -// return retry.RetryableError(err) -// } -// return retry.NonRetryableError(err) -// } - -// for _, contactPoint := range resp.Payload { -// idMap[MakeOrgResourceID(orgID, contactPoint.Name)] = true -// } -// return nil -// }); err != nil { -// return nil, err -// } -// } - -// var ids []string -// for id := range idMap { -// ids = append(ids, id) -// } - -// return ids, nil -// } +func listContactPoints(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { + idMap := map[string]bool{} + // Retry if the API returns 500 because it may be that the alertmanager is not ready in the org yet. + // The alertmanager is provisioned asynchronously when the org is created. + if err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { + resp, err := client.Provisioning.GetContactpoints(provisioning.NewGetContactpointsParams()) + if err != nil { + if orgID > 1 && (err.(*runtime.APIError).IsCode(500) || err.(*runtime.APIError).IsCode(403)) { + return retry.RetryableError(err) + } + return retry.NonRetryableError(err) + } + + for _, contactPoint := range resp.Payload { + idMap[MakeOrgResourceID(orgID, contactPoint.Name)] = true + } + return nil + }); err != nil { + return nil, err + } + + var ids []string + for id := range idMap { + ids = append(ids, id) + } + + return ids, nil +} func readContactPoint(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { client, orgID, name := OAPIClientFromExistingOrgResource(meta, data.Id()) diff --git a/internal/resources/grafana/resource_alerting_message_template.go b/internal/resources/grafana/resource_alerting_message_template.go index e1cac1b18..e356a18e6 100644 --- a/internal/resources/grafana/resource_alerting_message_template.go +++ b/internal/resources/grafana/resource_alerting_message_template.go @@ -21,8 +21,8 @@ func resourceMessageTemplate() *common.Resource { Description: ` Manages Grafana Alerting message templates. -* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/template-notifications/create-notification-templates/) -* [HTTP API](https://grafana.com/docs/grafana/next/developers/http_api/alerting_provisioning/#templates) +* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/) +* [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#templates) This resource requires Grafana 9.1.0 or later. `, @@ -66,37 +66,28 @@ This resource requires Grafana 9.1.0 or later. "grafana_message_template", orgResourceIDString("name"), schema, - ).WithLister(listerFunction(listMessageTemplate)) + ).WithLister(listerFunctionOrgResource(listMessageTemplate)) } -func listMessageTemplate(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { - orgIDs, err := data.OrgIDs(client) - if err != nil { - return nil, err - } - +func listMessageTemplate(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { var ids []string - for _, orgID := range orgIDs { - client = client.Clone().WithOrgID(orgID) - - // Retry if the API returns 500 because it may be that the alertmanager is not ready in the org yet. - // The alertmanager is provisioned asynchronously when the org is created. - if err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { - resp, err := client.Provisioning.GetTemplates() - if err != nil { - if orgID > 1 && (err.(*runtime.APIError).IsCode(500) || err.(*runtime.APIError).IsCode(403)) { - return retry.RetryableError(err) - } - return retry.NonRetryableError(err) + // Retry if the API returns 500 because it may be that the alertmanager is not ready in the org yet. + // The alertmanager is provisioned asynchronously when the org is created. + if err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { + resp, err := client.Provisioning.GetTemplates() + if err != nil { + if orgID > 1 && (err.(*runtime.APIError).IsCode(500) || err.(*runtime.APIError).IsCode(403)) { + return retry.RetryableError(err) } + return retry.NonRetryableError(err) + } - for _, template := range resp.Payload { - ids = append(ids, MakeOrgResourceID(orgID, template.Name)) - } - return nil - }); err != nil { - return nil, err + for _, template := range resp.Payload { + ids = append(ids, MakeOrgResourceID(orgID, template.Name)) } + return nil + }); err != nil { + return nil, err } return ids, nil diff --git a/internal/resources/grafana/resource_alerting_message_template_test.go b/internal/resources/grafana/resource_alerting_message_template_test.go index abb7ed1f5..897ee76a5 100644 --- a/internal/resources/grafana/resource_alerting_message_template_test.go +++ b/internal/resources/grafana/resource_alerting_message_template_test.go @@ -27,6 +27,7 @@ func TestAccMessageTemplate_basic(t *testing.T) { alertingMessageTemplateCheckExists.exists("grafana_message_template.my_template", &tmpl), resource.TestCheckResourceAttr("grafana_message_template.my_template", "name", "My Reusable Template"), resource.TestCheckResourceAttr("grafana_message_template.my_template", "template", "{{define \"My Reusable Template\" }}\n template content\n{{ end }}"), + testutils.CheckLister("grafana_message_template.my_template"), ), }, // Test import. diff --git a/internal/resources/grafana/resource_alerting_mute_timing.go b/internal/resources/grafana/resource_alerting_mute_timing.go index 6c637f10d..9193fce0c 100644 --- a/internal/resources/grafana/resource_alerting_mute_timing.go +++ b/internal/resources/grafana/resource_alerting_mute_timing.go @@ -22,8 +22,8 @@ func resourceMuteTiming() *common.Resource { Description: ` Manages Grafana Alerting mute timings. -* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/mute-timings/) -* [HTTP API](https://grafana.com/docs/grafana/next/developers/http_api/alerting_provisioning/#mute-timings) +* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/) +* [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#mute-timings) This resource requires Grafana 9.1.0 or later. `, @@ -133,37 +133,28 @@ This resource requires Grafana 9.1.0 or later. "grafana_mute_timing", orgResourceIDString("name"), schema, - ).WithLister(listerFunction(listMuteTimings)) + ).WithLister(listerFunctionOrgResource(listMuteTimings)) } -func listMuteTimings(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { - orgIDs, err := data.OrgIDs(client) - if err != nil { - return nil, err - } - +func listMuteTimings(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { var ids []string - for _, orgID := range orgIDs { - client = client.Clone().WithOrgID(orgID) - - // Retry if the API returns 500 because it may be that the alertmanager is not ready in the org yet. - // The alertmanager is provisioned asynchronously when the org is created. - if err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { - resp, err := client.Provisioning.GetMuteTimings() - if err != nil { - if orgID > 1 && (err.(*runtime.APIError).IsCode(500) || err.(*runtime.APIError).IsCode(403)) { - return retry.RetryableError(err) - } - return retry.NonRetryableError(err) + // Retry if the API returns 500 because it may be that the alertmanager is not ready in the org yet. + // The alertmanager is provisioned asynchronously when the org is created. + if err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { + resp, err := client.Provisioning.GetMuteTimings() + if err != nil { + if orgID > 1 && (err.(*runtime.APIError).IsCode(500) || err.(*runtime.APIError).IsCode(403)) { + return retry.RetryableError(err) } + return retry.NonRetryableError(err) + } - for _, muteTiming := range resp.Payload { - ids = append(ids, MakeOrgResourceID(orgID, muteTiming.Name)) - } - return nil - }); err != nil { - return nil, err + for _, muteTiming := range resp.Payload { + ids = append(ids, MakeOrgResourceID(orgID, muteTiming.Name)) } + return nil + }); err != nil { + return nil, err } return ids, nil @@ -261,7 +252,36 @@ func deleteMuteTiming(ctx context.Context, data *schema.ResourceData, meta inter } } - _, err = client.Provisioning.DeleteMuteTiming(name) + // Remove the mute timing from alert rules + ruleResp, err := client.Provisioning.GetAlertRules() + if err != nil { + return diag.FromErr(err) + } + rules := ruleResp.Payload + for _, rule := range rules { + if rule.NotificationSettings == nil { + continue + } + + var muteTimeIntervals []string + for _, m := range rule.NotificationSettings.MuteTimeIntervals { + if m != name { + muteTimeIntervals = append(muteTimeIntervals, m) + } + } + if len(muteTimeIntervals) != len(rule.NotificationSettings.MuteTimeIntervals) { + rule.NotificationSettings.MuteTimeIntervals = muteTimeIntervals + params := provisioning.NewPutAlertRuleParams().WithBody(rule).WithUID(rule.UID) + _, err = client.Provisioning.PutAlertRule(params) + if err != nil { + return diag.FromErr(err) + } + } + } + + // Delete the mute timing + params := provisioning.NewDeleteMuteTimingParams().WithName(name) + _, err = client.Provisioning.DeleteMuteTiming(params) diag, _ := common.CheckReadError("mute timing", data, err) return diag } diff --git a/internal/resources/grafana/resource_alerting_mute_timing_test.go b/internal/resources/grafana/resource_alerting_mute_timing_test.go index 8c1f2efc4..c8bfb3db7 100644 --- a/internal/resources/grafana/resource_alerting_mute_timing_test.go +++ b/internal/resources/grafana/resource_alerting_mute_timing_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/grafana/grafana-openapi-client-go/models" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/grafana/terraform-provider-grafana/v3/internal/testutils" @@ -37,6 +38,7 @@ func TestAccMuteTiming_basic(t *testing.T) { resource.TestCheckResourceAttr("grafana_mute_timing.my_mute_timing", "intervals.0.years.0", "2030"), resource.TestCheckResourceAttr("grafana_mute_timing.my_mute_timing", "intervals.0.years.1", "2025:2026"), resource.TestCheckResourceAttr("grafana_mute_timing.my_mute_timing", "intervals.0.location", "America/New_York"), + testutils.CheckLister("grafana_mute_timing.my_mute_timing"), ), }, // Test import. @@ -142,7 +144,7 @@ func TestAccMuteTiming_RemoveInUse(t *testing.T) { contact_point = grafana_contact_point.default_policy.name } } - + resource "grafana_mute_timing" "test" { count = local.use_mute ? 1 : 0 org_id = grafana_organization.my_org.id @@ -163,3 +165,77 @@ func TestAccMuteTiming_RemoveInUse(t *testing.T) { }, }) } + +func TestAccMuteTiming_RemoveInUseInAlertRule(t *testing.T) { + testutils.CheckCloudInstanceTestsEnabled(t) // TODO: Switch to OSS when this is released: https://github.com/grafana/grafana/pull/90500 + + randomStr := acctest.RandString(6) + + config := func(mute bool) string { + return fmt.Sprintf(` + locals { + use_mute = %[2]t + } + + resource "grafana_folder" "rule_folder" { + title = "%[1]s" + } + + resource "grafana_contact_point" "default_policy" { + name = "%[1]s" + email { + addresses = ["test@example.com"] + } + } + + resource "grafana_rule_group" "this" { + name = "%[1]s" + folder_uid = grafana_folder.rule_folder.uid + interval_seconds = 60 + + rule { + name = "%[1]s" + condition = "B" + notification_settings { + contact_point = grafana_contact_point.default_policy.name + group_by = ["..."] + mute_timings = local.use_mute ? [grafana_mute_timing.test[0].name] : [] + } + data { + ref_id = "A" + query_type = "" + relative_time_range { + from = 600 + to = 0 + } + datasource_uid = "PD8C576611E62080A" + model = jsonencode({ + hide = false + intervalMs = 1000 + maxDataPoints = 43200 + refId = "A" + }) + } + } + } + + + resource "grafana_mute_timing" "test" { + count = local.use_mute ? 1 : 0 + name = "%[1]s" + intervals {} + }`, randomStr, mute) + } + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config(true), + }, + { + Config: config(false), + }, + }, + }) +} diff --git a/internal/resources/grafana/resource_alerting_notification_policy.go b/internal/resources/grafana/resource_alerting_notification_policy.go index 1aa9dca75..54383a13b 100644 --- a/internal/resources/grafana/resource_alerting_notification_policy.go +++ b/internal/resources/grafana/resource_alerting_notification_policy.go @@ -24,8 +24,8 @@ Sets the global notification policy for Grafana. !> This resource manages the entire notification policy tree, and will overwrite any existing policies. -* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/) -* [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/) +* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/) +* [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#notification-policies) This resource requires Grafana 9.1.0 or later. `, @@ -91,7 +91,7 @@ This resource requires Grafana 9.1.0 or later. "grafana_notification_policy", orgResourceIDString("anyString"), schema, - ).WithLister(listerFunction(listNotificationPolicies)) + ).WithLister(listerFunctionOrgResource(listNotificationPolicies)) } // The maximum depth of policy tree that the provider supports, as Terraform does not allow for infinitely recursive schemas. @@ -191,35 +191,26 @@ func policySchema(depth uint) *schema.Resource { return resource } -func listNotificationPolicies(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { - orgIDs, err := data.OrgIDs(client) - if err != nil { - return nil, err - } - +func listNotificationPolicies(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { var ids []string - for _, orgID := range orgIDs { - client = client.Clone().WithOrgID(orgID) - - // Retry if the API returns 500 because it may be that the alertmanager is not ready in the org yet. - // The alertmanager is provisioned asynchronously when the org is created. - if err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { - _, err := client.Provisioning.GetPolicyTree() - if err != nil { - if orgID > 1 && (err.(*runtime.APIError).IsCode(500) || err.(*runtime.APIError).IsCode(403)) { - return retry.RetryableError(err) - } - return retry.NonRetryableError(err) + // Retry if the API returns 500 because it may be that the alertmanager is not ready in the org yet. + // The alertmanager is provisioned asynchronously when the org is created. + if err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { + _, err := client.Provisioning.GetPolicyTree() + if err != nil { + if orgID > 1 && (err.(*runtime.APIError).IsCode(500) || err.(*runtime.APIError).IsCode(403)) { + return retry.RetryableError(err) } - - return nil - }); err != nil { - return nil, err + return retry.NonRetryableError(err) } - ids = append(ids, MakeOrgResourceID(orgID, PolicySingletonID)) + return nil + }); err != nil { + return nil, err } + ids = append(ids, MakeOrgResourceID(orgID, PolicySingletonID)) + return ids, nil } diff --git a/internal/resources/grafana/resource_alerting_notification_policy_test.go b/internal/resources/grafana/resource_alerting_notification_policy_test.go index 9c73c7813..0e9b3550c 100644 --- a/internal/resources/grafana/resource_alerting_notification_policy_test.go +++ b/internal/resources/grafana/resource_alerting_notification_policy_test.go @@ -65,6 +65,7 @@ func TestAccNotificationPolicy_basic(t *testing.T) { resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.1.matcher.0.match", "=~"), resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.1.matcher.0.value", "another value.*"), resource.TestCheckResourceAttr("grafana_notification_policy.my_notification_policy", "policy.1.group_by.0", "..."), + testutils.CheckLister("grafana_notification_policy.my_notification_policy"), ), }, // Test import. diff --git a/internal/resources/grafana/resource_alerting_rule_group.go b/internal/resources/grafana/resource_alerting_rule_group.go index 46bb1bc22..4ceef0105 100644 --- a/internal/resources/grafana/resource_alerting_rule_group.go +++ b/internal/resources/grafana/resource_alerting_rule_group.go @@ -34,7 +34,7 @@ func resourceRuleGroup() *common.Resource { Description: ` Manages Grafana Alerting rule groups. -* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/alerting-rules/) +* [Official documentation](https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/terraform-provisioning/) * [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#alert-rules) This resource requires Grafana 9.1.0 or later. @@ -255,37 +255,28 @@ This resource requires Grafana 9.1.0 or later. "grafana_rule_group", resourceRuleGroupID, schema, - ).WithLister(listerFunction(listRuleGroups)) + ).WithLister(listerFunctionOrgResource(listRuleGroups)) } -func listRuleGroups(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { - orgIDs, err := data.OrgIDs(client) - if err != nil { - return nil, err - } - +func listRuleGroups(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { idMap := map[string]bool{} - for _, orgID := range orgIDs { - client = client.Clone().WithOrgID(orgID) - - // Retry if the API returns 500 because it may be that the alertmanager is not ready in the org yet. - // The alertmanager is provisioned asynchronously when the org is created. - if err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { - resp, err := client.Provisioning.GetAlertRules() - if err != nil { - if orgID > 1 && (err.(*runtime.APIError).IsCode(500) || err.(*runtime.APIError).IsCode(403)) { - return retry.RetryableError(err) - } - return retry.NonRetryableError(err) + // Retry if the API returns 500 because it may be that the alertmanager is not ready in the org yet. + // The alertmanager is provisioned asynchronously when the org is created. + if err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { + resp, err := client.Provisioning.GetAlertRules() + if err != nil { + if orgID > 1 && (err.(*runtime.APIError).IsCode(500) || err.(*runtime.APIError).IsCode(403)) { + return retry.RetryableError(err) } + return retry.NonRetryableError(err) + } - for _, rule := range resp.Payload { - idMap[resourceRuleGroupID.Make(orgID, rule.FolderUID, rule.RuleGroup)] = true - } - return nil - }); err != nil { - return nil, err + for _, rule := range resp.Payload { + idMap[resourceRuleGroupID.Make(orgID, rule.FolderUID, rule.RuleGroup)] = true } + return nil + }); err != nil { + return nil, err } var ids []string diff --git a/internal/resources/grafana/resource_alerting_rule_group_test.go b/internal/resources/grafana/resource_alerting_rule_group_test.go index e2dc970cd..4a5edf8b6 100644 --- a/internal/resources/grafana/resource_alerting_rule_group_test.go +++ b/internal/resources/grafana/resource_alerting_rule_group_test.go @@ -34,6 +34,7 @@ func TestAccAlertRule_basic(t *testing.T) { resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "org_id", "1"), resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.#", "1"), resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.0.data.0.model", "{\"hide\":false,\"refId\":\"A\"}"), + testutils.CheckLister("grafana_rule_group.my_alert_rule"), ), }, // Test "for: 0s" diff --git a/internal/resources/grafana/resource_annotation.go b/internal/resources/grafana/resource_annotation.go index 9ff897d1f..2961d7d2c 100644 --- a/internal/resources/grafana/resource_annotation.go +++ b/internal/resources/grafana/resource_annotation.go @@ -84,27 +84,18 @@ func resourceAnnotation() *common.Resource { "grafana_annotation", orgResourceIDInt("id"), schema, - ).WithLister(listerFunction(listAnnotations)) + ).WithLister(listerFunctionOrgResource(listAnnotations)) } -func listAnnotations(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { - orgIDs, err := data.OrgIDs(client) +func listAnnotations(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { + var ids []string + resp, err := client.Annotations.GetAnnotations(annotations.NewGetAnnotationsParams()) if err != nil { return nil, err } - var ids []string - for _, orgID := range orgIDs { - client = client.Clone().WithOrgID(orgID) - - resp, err := client.Annotations.GetAnnotations(annotations.NewGetAnnotationsParams()) - if err != nil { - return nil, err - } - - for _, annotation := range resp.Payload { - ids = append(ids, MakeOrgResourceID(orgID, annotation.ID)) - } + for _, annotation := range resp.Payload { + ids = append(ids, MakeOrgResourceID(orgID, annotation.ID)) } return ids, nil diff --git a/internal/resources/grafana/resource_annotation_test.go b/internal/resources/grafana/resource_annotation_test.go index 1d36631f4..f8d99338c 100644 --- a/internal/resources/grafana/resource_annotation_test.go +++ b/internal/resources/grafana/resource_annotation_test.go @@ -31,6 +31,7 @@ func TestAccAnnotation_basic(t *testing.T) { Check: resource.ComposeTestCheckFunc( annotationsCheckExists.exists("grafana_annotation.test", &annotation), resource.TestCheckResourceAttr("grafana_annotation.test", "text", testAccAnnotationInitialText), + testutils.CheckLister("grafana_annotation.test"), ), }, { diff --git a/internal/resources/grafana/resource_dashboard.go b/internal/resources/grafana/resource_dashboard.go index c6498e7ac..f40ab71e0 100644 --- a/internal/resources/grafana/resource_dashboard.go +++ b/internal/resources/grafana/resource_dashboard.go @@ -99,31 +99,22 @@ Manages Grafana dashboards. "grafana_dashboard", orgResourceIDString("uid"), schema, - ).WithLister(listerFunction(listDashboards)) + ).WithLister(listerFunctionOrgResource(listDashboards)) } -func listDashboards(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { - return listDashboardOrFolder(client, data, "dash-db") +func listDashboards(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { + return listDashboardOrFolder(client, orgID, "dash-db") } -func listDashboardOrFolder(client *goapi.GrafanaHTTPAPI, data *ListerData, searchType string) ([]string, error) { - orgIDs, err := data.OrgIDs(client) +func listDashboardOrFolder(client *goapi.GrafanaHTTPAPI, orgID int64, searchType string) ([]string, error) { + uids := []string{} + resp, err := client.Search.Search(search.NewSearchParams().WithType(common.Ref(searchType))) if err != nil { return nil, err } - uids := []string{} - for _, orgID := range orgIDs { - client = client.Clone().WithOrgID(orgID) - - resp, err := client.Search.Search(search.NewSearchParams().WithType(common.Ref(searchType))) - if err != nil { - return nil, err - } - - for _, item := range resp.Payload { - uids = append(uids, MakeOrgResourceID(orgID, item.UID)) - } + for _, item := range resp.Payload { + uids = append(uids, MakeOrgResourceID(orgID, item.UID)) } return uids, nil diff --git a/internal/resources/grafana/resource_dashboard_test.go b/internal/resources/grafana/resource_dashboard_test.go index 8aa005eaf..1461e8125 100644 --- a/internal/resources/grafana/resource_dashboard_test.go +++ b/internal/resources/grafana/resource_dashboard_test.go @@ -51,6 +51,7 @@ func TestAccDashboard_basic(t *testing.T) { resource.TestCheckResourceAttr( "grafana_dashboard.test", "config_json", expectedInitialConfig, ), + testutils.CheckLister("grafana_dashboard.test"), ), }, { @@ -238,6 +239,7 @@ func TestAccDashboard_inOrg(t *testing.T) { checkResourceIsInOrg("grafana_dashboard.test", "grafana_organization.test"), testAccDashboardCheckExistsInFolder(&dashboard, &folder), + testutils.CheckLister("grafana_dashboard.test"), ), }, }, diff --git a/internal/resources/grafana/resource_data_source.go b/internal/resources/grafana/resource_data_source.go index 1957ebfc0..bc51c6157 100644 --- a/internal/resources/grafana/resource_data_source.go +++ b/internal/resources/grafana/resource_data_source.go @@ -36,7 +36,20 @@ source selected (via the 'type' argument). SchemaVersion: 1, Importer: &schema.ResourceImporter{ - StateContext: schema.ImportStatePassthroughContext, + StateContext: func(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + client, _, idStr := OAPIClientFromExistingOrgResource(meta, d.Id()) + + resp, err := client.Datasources.GetDataSourceByUID(idStr) + if err != nil { + return nil, err + } + + if resp.Payload.ReadOnly { + return nil, fmt.Errorf("this Grafana data source is read-only. It cannot be imported as a resource. Use the `data_grafana_data_source` data source instead") + } + + return schema.ImportStatePassthroughContext(ctx, d, meta) + }, }, Schema: map[string]*schema.Schema{ @@ -114,7 +127,9 @@ source selected (via the 'type' argument). "grafana_data_source", orgResourceIDString("uid"), schema, - ).WithLister(listerFunction(listDatasources)) + ). + WithLister(listerFunctionOrgResource(listDatasources)). + WithPreferredResourceNameField("name") } func datasourceHTTPHeadersAttribute() *schema.Schema { @@ -182,23 +197,18 @@ func datasourceSecureJSONDataAttribute() *schema.Schema { } } -func listDatasources(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { - orgIDs, err := data.OrgIDs(client) +func listDatasources(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { + var ids []string + resp, err := client.Datasources.GetDataSources() if err != nil { return nil, err } - var ids []string - for _, orgID := range orgIDs { - client = client.Clone().WithOrgID(orgID) - resp, err := client.Datasources.GetDataSources() - if err != nil { - return nil, err - } - - for _, ds := range resp.Payload { - ids = append(ids, MakeOrgResourceID(orgID, ds.UID)) + for _, ds := range resp.Payload { + if ds.ReadOnly { + continue } + ids = append(ids, MakeOrgResourceID(orgID, ds.UID)) } return ids, nil diff --git a/internal/resources/grafana/resource_data_source_test.go b/internal/resources/grafana/resource_data_source_test.go index 99c7d4699..3c6269c4e 100644 --- a/internal/resources/grafana/resource_data_source_test.go +++ b/internal/resources/grafana/resource_data_source_test.go @@ -59,6 +59,7 @@ func TestAccDataSource_Loki(t *testing.T) { resource.TestCheckResourceAttr("grafana_data_source.loki", "name", dsName), resource.TestCheckResourceAttr("grafana_data_source.loki", "type", "loki"), resource.TestCheckResourceAttr("grafana_data_source.loki", "url", "http://acc-test.invalid/"), + testutils.CheckLister("grafana_data_source.loki"), func(s *terraform.State) error { jsonData := dataSource.JSONData.(map[string]interface{}) if jsonData["derivedFields"] == nil { @@ -425,6 +426,24 @@ func TestAccDataSource_SeparateConfig(t *testing.T) { }) } +func TestAccDataSource_ImportReadOnly(t *testing.T) { + testutils.CheckCloudInstanceTestsEnabled(t) + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + { + Config: `resource "grafana_data_source" "prometheus" {}`, + ImportState: true, + ResourceName: "grafana_data_source.prometheus", + ImportStateVerify: true, + ImportStateId: "grafanacloud-prom", + ExpectError: regexp.MustCompile("this Grafana data source is read-only. It cannot be imported as a resource. Use the `data_grafana_data_source` data source instead"), + }, + }, + }) +} + func testAccDatasourceInOrganization(orgName string) string { return fmt.Sprintf(` resource "grafana_organization" "test" { diff --git a/internal/resources/grafana/resource_folder.go b/internal/resources/grafana/resource_folder.go index 05ee88904..4d64d8e48 100644 --- a/internal/resources/grafana/resource_folder.go +++ b/internal/resources/grafana/resource_folder.go @@ -78,11 +78,11 @@ func resourceFolder() *common.Resource { "grafana_folder", orgResourceIDString("uid"), schema, - ).WithLister(listerFunction(listFolders)) + ).WithLister(listerFunctionOrgResource(listFolders)) } -func listFolders(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { - return listDashboardOrFolder(client, data, "dash-folder") +func listFolders(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { + return listDashboardOrFolder(client, orgID, "dash-folder") } func CreateFolder(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { diff --git a/internal/resources/grafana/resource_folder_test.go b/internal/resources/grafana/resource_folder_test.go index c9422b0e1..f41d9cd99 100644 --- a/internal/resources/grafana/resource_folder_test.go +++ b/internal/resources/grafana/resource_folder_test.go @@ -8,15 +8,16 @@ import ( "strings" "testing" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + + "github.com/grafana/grafana-openapi-client-go/client/service_accounts" "github.com/grafana/grafana-openapi-client-go/models" "github.com/grafana/terraform-provider-grafana/v3/internal/common" "github.com/grafana/terraform-provider-grafana/v3/internal/resources/grafana" "github.com/grafana/terraform-provider-grafana/v3/internal/testutils" - - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) func TestAccFolder_basic(t *testing.T) { @@ -46,6 +47,7 @@ func TestAccFolder_basic(t *testing.T) { resource.TestCheckResourceAttr("grafana_folder.test_folder_with_uid", "uid", "test-folder-uid"), resource.TestCheckResourceAttr("grafana_folder.test_folder_with_uid", "title", "Terraform Test Folder With UID"), resource.TestCheckResourceAttr("grafana_folder.test_folder_with_uid", "url", strings.TrimRight(os.Getenv("GRAFANA_URL"), "/")+"/dashboards/f/test-folder-uid/terraform-test-folder-with-uid"), + testutils.CheckLister("grafana_folder.test_folder_with_uid"), ), }, { @@ -284,26 +286,40 @@ func TestAccFolder_createFromDifferentRoles(t *testing.T) { } { t.Run(tc.role, func(t *testing.T) { var folder models.Folder - var name = acctest.RandomWithPrefix(tc.role + "-key") + var saName = acctest.RandomWithPrefix(tc.role + "-sa") + var saTokenName = acctest.RandomWithPrefix(tc.role + "-token") - // Create an API key with the correct role and inject it in envvars. This auth will be used when the test runs + // Create a service account token with the correct role and inject it in envvars. This auth will be used when the test runs client := grafanaTestClient() - resp, err := client.APIKeys.AddAPIkey(&models.AddAPIKeyCommand{ - Name: name, - Role: tc.role, - }) + + sa, err := client.ServiceAccounts.CreateServiceAccount( + service_accounts.NewCreateServiceAccountParams().WithBody(&models.CreateServiceAccountForm{ + Name: saName, + Role: tc.role, + }), + ) + if err != nil { + t.Fatal(err) + } + defer client.ServiceAccounts.DeleteServiceAccount(sa.Payload.ID) + + saToken, err := client.ServiceAccounts.CreateToken( + service_accounts.NewCreateTokenParams().WithBody(&models.AddServiceAccountTokenCommand{ + Name: saTokenName, + }).WithServiceAccountID(sa.Payload.ID), + ) if err != nil { t.Fatal(err) } - defer client.APIKeys.DeleteAPIkey(resp.Payload.ID) + oldValue := os.Getenv("GRAFANA_AUTH") defer os.Setenv("GRAFANA_AUTH", oldValue) - os.Setenv("GRAFANA_AUTH", resp.Payload.Key) + os.Setenv("GRAFANA_AUTH", saToken.Payload.Key) config := fmt.Sprintf(` resource "grafana_folder" "bar" { title = "%[1]s" - }`, name) + }`, saName) // Do not make parallel, fiddling with auth will break other tests that run in parallel resource.Test(t, resource.TestCase{ @@ -317,7 +333,7 @@ func TestAccFolder_createFromDifferentRoles(t *testing.T) { folderCheckExists.exists("grafana_folder.bar", &folder), resource.TestMatchResourceAttr("grafana_folder.bar", "id", defaultOrgIDRegexp), resource.TestMatchResourceAttr("grafana_folder.bar", "uid", common.UIDRegexp), - resource.TestCheckResourceAttr("grafana_folder.bar", "title", name), + resource.TestCheckResourceAttr("grafana_folder.bar", "title", saName), ), }, }, diff --git a/internal/resources/grafana/resource_library_panel.go b/internal/resources/grafana/resource_library_panel.go index 426c5f79e..128d17f07 100644 --- a/internal/resources/grafana/resource_library_panel.go +++ b/internal/resources/grafana/resource_library_panel.go @@ -116,28 +116,19 @@ Manages Grafana library panels. "grafana_library_panel", orgResourceIDString("uid"), schema, - ).WithLister(listerFunction(listLibraryPanels)) + ).WithLister(listerFunctionOrgResource(listLibraryPanels)) } -func listLibraryPanels(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { - orgIDs, err := data.OrgIDs(client) +func listLibraryPanels(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { + var ids []string + params := library_elements.NewGetLibraryElementsParams().WithKind(common.Ref(libraryPanelKind)) + resp, err := client.LibraryElements.GetLibraryElements(params) if err != nil { return nil, err } - var ids []string - for _, orgID := range orgIDs { - client = client.Clone().WithOrgID(orgID) - - params := library_elements.NewGetLibraryElementsParams().WithKind(common.Ref(libraryPanelKind)) - resp, err := client.LibraryElements.GetLibraryElements(params) - if err != nil { - return nil, err - } - - for _, panel := range resp.Payload.Result.Elements { - ids = append(ids, MakeOrgResourceID(orgID, panel.UID)) - } + for _, panel := range resp.Payload.Result.Elements { + ids = append(ids, MakeOrgResourceID(orgID, panel.UID)) } return ids, nil diff --git a/internal/resources/grafana/resource_library_panel_test.go b/internal/resources/grafana/resource_library_panel_test.go index 079159122..b004e3c0e 100644 --- a/internal/resources/grafana/resource_library_panel_test.go +++ b/internal/resources/grafana/resource_library_panel_test.go @@ -32,6 +32,7 @@ func TestAccLibraryPanel_basic(t *testing.T) { resource.TestCheckResourceAttr("grafana_library_panel.test", "name", name), resource.TestCheckResourceAttr("grafana_library_panel.test", "version", "1"), resource.TestCheckResourceAttr("grafana_library_panel.test", "model_json", fmt.Sprintf(`{"description":"","title":"%s","type":""}`, name)), + testutils.CheckLister("grafana_library_panel.test"), ), }, { diff --git a/internal/resources/grafana/resource_organization.go b/internal/resources/grafana/resource_organization.go index c3c3a67da..e6c384e5a 100644 --- a/internal/resources/grafana/resource_organization.go +++ b/internal/resources/grafana/resource_organization.go @@ -46,7 +46,8 @@ func resourceOrganization() *common.Resource { This resource represents an instance-scoped resource and uses Grafana's admin APIs. It does not work with API tokens or service accounts which are org-scoped. -You must use basic auth. +You must use basic auth. +This resource is also not compatible with Grafana Cloud, as it does not allow basic auth. `, CreateContext: CreateOrganization, @@ -149,7 +150,9 @@ set to true. This feature is only available in Grafana 10.2+. "grafana_organization", common.NewResourceID(common.IntIDField("id")), schema, - ).WithLister(listerFunction(listOrganizations)) + ). + WithLister(listerFunction(listOrganizations)). + WithPreferredResourceNameField("name") } func listOrganizations(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { diff --git a/internal/resources/grafana/resource_playlist.go b/internal/resources/grafana/resource_playlist.go index 6d6067a0a..af0570ed9 100644 --- a/internal/resources/grafana/resource_playlist.go +++ b/internal/resources/grafana/resource_playlist.go @@ -77,27 +77,20 @@ func resourcePlaylist() *common.Resource { "grafana_playlist", orgResourceIDString("uid"), schema, - ).WithLister(listerFunction(listPlaylists)) + ). + WithLister(listerFunctionOrgResource(listPlaylists)). + WithPreferredResourceNameField("name") } -func listPlaylists(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { - orgIDs, err := data.OrgIDs(client) +func listPlaylists(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { + var ids []string + resp, err := client.Playlists.SearchPlaylists(playlists.NewSearchPlaylistsParams()) if err != nil { return nil, err } - var ids []string - for _, orgID := range orgIDs { - client = client.Clone().WithOrgID(orgID) - - resp, err := client.Playlists.SearchPlaylists(playlists.NewSearchPlaylistsParams()) - if err != nil { - return nil, err - } - - for _, playlist := range resp.Payload { - ids = append(ids, MakeOrgResourceID(orgID, playlist.UID)) - } + for _, playlist := range resp.Payload { + ids = append(ids, MakeOrgResourceID(orgID, playlist.UID)) } return ids, nil diff --git a/internal/resources/grafana/resource_playlist_test.go b/internal/resources/grafana/resource_playlist_test.go index 39ee0aab8..3eb5f5156 100644 --- a/internal/resources/grafana/resource_playlist_test.go +++ b/internal/resources/grafana/resource_playlist_test.go @@ -39,6 +39,7 @@ func TestAccPlaylist_basic(t *testing.T) { "order": "2", "title": "Terraform Dashboard By ID", }), + testutils.CheckLister(paylistResource), ), }, { diff --git a/internal/resources/grafana/resource_report.go b/internal/resources/grafana/resource_report.go index b92928e99..f9ef1fb83 100644 --- a/internal/resources/grafana/resource_report.go +++ b/internal/resources/grafana/resource_report.go @@ -249,30 +249,23 @@ func resourceReport() *common.Resource { "grafana_report", orgResourceIDInt("id"), schema, - ).WithLister(listerFunction(listReports)) + ). + WithLister(listerFunctionOrgResource(listReports)). + WithPreferredResourceNameField("name") } -func listReports(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { - orgIDs, err := data.OrgIDs(client) +func listReports(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { + var ids []string + resp, err := client.Reports.GetReports() + if err != nil && common.IsNotFoundError(err) { + return nil, nil // Reports are not available in the current Grafana version (Probably OSS) + } if err != nil { return nil, err } - var ids []string - for _, orgID := range orgIDs { - client = client.Clone().WithOrgID(orgID) - - resp, err := client.Reports.GetReports() - if err != nil && common.IsNotFoundError(err) { - return nil, nil // Reports are not available in the current Grafana version (Probably OSS) - } - if err != nil { - return nil, err - } - - for _, report := range resp.Payload { - ids = append(ids, MakeOrgResourceID(orgID, report.ID)) - } + for _, report := range resp.Payload { + ids = append(ids, MakeOrgResourceID(orgID, report.ID)) } return ids, nil @@ -400,10 +393,10 @@ func DeleteReport(ctx context.Context, d *schema.ResourceData, meta interface{}) return diag } -func schemaToReport(d *schema.ResourceData) (models.CreateOrUpdateReportConfig, error) { +func schemaToReport(d *schema.ResourceData) (models.CreateOrUpdateReport, error) { frequency := d.Get("schedule.0.frequency").(string) timezone := d.Get("schedule.0.timezone").(string) - report := models.CreateOrUpdateReportConfig{ + report := models.CreateOrUpdateReport{ Name: d.Get("name").(string), Recipients: strings.Join(common.ListToStringSlice(d.Get("recipients").([]interface{})), ","), ReplyTo: d.Get("reply_to").(string), @@ -433,7 +426,7 @@ func schemaToReport(d *schema.ResourceData) (models.CreateOrUpdateReportConfig, location, err := time.LoadLocation(timezone) if err != nil { - return models.CreateOrUpdateReportConfig{}, err + return models.CreateOrUpdateReport{}, err } // Set schedule start time @@ -441,7 +434,7 @@ func schemaToReport(d *schema.ResourceData) (models.CreateOrUpdateReportConfig, if startTimeStr := d.Get("schedule.0.start_time").(string); startTimeStr != "" { date, err := formatDate(startTimeStr, location) if err != nil { - return models.CreateOrUpdateReportConfig{}, err + return models.CreateOrUpdateReport{}, err } report.Schedule.StartDate = date } @@ -452,7 +445,7 @@ func schemaToReport(d *schema.ResourceData) (models.CreateOrUpdateReportConfig, if endTimeStr := d.Get("schedule.0.end_time").(string); endTimeStr != "" { date, err := formatDate(endTimeStr, location) if err != nil { - return models.CreateOrUpdateReportConfig{}, err + return models.CreateOrUpdateReport{}, err } report.Schedule.EndDate = date } @@ -471,7 +464,7 @@ func schemaToReport(d *schema.ResourceData) (models.CreateOrUpdateReportConfig, customInterval := d.Get("schedule.0.custom_interval").(string) amount, unit, err := parseCustomReportInterval(customInterval) if err != nil { - return models.CreateOrUpdateReportConfig{}, err + return models.CreateOrUpdateReport{}, err } report.Schedule.IntervalAmount = int64(amount) report.Schedule.IntervalFrequency = unit @@ -480,7 +473,7 @@ func schemaToReport(d *schema.ResourceData) (models.CreateOrUpdateReportConfig, return report, nil } -func setDashboards(report models.CreateOrUpdateReportConfig, d *schema.ResourceData) models.CreateOrUpdateReportConfig { +func setDashboards(report models.CreateOrUpdateReport, d *schema.ResourceData) models.CreateOrUpdateReport { dashboards := d.Get("dashboards").([]interface{}) for _, dashboard := range dashboards { dash := dashboard.(map[string]interface{}) diff --git a/internal/resources/grafana/resource_report_test.go b/internal/resources/grafana/resource_report_test.go index b468c0cd4..d1150bd28 100644 --- a/internal/resources/grafana/resource_report_test.go +++ b/internal/resources/grafana/resource_report_test.go @@ -54,6 +54,7 @@ func TestAccResourceReport_Multiple_Dashboards(t *testing.T) { resource.TestCheckResourceAttr("grafana_report.test", "dashboards.1.time_range.0.from", ""), resource.TestCheckResourceAttr("grafana_report.test", "dashboards.1.time_range.0.to", ""), resource.TestCheckResourceAttr("grafana_report.test", "dashboards.1.uid", randomUID2), + testutils.CheckLister("grafana_report.test"), ), }, }, diff --git a/internal/resources/grafana/resource_service_account.go b/internal/resources/grafana/resource_service_account.go index c33435873..fc00b4f7b 100644 --- a/internal/resources/grafana/resource_service_account.go +++ b/internal/resources/grafana/resource_service_account.go @@ -6,6 +6,7 @@ import ( "sync" "time" + goapi "github.com/grafana/grafana-openapi-client-go/client" "github.com/grafana/grafana-openapi-client-go/client/service_accounts" "github.com/grafana/grafana-openapi-client-go/models" "github.com/grafana/terraform-provider-grafana/v3/internal/common" @@ -61,7 +62,33 @@ func resourceServiceAccount() *common.Resource { "grafana_service_account", orgResourceIDInt("id"), schema, - ) + ). + WithLister(listerFunctionOrgResource(listServiceAccounts)). + WithPreferredResourceNameField("name") +} + +func listServiceAccounts(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { + var ids []string + var page int64 = 1 + for { + params := service_accounts.NewSearchOrgServiceAccountsWithPagingParams().WithPage(&page) + resp, err := client.ServiceAccounts.SearchOrgServiceAccountsWithPaging(params) + if err != nil { + return nil, err + } + + for _, sa := range resp.Payload.ServiceAccounts { + ids = append(ids, MakeOrgResourceID(orgID, sa.ID)) + } + + if resp.Payload.TotalCount <= int64(len(ids)) { + break + } + + page++ + } + + return ids, nil } func CreateServiceAccount(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { diff --git a/internal/resources/grafana/resource_service_account_test.go b/internal/resources/grafana/resource_service_account_test.go index 548e883f2..db1ece995 100644 --- a/internal/resources/grafana/resource_service_account_test.go +++ b/internal/resources/grafana/resource_service_account_test.go @@ -34,6 +34,7 @@ func TestAccServiceAccount_basic(t *testing.T) { resource.TestCheckResourceAttr("grafana_service_account.test", "role", "Editor"), resource.TestCheckResourceAttr("grafana_service_account.test", "is_disabled", "false"), resource.TestMatchResourceAttr("grafana_service_account.test", "id", defaultOrgIDRegexp), + testutils.CheckLister("grafana_service_account.test"), ), }, // Change the name. Check that the ID stays the same. diff --git a/internal/resources/grafana/resource_service_account_token.go b/internal/resources/grafana/resource_service_account_token.go index 67b475768..0e69bd22c 100644 --- a/internal/resources/grafana/resource_service_account_token.go +++ b/internal/resources/grafana/resource_service_account_token.go @@ -2,7 +2,6 @@ package grafana import ( "context" - "log" "strconv" "github.com/grafana/grafana-openapi-client-go/client/service_accounts" @@ -138,10 +137,7 @@ func serviceAccountTokenRead(ctx context.Context, d *schema.ResourceData, m inte } } - log.Printf("[WARN] removing service account token%d from state because it no longer exists in grafana", id) - d.SetId("") - - return nil + return common.WarnMissing("service account token", d) } func serviceAccountTokenDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { diff --git a/internal/resources/grafana/resource_sso_settings.go b/internal/resources/grafana/resource_sso_settings.go index fe9918ee9..59be721dc 100644 --- a/internal/resources/grafana/resource_sso_settings.go +++ b/internal/resources/grafana/resource_sso_settings.go @@ -18,6 +18,7 @@ const ( providerKey = "provider_name" oauth2SettingsKey = "oauth2_settings" samlSettingsKey = "saml_settings" + ldapSettingsKey = "ldap_settings" customFieldsKey = "custom" ) @@ -25,7 +26,7 @@ func resourceSSOSettings() *common.Resource { schema := &schema.Resource{ Description: ` -Manages Grafana SSO Settings for OAuth2 and SAML. Support for SAML is currently in preview, it will be available in Grafana Enterprise starting with v11.1. +Manages Grafana SSO Settings for OAuth2, SAML and LDAP. Support for LDAP is currently in preview, it will be available in Grafana starting with v11.3. * [Official documentation](https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication/) * [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/sso-settings/) @@ -43,8 +44,8 @@ Manages Grafana SSO Settings for OAuth2 and SAML. Support for SAML is currently providerKey: { Type: schema.TypeString, Required: true, - Description: "The name of the SSO provider. Supported values: github, gitlab, google, azuread, okta, generic_oauth, saml.", - ValidateFunc: validation.StringInSlice([]string{"github", "gitlab", "google", "azuread", "okta", "generic_oauth", "saml"}, false), + Description: "The name of the SSO provider. Supported values: github, gitlab, google, azuread, okta, generic_oauth, saml, ldap.", + ValidateFunc: validation.StringInSlice([]string{"github", "gitlab", "google", "azuread", "okta", "generic_oauth", "saml", "ldap"}, false), }, oauth2SettingsKey: { Type: schema.TypeSet, @@ -53,7 +54,7 @@ Manages Grafana SSO Settings for OAuth2 and SAML. Support for SAML is currently MinItems: 0, Description: "The OAuth2 settings set. Required for github, gitlab, google, azuread, okta, generic_oauth providers.", Elem: oauth2SettingsSchema, - ConflictsWith: []string{samlSettingsKey}, + ConflictsWith: []string{samlSettingsKey, ldapSettingsKey}, }, samlSettingsKey: { Type: schema.TypeSet, @@ -62,7 +63,16 @@ Manages Grafana SSO Settings for OAuth2 and SAML. Support for SAML is currently MinItems: 0, Description: "The SAML settings set. Required for the saml provider.", Elem: samlSettingsSchema, - ConflictsWith: []string{oauth2SettingsKey}, + ConflictsWith: []string{oauth2SettingsKey, ldapSettingsKey}, + }, + ldapSettingsKey: { + Type: schema.TypeSet, + Optional: true, + MaxItems: 1, + MinItems: 0, + Description: "The LDAP settings set. Required for the ldap provider.", + Elem: ldapSettingsSchema, + ConflictsWith: []string{oauth2SettingsKey, samlSettingsKey}, }, }, } @@ -204,6 +214,16 @@ var oauth2SettingsSchema = &schema.Resource{ Optional: true, Description: "Prevent synchronizing users’ organization roles from your IdP.", }, + "org_mapping": { + Type: schema.TypeString, + Optional: true, + Description: "List of comma- or space-separated Organization:OrgIdOrOrgName:Role mappings. Organization can be * meaning “All users”. Role is optional and can have the following values: None, Viewer, Editor or Admin.", + }, + "org_attribute_path": { + Type: schema.TypeString, + Optional: true, + Description: `JMESPath expression to use for the organization mapping lookup from the user ID token. The extracted list will be used for the organization mapping (to match "Organization" in the "org_mapping"). Only applicable to Generic OAuth and Okta.`, + }, "define_allowed_groups": { Type: schema.TypeBool, Optional: true, @@ -440,6 +460,227 @@ var samlSettingsSchema = &schema.Resource{ Optional: true, Description: "Prevent synchronizing users’ organization roles from your IdP.", }, + "client_id": { + Type: schema.TypeString, + Optional: true, + Description: "The client Id of your OAuth2 app.", + }, + "client_secret": { + Type: schema.TypeString, + Optional: true, + // Sensitive: true, + Description: "The client secret of your OAuth2 app.", + }, + "token_url": { + Type: schema.TypeString, + Optional: true, + Description: "The token endpoint of your OAuth2 provider. Required for Azure AD providers.", + }, + "force_use_graph_api": { + Type: schema.TypeBool, + Optional: true, + Description: "If enabled, Grafana will fetch groups from Microsoft Graph API instead of using the groups claim from the ID token.", + }, + }, +} + +var ldapSettingsSchema = &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: true, + Description: "Define whether this configuration is enabled for LDAP.", + }, + "allow_sign_up": { + Type: schema.TypeBool, + Optional: true, + Description: "Whether to allow new Grafana user creation through LDAP login. If set to false, then only existing Grafana users can log in with LDAP.", + }, + "skip_org_role_sync": { + Type: schema.TypeBool, + Optional: true, + Description: "Prevent synchronizing users’ organization roles from LDAP.", + }, + "config": { + Type: schema.TypeList, + Required: true, + MaxItems: 1, + MinItems: 1, + Description: "The LDAP configuration.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "servers": { + Type: schema.TypeList, + Required: true, + MinItems: 1, + Description: "The LDAP servers configuration.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "host": { + Type: schema.TypeString, + Required: true, + Description: "The LDAP server host.", + }, + "port": { + Type: schema.TypeInt, + Optional: true, + Description: "The LDAP server port.", + }, + "use_ssl": { + Type: schema.TypeBool, + Optional: true, + Description: "Set to true if LDAP server should use an encrypted TLS connection (either with STARTTLS or LDAPS).", + }, + "start_tls": { + Type: schema.TypeBool, + Optional: true, + Description: "If set to true, use LDAP with STARTTLS instead of LDAPS.", + }, + "tls_ciphers": { + Type: schema.TypeList, + Optional: true, + Description: "Accepted TLS ciphers. For a complete list of supported ciphers, refer to: https://go.dev/src/crypto/tls/cipher_suites.go.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "min_tls_version": { + Type: schema.TypeString, + Optional: true, + Description: "Minimum TLS version allowed. Accepted values are: TLS1.2, TLS1.3.", + }, + "ssl_skip_verify": { + Type: schema.TypeBool, + Optional: true, + Description: "If set to true, the SSL cert validation will be skipped.", + }, + "root_ca_cert": { + Type: schema.TypeString, + Optional: true, + Description: "The path to the root CA certificate.", + }, + "root_ca_cert_value": { + Type: schema.TypeList, + Optional: true, + Description: "The Base64 encoded values of the root CA certificates.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "client_cert": { + Type: schema.TypeString, + Optional: true, + Description: "The path to the client certificate.", + }, + "client_cert_value": { + Type: schema.TypeString, + Optional: true, + Description: "The Base64 encoded value of the client certificate.", + }, + "client_key": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + Description: "The path to the client private key.", + }, + "client_key_value": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + Description: "The Base64 encoded value of the client private key.", + }, + "bind_dn": { + Type: schema.TypeString, + Optional: true, + Description: "The search user bind DN.", + }, + "bind_password": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + Description: "The search user bind password.", + }, + "timeout": { + Type: schema.TypeInt, + Optional: true, + Description: "The timeout in seconds for connecting to the LDAP host.", + }, + "search_filter": { + Type: schema.TypeString, + Required: true, + Description: "The user search filter, for example \"(cn=%s)\" or \"(sAMAccountName=%s)\" or \"(uid=%s)\".", + }, + "search_base_dns": { + Type: schema.TypeList, + Required: true, + MinItems: 1, + Description: "An array of base DNs to search through.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "group_search_filter": { + Type: schema.TypeString, + Optional: true, + Description: "Group search filter, to retrieve the groups of which the user is a member (only set if memberOf attribute is not available).", + }, + "group_search_base_dns": { + Type: schema.TypeList, + Optional: true, + Description: "An array of the base DNs to search through for groups. Typically uses ou=groups.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "group_search_filter_user_attribute": { + Type: schema.TypeString, + Optional: true, + Description: "The %s in the search filter will be replaced with the attribute defined in this field.", + }, + "attributes": { + Type: schema.TypeMap, + Optional: true, + Description: "The LDAP server attributes. The following attributes can be configured: email, member_of, name, surname, username.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "group_mappings": { + Type: schema.TypeList, + Optional: true, + Description: "For mapping an LDAP group to a Grafana organization and role.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "group_dn": { + Type: schema.TypeString, + Required: true, + Description: "LDAP distinguished name (DN) of LDAP group. If you want to match all (or no LDAP groups) then you can use wildcard (\"*\").", + }, + "org_role": { + Type: schema.TypeString, + Required: true, + Description: "Assign users of group_dn the organization role Admin, Editor, or Viewer.", + }, + "org_id": { + Type: schema.TypeInt, + Optional: true, + Description: "The Grafana organization database id.", + }, + "grafana_admin": { + Type: schema.TypeBool, + Optional: true, + Description: "If set to true, it makes the user of group_dn Grafana server admin.", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, }, } @@ -464,6 +705,11 @@ func ReadSSOSettings(ctx context.Context, d *schema.ResourceData, meta interface payload := resp.GetPayload() + settingsFromAPI, err := getSettingsForTF(payload) + if err != nil { + return diag.FromErr(err) + } + var settingsFromTfState map[string]any settingsFromTfStateList := d.Get(settingsKey).(*schema.Set).List() if len(settingsFromTfStateList) > 0 { @@ -483,7 +729,7 @@ func ReadSSOSettings(ctx context.Context, d *schema.ResourceData, meta interface } } - for k, v := range payload.Settings.(map[string]any) { + for k, v := range settingsFromAPI { key := toSnake(k) if _, ok := settingsSchema.Schema[key]; ok { @@ -493,10 +739,14 @@ func ReadSSOSettings(ctx context.Context, d *schema.ResourceData, meta interface // importing existing sso settings into terraform. Otherwise, the API response may return fields // that don't exist in the terraform state. We ignore them because they are not managed by terraform. if ok || len(settingsFromTfState) == 0 { - if isSecret(key) { + switch { + case provider == "ldap" && key == "config": + // special case for LDAP as the settings are nested + settingsSnake[key] = getSettingsWithSecretsForLdap(val, v) + case isSecret(key): // secrets are not exposed by the SSO Settings API, we get them from the terraform state settingsSnake[key] = val - } else if !isIgnored(provider, key) { + case !isIgnored(provider, key): // some fields are returned by the API, but they are read only, so we ignore them settingsSnake[key] = v } @@ -535,6 +785,8 @@ func UpdateSSOSettings(ctx context.Context, d *schema.ResourceData, meta interfa return diag.FromErr(err) } + settings = getSettingsForAPI(provider, settings) + if isOAuth2Provider(provider) { diags := validateOAuth2CustomFields(settings) if diags != nil { @@ -588,6 +840,10 @@ func isSamlProvider(provider string) bool { return provider == "saml" } +func isLdapProvider(provider string) bool { + return provider == "ldap" +} + func getSettingsKey(provider string) (string, error) { if isOAuth2Provider(provider) { return oauth2SettingsKey, nil @@ -595,6 +851,9 @@ func getSettingsKey(provider string) (string, error) { if isSamlProvider(provider) { return samlSettingsKey, nil } + if isLdapProvider(provider) { + return ldapSettingsKey, nil + } return "", fmt.Errorf("no settings key found for provider %s", provider) } @@ -606,6 +865,9 @@ func getSettingsSchema(provider string) (*schema.Resource, error) { if isSamlProvider(provider) { return samlSettingsSchema, nil } + if isLdapProvider(provider) { + return ldapSettingsSchema, nil + } return nil, fmt.Errorf("no settings schema found for provider %s", provider) } @@ -624,6 +886,77 @@ func getSettingOk(key string, settings map[string]any) (any, bool) { return val, ok } +func getSettingsWithSecretsForLdap(state any, config any) any { + secretFields := []string{"client_key", "client_key_value", "bind_password"} + + stateSlice, ok := state.([]any) + if !ok { + return config + } + + configSlice, ok := config.([]any) + if !ok { + return config + } + + if len(stateSlice) == 0 || len(configSlice) == 0 { + return config + } + + stateServers, ok := stateSlice[0].(map[string]any)["servers"].([]any) + if !ok { + return config + } + + configServers, ok := configSlice[0].(map[string]any)["servers"].([]any) + if !ok { + return config + } + + for i, serverRaw := range configServers { + server := serverRaw.(map[string]any) + for _, field := range secretFields { + if len(stateServers) < i+1 { + continue + } + + secret, ok := stateServers[i].(map[string]any)[field].(string) + if ok { + server[field] = secret + } + } + } + + return config +} + +func getSettingsForTF(payload *models.GetProviderSettingsOKBody) (map[string]any, error) { + settings, ok := payload.Settings.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid settings format: %v", payload.Settings) + } + + if payload.Provider == "ldap" { + // config is represented as an array in terraform + settings["config"] = []any{settings["config"]} + } + + return settings, nil +} + +func getSettingsForAPI(provider string, settings map[string]any) map[string]any { + if provider == "ldap" { + config := settings["config"].([]any) + + if len(config) > 0 { + // config is an object in API + settings["config"] = config[0] + } + } + + return settings +} + func getSettingsFromResourceData(d *schema.ResourceData, settingsKey string) (map[string]any, error) { settingsList := d.Get(settingsKey).(*schema.Set).List() @@ -638,6 +971,7 @@ func getSettingsFromResourceData(d *schema.ResourceData, settingsKey string) (ma // sometimes the settings set contains some empty items that we want to ignore // we are only interested in the settings that have one of the following: // - the client_id set because the client_id is a required field for OAuth2 providers + // - a non-empty config for LDAP // - the private_key or private_key_path set because those are required fields for SAML for _, item := range settingsList { settings := item.(map[string]any) @@ -647,6 +981,11 @@ func getSettingsFromResourceData(d *schema.ResourceData, settingsKey string) (ma return settings, nil } + config, okConfig := settings["config"].([]any) + if okConfig && len(config) > 0 { + return settings, nil + } + privateKey, okPrivateKey := settings["private_key"] privateKeyPath, okPrivateKeyPath := settings["private_key_path"] if (okPrivateKey && privateKey != "") || (okPrivateKeyPath && privateKeyPath != "") { @@ -664,6 +1003,7 @@ var validationsByProvider = map[string][]validateFunc{ ssoValidateNotEmpty("auth_url"), ssoValidateNotEmpty("token_url"), ssoValidateEmpty("api_url"), + ssoValidateEmpty("org_attribute_path"), ssoValidateURL("auth_url"), ssoValidateURL("token_url"), }, @@ -674,6 +1014,7 @@ var validationsByProvider = map[string][]validateFunc{ ssoValidateURL("auth_url"), ssoValidateURL("token_url"), ssoValidateURL("api_url"), + ssoValidateInterdependencyXOR("org_attribute_path", "org_mapping"), }, "okta": { ssoValidateNotEmpty("auth_url"), @@ -682,28 +1023,36 @@ var validationsByProvider = map[string][]validateFunc{ ssoValidateURL("auth_url"), ssoValidateURL("token_url"), ssoValidateURL("api_url"), + ssoValidateInterdependencyXOR("org_attribute_path", "org_mapping"), }, "github": { ssoValidateEmpty("auth_url"), ssoValidateEmpty("token_url"), ssoValidateEmpty("api_url"), + ssoValidateEmpty("org_attribute_path"), }, "gitlab": { ssoValidateEmpty("auth_url"), ssoValidateEmpty("token_url"), ssoValidateEmpty("api_url"), + ssoValidateEmpty("org_attribute_path"), }, "google": { ssoValidateEmpty("auth_url"), ssoValidateEmpty("token_url"), ssoValidateEmpty("api_url"), + ssoValidateEmpty("org_attribute_path"), }, "saml": { - ssoValidateOnlyOneOf("certificate", "certificate_path"), - ssoValidateOnlyOneOf("private_key", "private_key_path"), + ssoValidateInterdependencyXOR("certificate", "private_key"), + ssoValidateInterdependencyXOR("certificate_path", "private_key_path"), ssoValidateOnlyOneOf("idp_metadata", "idp_metadata_path", "idp_metadata_url"), ssoValidateURL("idp_metadata_url"), + ssoValidateInterdependencyXOR("client_id", "client_secret", "token_url"), + ssoValidateURL("token_url"), }, + // no client side validations for LDAP because the settings are nested + "ldap": {}, } func validateSSOSettings(provider string, settings map[string]any) error { @@ -870,3 +1219,25 @@ func ssoValidateOnlyOneOf(keys ...string) validateFunc { return nil } } + +// XOR validation of variables +func ssoValidateInterdependencyXOR(keys ...string) validateFunc { + return func(settingsMap map[string]any, provider string) error { + configuredKeys := 0 + nonConfiguredKeys := 0 + + for _, key := range keys { + if settingsMap[key].(string) != "" { + configuredKeys++ + } else { + nonConfiguredKeys++ + } + } + + if configuredKeys != len(keys) && nonConfiguredKeys != len(keys) { + return fmt.Errorf("all variables in %v must be configured or empty for provider %s", keys, provider) + } + + return nil + } +} diff --git a/internal/resources/grafana/resource_sso_settings_test.go b/internal/resources/grafana/resource_sso_settings_test.go index e59cacb0a..c21eac893 100644 --- a/internal/resources/grafana/resource_sso_settings_test.go +++ b/internal/resources/grafana/resource_sso_settings_test.go @@ -107,11 +107,92 @@ func TestSSOSettings_basic_saml(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "saml_settings.0.allow_sign_up", "true"), ), }, + { + Config: testConfigForSAMLProviderWithAzureAD, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "provider_name", provider), + resource.TestCheckResourceAttr(resourceName, "saml_settings.#", "1"), + resource.TestCheckResourceAttr(resourceName, "saml_settings.0.certificate_path", "devenv/docker/blocks/auth/saml-enterprise/cert.crt"), + resource.TestCheckResourceAttr(resourceName, "saml_settings.0.private_key_path", "devenv/docker/blocks/auth/saml-enterprise/key.pem"), + resource.TestCheckResourceAttr(resourceName, "saml_settings.0.idp_metadata_url", "https://nexus.microsoftonline-p.com/federationmetadata/saml20/federationmetadata.xml"), + resource.TestCheckResourceAttr(resourceName, "saml_settings.0.signature_algorithm", "rsa-sha256"), + resource.TestCheckResourceAttr(resourceName, "saml_settings.0.metadata_valid_duration", "24h"), + resource.TestCheckResourceAttr(resourceName, "saml_settings.0.assertion_attribute_email", "email"), + resource.TestCheckResourceAttr(resourceName, "saml_settings.0.client_id", "client_id"), + resource.TestCheckResourceAttr(resourceName, "saml_settings.0.client_secret", "client_secret"), + resource.TestCheckResourceAttr(resourceName, "saml_settings.0.token_url", "https://myidp.com/oauth/token"), + resource.TestCheckResourceAttr(resourceName, "saml_settings.0.force_use_graph_api", "true"), + ), + }, { ResourceName: resourceName, ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"saml_settings.0.private_key_path", "saml_settings.0.certificate_path"}, + ImportStateVerifyIgnore: []string{"saml_settings.0.private_key_path", "saml_settings.0.certificate_path", "saml_settings.0.client_secret", "saml_settings.0.token_url"}, + }, + }, + }) +} + +func TestSSOSettings_basic_ldap(t *testing.T) { + testutils.CheckOSSTestsEnabled(t, ">=11.3") + + provider := "ldap" + + api := grafanaTestClient() + + defaultSettings, err := api.SsoSettings.GetProviderSettings(provider) + if err != nil { + t.Fatalf("failed to fetch the default settings for provider %s: %v", provider, err) + } + + resourceName := "grafana_sso_settings.ldap_sso_settings" + + resource.Test(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + CheckDestroy: checkSsoSettingsReset(api, provider, defaultSettings.Payload), + Steps: []resource.TestStep{ + { + Config: testConfigForLdapProvider, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "provider_name", provider), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.#", "1"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.#", "1"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.#", "1"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.host", "127.0.0.1"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.search_filter", "(cn=%s)"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.search_base_dns.#", "1"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.search_base_dns.0", "dc=grafana,dc=org"), + ), + }, + { + Config: testConfigForLdapProviderUpdated, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "provider_name", provider), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.#", "1"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.#", "1"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.#", "1"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.host", "127.0.0.5"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.bind_password", "password"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.search_filter", "(cn=%s)"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.search_base_dns.#", "1"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.search_base_dns.0", "dc=grafana,dc=org"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.attributes.email", "email"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.attributes.name", "name"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.group_mappings.#", "2"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.group_mappings.0.group_dn", "cn=superadmins,dc=grafana,dc=org"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.group_mappings.0.org_role", "Admin"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.group_mappings.0.org_id", "1"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.group_mappings.0.grafana_admin", "true"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.group_mappings.1.group_dn", "cn=users,dc=grafana,dc=org"), + resource.TestCheckResourceAttr(resourceName, "ldap_settings.0.config.0.servers.0.group_mappings.1.org_role", "Editor"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"ldap_settings.0.config.0.servers.0.bind_password"}, }, }, }) @@ -381,6 +462,67 @@ const testConfigForSamlProviderUpdated = `resource "grafana_sso_settings" "saml_ } }` +const testConfigForSAMLProviderWithAzureAD = `resource "grafana_sso_settings" "saml_sso_settings" { + provider_name = "saml" + saml_settings { + certificate_path = "devenv/docker/blocks/auth/saml-enterprise/cert.crt" + private_key_path = "devenv/docker/blocks/auth/saml-enterprise/key.pem" + idp_metadata_url = "https://nexus.microsoftonline-p.com/federationmetadata/saml20/federationmetadata.xml" + signature_algorithm = "rsa-sha256" + metadata_valid_duration = "24h" + assertion_attribute_email = "email" + client_id = "client_id" + client_secret = "client_secret" + token_url = "https://myidp.com/oauth/token" + force_use_graph_api = true + } +}` + +const testConfigForLdapProvider = `resource "grafana_sso_settings" "ldap_sso_settings" { + provider_name = "ldap" + ldap_settings { + config { + servers { + host = "127.0.0.1" + search_filter = "(cn=%s)" + search_base_dns = [ + "dc=grafana,dc=org", + ] + } + } + } +}` + +const testConfigForLdapProviderUpdated = `resource "grafana_sso_settings" "ldap_sso_settings" { + provider_name = "ldap" + ldap_settings { + config { + servers { + host = "127.0.0.5" + search_filter = "(cn=%s)" + bind_password = "password" + search_base_dns = [ + "dc=grafana,dc=org", + ] + attributes = { + email = "email" + name = "name" + } + group_mappings { + group_dn = "cn=superadmins,dc=grafana,dc=org" + org_role = "Admin" + org_id = 1 + grafana_admin = true + } + group_mappings { + group_dn = "cn=users,dc=grafana,dc=org" + org_role = "Editor" + } + } + } + } +}` + const testConfigWithCustomFields = `resource "grafana_sso_settings" "sso_settings" { provider_name = "github" oauth2_settings { @@ -533,11 +675,10 @@ var testConfigsWithValidationErrors = []string{ api_url = "https://login.microsoftonline.com/12345/oauth2/v2.0/userinfo" } }`, - // certificate and certificate_path are both configured for saml + // mixed path and value are configured for saml for certificate and private_key `resource "grafana_sso_settings" "saml_sso_settings" { provider_name = "saml" saml_settings { - certificate = "this-is-a-valid-certificate" certificate_path = "/valid/certificate/path" private_key = "this-is-a-valid-private-key" idp_metadata_path = "/path/to/metadata" @@ -551,4 +692,48 @@ var testConfigsWithValidationErrors = []string{ private_key = "this-is-a-valid-private-key" } }`, + // missing value for client_secret + `resource "grafana_sso_settings" "saml_sso_settings" { + provider_name = "saml" + saml_settings { + certificate = "this-is-a-valid-certificate" + private_key = "this-is-a-valid-private-key" + idp_metadata_path = "/path/to/metadata" + client_id = "client_id" + client_secret = "" + token_url = "https://myidp.com/oauth/token" + } +}`, + // org_attribute_path is not empty for AzureAD + `resource "grafana_sso_settings" "azure_sso_settings" { + provider_name = "azuread" + oauth2_settings { + client_id = "client_id" + auth_url = "https://login.microsoftonline.com/12345/oauth2/v2.0/authorize" + token_url = "https://login.microsoftonline.com/12345/oauth2/v2.0/token" + org_attribute_path = "org" + } + }`, + // org_mapping is configured but org_attribute_path is missing for Okta + `resource "grafana_sso_settings" "okta_sso_settings" { + provider_name = "okta" + oauth2_settings { + client_id = "client_id" + auth_url = "https://tenantid123.okta.com/oauth2/v1/auth" + token_url = "https://tenantid123.okta.com/oauth2/v1/token" + api_url = "https://tenantid123.okta.com/oauth2/v1/userinfo" + org_mapping = "[\"Group A:1:Editor\",\"Group A:2:Admin\"]" + } + }`, + // org_attribute_path is configured but org_mapping is missing for Generic OAuth + `resource "grafana_sso_settings" "generic_oauth_sso_settings" { + provider_name = "generic_oauth" + oauth2_settings { + client_id = "client_id" + auth_url = "https://tenantid123.okta.com/oauth2/v1/auth" + token_url = "https://tenantid123.okta.com/oauth2/v1/token" + api_url = "https://tenantid123.okta.com/oauth2/v1/userinfo" + org_attribute_path = "groups" + } + }`, } diff --git a/internal/resources/grafana/resource_team.go b/internal/resources/grafana/resource_team.go index 8426ca8c1..00d545e80 100644 --- a/internal/resources/grafana/resource_team.go +++ b/internal/resources/grafana/resource_team.go @@ -7,6 +7,7 @@ import ( "strconv" goapi "github.com/grafana/grafana-openapi-client-go/client" + "github.com/grafana/grafana-openapi-client-go/client/teams" "github.com/grafana/grafana-openapi-client-go/models" "github.com/grafana/terraform-provider-grafana/v3/internal/common" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -85,6 +86,9 @@ to the team. Note: users specified here must already exist in Grafana. Type: schema.TypeBool, Optional: true, Default: true, + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + return old == new || (old == "" && new == "true") + }, Description: ` Ignores team members that have been added to team by [Team Sync](https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-team-sync/). Team Sync can be provisioned using [grafana_team_external_group resource](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/team_external_group). @@ -154,7 +158,33 @@ Team Sync can be provisioned using [grafana_team_external_group resource](https: "grafana_team", orgResourceIDInt("id"), schema, - ) + ). + WithLister(listerFunctionOrgResource(listTeams)). + WithPreferredResourceNameField("name") +} + +func listTeams(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { + var ids []string + var page int64 = 1 + for { + params := teams.NewSearchTeamsParams().WithPage(&page) + resp, err := client.Teams.SearchTeams(params) + if err != nil { + return nil, err + } + + for _, team := range resp.Payload.Teams { + ids = append(ids, MakeOrgResourceID(orgID, team.ID)) + } + + if resp.Payload.TotalCount <= int64(len(ids)) { + break + } + + page++ + } + + return ids, nil } func CreateTeam(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { diff --git a/internal/resources/grafana/resource_team_test.go b/internal/resources/grafana/resource_team_test.go index 4d88ae802..48e9b8158 100644 --- a/internal/resources/grafana/resource_team_test.go +++ b/internal/resources/grafana/resource_team_test.go @@ -33,6 +33,7 @@ func TestAccTeam_basic(t *testing.T) { resource.TestCheckResourceAttr("grafana_team.test", "email", teamName+"@example.com"), resource.TestMatchResourceAttr("grafana_team.test", "id", defaultOrgIDRegexp), resource.TestCheckResourceAttr("grafana_team.test", "org_id", "1"), + testutils.CheckLister("grafana_team.test"), ), }, { diff --git a/internal/resources/grafana/resource_user.go b/internal/resources/grafana/resource_user.go index e4e99e14f..701e0060e 100644 --- a/internal/resources/grafana/resource_user.go +++ b/internal/resources/grafana/resource_user.go @@ -5,6 +5,8 @@ import ( "strconv" "strings" + goapi "github.com/grafana/grafana-openapi-client-go/client" + "github.com/grafana/grafana-openapi-client-go/client/users" "github.com/grafana/grafana-openapi-client-go/models" "github.com/grafana/terraform-provider-grafana/v3/internal/common" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -23,6 +25,7 @@ func resourceUser() *common.Resource { This resource represents an instance-scoped resource and uses Grafana's admin APIs. It does not work with API tokens or service accounts which are org-scoped. You must use basic auth. +This resource is also not compatible with Grafana Cloud, as it does not allow basic auth. `, CreateContext: CreateUser, @@ -75,7 +78,33 @@ You must use basic auth. "grafana_user", resourceUserID, schema, - ) + ). + WithLister(listerFunction(listUsers)). + WithPreferredResourceNameField("login") +} + +func listUsers(ctx context.Context, client *goapi.GrafanaHTTPAPI, data *ListerData) ([]string, error) { + var ids []string + var page int64 = 1 + for { + params := users.NewSearchUsersParams().WithPage(&page) + resp, err := client.Users.SearchUsers(params) + if err != nil { + return nil, err + } + + for _, user := range resp.Payload { + ids = append(ids, strconv.FormatInt(user.ID, 10)) + } + + if len(resp.Payload) == 0 { + break + } + + page++ + } + + return ids, nil } func CreateUser(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { diff --git a/internal/resources/grafana/resources.go b/internal/resources/grafana/resources.go index 60fd8f046..99cf44a5e 100644 --- a/internal/resources/grafana/resources.go +++ b/internal/resources/grafana/resources.go @@ -91,6 +91,7 @@ var DataSources = addValidationToDataSources( datasourceFolder(), datasourceFolders(), datasourceLibraryPanel(), + datasourceLibraryPanels(), datasourceUser(), datasourceUsers(), datasourceRole(), diff --git a/internal/resources/machinelearning/resource_alert.go b/internal/resources/machinelearning/resource_alert.go new file mode 100644 index 000000000..c763ee77d --- /dev/null +++ b/internal/resources/machinelearning/resource_alert.go @@ -0,0 +1,481 @@ +package machinelearning + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/grafana/machine-learning-go-client/mlapi" + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/prometheus/common/model" +) + +var ( + resourceAlertID = common.NewResourceID(common.StringIDField("id")) + resourceAlertName = "grafana_machine_learning_alert" + + // Check interface + _ resource.ResourceWithImportState = (*alertResource)(nil) +) + +func resourceAlert() *common.Resource { + return common.NewResource( + common.CategoryMachineLearning, + resourceAlertName, + resourceAlertID, + &alertResource{}, + ) +} + +type resourceAlertModel struct { + ID types.String `tfsdk:"id"` + JobID types.String `tfsdk:"job_id"` + OutlierID types.String `tfsdk:"outlier_id"` + Title types.String `tfsdk:"title"` + AnomalyCondition types.String `tfsdk:"anomaly_condition"` + For types.String `tfsdk:"for"` + Threshold types.String `tfsdk:"threshold"` + Window types.String `tfsdk:"window"` + Labels types.Map `tfsdk:"labels"` + Annotations types.Map `tfsdk:"annotations"` + NoDataState types.String `tfsdk:"no_data_state"` +} + +type alertResource struct { + mlapi *mlapi.Client +} + +func (r *alertResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Configure is called multiple times (sometimes when ProviderData is not yet available), we only want to configure once + if req.ProviderData == nil || r.mlapi != nil { + return + } + + client, ok := req.ProviderData.(*common.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected resource configure type", + fmt.Sprintf("Expected *common.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.mlapi = client.MLAPI +} + +func (r *alertResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "grafana_machine_learning_alert" +} + +func (r *alertResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "job_id": schema.StringAttribute{ + Description: "The forecast this alert belongs to.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRelative().AtParent().AtName("job_id"), + path.MatchRelative().AtParent().AtName("outlier_id"), + ), + }, + }, + "outlier_id": schema.StringAttribute{ + Description: "The forecast this alert belongs to.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRelative().AtParent().AtName("job_id"), + path.MatchRelative().AtParent().AtName("outlier_id"), + ), + }, + }, + "id": schema.StringAttribute{ + Description: "The ID of the alert.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "title": schema.StringAttribute{ + Description: "The title of the alert.", + Required: true, + }, + "anomaly_condition": schema.StringAttribute{ + Description: "The condition for when to consider a point as anomalous.", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("any", "low", "high"), + stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("outlier_id")), + }, + }, + "for": schema.StringAttribute{ + Description: "How long values must be anomalous before firing an alert.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("0s"), + Validators: []validator.String{ + anyDuration(), + }, + }, + "threshold": schema.StringAttribute{ + Description: "The threshold of points over the window that need to be anomalous to alert.", + Optional: true, + }, + "window": schema.StringAttribute{ + Description: "How much time to average values over", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("0s"), + Validators: []validator.String{ + maxDuration(24 * time.Hour), + }, + }, + "labels": schema.MapAttribute{ + Description: "Labels to add to the alert generated in Grafana.", + Optional: true, + ElementType: types.StringType, + }, + "annotations": schema.MapAttribute{ + Description: "Annotations to add to the alert generated in Grafana.", + Optional: true, + ElementType: types.StringType, + }, + "no_data_state": schema.StringAttribute{ + Description: "How the alert should be processed when no data is returned by the underlying series", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("Alerting", "NoData", "OK"), + }, + }, + }, + } +} + +func (r *alertResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Import ID looks like: /(jobs|outliers)//alerts/ + id := strings.TrimLeft(req.ID, "/") + parts := strings.Split(id, "/") + if len(parts) != 4 || + (parts[0] != "jobs" && parts[0] != "outliers") || + parts[2] != "alerts" { + resp.Diagnostics.AddError("Invalid import ID format", "Import ID must be in the format '/(jobs|outliers)//alerts/'") + return + } + model := resourceAlertModel{ + ID: types.StringValue(parts[3]), + } + if parts[0] == "jobs" { + model.JobID = types.StringValue(parts[1]) + } else { + model.OutlierID = types.StringValue(parts[1]) + } + + data, diags := r.read(ctx, model) + if diags != nil { + resp.Diagnostics = diags + return + } + if data == nil { + resp.Diagnostics.AddError("Resource not found", "Resource not found") + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, data)...) +} + +func (r *alertResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + if r.mlapi == nil { + resp.Diagnostics.AddError("Client not configured", "Client not configured") + return + } + + // Read Terraform plan data into the model + var data resourceAlertModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + alert, err := alertFromModel(data) + if err != nil { + resp.Diagnostics.AddError("Unable to make alert structure", err.Error()) + return + } + if data.JobID.ValueString() != "" { + alert, err = r.mlapi.NewJobAlert(ctx, data.JobID.ValueString(), alert) + } else { + alert, err = r.mlapi.NewOutlierAlert(ctx, data.OutlierID.ValueString(), alert) + } + if err != nil { + resp.Diagnostics.AddError("Unable to create resource", err.Error()) + return + } + + // Read created resource + data.ID = types.StringValue(alert.ID) + readData, diags := r.read(ctx, data) + if diags != nil { + resp.Diagnostics = diags + return + } + if readData == nil { + resp.Diagnostics.AddError("Unable to read created resource", "Resource not found") + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, readData)...) +} + +func (r *alertResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Read Terraform state data into the model + var data resourceAlertModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + // Read from API + readData, diags := r.read(ctx, data) + if diags != nil { + resp.Diagnostics = diags + return + } + if readData == nil { + resp.State.RemoveResource(ctx) + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, readData)...) +} + +func (r *alertResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + if r.mlapi == nil { + resp.Diagnostics.AddError("Client not configured", "Client not configured") + return + } + + // Read Terraform plan data into the model + var data resourceAlertModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + alert, err := alertFromModel(data) + if err != nil { + resp.Diagnostics.AddError("Unable to make alert structure", err.Error()) + return + } + if data.JobID.ValueString() != "" { + _, err = r.mlapi.UpdateJobAlert(ctx, data.JobID.ValueString(), alert) + } else { + _, err = r.mlapi.UpdateOutlierAlert(ctx, data.OutlierID.ValueString(), alert) + } + if err != nil { + resp.Diagnostics.AddError("Unable to Update Resource", err.Error()) + return + } + + // Read updated resource + readData, diags := r.read(ctx, data) + if diags != nil { + resp.Diagnostics = diags + return + } + if readData == nil { + resp.Diagnostics.AddError("Unable to read updated resource", "Resource not found") + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, readData)...) +} + +func (r *alertResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + if r.mlapi == nil { + resp.Diagnostics.AddError("Client not configured", "Client not configured") + return + } + + // Read Terraform plan data into the model + var data resourceAlertModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + var err error + if data.JobID.ValueString() != "" { + err = r.mlapi.DeleteJobAlert(ctx, data.JobID.ValueString(), data.ID.ValueString()) + } else { + err = r.mlapi.DeleteOutlierAlert(ctx, data.OutlierID.ValueString(), data.ID.ValueString()) + } + if err != nil { + resp.Diagnostics.AddError("Unable to Delete Resource", err.Error()) + } +} + +func (r *alertResource) read(ctx context.Context, model resourceAlertModel) (*resourceAlertModel, diag.Diagnostics) { + if r.mlapi == nil { + return nil, diag.Diagnostics{diag.NewErrorDiagnostic("client not configured", "client not configured")} + } + + var ( + alert mlapi.Alert + err error + ) + if model.JobID.ValueString() != "" { + alert, err = r.mlapi.JobAlert(ctx, model.JobID.ValueString(), model.ID.ValueString()) + } else { + alert, err = r.mlapi.OutlierAlert(ctx, model.OutlierID.ValueString(), model.ID.ValueString()) + } + if err != nil { + return nil, diag.Diagnostics{diag.NewErrorDiagnostic("Unable to read resource", err.Error())} + } + + data := &resourceAlertModel{} + data.ID = model.ID + data.JobID = model.JobID + data.OutlierID = model.OutlierID + data.Title = types.StringValue(alert.Title) + if alert.AnomalyCondition != "" { + data.AnomalyCondition = types.StringValue(string(alert.AnomalyCondition)) + } + data.For = types.StringValue(alert.For.String()) + if alert.Threshold != "" { + data.Threshold = types.StringValue(alert.Threshold) + } + data.Window = types.StringValue(alert.Window.String()) + data.Labels = labelsToMapValue(alert.Labels) + data.Annotations = labelsToMapValue(alert.Annotations) + if alert.NoDataState != "" { + data.NoDataState = types.StringValue(string(alert.NoDataState)) + } + + return data, nil +} + +func alertFromModel(model resourceAlertModel) (mlapi.Alert, error) { + forClause, err := parseDuration(model.For.ValueString()) + if err != nil { + return mlapi.Alert{}, err + } + window, err := parseDuration(model.Window.ValueString()) + if err != nil { + return mlapi.Alert{}, err + } + labels, err := mapToLabels(model.Labels) + if err != nil { + return mlapi.Alert{}, err + } + annotations, err := mapToLabels(model.Annotations) + if err != nil { + return mlapi.Alert{}, err + } + return mlapi.Alert{ + ID: model.ID.ValueString(), + Title: model.Title.ValueString(), + AnomalyCondition: mlapi.AnomalyCondition(model.AnomalyCondition.ValueString()), + For: forClause, + Threshold: model.Threshold.ValueString(), + Window: window, + Labels: labels, + Annotations: annotations, + NoDataState: mlapi.NoDataState(model.NoDataState.ValueString()), + }, nil +} + +func labelsToMapValue(labels map[string]string) basetypes.MapValue { + if labels == nil { + return basetypes.NewMapNull(types.StringType) + } + values := map[string]attr.Value{} + for k, v := range labels { + values[k] = types.StringValue(v) + } + return types.MapValueMust(types.StringType, values) +} + +func mapToLabels(m basetypes.MapValue) (map[string]string, error) { + if m.IsNull() { + return nil, nil + } + labels := map[string]string{} + for k, v := range m.Elements() { + if vString, ok := v.(types.String); ok { + labels[k] = vString.ValueString() + } else { + return nil, fmt.Errorf("invalid label value for %s: %v", k, v) + } + } + return labels, nil +} + +func parseDuration(s string) (model.Duration, error) { + if s == "" { + return 0, nil + } + return model.ParseDuration(s) +} + +type durationValidator struct { + max model.Duration +} + +func (v durationValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v durationValidator) MarkdownDescription(_ context.Context) string { + if v.max == 0 { + return "value must be a duration like 5m" + } + return fmt.Sprintf("value must be a duration less than: %s", v.max) +} + +func (v durationValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue + + duration, err := model.ParseDuration(request.ConfigValue.ValueString()) + + if err != nil || (v.max > 0 && duration > v.max) { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + request.Path, + v.Description(ctx), + value.String(), + )) + } +} + +func anyDuration() validator.String { + return durationValidator{} +} + +func maxDuration(max time.Duration) validator.String { + return durationValidator{ + max: model.Duration(max), + } +} diff --git a/internal/resources/machinelearning/resource_alert_test.go b/internal/resources/machinelearning/resource_alert_test.go new file mode 100644 index 000000000..15c9cfa01 --- /dev/null +++ b/internal/resources/machinelearning/resource_alert_test.go @@ -0,0 +1,241 @@ +package machinelearning_test + +import ( + "context" + "fmt" + "regexp" + "testing" + + "github.com/grafana/machine-learning-go-client/mlapi" + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/grafana/terraform-provider-grafana/v3/internal/testutils" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccResourceJobAlert(t *testing.T) { + testutils.CheckCloudInstanceTestsEnabled(t) + + randomJobName := acctest.RandomWithPrefix("Test Job") + randomAlertName := acctest.RandomWithPrefix("Test Alert") + + var job mlapi.Job + var alert mlapi.Alert + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + CheckDestroy: resource.ComposeTestCheckFunc( + testAccMLJobAlertCheckDestroy(&job, &alert), + testAccMLJobCheckDestroy(&job), + ), + Steps: []resource.TestStep{ + { + Config: testutils.TestAccExampleWithReplace(t, "resources/grafana_machine_learning_alert/forecast_alert.tf", map[string]string{ + "Test Job": randomJobName, + "Test Alert": randomAlertName, + }), + Check: resource.ComposeTestCheckFunc( + testAccMLJobCheckExists("grafana_machine_learning_job.test_alert_job", &job), + testAccMLJobAlertCheckExists("grafana_machine_learning_alert.test_job_alert", &job, &alert), + resource.TestCheckResourceAttrSet("grafana_machine_learning_alert.test_job_alert", "id"), + resource.TestCheckResourceAttr("grafana_machine_learning_alert.test_job_alert", "title", randomAlertName), + resource.TestCheckResourceAttr("grafana_machine_learning_alert.test_job_alert", "anomaly_condition", "any"), + resource.TestCheckResourceAttr("grafana_machine_learning_alert.test_job_alert", "threshold", ">0.8"), + resource.TestCheckResourceAttr("grafana_machine_learning_alert.test_job_alert", "window", "15m"), + ), + }, + // Update the alert with a new anomaly condition. + { + Config: testutils.TestAccExampleWithReplace(t, "resources/grafana_machine_learning_alert/forecast_alert.tf", map[string]string{ + "Test Job": randomJobName, + "Test Alert": randomAlertName, + "\"any\"": "\"low\"", + }), + Check: resource.ComposeTestCheckFunc( + testAccMLJobCheckExists("grafana_machine_learning_job.test_alert_job", &job), + testAccMLJobAlertCheckExists("grafana_machine_learning_alert.test_job_alert", &job, &alert), + resource.TestCheckResourceAttrSet("grafana_machine_learning_alert.test_job_alert", "id"), + resource.TestCheckResourceAttr("grafana_machine_learning_alert.test_job_alert", "title", randomAlertName), + resource.TestCheckResourceAttr("grafana_machine_learning_alert.test_job_alert", "anomaly_condition", "low"), + resource.TestCheckResourceAttr("grafana_machine_learning_alert.test_job_alert", "threshold", ">0.8"), + resource.TestCheckResourceAttr("grafana_machine_learning_alert.test_job_alert", "window", "15m"), + ), + }, + { + ResourceName: "grafana_machine_learning_alert.test_job_alert", + ImportState: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return fmt.Sprintf("/jobs/%s/alerts/%s", job.ID, alert.ID), nil + }, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccResourceOutlierAlert(t *testing.T) { + testutils.CheckCloudInstanceTestsEnabled(t) + + randomOutlierName := acctest.RandomWithPrefix("Test Outlier") + randomAlertName := acctest.RandomWithPrefix("Test Alert") + + var outlier mlapi.OutlierDetector + var alert mlapi.Alert + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + CheckDestroy: resource.ComposeTestCheckFunc( + testAccMLOutlierAlertCheckDestroy(&outlier, &alert), + testAccMLOutlierCheckDestroy(&outlier), + ), + Steps: []resource.TestStep{ + { + Config: testutils.TestAccExampleWithReplace(t, "resources/grafana_machine_learning_alert/outlier_alert.tf", map[string]string{ + "Test Outlier": randomOutlierName, + "Test Alert": randomAlertName, + }), + Check: resource.ComposeTestCheckFunc( + testAccMLOutlierCheckExists("grafana_machine_learning_outlier_detector.test_alert_outlier_detector", &outlier), + testAccMLOutlierAlertCheckExists("grafana_machine_learning_alert.test_outlier_alert", &outlier, &alert), + resource.TestCheckResourceAttr("grafana_machine_learning_alert.test_outlier_alert", "title", randomAlertName), + resource.TestCheckResourceAttr("grafana_machine_learning_alert.test_outlier_alert", "window", "1h"), + ), + }, + // Test updating the window. + { + Config: testutils.TestAccExampleWithReplace(t, "resources/grafana_machine_learning_alert/outlier_alert.tf", map[string]string{ + "Test Outlier": randomOutlierName, + "Test Alert": randomAlertName, + "\"1h\"": "\"30m\"", + }), + Check: resource.ComposeTestCheckFunc( + testAccMLOutlierCheckExists("grafana_machine_learning_outlier_detector.test_alert_outlier_detector", &outlier), + testAccMLOutlierAlertCheckExists("grafana_machine_learning_alert.test_outlier_alert", &outlier, &alert), + resource.TestCheckResourceAttr("grafana_machine_learning_alert.test_outlier_alert", "title", randomAlertName), + resource.TestCheckResourceAttr("grafana_machine_learning_alert.test_outlier_alert", "window", "30m"), + ), + }, + { + ResourceName: "grafana_machine_learning_alert.test_outlier_alert", + ImportState: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return fmt.Sprintf("/outliers/%s/alerts/%s", outlier.ID, alert.ID), nil + }, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccMLJobAlertCheckExists(rn string, job *mlapi.Job, alert *mlapi.Alert) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[rn] + if !ok { + return fmt.Errorf("resource not found: %s\n %#v", rn, s.RootModule().Resources) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("resource id not set") + } + + client := testutils.Provider.Meta().(*common.Client).MLAPI + gotAlert, err := client.JobAlert(context.Background(), job.ID, rs.Primary.ID) + if err != nil { + return fmt.Errorf("error getting job: %s", err) + } + + *alert = gotAlert + + return nil + } +} + +func testAccMLOutlierAlertCheckExists(rn string, outlier *mlapi.OutlierDetector, alert *mlapi.Alert) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[rn] + if !ok { + return fmt.Errorf("resource not found: %s\n %#v", rn, s.RootModule().Resources) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("resource id not set") + } + + client := testutils.Provider.Meta().(*common.Client).MLAPI + gotAlert, err := client.OutlierAlert(context.Background(), outlier.ID, rs.Primary.ID) + if err != nil { + return fmt.Errorf("error getting job: %s", err) + } + + *alert = gotAlert + + return nil + } +} + +func testAccMLJobAlertCheckDestroy(job *mlapi.Job, alert *mlapi.Alert) resource.TestCheckFunc { + return func(s *terraform.State) error { + // This check is to make sure that no pointer conversions are incorrect + // while mutating alert. + if job.ID == "" { + return fmt.Errorf("checking deletion of empty job id") + } + if alert.ID == "" { + return fmt.Errorf("checking deletion of empty alert id") + } + client := testutils.Provider.Meta().(*common.Client).MLAPI + _, err := client.JobAlert(context.Background(), job.ID, alert.ID) + if err == nil { + return fmt.Errorf("job still exists on server") + } + return nil + } +} + +func testAccMLOutlierAlertCheckDestroy(outlier *mlapi.OutlierDetector, alert *mlapi.Alert) resource.TestCheckFunc { + return func(s *terraform.State) error { + // This check is to make sure that no pointer conversions are incorrect + // while mutating alert. + if outlier.ID == "" { + return fmt.Errorf("checking deletion of empty outlier id") + } + if alert.ID == "" { + return fmt.Errorf("checking deletion of empty alert id") + } + client := testutils.Provider.Meta().(*common.Client).MLAPI + _, err := client.OutlierAlert(context.Background(), outlier.ID, alert.ID) + if err == nil { + return fmt.Errorf("job still exists on server") + } + return nil + } +} + +func TestAccResourceInvalidMachineLearningAlert(t *testing.T) { + testutils.CheckCloudInstanceTestsEnabled(t) + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +resource "grafana_machine_learning_alert" "invalid" { + job_id = "xyz" + title = "Test Job" + for = "foo" +} +`, + ExpectError: regexp.MustCompile(".*value must be a duration.*"), + }, + { + Config: ` +resource "grafana_machine_learning_alert" "invalid" { + job_id = "xyz" + title = "Test Job" + window = "25h" +} +`, + ExpectError: regexp.MustCompile(".*value must be a duration less than: 1d.*"), + }, + }, + }) +} diff --git a/internal/resources/machinelearning/resource_holiday.go b/internal/resources/machinelearning/resource_holiday.go index 77750c5ac..23d53e975 100644 --- a/internal/resources/machinelearning/resource_holiday.go +++ b/internal/resources/machinelearning/resource_holiday.go @@ -109,7 +109,9 @@ resource "grafana_machine_learning_job" "test_job" { "grafana_machine_learning_holiday", resourceHolidayID, schema, - ).WithLister(lister(listHolidays)) + ). + WithLister(lister(listHolidays)). + WithPreferredResourceNameField("name") } func listHolidays(ctx context.Context, client *mlapi.Client) ([]string, error) { @@ -180,11 +182,7 @@ func resourceHolidayUpdate(ctx context.Context, d *schema.ResourceData, meta int func resourceHolidayDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { c := meta.(*common.Client).MLAPI err := c.DeleteHoliday(ctx, d.Id()) - if err != nil { - return diag.FromErr(err) - } - d.SetId("") - return nil + return diag.FromErr(err) } func makeMLHoliday(d *schema.ResourceData) (mlapi.Holiday, error) { diff --git a/internal/resources/machinelearning/resource_holiday_test.go b/internal/resources/machinelearning/resource_holiday_test.go index 93fe3ebfe..b0e20f176 100644 --- a/internal/resources/machinelearning/resource_holiday_test.go +++ b/internal/resources/machinelearning/resource_holiday_test.go @@ -32,6 +32,7 @@ func TestAccResourceHoliday(t *testing.T) { testAccMLHolidayCheckExists("grafana_machine_learning_holiday.ical", &holiday), resource.TestCheckResourceAttrSet("grafana_machine_learning_holiday.ical", "id"), resource.TestCheckResourceAttr("grafana_machine_learning_holiday.ical", "name", randomName), + testutils.CheckLister("grafana_machine_learning_holiday.ical"), ), }, { diff --git a/internal/resources/machinelearning/resource_job.go b/internal/resources/machinelearning/resource_job.go index 54d7e347a..32b3a09cd 100644 --- a/internal/resources/machinelearning/resource_job.go +++ b/internal/resources/machinelearning/resource_job.go @@ -104,7 +104,9 @@ A job defines the queries and model parameters for a machine learning task. "grafana_machine_learning_job", resourceJobID, schema, - ).WithLister(lister(listJobs)) + ). + WithLister(lister(listJobs)). + WithPreferredResourceNameField("name") } func listJobs(ctx context.Context, client *mlapi.Client) ([]string, error) { @@ -175,11 +177,7 @@ func resourceJobUpdate(ctx context.Context, d *schema.ResourceData, meta interfa func resourceJobDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { c := meta.(*common.Client).MLAPI err := c.DeleteJob(ctx, d.Id()) - if err != nil { - return diag.FromErr(err) - } - d.SetId("") - return nil + return diag.FromErr(err) } func makeMLJob(d *schema.ResourceData, meta interface{}) (mlapi.Job, error) { diff --git a/internal/resources/machinelearning/resource_job_test.go b/internal/resources/machinelearning/resource_job_test.go index 43684d7df..8bd86dd45 100644 --- a/internal/resources/machinelearning/resource_job_test.go +++ b/internal/resources/machinelearning/resource_job_test.go @@ -42,6 +42,7 @@ func TestAccResourceJob(t *testing.T) { resource.TestCheckResourceAttr("grafana_machine_learning_job.test_job", "query_params.expr", "grafanacloud_grafana_instance_active_user_count"), resource.TestCheckResourceAttr("grafana_machine_learning_job.test_job", "interval", "300"), resource.TestCheckResourceAttr("grafana_machine_learning_job.test_job", "training_window", "7776000"), + testutils.CheckLister("grafana_machine_learning_job.test_job"), ), }, { diff --git a/internal/resources/machinelearning/resource_outlier_detector.go b/internal/resources/machinelearning/resource_outlier_detector.go index c5e5e0fc1..ed178e3d0 100644 --- a/internal/resources/machinelearning/resource_outlier_detector.go +++ b/internal/resources/machinelearning/resource_outlier_detector.go @@ -122,7 +122,9 @@ Visit https://grafana.com/docs/grafana-cloud/machine-learning/outlier-detection/ "grafana_machine_learning_outlier_detector", resourceOutlierDetectorID, schema, - ).WithLister(lister(listOutliers)) + ). + WithLister(lister(listOutliers)). + WithPreferredResourceNameField("name") } func listOutliers(ctx context.Context, client *mlapi.Client) ([]string, error) { @@ -186,11 +188,7 @@ func resourceOutlierUpdate(ctx context.Context, d *schema.ResourceData, meta int func resourceOutlierDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { c := meta.(*common.Client).MLAPI err := c.DeleteOutlierDetector(ctx, d.Id()) - if err != nil { - return diag.FromErr(err) - } - d.SetId("") - return nil + return diag.FromErr(err) } func convertToSetStructure(al mlapi.OutlierAlgorithm) []interface{} { diff --git a/internal/resources/machinelearning/resource_outlier_detector_test.go b/internal/resources/machinelearning/resource_outlier_detector_test.go index 5b05840aa..5b13d3d23 100644 --- a/internal/resources/machinelearning/resource_outlier_detector_test.go +++ b/internal/resources/machinelearning/resource_outlier_detector_test.go @@ -39,6 +39,7 @@ func TestAccResourceOutlierDetector(t *testing.T) { resource.TestCheckResourceAttr("grafana_machine_learning_outlier_detector.my_mad_outlier_detector", "interval", "300"), resource.TestCheckResourceAttr("grafana_machine_learning_outlier_detector.my_mad_outlier_detector", "algorithm.0.name", "mad"), resource.TestCheckResourceAttr("grafana_machine_learning_outlier_detector.my_mad_outlier_detector", "algorithm.0.sensitivity", "0.7"), + testutils.CheckLister("grafana_machine_learning_outlier_detector.my_mad_outlier_detector"), ), }, { diff --git a/internal/resources/machinelearning/resources.go b/internal/resources/machinelearning/resources.go index fb3b020f7..a82c6f4b7 100644 --- a/internal/resources/machinelearning/resources.go +++ b/internal/resources/machinelearning/resources.go @@ -35,4 +35,5 @@ var Resources = []*common.Resource{ resourceJob(), resourceHoliday(), resourceOutlierDetector(), + resourceAlert(), } diff --git a/internal/resources/oncall/data_source_user.go b/internal/resources/oncall/data_source_user.go index 106178df1..a82e72d0f 100644 --- a/internal/resources/oncall/data_source_user.go +++ b/internal/resources/oncall/data_source_user.go @@ -2,64 +2,80 @@ package oncall import ( "context" + "fmt" onCallAPI "github.com/grafana/amixr-api-go-client" "github.com/grafana/terraform-provider-grafana/v3/internal/common" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) +var dataSourceUserName = "grafana_oncall_user" + func dataSourceUser() *common.DataSource { - schema := &schema.Resource{ - Description: ` -* [HTTP API](https://grafana.com/docs/oncall/latest/oncall-api-reference/users/) -`, - ReadContext: withClient[schema.ReadContextFunc](dataSourceUserRead), - Schema: map[string]*schema.Schema{ - "username": { - Type: schema.TypeString, + return common.NewDataSource(common.CategoryOnCall, dataSourceUserName, &userDataSource{}) +} + +type userDataSource struct { + basePluginFrameworkDataSource +} + +func (r *userDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = dataSourceUserName +} + +func (r *userDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "* [HTTP API](https://grafana.com/docs/oncall/latest/oncall-api-reference/users/)", + Attributes: map[string]schema.Attribute{ + "username": schema.StringAttribute{ Required: true, Description: "The username of the user.", }, - "email": { - Type: schema.TypeString, + "id": schema.StringAttribute{ + Computed: true, + Description: "The ID of the user.", + }, + "email": schema.StringAttribute{ Computed: true, Description: "The email of the user.", }, - "role": { - Type: schema.TypeString, + "role": schema.StringAttribute{ Computed: true, Description: "The role of the user.", }, }, } - return common.NewLegacySDKDataSource(common.CategoryOnCall, "grafana_oncall_user", schema) } -func dataSourceUserRead(ctx context.Context, d *schema.ResourceData, client *onCallAPI.Client) diag.Diagnostics { - options := &onCallAPI.ListUserOptions{} - usernameData := d.Get("username").(string) - - options.Username = usernameData +func (r *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + // Read Terraform state data into the model + var data userDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) - usersResponse, _, err := client.Users.ListUsers(options) + options := &onCallAPI.ListUserOptions{ + Username: data.Username.ValueString(), + } + usersResponse, _, err := r.client.Users.ListUsers(options) if err != nil { - return diag.FromErr(err) + resp.Diagnostics.AddError("Failed to list users", err.Error()) + return } if len(usersResponse.Users) == 0 { - return diag.Errorf("couldn't find a user matching: %s", options.Username) + resp.Diagnostics.AddError("user not found", fmt.Sprintf("couldn't find a user matching: %s", options.Username)) + return } else if len(usersResponse.Users) != 1 { - return diag.Errorf("more than one user found matching: %s", options.Username) + resp.Diagnostics.AddError("more than one user found", fmt.Sprintf("more than one user found matching: %s", options.Username)) + return } user := usersResponse.Users[0] + data.ID = basetypes.NewStringValue(user.ID) + data.Email = basetypes.NewStringValue(user.Email) + data.Role = basetypes.NewStringValue(user.Role) - d.Set("email", user.Email) - d.Set("username", user.Username) - d.Set("role", user.Role) - - d.SetId(user.ID) - - return nil + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, data)...) } diff --git a/internal/resources/oncall/data_source_users.go b/internal/resources/oncall/data_source_users.go new file mode 100644 index 000000000..f468e453c --- /dev/null +++ b/internal/resources/oncall/data_source_users.go @@ -0,0 +1,101 @@ +package oncall + +import ( + "context" + + onCallAPI "github.com/grafana/amixr-api-go-client" + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +var dataSourceUsersName = "grafana_oncall_users" + +func dataSourceUsers() *common.DataSource { + return common.NewDataSource(common.CategoryOnCall, dataSourceUsersName, &usersDataSource{}) +} + +type usersDataSource struct { + basePluginFrameworkDataSource +} + +func (r *usersDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = dataSourceUsersName +} + +func (r *usersDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "* [HTTP API](https://grafana.com/docs/oncall/latest/oncall-api-reference/users/)", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "users": schema.ListAttribute{ + ElementType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "username": types.StringType, + "email": types.StringType, + "role": types.StringType, + }, + }, + Computed: true, + }, + }, + } +} + +type userDataSourceModel struct { + ID basetypes.StringValue `tfsdk:"id"` + Username basetypes.StringValue `tfsdk:"username"` + Email basetypes.StringValue `tfsdk:"email"` + Role basetypes.StringValue `tfsdk:"role"` +} + +type usersDataSourceModel struct { + ID basetypes.StringValue `tfsdk:"id"` + Users []userDataSourceModel `tfsdk:"users"` +} + +func (r *usersDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + // Read Terraform state data into the model + var data usersDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + allUsers := []userDataSourceModel{} + page := 1 + for { + options := &onCallAPI.ListUserOptions{ + ListOptions: onCallAPI.ListOptions{ + Page: page, + }, + } + usersResponse, _, err := r.client.Users.ListUsers(options) + if err != nil { + resp.Diagnostics.AddError("Failed to list users", err.Error()) + return + } + + for _, user := range usersResponse.Users { + allUsers = append(allUsers, userDataSourceModel{ + ID: basetypes.NewStringValue(user.ID), + Username: basetypes.NewStringValue(user.Username), + Email: basetypes.NewStringValue(user.Email), + Role: basetypes.NewStringValue(user.Role), + }) + } + + if usersResponse.PaginatedResponse.Next == nil { + break + } + } + + data.ID = basetypes.NewStringValue("oncall_users") // singleton + data.Users = allUsers + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, data)...) +} diff --git a/internal/resources/oncall/resource_escalation.go b/internal/resources/oncall/resource_escalation.go index 56a541ba2..479a67f88 100644 --- a/internal/resources/oncall/resource_escalation.go +++ b/internal/resources/oncall/resource_escalation.go @@ -3,7 +3,6 @@ package oncall import ( "context" "fmt" - "log" "net/http" "strings" @@ -225,7 +224,8 @@ func resourceEscalation() *common.Resource { "grafana_oncall_escalation", resourceID, schema, - ).WithLister(oncallListerFunction(listEscalations)) + ). + WithLister(oncallListerFunction(listEscalations)) } func listEscalations(client *onCallAPI.Client, listOptions onCallAPI.ListOptions) (ids []string, nextPage *string, err error) { @@ -356,9 +356,7 @@ func resourceEscalationRead(ctx context.Context, d *schema.ResourceData, client escalation, r, err := client.Escalations.GetEscalation(d.Id(), &onCallAPI.GetEscalationOptions{}) if err != nil { if r != nil && r.StatusCode == http.StatusNotFound { - log.Printf("[WARN] removing escalation %s from state because it no longer exists", d.Id()) - d.SetId("") - return nil + return common.WarnMissing("escalation", d) } return diag.FromErr(err) } @@ -366,16 +364,36 @@ func resourceEscalationRead(ctx context.Context, d *schema.ResourceData, client d.Set("escalation_chain_id", escalation.EscalationChainId) d.Set("position", escalation.Position) d.Set("type", escalation.Type) - d.Set("duration", escalation.Duration) - d.Set("notify_on_call_from_schedule", escalation.NotifyOnCallFromSchedule) - d.Set("persons_to_notify", escalation.PersonsToNotify) - d.Set("persons_to_notify_next_each_time", escalation.PersonsToNotifyEachTime) - d.Set("notify_to_team_members", escalation.TeamToNotify) - d.Set("group_to_notify", escalation.GroupToNotify) - d.Set("action_to_trigger", escalation.ActionToTrigger) - d.Set("important", escalation.Important) - d.Set("notify_if_time_from", escalation.NotifyIfTimeFrom) - d.Set("notify_if_time_to", escalation.NotifyIfTimeTo) + if escalation.Duration != nil { + d.Set("duration", escalation.Duration) + } + if escalation.NotifyOnCallFromSchedule != nil { + d.Set("notify_on_call_from_schedule", escalation.NotifyOnCallFromSchedule) + } + if escalation.PersonsToNotify != nil { + d.Set("persons_to_notify", escalation.PersonsToNotify) + } + if escalation.PersonsToNotifyEachTime != nil { + d.Set("persons_to_notify_next_each_time", escalation.PersonsToNotifyEachTime) + } + if escalation.TeamToNotify != nil { + d.Set("notify_to_team_members", escalation.TeamToNotify) + } + if escalation.GroupToNotify != nil { + d.Set("group_to_notify", escalation.GroupToNotify) + } + if escalation.ActionToTrigger != nil { + d.Set("action_to_trigger", escalation.ActionToTrigger) + } + if escalation.Important != nil { + d.Set("important", escalation.Important) + } + if escalation.NotifyIfTimeFrom != nil { + d.Set("notify_if_time_from", escalation.NotifyIfTimeFrom) + } + if escalation.NotifyIfTimeTo != nil { + d.Set("notify_if_time_to", escalation.NotifyIfTimeTo) + } return nil } @@ -473,11 +491,5 @@ func resourceEscalationUpdate(ctx context.Context, d *schema.ResourceData, clien func resourceEscalationDelete(ctx context.Context, d *schema.ResourceData, client *onCallAPI.Client) diag.Diagnostics { _, err := client.Escalations.DeleteEscalation(d.Id(), &onCallAPI.DeleteEscalationOptions{}) - if err != nil { - return diag.FromErr(err) - } - - d.SetId("") - - return nil + return diag.FromErr(err) } diff --git a/internal/resources/oncall/resource_escalation_chain.go b/internal/resources/oncall/resource_escalation_chain.go index 86cf4593a..381c0f60f 100644 --- a/internal/resources/oncall/resource_escalation_chain.go +++ b/internal/resources/oncall/resource_escalation_chain.go @@ -2,7 +2,6 @@ package oncall import ( "context" - "log" "net/http" onCallAPI "github.com/grafana/amixr-api-go-client" @@ -43,7 +42,9 @@ func resourceEscalationChain() *common.Resource { "grafana_oncall_escalation_chain", resourceID, schema, - ).WithLister(oncallListerFunction(listEscalationChains)) + ). + WithLister(oncallListerFunction(listEscalationChains)). + WithPreferredResourceNameField("name") } func listEscalationChains(client *onCallAPI.Client, listOptions onCallAPI.ListOptions) (ids []string, nextPage *string, err error) { @@ -80,9 +81,7 @@ func resourceEscalationChainRead(ctx context.Context, d *schema.ResourceData, cl escalationChain, r, err := client.EscalationChains.GetEscalationChain(d.Id(), &onCallAPI.GetEscalationChainOptions{}) if err != nil { if r != nil && r.StatusCode == http.StatusNotFound { - log.Printf("[WARN] removing escalation chain %s from state because it no longer exists", d.Get("name").(string)) - d.SetId("") - return nil + return common.WarnMissing("escalation chain", d) } return diag.FromErr(err) } @@ -113,11 +112,5 @@ func resourceEscalationChainUpdate(ctx context.Context, d *schema.ResourceData, func resourceEscalationChainDelete(ctx context.Context, d *schema.ResourceData, client *onCallAPI.Client) diag.Diagnostics { _, err := client.EscalationChains.DeleteEscalationChain(d.Id(), &onCallAPI.DeleteEscalationChainOptions{}) - if err != nil { - return diag.FromErr(err) - } - - d.SetId("") - - return nil + return diag.FromErr(err) } diff --git a/internal/resources/oncall/resource_escalation_test.go b/internal/resources/oncall/resource_escalation_test.go index 7e060e0e7..33b9acc8c 100644 --- a/internal/resources/oncall/resource_escalation_test.go +++ b/internal/resources/oncall/resource_escalation_test.go @@ -40,6 +40,21 @@ func TestAccOnCallEscalation_basic(t *testing.T) { resource.TestCheckResourceAttrSet("grafana_oncall_escalation.test-acc-escalation-policy-team", "notify_to_team_members"), ), }, + { + ImportState: true, + ResourceName: "grafana_oncall_escalation.test-acc-escalation", + ImportStateVerify: true, + }, + { + ImportState: true, + ResourceName: "grafana_oncall_escalation.test-acc-escalation-repeat", + ImportStateVerify: true, + }, + { + ImportState: true, + ResourceName: "grafana_oncall_escalation.test-acc-escalation-policy-team", + ImportStateVerify: true, + }, }, }) } diff --git a/internal/resources/oncall/resource_integration.go b/internal/resources/oncall/resource_integration.go index 5ad41f527..dcc865656 100644 --- a/internal/resources/oncall/resource_integration.go +++ b/internal/resources/oncall/resource_integration.go @@ -3,7 +3,6 @@ package oncall import ( "context" "fmt" - "log" "net/http" "strings" @@ -49,7 +48,7 @@ var integrationTypesVerbal = strings.Join(integrationTypes, ", ") func resourceIntegration() *common.Resource { schema := &schema.Resource{ Description: ` -* [Official documentation](https://grafana.com/docs/oncall/latest/integrations/) +* [Official documentation](https://grafana.com/docs/oncall/latest/configure/integrations/) * [HTTP API](https://grafana.com/docs/oncall/latest/oncall-api-reference/) `, @@ -241,7 +240,9 @@ func resourceIntegration() *common.Resource { "grafana_oncall_integration", resourceID, schema, - ).WithLister(oncallListerFunction(listIntegrations)) + ). + WithLister(oncallListerFunction(listIntegrations)). + WithPreferredResourceNameField("name") } func listIntegrations(client *onCallAPI.Client, listOptions onCallAPI.ListOptions) (ids []string, nextPage *string, err error) { @@ -346,9 +347,7 @@ func resourceIntegrationRead(ctx context.Context, d *schema.ResourceData, client integration, r, err := client.Integrations.GetIntegration(d.Id(), options) if err != nil { if r != nil && r.StatusCode == http.StatusNotFound { - log.Printf("[WARN] removing integreation %s from state because it no longer exists", d.Get("name").(string)) - d.SetId("") - return nil + return common.WarnMissing("integration", d) } return diag.FromErr(err) } @@ -366,13 +365,7 @@ func resourceIntegrationRead(ctx context.Context, d *schema.ResourceData, client func resourceIntegrationDelete(ctx context.Context, d *schema.ResourceData, client *onCallAPI.Client) diag.Diagnostics { options := &onCallAPI.DeleteIntegrationOptions{} _, err := client.Integrations.DeleteIntegration(d.Id(), options) - if err != nil { - return diag.FromErr(err) - } - - d.SetId("") - - return nil + return diag.FromErr(err) } func flattenRouteSlack(in *onCallAPI.SlackRoute) []map[string]interface{} { diff --git a/internal/resources/oncall/resource_outgoing_webhook.go b/internal/resources/oncall/resource_outgoing_webhook.go index 5fc75c9d4..bd1cfee24 100644 --- a/internal/resources/oncall/resource_outgoing_webhook.go +++ b/internal/resources/oncall/resource_outgoing_webhook.go @@ -2,7 +2,6 @@ package oncall import ( "context" - "log" "net/http" onCallAPI "github.com/grafana/amixr-api-go-client" @@ -109,7 +108,9 @@ func resourceOutgoingWebhook() *common.Resource { "grafana_oncall_outgoing_webhook", resourceID, schema, - ).WithLister(oncallListerFunction(listWebhooks)) + ). + WithLister(oncallListerFunction(listWebhooks)). + WithPreferredResourceNameField("name") } func listWebhooks(client *onCallAPI.Client, listOptions onCallAPI.ListOptions) (ids []string, nextPage *string, err error) { @@ -206,9 +207,7 @@ func resourceOutgoingWebhookRead(ctx context.Context, d *schema.ResourceData, cl outgoingWebhook, r, err := client.Webhooks.GetWebhook(d.Id(), &onCallAPI.GetWebhookOptions{}) if err != nil { if r != nil && r.StatusCode == http.StatusNotFound { - log.Printf("[WARN] removing outgoingWebhook %s from state because it no longer exists", d.Get("name").(string)) - d.SetId("") - return nil + return common.WarnMissing("outgoing webhook", d) } return diag.FromErr(err) } @@ -309,11 +308,5 @@ func resourceOutgoingWebhookUpdate(ctx context.Context, d *schema.ResourceData, func resourceOutgoingWebhookDelete(ctx context.Context, d *schema.ResourceData, client *onCallAPI.Client) diag.Diagnostics { _, err := client.Webhooks.DeleteWebhook(d.Id(), &onCallAPI.DeleteWebhookOptions{}) - if err != nil { - return diag.FromErr(err) - } - - d.SetId("") - - return nil + return diag.FromErr(err) } diff --git a/internal/resources/oncall/resource_route.go b/internal/resources/oncall/resource_route.go index e2e99d1e1..9d4dd3400 100644 --- a/internal/resources/oncall/resource_route.go +++ b/internal/resources/oncall/resource_route.go @@ -3,7 +3,6 @@ package oncall import ( "context" "fmt" - "log" "net/http" "strings" @@ -185,9 +184,7 @@ func resourceRouteRead(ctx context.Context, d *schema.ResourceData, client *onCa route, r, err := client.Routes.GetRoute(d.Id(), &onCallAPI.GetRouteOptions{}) if err != nil { if r != nil && r.StatusCode == http.StatusNotFound { - log.Printf("[WARN] removing route %s from state because it no longer exists", d.Id()) - d.SetId("") - return nil + return common.WarnMissing("route", d) } return diag.FromErr(err) } @@ -246,11 +243,5 @@ func resourceRouteUpdate(ctx context.Context, d *schema.ResourceData, client *on func resourceRouteDelete(ctx context.Context, d *schema.ResourceData, client *onCallAPI.Client) diag.Diagnostics { _, err := client.Routes.DeleteRoute(d.Id(), &onCallAPI.DeleteRouteOptions{}) - if err != nil { - return diag.FromErr(err) - } - - d.SetId("") - - return nil + return diag.FromErr(err) } diff --git a/internal/resources/oncall/resource_schedule.go b/internal/resources/oncall/resource_schedule.go index 4e4f75c43..5d535b9c2 100644 --- a/internal/resources/oncall/resource_schedule.go +++ b/internal/resources/oncall/resource_schedule.go @@ -2,8 +2,8 @@ package oncall import ( "context" - "log" "net/http" + "slices" "strings" onCallAPI "github.com/grafana/amixr-api-go-client" @@ -106,7 +106,9 @@ func resourceSchedule() *common.Resource { "grafana_oncall_schedule", resourceID, schema, - ).WithLister(oncallListerFunction(listSchedules)) + ). + WithLister(oncallListerFunction(listSchedules)). + WithPreferredResourceNameField("name") } func listSchedules(client *onCallAPI.Client, listOptions onCallAPI.ListOptions) (ids []string, nextPage *string, err error) { @@ -115,6 +117,9 @@ func listSchedules(client *onCallAPI.Client, listOptions onCallAPI.ListOptions) return nil, nil, err } for _, i := range resp.Schedules { + if !slices.Contains(scheduleTypeOptions, i.Type) { + continue + } ids = append(ids, i.ID) } return ids, resp.Next, nil @@ -252,9 +257,7 @@ func resourceScheduleRead(ctx context.Context, d *schema.ResourceData, client *o schedule, r, err := client.Schedules.GetSchedule(d.Id(), options) if err != nil { if r != nil && r.StatusCode == http.StatusNotFound { - log.Printf("[WARN] removing schedule %s from state because it no longer exists", d.Get("name").(string)) - d.SetId("") - return nil + return common.WarnMissing("schedule", d) } return diag.FromErr(err) } @@ -275,13 +278,7 @@ func resourceScheduleRead(ctx context.Context, d *schema.ResourceData, client *o func resourceScheduleDelete(ctx context.Context, d *schema.ResourceData, client *onCallAPI.Client) diag.Diagnostics { options := &onCallAPI.DeleteScheduleOptions{} _, err := client.Schedules.DeleteSchedule(d.Id(), options) - if err != nil { - return diag.FromErr(err) - } - - d.SetId("") - - return nil + return diag.FromErr(err) } func flattenScheduleSlack(in *onCallAPI.SlackSchedule) []map[string]interface{} { diff --git a/internal/resources/oncall/resource_schedule_test.go b/internal/resources/oncall/resource_schedule_test.go index 222687def..5876cfdc7 100644 --- a/internal/resources/oncall/resource_schedule_test.go +++ b/internal/resources/oncall/resource_schedule_test.go @@ -28,6 +28,11 @@ func TestAccOnCallSchedule_basic(t *testing.T) { resource.TestCheckResourceAttr("grafana_oncall_schedule.test-acc-schedule", "enable_web_overrides", "false"), ), }, + { + ImportState: true, + ResourceName: "grafana_oncall_schedule.test-acc-schedule", + ImportStateVerify: true, + }, { Config: testAccOnCallScheduleConfigOverrides(scheduleName, true), Check: resource.ComposeTestCheckFunc( @@ -35,6 +40,11 @@ func TestAccOnCallSchedule_basic(t *testing.T) { resource.TestCheckResourceAttr("grafana_oncall_schedule.test-acc-schedule", "enable_web_overrides", "true"), ), }, + { + ImportState: true, + ResourceName: "grafana_oncall_schedule.test-acc-schedule", + ImportStateVerify: true, + }, { Config: testAccOnCallScheduleConfigOverrides(scheduleName, false), Check: resource.ComposeTestCheckFunc( @@ -42,6 +52,11 @@ func TestAccOnCallSchedule_basic(t *testing.T) { resource.TestCheckResourceAttr("grafana_oncall_schedule.test-acc-schedule", "enable_web_overrides", "false"), ), }, + { + ImportState: true, + ResourceName: "grafana_oncall_schedule.test-acc-schedule", + ImportStateVerify: true, + }, }, }) } diff --git a/internal/resources/oncall/resource_shift.go b/internal/resources/oncall/resource_shift.go index 49525c1b6..c8862d0ce 100644 --- a/internal/resources/oncall/resource_shift.go +++ b/internal/resources/oncall/resource_shift.go @@ -3,7 +3,6 @@ package oncall import ( "context" "fmt" - "log" "net/http" "strings" @@ -187,7 +186,9 @@ func resourceOnCallShift() *common.Resource { "grafana_oncall_on_call_shift", resourceID, schema, - ).WithLister(oncallListerFunction(listShifts)) + ). + WithLister(oncallListerFunction(listShifts)). + WithPreferredResourceNameField("name") } func listShifts(client *onCallAPI.Client, listOptions onCallAPI.ListOptions) (ids []string, nextPage *string, err error) { @@ -293,6 +294,12 @@ func resourceOnCallShiftCreate(ctx context.Context, d *schema.ResourceData, clie rollingUsersData, rollingUsersOk := d.GetOk(rollingUsers) if rollingUsersOk { if typeData == rollingUsers { + listSet := rollingUsersData.([]interface{}) + for _, set := range listSet { + if set == nil { + return diag.Errorf("`rolling_users` can not include an empty group") + } + } rollingUsersDataSlice := common.ListOfSetsToStringSlice(rollingUsersData.([]interface{})) createOptions.RollingUsers = &rollingUsersDataSlice } else { @@ -420,6 +427,12 @@ func resourceOnCallShiftUpdate(ctx context.Context, d *schema.ResourceData, clie rollingUsersData, rollingUsersOk := d.GetOk(rollingUsers) if rollingUsersOk { if typeData == rollingUsers { + listSet := rollingUsersData.([]interface{}) + for _, set := range listSet { + if set == nil { + return diag.Errorf("`rolling_users` can not include an empty group") + } + } rollingUsersDataSlice := common.ListOfSetsToStringSlice(rollingUsersData.([]interface{})) updateOptions.RollingUsers = &rollingUsersDataSlice } else { @@ -448,9 +461,7 @@ func resourceOnCallShiftRead(ctx context.Context, d *schema.ResourceData, client onCallShift, r, err := client.OnCallShifts.GetOnCallShift(d.Id(), options) if err != nil { if r != nil && r.StatusCode == http.StatusNotFound { - log.Printf("[WARN] removing on-call shift %s from state because it no longer exists", d.Id()) - d.SetId("") - return nil + return common.WarnMissing("on-call shift", d) } return diag.FromErr(err) } @@ -478,11 +489,5 @@ func resourceOnCallShiftRead(ctx context.Context, d *schema.ResourceData, client func resourceOnCallShiftDelete(ctx context.Context, d *schema.ResourceData, client *onCallAPI.Client) diag.Diagnostics { options := &onCallAPI.DeleteOnCallShiftOptions{} _, err := client.OnCallShifts.DeleteOnCallShift(d.Id(), options) - if err != nil { - return diag.FromErr(err) - } - - d.SetId("") - - return nil + return diag.FromErr(err) } diff --git a/internal/resources/oncall/resource_shift_test.go b/internal/resources/oncall/resource_shift_test.go index 8c00be0e6..e92cd9b10 100644 --- a/internal/resources/oncall/resource_shift_test.go +++ b/internal/resources/oncall/resource_shift_test.go @@ -2,6 +2,7 @@ package oncall_test import ( "fmt" + "regexp" "testing" onCallAPI "github.com/grafana/amixr-api-go-client" @@ -46,6 +47,17 @@ func TestAccOnCallOnCallShift_basic(t *testing.T) { resource.TestCheckResourceAttr("grafana_oncall_on_call_shift.test-acc-on_call_shift", "frequency", "hourly"), ), }, + { + Config: testAccOnCallOnCallShiftEmptyRollingUsers(scheduleName, shiftName), + Check: resource.ComposeTestCheckFunc( + testAccCheckOnCallOnCallShiftResourceExists("grafana_oncall_on_call_shift.test-acc-on_call_shift"), + resource.TestCheckResourceAttr("grafana_oncall_on_call_shift.test-acc-on_call_shift", "rolling_users.#", "0"), + ), + }, + { + Config: testAccOnCallOnCallShiftRollingUsersEmptyGroup(scheduleName, shiftName), + ExpectError: regexp.MustCompile("Error: `rolling_users` can not include an empty group"), + }, { Config: testAccOnCallOnCallShiftConfigSingle(scheduleName, shiftName), Check: resource.ComposeTestCheckFunc( @@ -113,6 +125,52 @@ resource "grafana_oncall_on_call_shift" "test-acc-on_call_shift" { `, scheduleName, shiftName) } +func testAccOnCallOnCallShiftEmptyRollingUsers(scheduleName, shiftName string) string { + return fmt.Sprintf(` +resource "grafana_oncall_schedule" "test-acc-schedule" { + type = "calendar" + name = "%s" + time_zone = "UTC" +} + +resource "grafana_oncall_on_call_shift" "test-acc-on_call_shift" { + name = "%s" + type = "rolling_users" + start = "2020-09-04T16:00:00" + duration = 3600 + level = 1 + frequency = "weekly" + week_start = "SU" + interval = 2 + by_day = ["MO", "FR"] + rolling_users = [] +} +`, scheduleName, shiftName) +} + +func testAccOnCallOnCallShiftRollingUsersEmptyGroup(scheduleName, shiftName string) string { + return fmt.Sprintf(` +resource "grafana_oncall_schedule" "test-acc-schedule" { + type = "calendar" + name = "%s" + time_zone = "UTC" +} + +resource "grafana_oncall_on_call_shift" "test-acc-on_call_shift" { + name = "%s" + type = "rolling_users" + start = "2020-09-04T16:00:00" + duration = 3600 + level = 1 + frequency = "weekly" + week_start = "SU" + interval = 2 + by_day = ["MO", "FR"] + rolling_users = [[]] +} +`, scheduleName, shiftName) +} + func testAccOnCallOnCallShiftConfigSingle(scheduleName, shiftName string) string { return fmt.Sprintf(` resource "grafana_oncall_schedule" "test-acc-schedule" { diff --git a/internal/resources/oncall/resource_user_notification_rule_test.go b/internal/resources/oncall/resource_user_notification_rule_test.go index 6bcf9def0..36058dae5 100644 --- a/internal/resources/oncall/resource_user_notification_rule_test.go +++ b/internal/resources/oncall/resource_user_notification_rule_test.go @@ -15,12 +15,8 @@ func TestAccUserNotificationRule_basic(t *testing.T) { testutils.CheckCloudInstanceTestsEnabled(t) var ( - // We need an actual user to test the resource - // This is a user created from my personal email, but it can be replaced by any existing user - userID = "joeyorlando" resourceName = "grafana_oncall_user_notification_rule.test-acc-user_notification_rule" - - testSteps []resource.TestStep + testSteps []resource.TestStep ruleTypes = []string{ "wait", @@ -41,7 +37,6 @@ func TestAccUserNotificationRule_basic(t *testing.T) { config string testCheckFuncFunctions = []resource.TestCheckFunc{ testAccCheckOnCallUserNotificationRuleResourceExists(resourceName), - // resource.TestCheckResourceAttr(resourceName, "user_id", userID), resource.TestCheckResourceAttr(resourceName, "position", "1"), resource.TestCheckResourceAttr(resourceName, "type", ruleType), resource.TestCheckResourceAttr(resourceName, "important", fmt.Sprintf("%t", important)), @@ -49,10 +44,10 @@ func TestAccUserNotificationRule_basic(t *testing.T) { ) if ruleType == "wait" { - config = testAccOnCallUserNotificationRuleWait(userID, important) + config = testAccOnCallUserNotificationRuleWait(important) testCheckFuncFunctions = append(testCheckFuncFunctions, resource.TestCheckResourceAttr(resourceName, "duration", "300")) } else { - config = testAccOnCallUserNotificationRuleNotificationStep(ruleType, userID, important) + config = testAccOnCallUserNotificationRuleNotificationStep(ruleType, important) } testSteps = append(testSteps, resource.TestStep{ @@ -89,35 +84,33 @@ func testAccCheckOnCallUserNotificationRuleResourceDestroy(s *terraform.State) e return nil } -func testAccOnCallUserNotificationRuleWait(userName string, important bool) string { +func testAccOnCallUserNotificationRuleWait(important bool) string { return fmt.Sprintf(` -data "grafana_oncall_user" "user" { - username = "%s" -} +# Grab the first user from the full list of users +data "grafana_oncall_users" "all" {} resource "grafana_oncall_user_notification_rule" "test-acc-user_notification_rule" { - user_id = data.grafana_oncall_user.user.id + user_id = data.grafana_oncall_users.all.users[0].id type = "wait" position = 1 duration = 300 important = %t } -`, userName, important) +`, important) } -func testAccOnCallUserNotificationRuleNotificationStep(ruleType, userName string, important bool) string { +func testAccOnCallUserNotificationRuleNotificationStep(ruleType string, important bool) string { return fmt.Sprintf(` -data "grafana_oncall_user" "user" { - username = "%s" -} +# Grab the first user from the full list of users +data "grafana_oncall_users" "all" {} resource "grafana_oncall_user_notification_rule" "test-acc-user_notification_rule" { - user_id = data.grafana_oncall_user.user.id + user_id = data.grafana_oncall_users.all.users[0].id type = "%s" position = 1 important = %t } -`, userName, ruleType, important) +`, ruleType, important) } func testAccCheckOnCallUserNotificationRuleResourceExists(name string) resource.TestCheckFunc { diff --git a/internal/resources/oncall/resources.go b/internal/resources/oncall/resources.go index a971818fc..2411949bc 100644 --- a/internal/resources/oncall/resources.go +++ b/internal/resources/oncall/resources.go @@ -6,6 +6,7 @@ import ( onCallAPI "github.com/grafana/amixr-api-go-client" "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -38,6 +39,30 @@ func (r *basePluginFrameworkResource) Configure(ctx context.Context, req resourc r.client = client.OnCallClient } +type basePluginFrameworkDataSource struct { + client *onCallAPI.Client +} + +func (r *basePluginFrameworkDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Configure is called multiple times (sometimes when ProviderData is not yet available), we only want to configure once + if req.ProviderData == nil || r.client != nil { + return + } + + client, ok := req.ProviderData.(*common.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *common.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client.OnCallClient +} + type crudWithClientFunc func(ctx context.Context, d *schema.ResourceData, client *onCallAPI.Client) diag.Diagnostics func withClient[T schema.CreateContextFunc | schema.UpdateContextFunc | schema.ReadContextFunc | schema.DeleteContextFunc](f crudWithClientFunc) T { @@ -51,7 +76,6 @@ func withClient[T schema.CreateContextFunc | schema.UpdateContextFunc | schema.R } var DataSources = []*common.DataSource{ - dataSourceUser(), dataSourceEscalationChain(), dataSourceSchedule(), dataSourceSlackChannel(), @@ -59,6 +83,8 @@ var DataSources = []*common.DataSource{ dataSourceUserGroup(), dataSourceTeam(), dataSourceIntegration(), + dataSourceUser(), + dataSourceUsers(), } var Resources = []*common.Resource{ diff --git a/internal/resources/slo/data_source_slo.go b/internal/resources/slo/data_source_slo.go index 438cdc98b..7c420186b 100644 --- a/internal/resources/slo/data_source_slo.go +++ b/internal/resources/slo/data_source_slo.go @@ -3,7 +3,7 @@ package slo import ( "context" - slo "github.com/grafana/slo-openapi-client/go" + "github.com/grafana/slo-openapi-client/go/slo" "github.com/grafana/terraform-provider-grafana/v3/internal/common" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -88,6 +88,7 @@ func convertDatasourceSlo(slo slo.SloV00Slo) map[string]interface{} { retAlerting := unpackAlerting(slo.Alerting) ret["alerting"] = retAlerting + ret["search_expression"] = slo.SearchExpression return ret } diff --git a/internal/resources/slo/data_source_slo_test.go b/internal/resources/slo/data_source_slo_test.go index 2ac30779e..e71612cd8 100644 --- a/internal/resources/slo/data_source_slo_test.go +++ b/internal/resources/slo/data_source_slo_test.go @@ -3,7 +3,7 @@ package slo_test import ( "testing" - slo "github.com/grafana/slo-openapi-client/go" + "github.com/grafana/slo-openapi-client/go/slo" "github.com/grafana/terraform-provider-grafana/v3/internal/testutils" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" diff --git a/internal/resources/slo/resource_slo.go b/internal/resources/slo/resource_slo.go index 75678fdc3..f91c1198c 100644 --- a/internal/resources/slo/resource_slo.go +++ b/internal/resources/slo/resource_slo.go @@ -5,7 +5,7 @@ import ( "fmt" "regexp" - slo "github.com/grafana/slo-openapi-client/go" + "github.com/grafana/slo-openapi-client/go/slo" "github.com/grafana/terraform-provider-grafana/v3/internal/common" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -240,6 +240,11 @@ Resource manages Grafana SLOs. }, }, }, + "search_expression": { + Type: schema.TypeString, + Optional: true, + Description: "The name of a search expression in Grafana Asserts. This is used in the SLO UI to open the Asserts RCA workbench and in alerts to link to the RCA workbench.", + }, }, } @@ -248,18 +253,22 @@ Resource manages Grafana SLOs. "grafana_slo", resourceSloID, schema, - ).WithLister(listSlos) + ). + WithLister(listSlos). + WithPreferredResourceNameField("name") } var keyvalueSchema = &schema.Resource{ Schema: map[string]*schema.Schema{ "key": { - Type: schema.TypeString, - Required: true, + Type: schema.TypeString, + Required: true, + Description: `Key for filtering and identification`, }, "value": { - Type: schema.TypeString, - Required: true, + Type: schema.TypeString, + Required: true, + Description: `Templatable value`, }, }, } @@ -272,13 +281,7 @@ func listSlos(ctx context.Context, client *common.Client, data any) ([]string, e slolist, _, err := sloClient.DefaultAPI.V1SloGet(ctx).Execute() if err != nil { - // // TODO: Uninitialized SLO plugin. This should be handled better - // cast, ok := err.(*slo.GenericOpenAPIError) - // if ok && strings.Contains(cast.Error(), "status: 500") { - // return nil, nil - // } - - return nil, nil + return nil, err } var ids []string @@ -320,8 +323,11 @@ func resourceSloRead(ctx context.Context, d *schema.ResourceData, client *slo.AP sloID := d.Id() req := client.DefaultAPI.V1SloIdGet(ctx, sloID) - slo, _, err := req.Execute() + slo, r, err := req.Execute() if err != nil { + if r != nil && r.StatusCode == 404 { + return common.WarnMissing("SLO", d) + } return apiError("Unable to read SLO - API", err) } @@ -400,6 +406,11 @@ func packSloResource(d *schema.ResourceData) (slo.SloV00Slo, error) { DestinationDatasource: nil, } + // Check the Optional Search Expression Field + if searchexpression, ok := d.GetOk("search_expression"); ok && searchexpression != "" { + req.SearchExpression = common.Ref(searchexpression.(string)) + } + // Check the Optional Alerting Field if alerting, ok := d.GetOk("alerting"); ok { alertData, ok := alerting.([]interface{}) @@ -631,6 +642,7 @@ func setTerraformState(d *schema.ResourceData, slo slo.SloV00Slo) { retAlerting := unpackAlerting(slo.Alerting) d.Set("alerting", retAlerting) + d.Set("search_expression", slo.SearchExpression) } func apiError(action string, err error) diag.Diagnostics { diff --git a/internal/resources/slo/resource_slo_test.go b/internal/resources/slo/resource_slo_test.go index 73697e4ae..1c1241788 100644 --- a/internal/resources/slo/resource_slo_test.go +++ b/internal/resources/slo/resource_slo_test.go @@ -6,12 +6,13 @@ import ( "regexp" "testing" - slo "github.com/grafana/slo-openapi-client/go" + "github.com/grafana/slo-openapi-client/go/slo" "github.com/grafana/terraform-provider-grafana/v3/internal/common" "github.com/grafana/terraform-provider-grafana/v3/internal/testutils" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/stretchr/testify/require" ) func TestAccResourceSlo(t *testing.T) { @@ -40,6 +41,7 @@ func TestAccResourceSlo(t *testing.T) { resource.TestCheckResourceAttr("grafana_slo.test", "objectives.0.value", "0.995"), resource.TestCheckResourceAttr("grafana_slo.test", "objectives.0.window", "30d"), resource.TestCheckNoResourceAttr("grafana_slo.test", "folder_uid"), + testutils.CheckLister("grafana_slo.test"), ), }, { @@ -94,6 +96,12 @@ func TestAccResourceSlo(t *testing.T) { resource.TestCheckResourceAttr("grafana_slo.ratio", "query.0.ratio.0.group_by_labels.1", "instance"), ), }, + { + // Import test (this tests that all fields are read correctly) + ResourceName: "grafana_slo.ratio", + ImportState: true, + ImportStateVerify: true, + }, { // Tests Advanced Options Config: testutils.TestAccExample(t, "resources/grafana_slo/resource_ratio_advanced_options.tf"), @@ -104,6 +112,88 @@ func TestAccResourceSlo(t *testing.T) { resource.TestCheckResourceAttr("grafana_slo.ratio_options", "alerting.0.advanced_options.0.min_failures", "10"), ), }, + { + // Import test (this tests that all fields are read correctly) + ResourceName: "grafana_slo.ratio_options", + ImportState: true, + ImportStateVerify: true, + }, + { + // Tests the Search Expression + Config: testutils.TestAccExample(t, "resources/grafana_slo/resource_search_expression.tf"), + Check: resource.ComposeTestCheckFunc( + testAccSloCheckExists("grafana_slo.search_expression", &slo), + resource.TestCheckResourceAttr("grafana_slo.search_expression", "search_expression", "Entity Search for RCA Workbench"), + ), + }, + { + // Import test (this tests that all fields are read correctly) + ResourceName: "grafana_slo.search_expression", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +// Tests that recreating an out-of-band deleted SLO works without error. +func TestAccSLO_recreate(t *testing.T) { + testutils.CheckCloudInstanceTestsEnabled(t) + var slo slo.SloV00Slo + randomName := acctest.RandomWithPrefix("SLO Terraform Testing") + config := testutils.TestAccExampleWithReplace(t, "resources/grafana_slo/resource.tf", map[string]string{ + "Terraform Testing": randomName, + }) + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + + // Implicitly tests destroy + CheckDestroy: testAccSloCheckDestroy(&slo), + Steps: []resource.TestStep{ + // Create + { + Config: config, + Check: resource.ComposeTestCheckFunc( + testAccSloCheckExists("grafana_slo.test", &slo), + resource.TestCheckResourceAttrSet("grafana_slo.test", "id"), + resource.TestCheckResourceAttr("grafana_slo.test", "name", randomName), + resource.TestCheckResourceAttr("grafana_slo.test", "description", "Terraform Description"), + resource.TestCheckResourceAttr("grafana_slo.test", "query.0.type", "freeform"), + resource.TestCheckResourceAttr("grafana_slo.test", "query.0.freeform.0.query", "sum(rate(apiserver_request_total{code!=\"500\"}[$__rate_interval])) / sum(rate(apiserver_request_total[$__rate_interval]))"), + resource.TestCheckResourceAttr("grafana_slo.test", "objectives.0.value", "0.995"), + resource.TestCheckResourceAttr("grafana_slo.test", "objectives.0.window", "30d"), + resource.TestCheckNoResourceAttr("grafana_slo.test", "folder_uid"), + testutils.CheckLister("grafana_slo.test"), + ), + }, + // Delete out-of-band + { + PreConfig: func() { + client := testutils.Provider.Meta().(*common.Client).SLOClient + req := client.DefaultAPI.V1SloIdDelete(context.Background(), slo.Uuid) + _, err := req.Execute() + require.NoError(t, err) + }, + Config: config, + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + // Re-create + { + Config: config, + Check: resource.ComposeTestCheckFunc( + testAccSloCheckExists("grafana_slo.test", &slo), + resource.TestCheckResourceAttrSet("grafana_slo.test", "id"), + resource.TestCheckResourceAttr("grafana_slo.test", "name", randomName), + resource.TestCheckResourceAttr("grafana_slo.test", "description", "Terraform Description"), + resource.TestCheckResourceAttr("grafana_slo.test", "query.0.type", "freeform"), + resource.TestCheckResourceAttr("grafana_slo.test", "query.0.freeform.0.query", "sum(rate(apiserver_request_total{code!=\"500\"}[$__rate_interval])) / sum(rate(apiserver_request_total[$__rate_interval]))"), + resource.TestCheckResourceAttr("grafana_slo.test", "objectives.0.value", "0.995"), + resource.TestCheckResourceAttr("grafana_slo.test", "objectives.0.window", "30d"), + resource.TestCheckNoResourceAttr("grafana_slo.test", "folder_uid"), + testutils.CheckLister("grafana_slo.test"), + ), + }, }, }) } diff --git a/internal/resources/slo/resources.go b/internal/resources/slo/resources.go index 496bf0f82..c1e3d5bca 100644 --- a/internal/resources/slo/resources.go +++ b/internal/resources/slo/resources.go @@ -3,7 +3,7 @@ package slo import ( "context" - slo "github.com/grafana/slo-openapi-client/go" + "github.com/grafana/slo-openapi-client/go/slo" "github.com/grafana/terraform-provider-grafana/v3/internal/common" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" diff --git a/internal/resources/syntheticmonitoring/resource_check.go b/internal/resources/syntheticmonitoring/resource_check.go index ae14b1bd8..527cafe34 100644 --- a/internal/resources/syntheticmonitoring/resource_check.go +++ b/internal/resources/syntheticmonitoring/resource_check.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log" "strconv" "strings" @@ -791,28 +790,28 @@ multiple checks for a single endpoint to check different capabilities. "grafana_synthetic_monitoring_check", resourceCheckID, schema, - ) + ). + WithLister(listChecks). + WithPreferredResourceNameField("job") } -// TODO: Fix lister -// .WithLister(listChecks) -// func listChecks(ctx context.Context, client *common.Client, data any) ([]string, error) { -// smClient := client.SMAPI -// if smClient == nil { -// return nil, fmt.Errorf("client not configured for SM API") -// } - -// checkList, err := smClient.ListChecks(ctx) -// if err != nil { -// return nil, err -// } - -// var ids []string -// for _, check := range checkList { -// ids = append(ids, strconv.FormatInt(check.Id, 10)) -// } -// return ids, nil -// } +func listChecks(ctx context.Context, client *common.Client, data any) ([]string, error) { + smClient := client.SMAPI + if smClient == nil { + return nil, fmt.Errorf("client not configured for SM API") + } + + checkList, err := smClient.ListChecks(ctx) + if err != nil { + return nil, err + } + + var ids []string + for _, check := range checkList { + ids = append(ids, strconv.FormatInt(check.Id, 10)) + } + return ids, nil +} func resourceCheckCreate(ctx context.Context, d *schema.ResourceData, c *smapi.Client) diag.Diagnostics { chk, err := makeCheck(d) @@ -836,9 +835,7 @@ func resourceCheckRead(ctx context.Context, d *schema.ResourceData, c *smapi.Cli chk, err := c.GetCheck(ctx, id.(int64)) if err != nil { if strings.Contains(err.Error(), "404 Not Found") { - log.Printf("[WARN] removing check %s from state because it no longer exists", d.Id()) - d.SetId("") - return nil + return common.WarnMissing("check", d) } return diag.FromErr(err) } @@ -1166,17 +1163,12 @@ func resourceCheckUpdate(ctx context.Context, d *schema.ResourceData, c *smapi.C } func resourceCheckDelete(ctx context.Context, d *schema.ResourceData, c *smapi.Client) diag.Diagnostics { - var diags diag.Diagnostics id, err := resourceCheckID.Single(d.Id()) if err != nil { return diag.FromErr(err) } err = c.DeleteCheck(ctx, id.(int64)) - if err != nil { - return diag.FromErr(err) - } - d.SetId("") - return diags + return diag.FromErr(err) } // makeCheck populates an instance of sm.Check. We need this for create and @@ -1599,7 +1591,10 @@ func resourceCheckCustomizeDiff(ctx context.Context, diff *schema.ResourceDiff, if len(settingsList) == 0 { return fmt.Errorf("at least one check setting must be defined") } - settings := settingsList[0].(map[string]interface{}) + settings, ok := settingsList[0].(map[string]interface{}) + if !ok { + return fmt.Errorf("at least one check setting must be defined") + } count := 0 for k := range syntheticMonitoringCheckSettings.Schema { diff --git a/internal/resources/syntheticmonitoring/resource_check_test.go b/internal/resources/syntheticmonitoring/resource_check_test.go index 56509a6e1..c0a186b10 100644 --- a/internal/resources/syntheticmonitoring/resource_check_test.go +++ b/internal/resources/syntheticmonitoring/resource_check_test.go @@ -43,6 +43,7 @@ func TestAccResourceCheck_dns(t *testing.T) { resource.TestCheckResourceAttr("grafana_synthetic_monitoring_check.dns", "settings.0.dns.0.port", "53"), resource.TestCheckResourceAttr("grafana_synthetic_monitoring_check.dns", "settings.0.dns.0.record_type", "A"), resource.TestCheckResourceAttr("grafana_synthetic_monitoring_check.dns", "settings.0.dns.0.protocol", "UDP"), + testutils.CheckLister("grafana_synthetic_monitoring_check.dns"), ), }, { @@ -72,6 +73,11 @@ func TestAccResourceCheck_dns(t *testing.T) { resource.TestCheckResourceAttr("grafana_synthetic_monitoring_check.dns", "settings.0.dns.0.validate_additional_rrs.0.fail_if_not_matches_regexp.0", ".+-good-stuff*"), ), }, + { + ImportState: true, + ImportStateVerify: true, + ResourceName: "grafana_synthetic_monitoring_check.dns", + }, }, }) } @@ -136,6 +142,11 @@ func TestAccResourceCheck_http(t *testing.T) { resource.TestCheckResourceAttr("grafana_synthetic_monitoring_check.http", "settings.0.http.0.fail_if_header_matches_regexp.0.allow_missing", "true"), ), }, + { + ImportState: true, + ImportStateVerify: true, + ResourceName: "grafana_synthetic_monitoring_check.http", + }, }, }) } @@ -181,6 +192,11 @@ func TestAccResourceCheck_ping(t *testing.T) { resource.TestCheckResourceAttr("grafana_synthetic_monitoring_check.ping", "settings.0.ping.0.dont_fragment", "true"), ), }, + { + ImportState: true, + ImportStateVerify: true, + ResourceName: "grafana_synthetic_monitoring_check.ping", + }, }, }) } @@ -232,6 +248,11 @@ func TestAccResourceCheck_tcp(t *testing.T) { resource.TestMatchResourceAttr("grafana_synthetic_monitoring_check.tcp", "settings.0.tcp.0.tls_config.0.ca_cert", regexp.MustCompile((`^-{5}BEGIN CERTIFICATE`))), ), }, + { + ImportState: true, + ImportStateVerify: true, + ResourceName: "grafana_synthetic_monitoring_check.tcp", + }, }, }) } @@ -279,6 +300,11 @@ func TestAccResourceCheck_traceroute(t *testing.T) { resource.TestCheckResourceAttr("grafana_synthetic_monitoring_check.traceroute", "settings.0.traceroute.0.ptr_lookup", "false"), ), }, + { + ImportState: true, + ImportStateVerify: true, + ResourceName: "grafana_synthetic_monitoring_check.traceroute", + }, }, }) } @@ -364,6 +390,11 @@ func TestAccResourceCheck_multihttp(t *testing.T) { resource.TestCheckResourceAttr("grafana_synthetic_monitoring_check.multihttp", "settings.0.multihttp.0.entries.1.assertions.4.expression", "$.slideshow.slides"), ), }, + { + ImportState: true, + ImportStateVerify: true, + ResourceName: "grafana_synthetic_monitoring_check.multihttp", + }, }, }) } diff --git a/internal/resources/syntheticmonitoring/resource_probe.go b/internal/resources/syntheticmonitoring/resource_probe.go index 916043284..9cb0aa639 100644 --- a/internal/resources/syntheticmonitoring/resource_probe.go +++ b/internal/resources/syntheticmonitoring/resource_probe.go @@ -4,7 +4,6 @@ import ( "context" "encoding/base64" "fmt" - "log" "strconv" "strings" @@ -116,7 +115,9 @@ Grafana Synthetic Monitoring Agent. "grafana_synthetic_monitoring_probe", resourceProbeID, schema, - ).WithLister(listProbes) + ). + WithLister(listProbes). + WithPreferredResourceNameField("name") } func listProbes(ctx context.Context, client *common.Client, data any) ([]string, error) { @@ -160,9 +161,7 @@ func resourceProbeRead(ctx context.Context, d *schema.ResourceData, c *smapi.Cli prb, err := c.GetProbe(ctx, id.(int64)) if err != nil { if strings.Contains(err.Error(), "404 Not Found") { - log.Printf("[WARN] removing probe %s from state because it no longer exists", d.Id()) - d.SetId("") - return nil + return common.WarnMissing("probe", d) } return diag.FromErr(err) } @@ -225,7 +224,6 @@ You must also taint the check, or assign a new probe to it before deleting this } } - d.SetId("") return diag.FromErr(c.DeleteProbe(ctx, id)) } diff --git a/internal/resources/syntheticmonitoring/resource_probe_test.go b/internal/resources/syntheticmonitoring/resource_probe_test.go index 475d351fa..4bd795ac4 100644 --- a/internal/resources/syntheticmonitoring/resource_probe_test.go +++ b/internal/resources/syntheticmonitoring/resource_probe_test.go @@ -36,6 +36,7 @@ func TestAccResourceProbe(t *testing.T) { resource.TestCheckResourceAttr("grafana_synthetic_monitoring_probe.main", "public", "false"), resource.TestCheckResourceAttr("grafana_synthetic_monitoring_probe.main", "labels.type", "mountain"), resource.TestCheckResourceAttr("grafana_synthetic_monitoring_probe.main", "disable_scripted_checks", "false"), + testutils.CheckLister("grafana_synthetic_monitoring_probe.main"), ), }, { diff --git a/internal/testutils/lister.go b/internal/testutils/lister.go new file mode 100644 index 000000000..9544c7990 --- /dev/null +++ b/internal/testutils/lister.go @@ -0,0 +1,61 @@ +package testutils + +import ( + "context" + "fmt" + "os" + + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/grafana/terraform-provider-grafana/v3/internal/resources/cloud" + "github.com/grafana/terraform-provider-grafana/v3/internal/resources/grafana" + "github.com/grafana/terraform-provider-grafana/v3/pkg/provider" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +// CheckLister is a resource.TestCheckFunc that checks that the resource's lister +// function returns the given ID. +// This is meant to be used at least once in every resource's tests to ensure that +// the resource's lister function is working correctly. +func CheckLister(terraformResource string) resource.TestCheckFunc { + return func(s *terraform.State) error { + // Get the resource from the state + rs, ok := s.RootModule().Resources[terraformResource] + if !ok { + return fmt.Errorf("resource not found: %s", terraformResource) + } + id := rs.Primary.ID + + // Find the resource info + resource, ok := provider.ResourcesMap()[rs.Type] + if !ok { + return fmt.Errorf("resource type %s not found", rs.Type) + } + + // Get the resource's lister function + lister := resource.ListIDsFunc + if lister == nil { + return fmt.Errorf("resource %s does not have a lister function", terraformResource) + } + + // Get the list of IDs from the lister function + ctx := context.Background() + var listerData any = grafana.NewListerData(false, false) + if resource.Category == common.CategoryCloud { + listerData = cloud.NewListerData(os.Getenv("GRAFANA_CLOUD_ORG")) + } + ids, err := lister(ctx, Provider.Meta().(*common.Client), listerData) + if err != nil { + return fmt.Errorf("error listing %s: %w", terraformResource, err) + } + + // Check that the ID is in the list + for _, i := range ids { + if i == id { + return nil + } + } + + return fmt.Errorf("resource %s with ID %s not found in list: %v", terraformResource, id, ids) + } +} diff --git a/internal/testutils/provider.go b/internal/testutils/provider.go index 98698387d..05de3bd7d 100644 --- a/internal/testutils/provider.go +++ b/internal/testutils/provider.go @@ -2,6 +2,7 @@ package testutils import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -50,7 +51,11 @@ var ( configureResp, err := server.ConfigureProvider(context.Background(), &tfprotov5.ConfigureProviderRequest{Config: &testDynamicValue}) if err != nil || len(configureResp.Diagnostics) > 0 { if err == nil { - err = fmt.Errorf("provider configuration failed: %v", configureResp.Diagnostics) + errs := []error{} + for _, diag := range configureResp.Diagnostics { + errs = append(errs, fmt.Errorf("%s %s: %s", diag.Severity, diag.Summary, diag.Detail)) + } + err = errors.Join(errs...) } return nil, fmt.Errorf("failed to configure provider: %v", err) } diff --git a/pkg/generate/cloud.go b/pkg/generate/cloud.go index 5b5a1d136..e2c777fbd 100644 --- a/pkg/generate/cloud.go +++ b/pkg/generate/cloud.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana-com-public-clients/go/gcom" "github.com/grafana/grafana-openapi-client-go/client/service_accounts" "github.com/grafana/terraform-provider-grafana/v3/internal/resources/cloud" + "github.com/grafana/terraform-provider-grafana/v3/pkg/generate/postprocessing" "github.com/grafana/terraform-provider-grafana/v3/pkg/provider" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/hashicorp/terraform-exec/tfexec" @@ -32,13 +33,13 @@ type stack struct { onCallToken string } -func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, error) { +func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, GenerationResult) { // Gen provider providerBlock := hclwrite.NewBlock("provider", []string{"grafana"}) providerBlock.Body().SetAttributeValue("alias", cty.StringVal("cloud")) providerBlock.Body().SetAttributeValue("cloud_access_policy_token", cty.StringVal(cfg.Cloud.AccessPolicyToken)) if err := writeBlocks(filepath.Join(cfg.OutputDir, "cloud-provider.tf"), providerBlock); err != nil { - return nil, err + return nil, failure(err) } // Generate imports @@ -46,18 +47,18 @@ func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, error) { CloudAccessPolicyToken: types.StringValue(cfg.Cloud.AccessPolicyToken), } if err := config.SetDefaults(); err != nil { - return nil, err + return nil, failure(err) } client, err := provider.CreateClients(config) if err != nil { - return nil, err + return nil, failure(err) } cloudClient := client.GrafanaCloudAPI stacks, _, err := cloudClient.InstancesAPI.GetInstances(ctx).Execute() if err != nil { - return nil, err + return nil, failure(err) } // Cleanup SAs @@ -66,32 +67,30 @@ func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, error) { if cfg.Cloud.CreateStackServiceAccount { for _, stack := range stacks.Items { if err := createManagementStackServiceAccount(ctx, cloudClient, stack, managementServiceAccountName); err != nil { - return nil, err + return nil, failure(err) } } } data := cloud.NewListerData(cfg.Cloud.Org) - if err := generateImportBlocks(ctx, client, data, cloud.Resources, cfg, "cloud"); err != nil { - return nil, err + returnResult := generateImportBlocks(ctx, client, data, cloud.Resources, cfg, "cloud") + if returnResult.Blocks() == 0 { // Skip if no resources were found + return nil, returnResult } - postprocessor := &postprocessor{} - if postprocessor.plannedState, err = getPlannedState(ctx, cfg); err != nil { - return nil, err - } - if err := postprocessor.stripDefaults(filepath.Join(cfg.OutputDir, "cloud-resources.tf"), nil); err != nil { - return nil, err + plannedState, err := getPlannedState(ctx, cfg) + if err != nil { + return nil, failure(err) } - if err := postprocessor.wrapJSONFieldsInFunction(filepath.Join(cfg.OutputDir, "cloud-resources.tf")); err != nil { - return nil, err + if err := postprocessing.StripDefaults(filepath.Join(cfg.OutputDir, "cloud-resources.tf"), nil); err != nil { + return nil, failure(err) } - if err := postprocessor.replaceReferences(filepath.Join(cfg.OutputDir, "cloud-resources.tf"), nil); err != nil { - return nil, err + if err := postprocessing.ReplaceReferences(filepath.Join(cfg.OutputDir, "cloud-resources.tf"), plannedState, nil); err != nil { + return nil, failure(err) } if !cfg.Cloud.CreateStackServiceAccount { - return nil, nil + return nil, returnResult } // Add management service account (grafana_cloud_stack_service_account) @@ -148,7 +147,7 @@ func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, error) { providerBlock.Body().SetAttributeTraversal("sm_url", traversal("grafana_synthetic_monitoring_installation", stack.Slug, "stack_sm_api_url")) if err := writeBlocks(filepath.Join(cfg.OutputDir, fmt.Sprintf("stack-%s-provider.tf", stack.Slug)), saBlock, saTokenBlock, smInstallationMetricsPublishBlock, smInstallationTokenBlock, smInstallationBlock, providerBlock); err != nil { - return nil, fmt.Errorf("failed to write management service account blocks for stack %q: %w", stack.Slug, err) + return nil, failuref("failed to write management service account blocks for stack %q: %w", stack.Slug, err) } // Apply then go into the state and find the management key @@ -160,14 +159,14 @@ func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, error) { tfexec.Target("grafana_synthetic_monitoring_installation."+stack.Slug), ) if err != nil { - return nil, fmt.Errorf("failed to apply management service account blocks for stack %q: %w", stack.Slug, err) + return nil, failuref("failed to apply management service account blocks for stack %q: %w", stack.Slug, err) } } managedStacks := []stack{} state, err := getState(ctx, cfg) if err != nil { - return nil, err + return nil, failure(err) } stacksMap := map[string]stack{} for _, resource := range state.Values.RootModule.Resources { @@ -197,7 +196,7 @@ func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, error) { managedStacks = append(managedStacks, stack) } - return managedStacks, nil + return managedStacks, returnResult } func createManagementStackServiceAccount(ctx context.Context, cloudClient *gcom.APIClient, stack gcom.FormattedApiInstance, saName string) error { diff --git a/pkg/generate/config.go b/pkg/generate/config.go index 15ec5d06d..a19321778 100644 --- a/pkg/generate/config.go +++ b/pkg/generate/config.go @@ -1,6 +1,9 @@ package generate -import "github.com/hashicorp/terraform-exec/tfexec" +import ( + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-exec/tfexec" +) type OutputFormat string @@ -29,6 +32,12 @@ type CloudConfig struct { StackServiceAccountName string } +type TerraformInstallConfig struct { + InstallDir string + Version *version.Version + PluginDir string +} + type Config struct { // IncludeResources is a list of patterns to filter resources by. // If a resource name matches any of the patterns, it will be included in the output. @@ -37,10 +46,13 @@ type Config struct { // OutputDir is the directory to write the generated files to. OutputDir string // Clobber will overwrite existing files in the output directory. - Clobber bool - Format OutputFormat - ProviderVersion string - Grafana *GrafanaConfig - Cloud *CloudConfig - Terraform *tfexec.Terraform + Clobber bool + OutputCredentials bool + Format OutputFormat + ProviderVersion string + Grafana *GrafanaConfig + Cloud *CloudConfig + + TerraformInstallConfig TerraformInstallConfig + Terraform *tfexec.Terraform } diff --git a/pkg/generate/crossplane.go b/pkg/generate/crossplane.go index 34fefe4b3..6180ded9e 100644 --- a/pkg/generate/crossplane.go +++ b/pkg/generate/crossplane.go @@ -8,11 +8,14 @@ import ( "path/filepath" "strings" + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/grafana/terraform-provider-grafana/v3/pkg/provider" "gopkg.in/yaml.v2" ) func convertToCrossplane(cfg *Config) error { ctx := context.Background() + resourcesMap := provider.ResourcesMap() state, err := getPlannedState(ctx, cfg) if err != nil { @@ -92,23 +95,25 @@ func convertToCrossplane(cfg *Config) error { apiVersion := "oss.grafana.crossplane.io/v1alpha1" snakeCaseType := strings.TrimPrefix(r.Type, "grafana_") name := strings.ReplaceAll(strings.TrimPrefix(r.Name, "_"), "_", "-") + resourceInfo := resourcesMap[r.Type] - // TODO: Use categories from https://github.com/grafana/terraform-provider-grafana/pull/1588, when merged - switch { - case strings.HasPrefix(r.Type, "grafana_cloud"): + switch resourceInfo.Category { + case common.CategoryCloud: apiVersion = "cloud.grafana.crossplane.io/v1alpha1" snakeCaseType = strings.TrimPrefix(r.Type, "grafana_cloud_") - case strings.HasPrefix(r.Type, "grafana_synthetic_monitoring"): + case common.CategorySyntheticMonitoring: apiVersion = "sm.grafana.crossplane.io/v1alpha1" snakeCaseType = strings.TrimPrefix(r.Type, "grafana_synthetic_monitoring_") - case strings.HasPrefix(r.Type, "grafana_slo"): + case common.CategorySLO: apiVersion = "slo.grafana.crossplane.io/v1alpha1" - case r.Type == "grafana_contact_point" || - r.Type == "grafana_notification_policy" || - r.Type == "grafana_mute_timing" || - r.Type == "grafana_message_template" || - r.Type == "grafana_rule_group": + case common.CategoryAlerting: apiVersion = "alerting.grafana.crossplane.io/v1alpha1" + case common.CategoryMachineLearning: + apiVersion = "ml.grafana.crossplane.io/v1alpha1" + case common.CategoryOnCall: + apiVersion = "oncall.grafana.crossplane.io/v1alpha1" + case common.CategoryGrafanaEnterprise: + apiVersion = "enterprise.grafana.crossplane.io/v1alpha1" } kind := toCamelCase(snakeCaseType) diff --git a/pkg/generate/generate.go b/pkg/generate/generate.go index bba1563a7..ac1fbffb0 100644 --- a/pkg/generate/generate.go +++ b/pkg/generate/generate.go @@ -6,42 +6,98 @@ import ( "log" "os" "path/filepath" - "regexp" "sort" "strings" "sync" "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/grafana/terraform-provider-grafana/v3/pkg/generate/postprocessing" + "github.com/grafana/terraform-provider-grafana/v3/pkg/generate/utils" + "github.com/grafana/terraform-provider-grafana/v3/pkg/provider" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/hashicorp/terraform-exec/tfexec" "github.com/zclconf/go-cty/cty" ) -var ( - allowedTerraformChars = regexp.MustCompile(`[^a-zA-Z0-9_-]`) -) +// NonCriticalError is an error that is not critical to the generation process. +// It can be handled differently by the caller. +type NonCriticalError interface { + NonCriticalError() +} + +// ResourceError is an error that occurred while generating a resource. +type ResourceError struct { + Resource *common.Resource + Err error +} + +func (e ResourceError) Error() string { + return fmt.Sprintf("resource %s: %v", e.Resource.Name, e.Err) +} + +func (ResourceError) NonCriticalError() {} + +type NonCriticalGenerationFailure struct{ error } + +func (f NonCriticalGenerationFailure) NonCriticalError() {} -func Generate(ctx context.Context, cfg *Config) error { +type GenerationSuccess struct { + Resource *common.Resource + Blocks int +} + +type GenerationResult struct { + Success []GenerationSuccess + Errors []error +} + +func (r GenerationResult) Blocks() int { + blocks := 0 + for _, s := range r.Success { + blocks += s.Blocks + } + return blocks +} + +func failure(err error) GenerationResult { + return GenerationResult{ + Errors: []error{err}, + } +} + +func failuref(format string, args ...any) GenerationResult { + return failure(fmt.Errorf(format, args...)) +} + +func Generate(ctx context.Context, cfg *Config) GenerationResult { var err error if !filepath.IsAbs(cfg.OutputDir) { if cfg.OutputDir, err = filepath.Abs(cfg.OutputDir); err != nil { - return fmt.Errorf("failed to get absolute path for %s: %w", cfg.OutputDir, err) + return failuref("failed to get absolute path for %s: %w", cfg.OutputDir, err) } } if _, err := os.Stat(cfg.OutputDir); err == nil && cfg.Clobber { log.Printf("Deleting all files in %s", cfg.OutputDir) if err := os.RemoveAll(cfg.OutputDir); err != nil { - return fmt.Errorf("failed to delete %s: %s", cfg.OutputDir, err) + return failuref("failed to delete %s: %s", cfg.OutputDir, err) } } else if err == nil && !cfg.Clobber { - return fmt.Errorf("output dir %q already exists. Use the clobber option to delete it", cfg.OutputDir) + return failuref("output dir %q already exists. Use the clobber option to delete it", cfg.OutputDir) } log.Printf("Generating resources to %s", cfg.OutputDir) if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { - return fmt.Errorf("failed to create output directory %s: %s", cfg.OutputDir, err) + return failuref("failed to create output directory %s: %s", cfg.OutputDir, err) + } + + // Enable "unsensitive" mode for the provider + os.Setenv(provider.EnableGenerateEnvVar, "true") + defer os.Unsetenv(provider.EnableGenerateEnvVar) + if err := os.WriteFile(filepath.Join(cfg.OutputDir, provider.EnableGenerateMarkerFile), []byte("unsensitive!"), 0600); err != nil { + return failuref("failed to write marker file: %w", err) } + defer os.Remove(filepath.Join(cfg.OutputDir, provider.EnableGenerateMarkerFile)) // Generate provider installation block providerBlock := hclwrite.NewBlock("terraform", nil) @@ -52,27 +108,27 @@ func Generate(ctx context.Context, cfg *Config) error { })) providerBlock.Body().AppendBlock(requiredProvidersBlock) if err := writeBlocks(filepath.Join(cfg.OutputDir, "provider.tf"), providerBlock); err != nil { - log.Fatal(err) + return failure(err) } tf, err := setupTerraform(cfg) // Terraform init to download the provider if err != nil { - return fmt.Errorf("failed to run terraform init: %w", err) + return failuref("failed to run terraform init: %w", err) } cfg.Terraform = tf + var returnResult GenerationResult if cfg.Cloud != nil { - stacks, err := generateCloudResources(ctx, cfg) - if err != nil { - return err - } + log.Printf("Generating cloud resources") + var stacks []stack + stacks, returnResult = generateCloudResources(ctx, cfg) for _, stack := range stacks { stack.name = "stack-" + stack.slug - if err := generateGrafanaResources(ctx, cfg, stack, false); err != nil { - return err - } + stackResult := generateGrafanaResources(ctx, cfg, stack, false) + returnResult.Success = append(returnResult.Success, stackResult.Success...) + returnResult.Errors = append(returnResult.Errors, stackResult.Errors...) } } @@ -86,22 +142,43 @@ func Generate(ctx context.Context, cfg *Config) error { onCallToken: cfg.Grafana.OnCallAccessToken, onCallURL: cfg.Grafana.OnCallURL, } - if err := generateGrafanaResources(ctx, cfg, stack, true); err != nil { - return err + log.Printf("Generating Grafana resources") + returnResult = generateGrafanaResources(ctx, cfg, stack, true) + } + + if !cfg.OutputCredentials && cfg.Format != OutputFormatCrossplane { + if err := postprocessing.RedactCredentials(cfg.OutputDir); err != nil { + return failuref("failed to redact credentials: %w", err) } } - if cfg.Format == OutputFormatJSON { - return convertToTFJSON(cfg.OutputDir) + if returnResult.Blocks() == 0 { + if err := os.WriteFile(filepath.Join(cfg.OutputDir, "resources.tf"), []byte("# No resources were found\n"), 0600); err != nil { + return failure(err) + } + if err := os.WriteFile(filepath.Join(cfg.OutputDir, "imports.tf"), []byte("# No resources were found\n"), 0600); err != nil { + return failure(err) + } + return returnResult } + if cfg.Format == OutputFormatCrossplane { - return convertToCrossplane(cfg) + if err := convertToCrossplane(cfg); err != nil { + return failure(err) + } + return returnResult + } + + if cfg.Format == OutputFormatJSON { + if err := convertToTFJSON(cfg.OutputDir); err != nil { + return failure(err) + } } - return nil + return returnResult } -func generateImportBlocks(ctx context.Context, client *common.Client, listerData any, resources []*common.Resource, cfg *Config, provider string) error { +func generateImportBlocks(ctx context.Context, client *common.Client, listerData any, resources []*common.Resource, cfg *Config, provider string) GenerationResult { generatedFilename := func(suffix string) string { if provider == "" { return filepath.Join(cfg.OutputDir, suffix) @@ -112,7 +189,7 @@ func generateImportBlocks(ctx context.Context, client *common.Client, listerData resources, err := filterResources(resources, cfg.IncludeResources) if err != nil { - return err + return failure(err) } // Generate HCL blocks in parallel with a wait group @@ -138,7 +215,7 @@ func generateImportBlocks(ctx context.Context, client *common.Client, listerData } log.Printf("generating %s resources\n", resource.Name) - ids, err := lister(ctx, client, listerData) + listedIDs, err := lister(ctx, client, listerData) if err != nil { wg.Done() results <- result{ @@ -147,6 +224,16 @@ func generateImportBlocks(ctx context.Context, client *common.Client, listerData } return } + + // Make sure IDs are unique. If an API returns the same ID multiple times for any reason, we only want to import it once. + idMap := map[string]struct{}{} + for _, id := range listedIDs { + idMap[id] = struct{}{} + } + ids := []string{} + for id := range idMap { + ids = append(ids, id) + } sort.Strings(ids) // Write blocks like these @@ -156,12 +243,8 @@ func generateImportBlocks(ctx context.Context, client *common.Client, listerData // } var blocks []*hclwrite.Block for _, id := range ids { - cleanedID := allowedTerraformChars.ReplaceAllString(id, "_") - if provider != "cloud" { - cleanedID = strings.ReplaceAll(provider, "-", "_") + "_" + cleanedID - } - - matched, err := filterResourceByName(resource.Name, cleanedID, cfg.IncludeResources) + id := id + matched, err := filterResourceByName(resource.Name, id, cfg.IncludeResources) if err != nil { wg.Done() results <- result{ @@ -174,8 +257,13 @@ func generateImportBlocks(ctx context.Context, client *common.Client, listerData continue } + if provider != "cloud" && provider != "" { + id = provider + "_" + id + } + resourceName := postprocessing.CleanResourceName(id) + b := hclwrite.NewBlock("import", nil) - b.Body().SetAttributeTraversal("to", traversal(resource.Name, cleanedID)) + b.Body().SetAttributeTraversal("to", traversal(resource.Name, resourceName)) b.Body().SetAttributeValue("id", cty.StringVal(id)) if provider != "" { b.Body().SetAttributeTraversal("provider", traversal("grafana", provider)) @@ -197,12 +285,21 @@ func generateImportBlocks(ctx context.Context, client *common.Client, listerData wg.Wait() close(results) + returnResult := GenerationResult{} resultsSlice := []result{} for r := range results { if r.err != nil { - return fmt.Errorf("failed to generate %s resources: %w", r.resource.Name, r.err) + returnResult.Errors = append(returnResult.Errors, ResourceError{ + Resource: r.resource, + Err: r.err, + }) + } else { + resultsSlice = append(resultsSlice, r) + returnResult.Success = append(returnResult.Success, GenerationSuccess{ + Resource: r.resource, + Blocks: len(r.blocks), + }) } - resultsSlice = append(resultsSlice, r) } sort.Slice(resultsSlice, func(i, j int) bool { return resultsSlice[i].resource.Name < resultsSlice[j].resource.Name @@ -215,23 +312,71 @@ func generateImportBlocks(ctx context.Context, client *common.Client, listerData } if len(allBlocks) == 0 { - if err := os.WriteFile(generatedFilename("resources.tf"), []byte("# No resources were found\n"), 0600); err != nil { - return err + return returnResult + } + + if err := writeBlocks(generatedFilename("imports.tf"), allBlocks...); err != nil { + return failure(err) + } + _, err = cfg.Terraform.Plan(ctx, tfexec.GenerateConfigOut(generatedFilename("resources.tf"))) + if err != nil && !strings.Contains(err.Error(), "Missing required argument") { + // If resources.tf was created and is not empty, return the error as a "non-critical" error + if stat, statErr := os.Stat(generatedFilename("resources.tf")); statErr == nil && stat.Size() > 0 { + returnResult.Errors = append(returnResult.Errors, NonCriticalGenerationFailure{err}) + } else { + return failuref("failed to generate resources: %w", err) } - if err := os.WriteFile(generatedFilename("imports.tf"), []byte("# No resources were found\n"), 0600); err != nil { - return err + } + + for _, err := range []error{ + postprocessing.ReplaceNullSensitiveAttributes(generatedFilename("resources.tf")), + removeOrphanedImports(generatedFilename("imports.tf"), generatedFilename("resources.tf")), + postprocessing.UsePreferredResourceNames(generatedFilename("resources.tf"), generatedFilename("imports.tf")), + sortResourcesFile(generatedFilename("resources.tf")), + postprocessing.WrapJSONFieldsInFunction(generatedFilename("resources.tf")), + } { + if err != nil { + return failure(err) } - return nil } - if err := writeBlocks(generatedFilename("imports.tf"), allBlocks...); err != nil { + return returnResult +} + +// removeOrphanedImports removes import blocks that do not have a corresponding resource block in the resources file. +// These happen when the Terraform plan command has failed for some resources. +func removeOrphanedImports(importsFile, resourcesFile string) error { + imports, err := utils.ReadHCLFile(importsFile) + if err != nil { return err } - _, err = cfg.Terraform.Plan(ctx, tfexec.GenerateConfigOut(generatedFilename("resources.tf"))) + + resources, err := utils.ReadHCLFile(resourcesFile) if err != nil { - return fmt.Errorf("failed to generate resources: %w", err) + return err } - return sortResourcesFile(generatedFilename("resources.tf")) + + resourcesMap := map[string]struct{}{} + for _, block := range resources.Body().Blocks() { + if block.Type() != "resource" { + continue + } + + resourcesMap[strings.Join(block.Labels(), ".")] = struct{}{} + } + + for _, block := range imports.Body().Blocks() { + if block.Type() != "import" { + continue + } + + importTo := strings.TrimSpace(string(block.Body().GetAttribute("to").Expr().BuildTokens(nil).Bytes())) + if _, ok := resourcesMap[importTo]; !ok { + imports.Body().RemoveBlock(block) + } + } + + return writeBlocksFile(importsFile, true, imports.Body().Blocks()...) } func filterResources(resources []*common.Resource, includedResources []string) ([]*common.Resource, error) { @@ -263,13 +408,13 @@ func filterResources(resources []*common.Resource, includedResources []string) ( return filteredResources, nil } -func filterResourceByName(resourceType, resourceName string, includedResources []string) (bool, error) { +func filterResourceByName(resourceType, resourceID string, includedResources []string) (bool, error) { if len(includedResources) == 0 { return true, nil } for _, included := range includedResources { - matched, err := filepath.Match(included, resourceType+"."+resourceName) + matched, err := filepath.Match(included, resourceType+"."+resourceID) if err != nil { return false, err } diff --git a/pkg/generate/generate_test.go b/pkg/generate/generate_test.go index 51546fbb7..e41c893b9 100644 --- a/pkg/generate/generate_test.go +++ b/pkg/generate/generate_test.go @@ -2,13 +2,21 @@ package generate_test import ( "context" + "fmt" "os" "path/filepath" + "strconv" "strings" "testing" + "text/template" + "github.com/grafana/grafana-openapi-client-go/client/access_control" + "github.com/grafana/grafana-openapi-client-go/client/service_accounts" + "github.com/grafana/grafana-openapi-client-go/models" + "github.com/grafana/terraform-provider-grafana/v3/internal/common" "github.com/grafana/terraform-provider-grafana/v3/internal/testutils" "github.com/grafana/terraform-provider-grafana/v3/pkg/generate" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/stretchr/testify/assert" @@ -16,18 +24,79 @@ import ( "golang.org/x/exp/slices" ) -func TestAccGenerate(t *testing.T) { - if testing.Short() { - t.Skip("skipping long test") +type generateTestCase struct { + name string + config string // Terraform configuration to apply + stateCheck func(s *terraform.State) error // Check the Terraform state after applying. Useful to extract computed attributes from state. + generateConfig func(cfg *generate.Config) + check func(t *testing.T, tempDir string) // Check the generated files + resultCheck func(t *testing.T, result generate.GenerationResult) // Check the generation result + + tfInstallDir string // Directory where Terraform is installed. Used to avoid reinstalling it for each test case. +} + +func (tc *generateTestCase) Run(t *testing.T) { + stateCheck := func(s *terraform.State) error { return nil } + if tc.stateCheck != nil { + stateCheck = tc.stateCheck } - testutils.CheckOSSTestsEnabled(t) + t.Run(tc.name, func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tc.config, + Check: resource.ComposeTestCheckFunc( + stateCheck, + func(s *terraform.State) error { + tempDir := t.TempDir() + + // Default configs, use `generateConfig` to override + config := generate.Config{ + OutputDir: tempDir, + Clobber: true, + Format: generate.OutputFormatHCL, + ProviderVersion: "999.999.999", // Using the code from the current branch + Grafana: &generate.GrafanaConfig{ + URL: "http://localhost:3000", + Auth: "admin:admin", + }, + TerraformInstallConfig: generate.TerraformInstallConfig{ + InstallDir: tc.tfInstallDir, + PluginDir: pluginDir(t), + }, + } + if tc.generateConfig != nil { + tc.generateConfig(&config) + } - cases := []struct { - name string - config string - generateConfig func(cfg *generate.Config) - check func(t *testing.T, tempDir string) - }{ + result := generate.Generate(context.Background(), &config) + if tc.resultCheck != nil { + tc.resultCheck(t, result) + } else { + require.Len(t, result.Errors, 0, "expected no errors, got: %v", result.Errors) + } + + if tc.check != nil { + tc.check(t, tempDir) + } + + return nil + }, + ), + }, + }, + }) + }) +} + +func TestAccGenerate(t *testing.T) { + testutils.CheckEnterpriseTestsEnabled(t, ">=10.0.0") + + // Install Terraform to a temporary directory to avoid reinstalling it for each test case. + installDir := t.TempDir() + + cases := []generateTestCase{ { name: "dashboard", config: testutils.TestAccExample(t, "resources/grafana_dashboard/resource.tf"), @@ -38,6 +107,29 @@ func TestAccGenerate(t *testing.T) { }) }, }, + { + name: "large-dashboards-exported-to-files", + config: func() string { + absPath, err := filepath.Abs("testdata/generate/dashboard-large/resources.tf") + require.NoError(t, err) + content, err := os.ReadFile(absPath) + require.NoError(t, err) + config := strings.ReplaceAll(string(content), "${path.module}", filepath.Dir(absPath)) + return config + }(), + generateConfig: func(cfg *generate.Config) { + cfg.IncludeResources = []string{ + "grafana_dashboard.*", + "grafana_folder.*", + } + }, + check: func(t *testing.T, tempDir string) { + assertFiles(t, tempDir, "testdata/generate/dashboard-large", []string{ + ".terraform", + ".terraform.lock.hcl", + }) + }, + }, { name: "dashboard-json", config: testutils.TestAccExample(t, "resources/grafana_dashboard/resource.tf"), @@ -65,7 +157,7 @@ func TestAccGenerate(t *testing.T) { name: "dashboard-filter-strict", config: testutils.TestAccExample(t, "resources/grafana_dashboard/resource.tf"), generateConfig: func(cfg *generate.Config) { - cfg.IncludeResources = []string{"grafana_dashboard._1_my-dashboard-uid"} + cfg.IncludeResources = []string{"grafana_dashboard.my-dashboard-uid"} }, check: func(t *testing.T, tempDir string) { assertFiles(t, tempDir, "testdata/generate/dashboard-filtered", []string{ @@ -78,7 +170,7 @@ func TestAccGenerate(t *testing.T) { name: "dashboard-filter-wildcard-on-resource-type", config: testutils.TestAccExample(t, "resources/grafana_dashboard/resource.tf"), generateConfig: func(cfg *generate.Config) { - cfg.IncludeResources = []string{"*._1_my-dashboard-uid"} + cfg.IncludeResources = []string{"*.my-dashboard-uid"} }, check: func(t *testing.T, tempDir string) { assertFiles(t, tempDir, "testdata/generate/dashboard-filtered", []string{ @@ -113,6 +205,20 @@ func TestAccGenerate(t *testing.T) { }) }, }, + { + name: "with-creds", + config: testutils.TestAccExample(t, "resources/grafana_dashboard/resource.tf"), + generateConfig: func(cfg *generate.Config) { + cfg.IncludeResources = []string{"doesnot.exist"} + cfg.OutputCredentials = true + }, + check: func(t *testing.T, tempDir string) { + assertFiles(t, tempDir, "testdata/generate/empty-with-creds", []string{ + ".terraform", + ".terraform.lock.hcl", + }) + }, + }, { name: "alerting-in-org", config: func() string { @@ -141,49 +247,317 @@ func TestAccGenerate(t *testing.T) { }) }, }, + { + name: "fail-to-generate", + config: " ", + generateConfig: func(cfg *generate.Config) { + cfg.Grafana.IsGrafanaCloudStack = true // Querying Grafana Cloud stuff will fail (this is a local instance) + }, + resultCheck: func(t *testing.T, result generate.GenerationResult) { + require.Greater(t, len(result.Success), 0, "expected successes, got: %+v", result) + require.Greater(t, len(result.Errors), 1, "expected more than one error, got: %+v", result) + gotCloudErrors := false + for _, err := range result.Errors { + resourceError, ok := err.(generate.ResourceError) + require.True(t, ok, "expected ResourceError, got: %v", err) + if strings.HasPrefix(resourceError.Resource.Name, "grafana_machine_learning") || strings.HasPrefix(resourceError.Resource.Name, "grafana_slo") { + gotCloudErrors = true + break + } + } + require.True(t, gotCloudErrors, "expected errors related to Grafana Cloud resources, got: %v", result.Errors) + }, + }, } for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - resource.Test(t, resource.TestCase{ - ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, - Steps: []resource.TestStep{ - { - Config: tc.config, - Check: func(s *terraform.State) error { - tempDir := t.TempDir() - config := generate.Config{ - OutputDir: tempDir, - Clobber: true, - Format: generate.OutputFormatHCL, - ProviderVersion: "v3.0.0", - Grafana: &generate.GrafanaConfig{ - URL: "http://localhost:3000", - Auth: "admin:admin", - }, - } - if tc.generateConfig != nil { - tc.generateConfig(&config) - } + tc.tfInstallDir = installDir + tc.Run(t) + } +} - require.NoError(t, generate.Generate(context.Background(), &config)) - tc.check(t, tempDir) +func TestAccGenerate_RestrictedPermissions(t *testing.T) { + testutils.CheckEnterpriseTestsEnabled(t, ">=10.0.0") - return nil - }, - }, - }, + // Create SA with no permissions + randString := acctest.RandString(10) + client := testutils.Provider.Meta().(*common.Client).GrafanaAPI.Clone().WithOrgID(0) + sa, err := client.ServiceAccounts.CreateServiceAccount( + service_accounts.NewCreateServiceAccountParams().WithBody(&models.CreateServiceAccountForm{ + Name: "test-no-permissions-" + randString, + Role: "None", + }, + )) + require.NoError(t, err) + t.Cleanup(func() { + client.ServiceAccounts.DeleteServiceAccount(sa.Payload.ID) + }) + + saToken, err := client.ServiceAccounts.CreateToken( + service_accounts.NewCreateTokenParams().WithBody(&models.AddServiceAccountTokenCommand{ + Name: "test-no-permissions-" + randString, + }, + ).WithServiceAccountID(sa.Payload.ID), + ) + require.NoError(t, err) + + // Allow the SA to read dashboards + if _, err := client.AccessControl.CreateRole(&models.CreateRoleForm{ + Name: randString, + Permissions: []*models.Permission{ + { + Action: "dashboards:read", + Scope: "dashboards:*", + }, + { + Action: "folders:read", + Scope: "folders:*", + }, + }, + UID: randString, + }); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + client.AccessControl.DeleteRole(access_control.NewDeleteRoleParams().WithRoleUID(randString)) + }) + if _, err := client.AccessControl.SetUserRoles(sa.Payload.ID, &models.SetUserRolesCommand{ + RoleUids: []string{randString}, + Global: false, + }); err != nil { + t.Fatal(err) + } + + tc := generateTestCase{ + name: "restricted-permissions", + config: testutils.TestAccExample(t, "resources/grafana_dashboard/resource.tf"), + generateConfig: func(cfg *generate.Config) { + cfg.Grafana.Auth = saToken.Payload.Key + }, + resultCheck: func(t *testing.T, result generate.GenerationResult) { + assert.NotEmpty(t, result.Errors, "expected errors, got: %+v", result) + for _, err := range result.Errors { + // Check that all errors are non critical + _, ok := err.(generate.NonCriticalError) + assert.True(t, ok, "expected NonCriticalError, got: %v (Type: %T)", err, err) + } + }, + check: func(t *testing.T, tempDir string) { + assertFiles(t, tempDir, "testdata/generate/dashboard-restricted-permissions", []string{ + ".terraform", + ".terraform.lock.hcl", }) - }) + }, } + + tc.Run(t) +} + +func TestAccGenerate_SMCheck(t *testing.T) { + testutils.CheckCloudInstanceTestsEnabled(t) + + randomString := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + + var smCheckID string + tc := generateTestCase{ + name: "sm-check", + config: testutils.TestAccExampleWithReplace(t, "resources/grafana_synthetic_monitoring_check/http_basic.tf", map[string]string{ + `"HTTP Defaults"`: strconv.Quote(randomString), + }), + stateCheck: func(s *terraform.State) error { + checkResource, ok := s.RootModule().Resources["grafana_synthetic_monitoring_check.http"] + if !ok { + return fmt.Errorf("expected resource 'grafana_synthetic_monitoring_check.http' to be present") + } + smCheckID = checkResource.Primary.ID + return nil + }, + generateConfig: func(cfg *generate.Config) { + cfg.Grafana = &generate.GrafanaConfig{ + URL: os.Getenv("GRAFANA_URL"), + Auth: os.Getenv("GRAFANA_AUTH"), + SMURL: os.Getenv("GRAFANA_SM_URL"), + SMAccessToken: os.Getenv("GRAFANA_SM_ACCESS_TOKEN"), + } + cfg.IncludeResources = []string{"grafana_synthetic_monitoring_check." + smCheckID} + }, + check: func(t *testing.T, tempDir string) { + templateAttrs := map[string]string{ + "ID": smCheckID, + "Job": randomString, + } + assertFilesWithTemplating(t, tempDir, "testdata/generate/sm-check", []string{ + ".terraform", + ".terraform.lock.hcl", + }, templateAttrs) + }, + } + + tc.Run(t) +} + +func TestAccGenerate_OnCall(t *testing.T) { + testutils.CheckCloudInstanceTestsEnabled(t) + + randomString := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + + tfConfig := fmt.Sprintf(` + resource "grafana_oncall_integration" "test" { + name = "%[1]s" + type = "grafana" + default_route {} + } + + resource "grafana_oncall_escalation_chain" "test"{ + name = "%[1]s" + } + + resource "grafana_oncall_escalation" "test" { + escalation_chain_id = grafana_oncall_escalation_chain.test.id + type = "wait" + duration = "300" + position = 0 + } + + resource "grafana_oncall_schedule" "test" { + name = "%[1]s" + type = "calendar" + time_zone = "America/New_York" + } + `, randomString) + + var ( + oncallIntegrationID string + oncallEscalationChainID string + oncallEscalationID string + oncallScheduleID string + ) + tc := generateTestCase{ + name: "oncall", + config: tfConfig, + generateConfig: func(cfg *generate.Config) { + cfg.Grafana = &generate.GrafanaConfig{ + URL: os.Getenv("GRAFANA_URL"), + Auth: os.Getenv("GRAFANA_AUTH"), + OnCallURL: "https://oncall-prod-us-central-0.grafana.net/oncall", + OnCallAccessToken: os.Getenv("GRAFANA_ONCALL_ACCESS_TOKEN"), + } + cfg.IncludeResources = []string{ + "grafana_oncall_integration." + oncallIntegrationID, + "grafana_oncall_escalation_chain." + oncallEscalationChainID, + "grafana_oncall_escalation." + oncallEscalationID, + "grafana_oncall_schedule." + oncallScheduleID, + } + }, + stateCheck: func(s *terraform.State) error { + integrationResource, ok := s.RootModule().Resources["grafana_oncall_integration.test"] + if !ok { + return fmt.Errorf("expected resource 'grafana_oncall_integration.test' to be present") + } + oncallIntegrationID = integrationResource.Primary.ID + + chainResource, ok := s.RootModule().Resources["grafana_oncall_escalation_chain.test"] + if !ok { + return fmt.Errorf("expected resource 'grafana_oncall_escalation_chain.test' to be present") + } + oncallEscalationChainID = chainResource.Primary.ID + + escalationResource, ok := s.RootModule().Resources["grafana_oncall_escalation.test"] + if !ok { + return fmt.Errorf("expected resource 'grafana_oncall_escalation.test' to be present") + } + oncallEscalationID = escalationResource.Primary.ID + + scheduleResource, ok := s.RootModule().Resources["grafana_oncall_schedule.test"] + if !ok { + return fmt.Errorf("expected resource 'grafana_oncall_schedule.test' to be present") + } + oncallScheduleID = scheduleResource.Primary.ID + + return nil + }, + check: func(t *testing.T, tempDir string) { + templateAttrs := map[string]string{ + "Name": randomString, + "IntegrationID": oncallIntegrationID, + "EscalationChainID": oncallEscalationChainID, + "EscalationID": oncallEscalationID, + "ScheduleID": oncallScheduleID, + } + assertFilesWithTemplating(t, tempDir, "testdata/generate/oncall-resources", []string{ + ".terraform", + ".terraform.lock.hcl", + }, templateAttrs) + }, + } + + tc.Run(t) } // assertFiles checks that all files in the "expectedFilesDir" directory match the files in the "gotFilesDir" directory. func assertFiles(t *testing.T, gotFilesDir, expectedFilesDir string, ignoreDirEntries []string) { t.Helper() + assertFilesWithTemplating(t, gotFilesDir, expectedFilesDir, ignoreDirEntries, nil) +} + +// assertFilesWithTemplating checks that all files in the "expectedFilesDir" directory match the files in the "gotFilesDir" directory. +func assertFilesWithTemplating(t *testing.T, gotFilesDir, expectedFilesDir string, ignoreDirEntries []string, attributes map[string]string) { + t.Helper() + + if attributes != nil { + expectedFilesDir = templateDir(t, expectedFilesDir, attributes) + } + assertFilesSubdir(t, gotFilesDir, expectedFilesDir, "", ignoreDirEntries) } +func templateDir(t *testing.T, dir string, attributes map[string]string) string { + t.Helper() + + templatedDir := t.TempDir() + + // Copy all dirs and files from the expected directory to the templated directory + // Template all files that end with ".tmpl", renaming them to remove the ".tmpl" suffix + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relativePath, err := filepath.Rel(dir, path) + if err != nil { + return err + } + + templatedPath := filepath.Join(templatedDir, relativePath) + if info.IsDir() { + return os.MkdirAll(templatedPath, 0755) + } + + // Copy the file + isTmpl := strings.HasSuffix(info.Name(), ".tmpl") + templatedPath = strings.TrimSuffix(templatedPath, ".tmpl") + content, err := os.ReadFile(path) + if err != nil { + return err + } + if isTmpl { + fileTmpl, err := template.New(path).Parse(string(content)) + if err != nil { + return err + } + var templatedContent strings.Builder + if err := fileTmpl.Execute(&templatedContent, attributes); err != nil { + return err + } + content = []byte(templatedContent.String()) + } + return os.WriteFile(templatedPath, content, 0600) + }) + require.NoError(t, err) + + return templatedDir +} + func assertFilesSubdir(t *testing.T, gotFilesDir, expectedFilesDir, subdir string, ignoreDirEntries []string) { t.Helper() @@ -240,3 +614,11 @@ func assertFilesSubdir(t *testing.T, gotFilesDir, expectedFilesDir, subdir strin assert.Equal(t, strings.TrimSpace(string(expectedContent)), strings.TrimSpace(string(gotContent))) } } + +func pluginDir(t *testing.T) string { + t.Helper() + + repoRoot, err := filepath.Abs("../..") + require.NoError(t, err) + return filepath.Join(repoRoot, "testdata", "plugins") +} diff --git a/pkg/generate/grafana.go b/pkg/generate/grafana.go index 3e5855584..c3572a378 100644 --- a/pkg/generate/grafana.go +++ b/pkg/generate/grafana.go @@ -10,13 +10,14 @@ import ( "github.com/grafana/terraform-provider-grafana/v3/internal/resources/oncall" "github.com/grafana/terraform-provider-grafana/v3/internal/resources/slo" "github.com/grafana/terraform-provider-grafana/v3/internal/resources/syntheticmonitoring" + "github.com/grafana/terraform-provider-grafana/v3/pkg/generate/postprocessing" "github.com/grafana/terraform-provider-grafana/v3/pkg/provider" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/zclconf/go-cty/cty" ) -func generateGrafanaResources(ctx context.Context, cfg *Config, stack stack, genProvider bool) error { +func generateGrafanaResources(ctx context.Context, cfg *Config, stack stack, genProvider bool) GenerationResult { generatedFilename := func(suffix string) string { if stack.name == "" { return filepath.Join(cfg.OutputDir, suffix) @@ -41,12 +42,12 @@ func generateGrafanaResources(ctx context.Context, cfg *Config, stack stack, gen providerBlock.Body().SetAttributeValue("alias", cty.StringVal(stack.name)) } if err := writeBlocks(generatedFilename("provider.tf"), providerBlock); err != nil { - return err + return failure(err) } } singleOrg := !strings.Contains(stack.managementKey, ":") - listerData := grafana.NewListerData(singleOrg) + listerData := grafana.NewListerData(singleOrg, true) // Generate resources config := provider.ProviderConfig{ @@ -65,20 +66,22 @@ func generateGrafanaResources(ctx context.Context, cfg *Config, stack stack, gen config.OncallURL = types.StringValue(stack.onCallURL) } if err := config.SetDefaults(); err != nil { - return err + return failure(err) } client, err := provider.CreateClients(config) if err != nil { - return err + return failure(err) } if stack.isCloud { resources = append(resources, slo.Resources...) resources = append(resources, machinelearning.Resources...) } - if err := generateImportBlocks(ctx, client, listerData, resources, cfg, stack.name); err != nil { - return err + + returnResult := generateImportBlocks(ctx, client, listerData, resources, cfg, stack.name) + if returnResult.Blocks() == 0 { // Skip if no resources were found + return returnResult } stripDefaultsExtraFields := map[string]any{} @@ -88,24 +91,21 @@ func generateGrafanaResources(ctx context.Context, cfg *Config, stack stack, gen stripDefaultsExtraFields["org_id"] = `"1"` // Remove org_id if it's the default } - postprocessor := &postprocessor{} - if postprocessor.plannedState, err = getPlannedState(ctx, cfg); err != nil { - return err - } - if err := postprocessor.stripDefaults(generatedFilename("resources.tf"), stripDefaultsExtraFields); err != nil { - return err + plannedState, err := getPlannedState(ctx, cfg) + if err != nil { + return failure(err) } - if err := postprocessor.abstractDashboards(generatedFilename("resources.tf")); err != nil { - return err + if err := postprocessing.StripDefaults(generatedFilename("resources.tf"), stripDefaultsExtraFields); err != nil { + return failure(err) } - if err := postprocessor.wrapJSONFieldsInFunction(generatedFilename("resources.tf")); err != nil { - return err + if err := postprocessing.ExtractDashboards(generatedFilename("resources.tf"), plannedState); err != nil { + return failure(err) } - if err := postprocessor.replaceReferences(generatedFilename("resources.tf"), []string{ + if err := postprocessing.ReplaceReferences(generatedFilename("resources.tf"), plannedState, []string{ "*.org_id=grafana_organization.id", }); err != nil { - return err + return failure(err) } - return nil + return returnResult } diff --git a/pkg/generate/postprocessing.go b/pkg/generate/postprocessing.go deleted file mode 100644 index f55bae94e..000000000 --- a/pkg/generate/postprocessing.go +++ /dev/null @@ -1,494 +0,0 @@ -package generate - -import ( - "encoding/json" - "errors" - "fmt" - "log" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/hashicorp/hcl/v2/hclwrite" - tfjson "github.com/hashicorp/terraform-json" - "github.com/zclconf/go-cty/cty" -) - -// knownReferences is a map of all resource fields that can be referenced from another resource. -// For example, the `folder` field of a `grafana_dashboard` resource can be a `grafana_folder` reference. -// -//go:generate go run ./genreferences --file=$GOFILE --walk-dir=../.. -var knownReferences = []string{ - "grafana_annotation.dashboard_uid=grafana_dashboard.uid", - "grafana_annotation.org_id=grafana_organization.id", - "grafana_api_key.auth=grafana_api_key.key", - "grafana_cloud_access_policy.identifier=grafana_cloud_stack.id", - "grafana_cloud_access_policy_token.access_policy_id=grafana_cloud_access_policy.policy_id", - "grafana_cloud_plugin_installation.stack_slug=grafana_cloud_stack.slug", - "grafana_cloud_stack_service_account.stack_slug=grafana_cloud_stack.slug", - "grafana_cloud_stack_service_account_token.auth=grafana_cloud_stack_service_account_token.key", - "grafana_cloud_stack_service_account_token.service_account_id=grafana_cloud_stack_service_account.id", - "grafana_cloud_stack_service_account_token.stack_slug=grafana_cloud_stack.slug", - "grafana_cloud_stack_service_account_token.url=grafana_cloud_stack.url", - "grafana_contact_point.org_id=grafana_organization.id", - "grafana_dashboard.folder=grafana_folder.id", - "grafana_dashboard.folder=grafana_folder.uid", - "grafana_dashboard.name=grafana_library_panel.name", - "grafana_dashboard.org_id=grafana_organization.id", - "grafana_dashboard.org_id=grafana_organization.org_id", - "grafana_dashboard.uid=grafana_library_panel.uid", - "grafana_dashboard_permission.dashboard_uid=grafana_dashboard.uid", - "grafana_dashboard_permission.team_id=grafana_team.id", - "grafana_dashboard_permission.user_id=grafana_user.id", - "grafana_dashboard_permission_item.dashboard_uid=grafana_dashboard.uid", - "grafana_dashboard_permission_item.team=grafana_team.id", - "grafana_dashboard_permission_item.user=grafana_service_account.id", - "grafana_dashboard_permission_item.user=grafana_user.id", - "grafana_dashboard_public.dashboard_uid=grafana_dashboard.uid", - "grafana_dashboard_public.org_id=grafana_organization.org_id", - "grafana_data_source.datasourceUid=grafana_data_source.uid", - "grafana_data_source.org_id=grafana_organization.id", - "grafana_data_source_config.datasourceUid=grafana_data_source.uid", - "grafana_data_source_config.uid=grafana_data_source.uid", - "grafana_data_source_permission.datasource_uid=grafana_data_source.uid", - "grafana_data_source_permission.team_id=grafana_team.id", - "grafana_data_source_permission.user_id=grafana_service_account.id", - "grafana_data_source_permission.user_id=grafana_user.id", - "grafana_data_source_permission_item.datasource_uid=grafana_data_source.uid", - "grafana_data_source_permission_item.team=grafana_team.id", - "grafana_data_source_permission_item.user=grafana_service_account.id", - "grafana_data_source_permission_item.user=grafana_user.id", - "grafana_folder.org_id=grafana_organization.id", - "grafana_folder.org_id=grafana_organization.org_id", - "grafana_folder.parent_folder_uid=grafana_folder.uid", - "grafana_folder_permission.folder_uid=grafana_folder.uid", - "grafana_folder_permission.team_id=grafana_team.id", - "grafana_folder_permission.user_id=grafana_service_account.id", - "grafana_folder_permission.user_id=grafana_user.id", - "grafana_folder_permission_item.folder_uid=grafana_folder.uid", - "grafana_folder_permission_item.team=grafana_team.id", - "grafana_folder_permission_item.user=grafana_service_account.id", - "grafana_folder_permission_item.user=grafana_user.id", - "grafana_library_panel.folder_uid=grafana_folder.uid", - "grafana_library_panel.org_id=grafana_organization.id", - "grafana_machine_learning_job.datasource_uid=grafana_data_source.uid", - "grafana_message_template.org_id=grafana_organization.id", - "grafana_mute_timing.org_id=grafana_organization.id", - "grafana_notification_policy.contact_point=grafana_contact_point.name", - "grafana_notification_policy.mute_timings=grafana_mute_timing.name", - "grafana_notification_policy.org_id=grafana_organization.id", - "grafana_oncall_escalation.escalation_chain_id=grafana_oncall_escalation_chain.id", - "grafana_oncall_integration.escalation_chain_id=grafana_oncall_escalation_chain.id", - "grafana_oncall_route.escalation_chain_id=grafana_oncall_escalation_chain.id", - "grafana_oncall_route.integration_id=grafana_oncall_integration.id", - "grafana_organization.org_id=grafana_organization.id", - "grafana_organization_preferences.home_dashboard_uid=grafana_dashboard.uid", - "grafana_organization_preferences.org_id=grafana_organization.id", - "grafana_playlist.org_id=grafana_organization.id", - "grafana_report.dashboard_id=grafana_dashboard.dashboard_id", - "grafana_report.org_id=grafana_organization.id", - "grafana_report.uid=grafana_dashboard.uid", - "grafana_role.org_id=grafana_organization.id", - "grafana_role_assignment.auth=grafana_cloud_stack_service_account_token.key", - "grafana_role_assignment.org_id=grafana_organization.id", - "grafana_role_assignment.role_uid=grafana_role.uid", - "grafana_role_assignment.service_accounts=grafana_cloud_stack_service_account.id", - "grafana_role_assignment.service_accounts=grafana_service_account.id", - "grafana_role_assignment.teams=grafana_team.id", - "grafana_role_assignment.url=grafana_cloud_stack.url", - "grafana_role_assignment.users=grafana_user.id", - "grafana_role_assignment_item.role_uid=grafana_role.uid", - "grafana_role_assignment_item.service_account_id=grafana_service_account.id", - "grafana_role_assignment_item.team_id=grafana_team.id", - "grafana_role_assignment_item.user_id=grafana_user.id", - "grafana_rule_group.folder_uid=grafana_folder.uid", - "grafana_rule_group.org_id=grafana_organization.id", - "grafana_service_account.org_id=grafana_organization.id", - "grafana_service_account.role_uid=grafana_role.uid", - "grafana_service_account.service_account_id=grafana_service_account.id", - "grafana_service_account.team_id=grafana_team.id", - "grafana_service_account.user_id=grafana_user.id", - "grafana_service_account_permission.org_id=grafana_organization.id", - "grafana_service_account_permission.service_account_id=grafana_cloud_stack_service_account.id", - "grafana_service_account_permission.service_account_id=grafana_service_account.id", - "grafana_service_account_permission.team_id=grafana_team.id", - "grafana_service_account_permission.user_id=grafana_user.id", - "grafana_service_account_permission_item.auth=grafana_cloud_stack_service_account_token.key", - "grafana_service_account_permission_item.org_id=grafana_organization.id", - "grafana_service_account_permission_item.service_account_id=grafana_cloud_stack_service_account.id", - "grafana_service_account_permission_item.service_account_id=grafana_service_account.id", - "grafana_service_account_permission_item.team=grafana_team.id", - "grafana_service_account_permission_item.url=grafana_cloud_stack.url", - "grafana_service_account_permission_item.user=grafana_user.id", - "grafana_service_account_token.auth=grafana_service_account_token.key", - "grafana_service_account_token.service_account_id=grafana_service_account.id", - "grafana_slo.folder_uid=grafana_folder.uid", - "grafana_synthetic_monitoring_installation.logs_instance_id=grafana_cloud_stack.logs_user_id", - "grafana_synthetic_monitoring_installation.metrics_instance_id=grafana_cloud_stack.prometheus_user_id", - "grafana_synthetic_monitoring_installation.metrics_publisher_key=grafana_cloud_access_policy_token.token", - "grafana_synthetic_monitoring_installation.metrics_publisher_key=grafana_cloud_api_key.key", - "grafana_synthetic_monitoring_installation.sm_access_token=grafana_synthetic_monitoring_installation.sm_access_token", - "grafana_synthetic_monitoring_installation.sm_url=grafana_synthetic_monitoring_installation.stack_sm_api_url", - "grafana_synthetic_monitoring_installation.stack_id=grafana_cloud_stack.id", - "grafana_team.home_dashboard_uid=grafana_dashboard.uid", - "grafana_team.org_id=grafana_organization.id", - "grafana_team_external_group.team_id=grafana_team.id", - "grafana_team_preferences.home_dashboard_uid=grafana_dashboard.uid", - "grafana_team_preferences.team_id=grafana_team.id", -} - -type postprocessor struct { - plannedState *tfjson.Plan -} - -func (p *postprocessor) replaceReferences(fpath string, extraKnownReferences []string) error { - file, err := p.readHCLFile(fpath) - if err != nil { - return err - } - - hasChanges := false - - knownReferences := knownReferences - knownReferences = append(knownReferences, extraKnownReferences...) - - plannedResources := p.plannedState.PlannedValues.RootModule.Resources - - for _, block := range file.Body().Blocks() { - var blockResource *tfjson.StateResource - for _, plannedResource := range plannedResources { - if plannedResource.Type == block.Labels()[0] && plannedResource.Name == block.Labels()[1] { - blockResource = plannedResource - break - } - } - if blockResource == nil { - return fmt.Errorf("resource %s.%s not found in planned state", block.Labels()[0], block.Labels()[1]) - } - - for attrName := range block.Body().Attributes() { - attrValue := blockResource.AttributeValues[attrName] - attrReplaced := false - - // Check the field name. If it has a possible reference, we have to search for it in the resources - for _, ref := range knownReferences { - if attrReplaced { - break - } - - refFrom := strings.Split(ref, "=")[0] - refTo := strings.Split(ref, "=")[1] - hasPossibleReference := refFrom == fmt.Sprintf("%s.%s", block.Labels()[0], attrName) || (strings.HasPrefix(refFrom, "*.") && strings.HasSuffix(refFrom, fmt.Sprintf(".%s", attrName))) - if !hasPossibleReference { - continue - } - - refToResource := strings.Split(refTo, ".")[0] - refToAttr := strings.Split(refTo, ".")[1] - - for _, plannedResource := range plannedResources { - if plannedResource.Type != refToResource { - continue - } - - valueFromRef := plannedResource.AttributeValues[refToAttr] - // If the value from the first block matches the value from the second block, we have a reference - if attrValue == valueFromRef { - // Replace the value with the reference - block.Body().SetAttributeTraversal(attrName, traversal(plannedResource.Type, plannedResource.Name, refToAttr)) - hasChanges = true - attrReplaced = true - break - } - } - } - } - } - - if hasChanges { - log.Printf("Updating file: %s\n", fpath) - return os.WriteFile(fpath, file.Bytes(), 0600) - } - - return nil -} - -func (p *postprocessor) stripDefaults(fpath string, extraFieldsToRemove map[string]any) error { - file, err := p.readHCLFile(fpath) - if err != nil { - return err - } - - hasChanges := false - for _, block := range file.Body().Blocks() { - if s := p.stripDefaultsFromBlock(block, extraFieldsToRemove); s { - hasChanges = true - } - } - if hasChanges { - log.Printf("Updating file: %s\n", fpath) - return os.WriteFile(fpath, file.Bytes(), 0600) - } - return nil -} - -func (p *postprocessor) wrapJSONFieldsInFunction(fpath string) error { - file, err := p.readHCLFile(fpath) - if err != nil { - return err - } - - hasChanges := false - // Find json attributes and use jsonencode - for _, block := range file.Body().Blocks() { - for key, attr := range block.Body().Attributes() { - asMap, err := p.attributeToMap(attr) - if err != nil || asMap == nil { - continue - } - tokens := hclwrite.TokensForValue(HCL2ValueFromConfigValue(asMap)) - block.Body().SetAttributeRaw(key, hclwrite.TokensForFunctionCall("jsonencode", tokens)) - hasChanges = true - } - } - - if hasChanges { - log.Printf("Updating file: %s\n", fpath) - return os.WriteFile(fpath, file.Bytes(), 0600) - } - return nil -} - -func (p *postprocessor) abstractDashboards(fpath string) error { - fDir := filepath.Dir(fpath) - outPath := filepath.Join(fDir, "files") - - file, err := p.readHCLFile(fpath) - if err != nil { - return err - } - - hasChanges := false - dashboardJsons := map[string][]byte{} - for _, block := range file.Body().Blocks() { - labels := block.Labels() - if len(labels) == 0 || labels[0] != "grafana_dashboard" { - continue - } - - dashboard, err := p.attributeToJSON(block.Body().GetAttribute("config_json")) - if err != nil { - return err - } - - if dashboard == nil { - continue - } - - writeTo := filepath.Join(outPath, fmt.Sprintf("%s.json", block.Labels()[1])) - - // Replace $${ with ${ in the json. No need to escape in the json file - dashboard = []byte(strings.ReplaceAll(string(dashboard), "$${", "${")) - dashboardJsons[writeTo] = dashboard - - // Hacky relative path with interpolation - relativePath := strings.ReplaceAll(writeTo, fDir, "") - pathWithInterpolation := hclwrite.Tokens{ - {Type: hclsyntax.TokenOQuote, Bytes: []byte(`"`)}, - {Type: hclsyntax.TokenTemplateInterp, Bytes: []byte(`${`)}, - {Type: hclsyntax.TokenIdent, Bytes: []byte(`path.module`)}, - {Type: hclsyntax.TokenTemplateSeqEnd, Bytes: []byte(`}`)}, - {Type: hclsyntax.TokenQuotedLit, Bytes: []byte(relativePath)}, - {Type: hclsyntax.TokenCQuote, Bytes: []byte(`"`)}, - } - - block.Body().SetAttributeRaw( - "config_json", - hclwrite.TokensForFunctionCall("file", pathWithInterpolation), - ) - - hasChanges = true - } - if hasChanges { - log.Printf("Updating file: %s\n", fpath) - os.Mkdir(outPath, 0755) - for writeTo, dashboard := range dashboardJsons { - err := os.WriteFile(writeTo, dashboard, 0600) - if err != nil { - panic(err) - } - } - return os.WriteFile(fpath, file.Bytes(), 0600) - } - return nil -} - -func (p *postprocessor) attributeToMap(attr *hclwrite.Attribute) (map[string]interface{}, error) { - var err error - - // Convert jsonencode to raw json - s := strings.TrimPrefix(string(attr.Expr().BuildTokens(nil).Bytes()), " ") - - if strings.HasPrefix(s, "jsonencode(") { - return nil, nil // Figure out how to handle those - } - - if !strings.HasPrefix(s, "\"") { - // if expr is not a string, assume it's already converted, return (idempotency - return nil, nil - } - s, err = strconv.Unquote(s) - if err != nil { - return nil, err - } - s = strings.ReplaceAll(s, "$${", "${") // These are escaped interpolations - - var dashboardMap map[string]interface{} - err = json.Unmarshal([]byte(s), &dashboardMap) - if err != nil { - return nil, err - } - - return dashboardMap, nil -} - -func (p *postprocessor) attributeToJSON(attr *hclwrite.Attribute) ([]byte, error) { - jsonMap, err := p.attributeToMap(attr) - if err != nil || jsonMap == nil { - return nil, err - } - - jsonMarshalled, err := json.MarshalIndent(jsonMap, "", "\t") - if err != nil { - return nil, err - } - - return jsonMarshalled, nil -} - -func (p *postprocessor) readHCLFile(fpath string) (*hclwrite.File, error) { - src, err := os.ReadFile(fpath) - if err != nil { - return nil, err - } - - file, diags := hclwrite.ParseConfig(src, fpath, hcl.Pos{Line: 1, Column: 1}) - if diags.HasErrors() { - return nil, errors.New(diags.Error()) - } - - return file, nil -} - -func (p *postprocessor) stripDefaultsFromBlock(block *hclwrite.Block, extraFieldsToRemove map[string]any) bool { - hasChanges := false - for _, innblock := range block.Body().Blocks() { - if s := p.stripDefaultsFromBlock(innblock, extraFieldsToRemove); s { - hasChanges = true - } - if len(innblock.Body().Attributes()) == 0 && len(innblock.Body().Blocks()) == 0 { - if rm := block.Body().RemoveBlock(innblock); rm { - hasChanges = true - } - } - } - for name, attribute := range block.Body().Attributes() { - if string(attribute.Expr().BuildTokens(nil).Bytes()) == " null" { - if rm := block.Body().RemoveAttribute(name); rm != nil { - hasChanges = true - } - } - if string(attribute.Expr().BuildTokens(nil).Bytes()) == " {}" { - if rm := block.Body().RemoveAttribute(name); rm != nil { - hasChanges = true - } - } - if string(attribute.Expr().BuildTokens(nil).Bytes()) == " []" { - if rm := block.Body().RemoveAttribute(name); rm != nil { - hasChanges = true - } - } - for key, valueToRemove := range extraFieldsToRemove { - if name == key { - toRemove := false - fieldValue := strings.TrimSpace(string(attribute.Expr().BuildTokens(nil).Bytes())) - fieldValue, err := p.extractJSONEncode(fieldValue) - if err != nil { - continue - } - - if v, ok := valueToRemove.(bool); ok && v { - toRemove = true - } else if v, ok := valueToRemove.(string); ok && v == fieldValue { - toRemove = true - } - if toRemove { - if rm := block.Body().RemoveAttribute(name); rm != nil { - hasChanges = true - } - } - } - } - } - return hasChanges -} -func (p *postprocessor) extractJSONEncode(value string) (string, error) { - if !strings.HasPrefix(value, "jsonencode(") { - return "", nil - } - value = strings.TrimPrefix(value, "jsonencode(") - value = strings.TrimSuffix(value, ")") - - b, err := json.MarshalIndent(value, "", " ") - return string(b), err -} - -// BELOW IS FROM https://github.com/hashicorp/terraform/blob/main/internal/configs/hcl2shim/values.go - -// UnknownVariableValue is a sentinel value that can be used -// to denote that the value of a variable is unknown at this time. -// RawConfig uses this information to build up data about -// unknown keys. -const UnknownVariableValue = "74D93920-ED26-11E3-AC10-0800200C9A66" - -// HCL2ValueFromConfigValue is the opposite of configValueFromHCL2: it takes -// a value as would be returned from the old interpolator and turns it into -// a cty.Value so it can be used within, for example, an HCL2 EvalContext. -func HCL2ValueFromConfigValue(v interface{}) cty.Value { - if v == nil { - return cty.NullVal(cty.DynamicPseudoType) - } - if v == UnknownVariableValue { - return cty.DynamicVal - } - - switch tv := v.(type) { - case bool: - return cty.BoolVal(tv) - case string: - return cty.StringVal(tv) - case int: - return cty.NumberIntVal(int64(tv)) - case float64: - return cty.NumberFloatVal(tv) - case []interface{}: - vals := make([]cty.Value, len(tv)) - for i, ev := range tv { - vals[i] = HCL2ValueFromConfigValue(ev) - } - return cty.TupleVal(vals) - case map[string]interface{}: - vals := map[string]cty.Value{} - for k, ev := range tv { - vals[k] = HCL2ValueFromConfigValue(ev) - } - return cty.ObjectVal(vals) - default: - // HCL/HIL should never generate anything that isn't caught by - // the above, so if we get here something has gone very wrong. - panic(fmt.Errorf("can't convert %#v to cty.Value", v)) - } -} diff --git a/pkg/generate/postprocessing/extract_dashboards.go b/pkg/generate/postprocessing/extract_dashboards.go new file mode 100644 index 000000000..a8f06425e --- /dev/null +++ b/pkg/generate/postprocessing/extract_dashboards.go @@ -0,0 +1,98 @@ +package postprocessing + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hclwrite" + tfjson "github.com/hashicorp/terraform-json" +) + +func ExtractDashboards(fpath string, plannedState *tfjson.Plan) error { + fDir := filepath.Dir(fpath) + outPath := filepath.Join(fDir, "dashboards") + + return postprocessFile(fpath, func(file *hclwrite.File) error { + dashboardJsons := map[string][]byte{} + for _, block := range file.Body().Blocks() { + labels := block.Labels() + if len(labels) == 0 || labels[0] != "grafana_dashboard" { + continue + } + + var dashboardValue string + for _, r := range plannedState.PlannedValues.RootModule.Resources { + if r.Type != "grafana_dashboard" { + continue + } + if r.Name != labels[1] { + continue + } + dashboardValue = r.AttributeValues["config_json"].(string) + } + + // Skip dashboards that have 10 or fewer attributes (counted by commas) + // They are fine as inline JSON + if strings.Count(dashboardValue, ",") <= 10 { + continue + } + + writeTo := filepath.Join(outPath, fmt.Sprintf("%s.json", block.Labels()[1])) + dashboardJsons[writeTo] = []byte(dashboardValue) + + // Hacky relative path with interpolation + relativePath := strings.ReplaceAll(writeTo, fDir, "") + pathWithInterpolation := hclwrite.Tokens{ + {Type: hclsyntax.TokenOQuote, Bytes: []byte(`"`)}, + {Type: hclsyntax.TokenTemplateInterp, Bytes: []byte(`${`)}, + {Type: hclsyntax.TokenIdent, Bytes: []byte(`path.module`)}, + {Type: hclsyntax.TokenTemplateSeqEnd, Bytes: []byte(`}`)}, + {Type: hclsyntax.TokenQuotedLit, Bytes: []byte(relativePath)}, + {Type: hclsyntax.TokenCQuote, Bytes: []byte(`"`)}, + } + + block.Body().SetAttributeRaw( + "config_json", + hclwrite.TokensForFunctionCall("file", pathWithInterpolation), + ) + } + + if len(dashboardJsons) == 0 { + return nil + } + + if err := os.MkdirAll(outPath, 0755); err != nil { + return err + } + for writeTo, dashboard := range dashboardJsons { + dashboardFile, err := os.Create(writeTo) + if err != nil { + return err + } + + // Parse the JSON to format it nicely + var dashboardInterface interface{} + if err := json.Unmarshal(dashboard, &dashboardInterface); err != nil { + return err + } + dashboard, err := json.MarshalIndent(dashboardInterface, "", " ") + if err != nil { + return err + } + + if _, err := dashboardFile.Write(dashboard); err != nil { + return err + } + + if err := dashboardFile.Close(); err != nil { + return err + } + } + + return nil + }) +} diff --git a/pkg/generate/genreferences/main.go b/pkg/generate/postprocessing/genreferences/main.go similarity index 94% rename from pkg/generate/genreferences/main.go rename to pkg/generate/postprocessing/genreferences/main.go index c8507460a..6eba6e3cd 100644 --- a/pkg/generate/genreferences/main.go +++ b/pkg/generate/postprocessing/genreferences/main.go @@ -28,11 +28,22 @@ func main() { log.Fatal("examples-dir and file flags are required") } + walkDir, err := filepath.Abs(walkDir) + if err != nil { + log.Fatal(err) + } + exampleFiles := []string{} if err := filepath.Walk(walkDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } + if info.IsDir() { + if strings.HasPrefix(info.Name(), ".") { + return filepath.SkipDir + } + return nil + } if filepath.Ext(path) == ".tf" { exampleFiles = append(exampleFiles, path) } diff --git a/pkg/generate/postprocessing/hcl.go b/pkg/generate/postprocessing/hcl.go new file mode 100644 index 000000000..6d0d027ce --- /dev/null +++ b/pkg/generate/postprocessing/hcl.go @@ -0,0 +1,107 @@ +package postprocessing + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/zclconf/go-cty/cty" +) + +func traversal(root string, attrs ...string) hcl.Traversal { + tr := hcl.Traversal{hcl.TraverseRoot{Name: root}} + for _, attr := range attrs { + tr = append(tr, hcl.TraverseAttr{Name: attr}) + } + return tr +} + +func attributeToMap(attr *hclwrite.Attribute) (map[string]interface{}, error) { + var err error + + // Convert jsonencode to raw json + s := strings.TrimPrefix(string(attr.Expr().BuildTokens(nil).Bytes()), " ") + + if strings.HasPrefix(s, "jsonencode(") { + return nil, nil // Figure out how to handle those + } + + if !strings.HasPrefix(s, "\"") { + // if expr is not a string, assume it's already converted, return (idempotency + return nil, nil + } + s, err = strconv.Unquote(s) + if err != nil { + return nil, err + } + s = strings.ReplaceAll(s, "$${", "${") // These are escaped interpolations + + var dashboardMap map[string]interface{} + err = json.Unmarshal([]byte(s), &dashboardMap) + if err != nil { + return nil, err + } + + return dashboardMap, nil +} + +func extractJSONEncode(value string) (string, error) { + if !strings.HasPrefix(value, "jsonencode(") { + return "", nil + } + value = strings.TrimPrefix(value, "jsonencode(") + value = strings.TrimSuffix(value, ")") + + b, err := json.MarshalIndent(value, "", " ") + return string(b), err +} + +// BELOW IS FROM https://github.com/hashicorp/terraform/blob/main/internal/configs/hcl2shim/values.go + +// UnknownVariableValue is a sentinel value that can be used +// to denote that the value of a variable is unknown at this time. +// RawConfig uses this information to build up data about +// unknown keys. +const unknownVariableValue = "74D93920-ED26-11E3-AC10-0800200C9A66" + +// hcl2ValueFromConfigValue is the opposite of configValueFromHCL2: it takes +// a value as would be returned from the old interpolator and turns it into +// a cty.Value so it can be used within, for example, an HCL2 EvalContext. +func hcl2ValueFromConfigValue(v interface{}) cty.Value { + if v == nil { + return cty.NullVal(cty.DynamicPseudoType) + } + if v == unknownVariableValue { + return cty.DynamicVal + } + + switch tv := v.(type) { + case bool: + return cty.BoolVal(tv) + case string: + return cty.StringVal(tv) + case int: + return cty.NumberIntVal(int64(tv)) + case float64: + return cty.NumberFloatVal(tv) + case []interface{}: + vals := make([]cty.Value, len(tv)) + for i, ev := range tv { + vals[i] = hcl2ValueFromConfigValue(ev) + } + return cty.TupleVal(vals) + case map[string]interface{}: + vals := map[string]cty.Value{} + for k, ev := range tv { + vals[k] = hcl2ValueFromConfigValue(ev) + } + return cty.ObjectVal(vals) + default: + // HCL/HIL should never generate anything that isn't caught by + // the above, so if we get here something has gone very wrong. + panic(fmt.Errorf("can't convert %#v to cty.Value", v)) + } +} diff --git a/pkg/generate/postprocessing/postprocessing.go b/pkg/generate/postprocessing/postprocessing.go new file mode 100644 index 000000000..4ce8dcf0b --- /dev/null +++ b/pkg/generate/postprocessing/postprocessing.go @@ -0,0 +1,45 @@ +package postprocessing + +import ( + "os" + + "github.com/grafana/terraform-provider-grafana/v3/pkg/generate/utils" + "github.com/hashicorp/hcl/v2/hclwrite" +) + +type postprocessingFunc func(*hclwrite.File) error + +func postprocessFile(fpath string, fn postprocessingFunc) error { + file, err := utils.ReadHCLFile(fpath) + if err != nil { + return err + } + initialBytes := file.Bytes() + + if err := fn(file); err != nil { + return err + } + + // Write the file only if it has changed + if string(initialBytes) != string(file.Bytes()) { + stat, err := os.Stat(fpath) + if err != nil { + return err + } + + if err := os.WriteFile(fpath, file.Bytes(), stat.Mode()); err != nil { + return err + } + } + + return nil +} + +func postprocessFiles(fpaths []string, fn postprocessingFunc) error { + for _, fpath := range fpaths { + if err := postprocessFile(fpath, fn); err != nil { + return err + } + } + return nil +} diff --git a/pkg/generate/postprocessing/postprocessing_test.go b/pkg/generate/postprocessing/postprocessing_test.go new file mode 100644 index 000000000..31ea08f4d --- /dev/null +++ b/pkg/generate/postprocessing/postprocessing_test.go @@ -0,0 +1,35 @@ +package postprocessing + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func postprocessingTest(t *testing.T, testFile string, fn func(fpath string)) { + t.Helper() + + t.Run(testFile, func(t *testing.T) { + goldenFilepath := strings.Replace(testFile, ".tf", ".golden.tf", 1) + + // Copy the file to a temporary location + tmpFilepath := filepath.Join(t.TempDir(), filepath.Base(testFile)) + file, err := os.ReadFile(testFile) + require.NoError(t, err) + require.NoError(t, os.WriteFile(tmpFilepath, file, 0600)) + + // Run the postprocessing function + fn(tmpFilepath) + + // Compare the file with the golden file + got, err := os.ReadFile(tmpFilepath) + require.NoError(t, err) + want, err := os.ReadFile(goldenFilepath) + require.NoError(t, err) + + require.Equal(t, string(want), string(got)) + }) +} diff --git a/pkg/generate/postprocessing/preferred_resource_name.go b/pkg/generate/postprocessing/preferred_resource_name.go new file mode 100644 index 000000000..6637db3fc --- /dev/null +++ b/pkg/generate/postprocessing/preferred_resource_name.go @@ -0,0 +1,72 @@ +package postprocessing + +import ( + "regexp" + "strings" + + "github.com/grafana/terraform-provider-grafana/v3/pkg/provider" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclwrite" +) + +var allowedTerraformChars = regexp.MustCompile(`[^a-zA-Z0-9_-]`) + +// UsePreferredResourceNames replaces the resource name with the value of the preferred resource name field. +// The input files (resources.tf + imports.tf) are modified in place. +func UsePreferredResourceNames(fpaths ...string) error { + providerResources := provider.ResourcesMap() + replaceMap := map[string]hcl.Traversal{} + + // Go through all resource blocks first + if err := postprocessFiles(fpaths, func(file *hclwrite.File) error { + for _, block := range file.Body().Blocks() { + if block.Type() != "resource" { + continue + } + + resourceType := block.Labels()[0] + resourceInfo := providerResources[resourceType] + + if resourceInfo.PreferredResourceNameField == "" { + continue + } + + nameAttr := block.Body().GetAttribute(resourceInfo.PreferredResourceNameField) + if nameAttr == nil { + continue + } + newResourceName := strings.Trim(string(nameAttr.Expr().BuildTokens(nil).Bytes()), "\" ") // Unquote + trim spaces + newResourceName = CleanResourceName(newResourceName) + + replaceMap[strings.Join(block.Labels(), ".")] = traversal(resourceType, newResourceName) + block.SetLabels([]string{resourceType, newResourceName}) + } + return nil + }); err != nil { + return err + } + + // Go through all import blocks + return postprocessFiles(fpaths, func(file *hclwrite.File) error { + for _, block := range file.Body().Blocks() { + if block.Type() != "import" { + continue + } + + resourceTo := strings.TrimSpace(string(block.Body().GetAttribute("to").Expr().BuildTokens(nil).Bytes())) + if newResourceTo, ok := replaceMap[resourceTo]; ok { + block.Body().SetAttributeTraversal("to", newResourceTo) + } + } + + return nil + }) +} + +func CleanResourceName(name string) string { + cleaned := allowedTerraformChars.ReplaceAllString(name, "_") + if cleaned[0] >= '0' && cleaned[0] <= '9' { + cleaned = "_" + cleaned + } + return cleaned +} diff --git a/pkg/generate/postprocessing/preferred_resource_name_test.go b/pkg/generate/postprocessing/preferred_resource_name_test.go new file mode 100644 index 000000000..a3b7f1363 --- /dev/null +++ b/pkg/generate/postprocessing/preferred_resource_name_test.go @@ -0,0 +1,13 @@ +package postprocessing + +import "testing" + +func TestUsePreferredResourceNames(t *testing.T) { + for _, testFile := range []string{ + "testdata/preferred-resource-name.tf", + } { + postprocessingTest(t, testFile, func(fpath string) { + UsePreferredResourceNames(fpath) + }) + } +} diff --git a/pkg/generate/postprocessing/redact_credentials.go b/pkg/generate/postprocessing/redact_credentials.go new file mode 100644 index 000000000..2848ac526 --- /dev/null +++ b/pkg/generate/postprocessing/redact_credentials.go @@ -0,0 +1,43 @@ +package postprocessing + +import ( + "os" + "path/filepath" + "strings" + + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/zclconf/go-cty/cty" +) + +func RedactCredentials(dir string) error { + files, err := os.ReadDir(dir) + if err != nil { + return err + } + + for _, file := range files { + if !strings.HasSuffix(file.Name(), ".tf") { + continue + } + fpath := filepath.Join(dir, file.Name()) + err := postprocessFile(fpath, func(file *hclwrite.File) error { + for _, block := range file.Body().Blocks() { + if block.Type() != "provider" { + continue + } + for name := range block.Body().Attributes() { + if strings.Contains(name, "auth") || strings.Contains(name, "token") { + block.Body().SetAttributeValue(name, cty.StringVal("REDACTED")) + } + } + } + + return nil + }) + if err != nil { + return err + } + } + + return nil +} diff --git a/pkg/generate/postprocessing/replace_null_sensitive.go b/pkg/generate/postprocessing/replace_null_sensitive.go new file mode 100644 index 000000000..88037e80b --- /dev/null +++ b/pkg/generate/postprocessing/replace_null_sensitive.go @@ -0,0 +1,41 @@ +package postprocessing + +import ( + "log" + + "github.com/grafana/terraform-provider-grafana/v3/pkg/provider" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/zclconf/go-cty/cty" +) + +func ReplaceNullSensitiveAttributes(fpath string) error { + providerResources := provider.ResourcesMap() + return postprocessFile(fpath, func(file *hclwrite.File) error { + for _, block := range file.Body().Blocks() { + if block.Type() != "resource" { + continue + } + + resourceType := block.Labels()[0] + resourceInfo := providerResources[resourceType] + resourceSchema := resourceInfo.Schema + if resourceSchema == nil { + // Plugin Framework schema not implemented because we have no resources with sensitive attributes in it yet + log.Printf("resource %s doesn't use the legacy SDK", resourceType) + continue + } + + for key := range block.Body().Attributes() { + attrSchema := resourceSchema.Schema[key] + if attrSchema == nil { + // Attribute not found in schema + continue + } + if attrSchema.Sensitive && attrSchema.Required { + block.Body().SetAttributeValue(key, cty.StringVal("SENSITIVE_VALUE_TO_REPLACE")) + } + } + } + return nil + }) +} diff --git a/pkg/generate/postprocessing/replace_null_sensitive_test.go b/pkg/generate/postprocessing/replace_null_sensitive_test.go new file mode 100644 index 000000000..56f0d919a --- /dev/null +++ b/pkg/generate/postprocessing/replace_null_sensitive_test.go @@ -0,0 +1,13 @@ +package postprocessing + +import "testing" + +func TestReplaceNullSensitiveAttributes(t *testing.T) { + for _, testFile := range []string{ + "testdata/replace-user-password.tf", + } { + postprocessingTest(t, testFile, func(fpath string) { + ReplaceNullSensitiveAttributes(fpath) + }) + } +} diff --git a/pkg/generate/postprocessing/replace_references.go b/pkg/generate/postprocessing/replace_references.go new file mode 100644 index 000000000..85f4f0d6b --- /dev/null +++ b/pkg/generate/postprocessing/replace_references.go @@ -0,0 +1,189 @@ +package postprocessing + +import ( + "fmt" + "strings" + + "github.com/hashicorp/hcl/v2/hclwrite" + tfjson "github.com/hashicorp/terraform-json" +) + +// knownReferences is a map of all resource fields that can be referenced from another resource. +// For example, the `folder` field of a `grafana_dashboard` resource can be a `grafana_folder` reference. +// +//go:generate go run ./genreferences --file=$GOFILE --walk-dir=../../.. +var knownReferences = []string{ + "grafana_annotation.dashboard_uid=grafana_dashboard.uid", + "grafana_annotation.org_id=grafana_organization.id", + "grafana_cloud_access_policy.identifier=grafana_cloud_stack.id", + "grafana_cloud_access_policy_token.access_policy_id=grafana_cloud_access_policy.policy_id", + "grafana_cloud_plugin_installation.stack_slug=grafana_cloud_stack.slug", + "grafana_cloud_stack_service_account.stack_slug=grafana_cloud_stack.slug", + "grafana_cloud_stack_service_account_token.auth=grafana_cloud_stack_service_account_token.key", + "grafana_cloud_stack_service_account_token.service_account_id=grafana_cloud_stack_service_account.id", + "grafana_cloud_stack_service_account_token.stack_slug=grafana_cloud_stack.slug", + "grafana_cloud_stack_service_account_token.url=grafana_cloud_stack.url", + "grafana_contact_point.org_id=grafana_organization.id", + "grafana_dashboard.folder=grafana_folder.id", + "grafana_dashboard.folder=grafana_folder.uid", + "grafana_dashboard.name=grafana_library_panel.name", + "grafana_dashboard.org_id=grafana_organization.id", + "grafana_dashboard.org_id=grafana_organization.org_id", + "grafana_dashboard.uid=grafana_library_panel.uid", + "grafana_dashboard_permission.dashboard_uid=grafana_dashboard.uid", + "grafana_dashboard_permission.team_id=grafana_team.id", + "grafana_dashboard_permission.user_id=grafana_user.id", + "grafana_dashboard_permission_item.dashboard_uid=grafana_dashboard.uid", + "grafana_dashboard_permission_item.team=grafana_team.id", + "grafana_dashboard_permission_item.user=grafana_service_account.id", + "grafana_dashboard_permission_item.user=grafana_user.id", + "grafana_dashboard_public.dashboard_uid=grafana_dashboard.uid", + "grafana_dashboard_public.org_id=grafana_organization.org_id", + "grafana_data_source.datasourceUid=grafana_data_source.uid", + "grafana_data_source.org_id=grafana_organization.id", + "grafana_data_source_config.datasourceUid=grafana_data_source.uid", + "grafana_data_source_config.uid=grafana_data_source.uid", + "grafana_data_source_permission.datasource_uid=grafana_data_source.uid", + "grafana_data_source_permission.team_id=grafana_team.id", + "grafana_data_source_permission.user_id=grafana_service_account.id", + "grafana_data_source_permission.user_id=grafana_user.id", + "grafana_data_source_permission_item.datasource_uid=grafana_data_source.uid", + "grafana_data_source_permission_item.team=grafana_team.id", + "grafana_data_source_permission_item.user=grafana_service_account.id", + "grafana_data_source_permission_item.user=grafana_user.id", + "grafana_folder.org_id=grafana_organization.id", + "grafana_folder.org_id=grafana_organization.org_id", + "grafana_folder.parent_folder_uid=grafana_folder.uid", + "grafana_folder_permission.folder_uid=grafana_folder.uid", + "grafana_folder_permission.team_id=grafana_team.id", + "grafana_folder_permission.user_id=grafana_service_account.id", + "grafana_folder_permission.user_id=grafana_user.id", + "grafana_folder_permission_item.folder_uid=grafana_folder.uid", + "grafana_folder_permission_item.team=grafana_team.id", + "grafana_folder_permission_item.user=grafana_service_account.id", + "grafana_folder_permission_item.user=grafana_user.id", + "grafana_library_panel.folder_uid=grafana_folder.uid", + "grafana_library_panel.org_id=grafana_organization.id", + "grafana_machine_learning_alert.job_id=grafana_machine_learning_job.id", + "grafana_machine_learning_alert.outlier_id=grafana_machine_learning_outlier_detector.id", + "grafana_machine_learning_job.datasource_uid=grafana_data_source.uid", + "grafana_message_template.org_id=grafana_organization.id", + "grafana_mute_timing.org_id=grafana_organization.id", + "grafana_notification_policy.contact_point=grafana_contact_point.name", + "grafana_notification_policy.mute_timings=grafana_mute_timing.name", + "grafana_notification_policy.org_id=grafana_organization.id", + "grafana_oncall_escalation.escalation_chain_id=grafana_oncall_escalation_chain.id", + "grafana_oncall_integration.escalation_chain_id=grafana_oncall_escalation_chain.id", + "grafana_oncall_route.escalation_chain_id=grafana_oncall_escalation_chain.id", + "grafana_oncall_route.integration_id=grafana_oncall_integration.id", + "grafana_organization.org_id=grafana_organization.id", + "grafana_organization_preferences.home_dashboard_uid=grafana_dashboard.uid", + "grafana_organization_preferences.org_id=grafana_organization.id", + "grafana_playlist.org_id=grafana_organization.id", + "grafana_report.org_id=grafana_organization.id", + "grafana_report.uid=grafana_dashboard.uid", + "grafana_role.org_id=grafana_organization.id", + "grafana_role_assignment.auth=grafana_cloud_stack_service_account_token.key", + "grafana_role_assignment.org_id=grafana_organization.id", + "grafana_role_assignment.role_uid=grafana_role.uid", + "grafana_role_assignment.service_accounts=grafana_cloud_stack_service_account.id", + "grafana_role_assignment.service_accounts=grafana_service_account.id", + "grafana_role_assignment.teams=grafana_team.id", + "grafana_role_assignment.url=grafana_cloud_stack.url", + "grafana_role_assignment.users=grafana_user.id", + "grafana_role_assignment_item.role_uid=grafana_role.uid", + "grafana_role_assignment_item.service_account_id=grafana_service_account.id", + "grafana_role_assignment_item.team_id=grafana_team.id", + "grafana_role_assignment_item.user_id=grafana_user.id", + "grafana_rule_group.contact_point=grafana_contact_point.name", + "grafana_rule_group.folder_uid=grafana_folder.uid", + "grafana_rule_group.org_id=grafana_organization.id", + "grafana_service_account.org_id=grafana_organization.id", + "grafana_service_account.role_uid=grafana_role.uid", + "grafana_service_account.service_account_id=grafana_service_account.id", + "grafana_service_account.team_id=grafana_team.id", + "grafana_service_account.user_id=grafana_user.id", + "grafana_service_account_permission.org_id=grafana_organization.id", + "grafana_service_account_permission.service_account_id=grafana_cloud_stack_service_account.id", + "grafana_service_account_permission.service_account_id=grafana_service_account.id", + "grafana_service_account_permission.team_id=grafana_team.id", + "grafana_service_account_permission.user_id=grafana_user.id", + "grafana_service_account_permission_item.auth=grafana_cloud_stack_service_account_token.key", + "grafana_service_account_permission_item.org_id=grafana_organization.id", + "grafana_service_account_permission_item.service_account_id=grafana_cloud_stack_service_account.id", + "grafana_service_account_permission_item.service_account_id=grafana_service_account.id", + "grafana_service_account_permission_item.team=grafana_team.id", + "grafana_service_account_permission_item.url=grafana_cloud_stack.url", + "grafana_service_account_permission_item.user=grafana_user.id", + "grafana_service_account_token.service_account_id=grafana_service_account.id", + "grafana_slo.folder_uid=grafana_folder.uid", + "grafana_synthetic_monitoring_installation.metrics_publisher_key=grafana_cloud_access_policy_token.token", + "grafana_synthetic_monitoring_installation.sm_access_token=grafana_synthetic_monitoring_installation.sm_access_token", + "grafana_synthetic_monitoring_installation.sm_url=grafana_synthetic_monitoring_installation.stack_sm_api_url", + "grafana_synthetic_monitoring_installation.stack_id=grafana_cloud_stack.id", + "grafana_team.home_dashboard_uid=grafana_dashboard.uid", + "grafana_team.org_id=grafana_organization.id", + "grafana_team_external_group.team_id=grafana_team.id", + "grafana_team_preferences.home_dashboard_uid=grafana_dashboard.uid", + "grafana_team_preferences.team_id=grafana_team.id", +} + +func ReplaceReferences(fpath string, plannedState *tfjson.Plan, extraKnownReferences []string) error { + return postprocessFile(fpath, func(file *hclwrite.File) error { + knownReferences := knownReferences + knownReferences = append(knownReferences, extraKnownReferences...) + + plannedResources := plannedState.PlannedValues.RootModule.Resources + + for _, block := range file.Body().Blocks() { + var blockResource *tfjson.StateResource + for _, plannedResource := range plannedResources { + if plannedResource.Type == block.Labels()[0] && plannedResource.Name == block.Labels()[1] { + blockResource = plannedResource + break + } + } + if blockResource == nil { + return fmt.Errorf("resource %s.%s not found in planned state", block.Labels()[0], block.Labels()[1]) + } + + for attrName := range block.Body().Attributes() { + attrValue := blockResource.AttributeValues[attrName] + attrReplaced := false + + // Check the field name. If it has a possible reference, we have to search for it in the resources + for _, ref := range knownReferences { + if attrReplaced { + break + } + + refFrom := strings.Split(ref, "=")[0] + refTo := strings.Split(ref, "=")[1] + hasPossibleReference := refFrom == fmt.Sprintf("%s.%s", block.Labels()[0], attrName) || (strings.HasPrefix(refFrom, "*.") && strings.HasSuffix(refFrom, fmt.Sprintf(".%s", attrName))) + if !hasPossibleReference { + continue + } + + refToResource := strings.Split(refTo, ".")[0] + refToAttr := strings.Split(refTo, ".")[1] + + for _, plannedResource := range plannedResources { + if plannedResource.Type != refToResource { + continue + } + + valueFromRef := plannedResource.AttributeValues[refToAttr] + // If the value from the first block matches the value from the second block, we have a reference + if attrValue == valueFromRef { + // Replace the value with the reference + block.Body().SetAttributeTraversal(attrName, traversal(plannedResource.Type, plannedResource.Name, refToAttr)) + attrReplaced = true + break + } + } + } + } + } + return nil + }) +} diff --git a/pkg/generate/postprocessing/strip_defaults.go b/pkg/generate/postprocessing/strip_defaults.go new file mode 100644 index 000000000..b6e89dae8 --- /dev/null +++ b/pkg/generate/postprocessing/strip_defaults.go @@ -0,0 +1,55 @@ +package postprocessing + +import ( + "strings" + + "github.com/hashicorp/hcl/v2/hclwrite" +) + +func StripDefaults(fpath string, extraFieldsToRemove map[string]any) error { + return postprocessFile(fpath, func(file *hclwrite.File) error { + for _, block := range file.Body().Blocks() { + stripDefaultsFromBlock(block, extraFieldsToRemove) + } + return nil + }) +} + +func stripDefaultsFromBlock(block *hclwrite.Block, extraFieldsToRemove map[string]any) { + for _, innblock := range block.Body().Blocks() { + stripDefaultsFromBlock(innblock, extraFieldsToRemove) + if len(innblock.Body().Attributes()) == 0 && len(innblock.Body().Blocks()) == 0 { + block.Body().RemoveBlock(innblock) + } + } + for name, attribute := range block.Body().Attributes() { + if string(attribute.Expr().BuildTokens(nil).Bytes()) == " null" { + block.Body().RemoveAttribute(name) + } + if string(attribute.Expr().BuildTokens(nil).Bytes()) == " {}" { + block.Body().RemoveAttribute(name) + } + if string(attribute.Expr().BuildTokens(nil).Bytes()) == " []" { + block.Body().RemoveAttribute(name) + } + for key, valueToRemove := range extraFieldsToRemove { + if name == key { + toRemove := false + fieldValue := strings.TrimSpace(string(attribute.Expr().BuildTokens(nil).Bytes())) + fieldValue, err := extractJSONEncode(fieldValue) + if err != nil { + continue + } + + if v, ok := valueToRemove.(bool); ok && v { + toRemove = true + } else if v, ok := valueToRemove.(string); ok && v == fieldValue { + toRemove = true + } + if toRemove { + block.Body().RemoveAttribute(name) + } + } + } + } +} diff --git a/pkg/generate/postprocessing/testdata/preferred-resource-name.golden.tf b/pkg/generate/postprocessing/testdata/preferred-resource-name.golden.tf new file mode 100644 index 000000000..ad4fb2f28 --- /dev/null +++ b/pkg/generate/postprocessing/testdata/preferred-resource-name.golden.tf @@ -0,0 +1,27 @@ +import { + id = "12345" + to = grafana_synthetic_monitoring_check.testname +} + +resource "grafana_synthetic_monitoring_check" "testname" { + alert_sensitivity = "none" + basic_metrics_only = true + enabled = false + frequency = 60000 + job = "testname" + labels = { + foo = "bar" + } + probes = [7] + target = "https://grafana.com" + timeout = 3000 + settings { + http { + fail_if_not_ssl = false + fail_if_ssl = false + ip_version = "V4" + method = "GET" + no_follow_redirects = false + } + } +} diff --git a/pkg/generate/postprocessing/testdata/preferred-resource-name.tf b/pkg/generate/postprocessing/testdata/preferred-resource-name.tf new file mode 100644 index 000000000..26a65a18c --- /dev/null +++ b/pkg/generate/postprocessing/testdata/preferred-resource-name.tf @@ -0,0 +1,27 @@ +import { + id = "12345" + to = grafana_synthetic_monitoring_check._12345 +} + +resource "grafana_synthetic_monitoring_check" "_12345" { + alert_sensitivity = "none" + basic_metrics_only = true + enabled = false + frequency = 60000 + job = "testname" + labels = { + foo = "bar" + } + probes = [7] + target = "https://grafana.com" + timeout = 3000 + settings { + http { + fail_if_not_ssl = false + fail_if_ssl = false + ip_version = "V4" + method = "GET" + no_follow_redirects = false + } + } +} diff --git a/pkg/generate/postprocessing/testdata/replace-user-password.golden.tf b/pkg/generate/postprocessing/testdata/replace-user-password.golden.tf new file mode 100644 index 000000000..4709a3249 --- /dev/null +++ b/pkg/generate/postprocessing/testdata/replace-user-password.golden.tf @@ -0,0 +1,8 @@ +# __generated__ by Terraform +resource "grafana_user" "_1" { + email = "admin@localhost" + is_admin = true + login = "admin" + name = null + password = "SENSITIVE_VALUE_TO_REPLACE" +} diff --git a/pkg/generate/postprocessing/testdata/replace-user-password.tf b/pkg/generate/postprocessing/testdata/replace-user-password.tf new file mode 100644 index 000000000..0c88f368a --- /dev/null +++ b/pkg/generate/postprocessing/testdata/replace-user-password.tf @@ -0,0 +1,8 @@ +# __generated__ by Terraform +resource "grafana_user" "_1" { + email = "admin@localhost" + is_admin = true + login = "admin" + name = null + password = null +} diff --git a/pkg/generate/postprocessing/wrap_json.go b/pkg/generate/postprocessing/wrap_json.go new file mode 100644 index 000000000..86c82bf57 --- /dev/null +++ b/pkg/generate/postprocessing/wrap_json.go @@ -0,0 +1,21 @@ +package postprocessing + +import "github.com/hashicorp/hcl/v2/hclwrite" + +func WrapJSONFieldsInFunction(fpath string) error { + return postprocessFile(fpath, func(file *hclwrite.File) error { + // Find json attributes and use jsonencode + for _, block := range file.Body().Blocks() { + for key, attr := range block.Body().Attributes() { + asMap, err := attributeToMap(attr) + if err != nil || asMap == nil { + continue + } + tokens := hclwrite.TokensForValue(hcl2ValueFromConfigValue(asMap)) + block.Body().SetAttributeRaw(key, hclwrite.TokensForFunctionCall("jsonencode", tokens)) + } + } + + return nil + }) +} diff --git a/pkg/generate/terraform.go b/pkg/generate/terraform.go index 97994adfc..14d1cb1d0 100644 --- a/pkg/generate/terraform.go +++ b/pkg/generate/terraform.go @@ -5,11 +5,13 @@ import ( "encoding/json" "errors" "fmt" + "log" "os" "path/filepath" "strings" "github.com/hashicorp/go-version" + "github.com/hashicorp/hc-install/fs" "github.com/hashicorp/hc-install/product" "github.com/hashicorp/hc-install/releases" "github.com/hashicorp/hcl/v2" @@ -20,14 +22,42 @@ import ( ) func setupTerraform(cfg *Config) (*tfexec.Terraform, error) { - installer := &releases.ExactVersion{ - Product: product.Terraform, - Version: version.Must(version.NewVersion("1.8.4")), + var err error + + tfVersion := cfg.TerraformInstallConfig.Version + if tfVersion == nil { + // Not using latest to avoid unexpected breaking changes + log.Printf("No Terraform version specified, defaulting to version 1.8.5") + tfVersion = version.Must(version.NewVersion("1.8.5")) } - execPath, err := installer.Install(context.Background()) - if err != nil { - return nil, fmt.Errorf("error installing Terraform: %s", err) + // Check if Terraform is already installed + var execPath string + if cfg.TerraformInstallConfig.InstallDir != "" { + finder := fs.ExactVersion{ + Product: product.Terraform, + Version: tfVersion, + ExtraPaths: []string{ + cfg.TerraformInstallConfig.InstallDir, + }, + } + + if execPath, err = finder.Find(context.Background()); err == nil { + log.Printf("Terraform %s already installed at %s", tfVersion, execPath) + } + } + + // Install Terraform if not found + if execPath == "" { + log.Printf("Installing Terraform %s", tfVersion) + installer := &releases.ExactVersion{ + Product: product.Terraform, + Version: tfVersion, + InstallDir: cfg.TerraformInstallConfig.InstallDir, + } + if execPath, err = installer.Install(context.Background()); err != nil { + return nil, fmt.Errorf("error installing Terraform: %s", err) + } } tf, err := tfexec.NewTerraform(cfg.OutputDir, execPath) @@ -35,21 +65,34 @@ func setupTerraform(cfg *Config) (*tfexec.Terraform, error) { return nil, fmt.Errorf("error running NewTerraform: %s", err) } - err = tf.Init(context.Background(), tfexec.Upgrade(true)) + initOptions := []tfexec.InitOption{ + tfexec.Upgrade(true), + } + if cfg.TerraformInstallConfig.PluginDir != "" { + initOptions = append(initOptions, tfexec.PluginDir(cfg.TerraformInstallConfig.PluginDir)) + } + + err = tf.Init(context.Background(), initOptions...) if err != nil { - return nil, fmt.Errorf("error running Init: %s", err) + return nil, fmt.Errorf("error running Init: %w", err) } return tf, nil } func writeBlocks(filepath string, blocks ...*hclwrite.Block) error { + return writeBlocksFile(filepath, false, blocks...) +} + +func writeBlocksFile(filepath string, new bool, blocks ...*hclwrite.Block) error { contents := hclwrite.NewFile() - if fileBytes, err := os.ReadFile(filepath); err == nil { - var diags hcl.Diagnostics - contents, diags = hclwrite.ParseConfig(fileBytes, filepath, hcl.InitialPos) - if diags.HasErrors() { - return errors.Join(diags.Errs()...) + if !new { + if fileBytes, err := os.ReadFile(filepath); err == nil { + var diags hcl.Diagnostics + contents, diags = hclwrite.ParseConfig(fileBytes, filepath, hcl.InitialPos) + if diags.HasErrors() { + return errors.Join(diags.Errs()...) + } } } diff --git a/pkg/generate/testdata/generate/alerting-in-org/imports.tf b/pkg/generate/testdata/generate/alerting-in-org/imports.tf index e444ba111..514411661 100644 --- a/pkg/generate/testdata/generate/alerting-in-org/imports.tf +++ b/pkg/generate/testdata/generate/alerting-in-org/imports.tf @@ -1,3 +1,18 @@ +import { + to = grafana_contact_point._1_email_receiver + id = "1:email receiver" +} + +import { + to = grafana_contact_point._2_email_receiver + id = "2:email receiver" +} + +import { + to = grafana_contact_point._2_my-contact-point + id = "2:my-contact-point" +} + import { to = grafana_folder._2_alert-rule-folder id = "2:alert-rule-folder" @@ -24,7 +39,7 @@ import { } import { - to = grafana_organization._2 + to = grafana_organization.alerting-org id = "2" } diff --git a/pkg/generate/testdata/generate/alerting-in-org/provider.tf b/pkg/generate/testdata/generate/alerting-in-org/provider.tf index 0538588ae..ce3c1d837 100644 --- a/pkg/generate/testdata/generate/alerting-in-org/provider.tf +++ b/pkg/generate/testdata/generate/alerting-in-org/provider.tf @@ -2,12 +2,12 @@ terraform { required_providers { grafana = { source = "grafana/grafana" - version = "3.0.0" + version = "999.999.999" } } } provider "grafana" { url = "http://localhost:3000" - auth = "admin:admin" + auth = "REDACTED" } diff --git a/pkg/generate/testdata/generate/alerting-in-org/resources.tf b/pkg/generate/testdata/generate/alerting-in-org/resources.tf index dbdab1ed7..4ebbe2970 100644 --- a/pkg/generate/testdata/generate/alerting-in-org/resources.tf +++ b/pkg/generate/testdata/generate/alerting-in-org/resources.tf @@ -1,9 +1,44 @@ # __generated__ by Terraform # Please review these resources and move them into your main configuration files. +# __generated__ by Terraform from "1:email receiver" +resource "grafana_contact_point" "_1_email_receiver" { + disable_provenance = true + name = "email receiver" + email { + addresses = [""] + disable_resolve_message = false + single_email = false + } +} + +# __generated__ by Terraform from "2:email receiver" +resource "grafana_contact_point" "_2_email_receiver" { + disable_provenance = true + name = "email receiver" + org_id = grafana_organization.alerting-org.id + email { + addresses = [""] + disable_resolve_message = false + single_email = false + } +} + +# __generated__ by Terraform from "2:my-contact-point" +resource "grafana_contact_point" "_2_my-contact-point" { + disable_provenance = false + name = "my-contact-point" + org_id = grafana_organization.alerting-org.id + email { + addresses = ["hello@example.com"] + disable_resolve_message = false + single_email = false + } +} + # __generated__ by Terraform from "2:alert-rule-folder" resource "grafana_folder" "_2_alert-rule-folder" { - org_id = grafana_organization._2.id + org_id = grafana_organization.alerting-org.id title = "My Alert Rule Folder" uid = "alert-rule-folder" } @@ -11,14 +46,14 @@ resource "grafana_folder" "_2_alert-rule-folder" { # __generated__ by Terraform from "2:My Reusable Template" resource "grafana_message_template" "_2_My_Reusable_Template" { name = "My Reusable Template" - org_id = grafana_organization._2.id + org_id = grafana_organization.alerting-org.id template = "{{define \"My Reusable Template\" }}\n template content\n{{ end }}" } # __generated__ by Terraform from "2:My Mute Timing" resource "grafana_mute_timing" "_2_My_Mute_Timing" { name = "My Mute Timing" - org_id = grafana_organization._2.id + org_id = grafana_organization.alerting-org.id intervals { days_of_month = ["1:7", "-1"] location = "America/New_York" @@ -41,14 +76,14 @@ resource "grafana_notification_policy" "_1_policy" { # __generated__ by Terraform from "2:policy" resource "grafana_notification_policy" "_2_policy" { - contact_point = "my-contact-point" + contact_point = grafana_contact_point._2_my-contact-point.name disable_provenance = false group_by = ["..."] - org_id = grafana_organization._2.id + org_id = grafana_organization.alerting-org.id } # __generated__ by Terraform from "2" -resource "grafana_organization" "_2" { +resource "grafana_organization" "alerting-org" { admins = ["admin@localhost"] name = "alerting-org" } @@ -59,7 +94,7 @@ resource "grafana_rule_group" "_2_alert-rule-folder_My_Rule_Group" { folder_uid = grafana_folder._2_alert-rule-folder.uid interval_seconds = 240 name = "My Rule Group" - org_id = grafana_organization._2.id + org_id = grafana_organization.alerting-org.id rule { annotations = { a = "b" diff --git a/pkg/generate/testdata/generate/dashboard-crossplane/contact-point-email-receiver.yaml b/pkg/generate/testdata/generate/dashboard-crossplane/contact-point-email-receiver.yaml new file mode 100644 index 000000000..b9590aa72 --- /dev/null +++ b/pkg/generate/testdata/generate/dashboard-crossplane/contact-point-email-receiver.yaml @@ -0,0 +1,17 @@ +apiVersion: alerting.grafana.crossplane.io/v1alpha1 +kind: ContactPoint +metadata: + name: email-receiver + annotations: + crossplane.io/external-name: 1:email receiver +spec: + forProvider: + disableProvenance: true + email: + - addresses: + - + disable_resolve_message: false + single_email: false + name: email receiver + providerConfigRef: + name: grafana-provider diff --git a/pkg/generate/testdata/generate/dashboard-crossplane/dashboard-1-my-dashboard-uid.yaml b/pkg/generate/testdata/generate/dashboard-crossplane/dashboard-my-dashboard-uid.yaml similarity index 91% rename from pkg/generate/testdata/generate/dashboard-crossplane/dashboard-1-my-dashboard-uid.yaml rename to pkg/generate/testdata/generate/dashboard-crossplane/dashboard-my-dashboard-uid.yaml index 431cd4967..a27242431 100644 --- a/pkg/generate/testdata/generate/dashboard-crossplane/dashboard-1-my-dashboard-uid.yaml +++ b/pkg/generate/testdata/generate/dashboard-crossplane/dashboard-my-dashboard-uid.yaml @@ -1,7 +1,7 @@ apiVersion: oss.grafana.crossplane.io/v1alpha1 kind: Dashboard metadata: - name: 1-my-dashboard-uid + name: my-dashboard-uid annotations: crossplane.io/external-name: 1:my-dashboard-uid spec: diff --git a/pkg/generate/testdata/generate/dashboard-crossplane/folder-1-my-folder-uid.yaml b/pkg/generate/testdata/generate/dashboard-crossplane/folder-my-folder-uid.yaml similarity index 91% rename from pkg/generate/testdata/generate/dashboard-crossplane/folder-1-my-folder-uid.yaml rename to pkg/generate/testdata/generate/dashboard-crossplane/folder-my-folder-uid.yaml index b87c2baf7..b6975c9a5 100644 --- a/pkg/generate/testdata/generate/dashboard-crossplane/folder-1-my-folder-uid.yaml +++ b/pkg/generate/testdata/generate/dashboard-crossplane/folder-my-folder-uid.yaml @@ -1,7 +1,7 @@ apiVersion: oss.grafana.crossplane.io/v1alpha1 kind: Folder metadata: - name: 1-my-folder-uid + name: my-folder-uid annotations: crossplane.io/external-name: 1:my-folder-uid spec: diff --git a/pkg/generate/testdata/generate/dashboard-crossplane/notification-policy-1-policy.yaml b/pkg/generate/testdata/generate/dashboard-crossplane/notification-policy-policy.yaml similarity index 95% rename from pkg/generate/testdata/generate/dashboard-crossplane/notification-policy-1-policy.yaml rename to pkg/generate/testdata/generate/dashboard-crossplane/notification-policy-policy.yaml index 15d1fc2e6..b01b5cffa 100644 --- a/pkg/generate/testdata/generate/dashboard-crossplane/notification-policy-1-policy.yaml +++ b/pkg/generate/testdata/generate/dashboard-crossplane/notification-policy-policy.yaml @@ -1,7 +1,7 @@ apiVersion: alerting.grafana.crossplane.io/v1alpha1 kind: NotificationPolicy metadata: - name: 1-policy + name: policy annotations: crossplane.io/external-name: 1:policy spec: diff --git a/pkg/generate/testdata/generate/dashboard-crossplane/user-admin.yaml b/pkg/generate/testdata/generate/dashboard-crossplane/user-admin.yaml new file mode 100644 index 000000000..a0d0a1066 --- /dev/null +++ b/pkg/generate/testdata/generate/dashboard-crossplane/user-admin.yaml @@ -0,0 +1,14 @@ +apiVersion: oss.grafana.crossplane.io/v1alpha1 +kind: User +metadata: + name: admin + annotations: + crossplane.io/external-name: "1" +spec: + forProvider: + email: admin@localhost + isAdmin: true + login: admin + password: SENSITIVE_VALUE_TO_REPLACE + providerConfigRef: + name: grafana-provider diff --git a/pkg/generate/testdata/generate/dashboard-filtered/imports.tf b/pkg/generate/testdata/generate/dashboard-filtered/imports.tf index 7a41c7fb1..72ef42366 100644 --- a/pkg/generate/testdata/generate/dashboard-filtered/imports.tf +++ b/pkg/generate/testdata/generate/dashboard-filtered/imports.tf @@ -1,4 +1,4 @@ import { - to = grafana_dashboard._1_my-dashboard-uid - id = "1:my-dashboard-uid" + to = grafana_dashboard.my-dashboard-uid + id = "my-dashboard-uid" } diff --git a/pkg/generate/testdata/generate/dashboard-filtered/provider.tf b/pkg/generate/testdata/generate/dashboard-filtered/provider.tf index 0538588ae..ce3c1d837 100644 --- a/pkg/generate/testdata/generate/dashboard-filtered/provider.tf +++ b/pkg/generate/testdata/generate/dashboard-filtered/provider.tf @@ -2,12 +2,12 @@ terraform { required_providers { grafana = { source = "grafana/grafana" - version = "3.0.0" + version = "999.999.999" } } } provider "grafana" { url = "http://localhost:3000" - auth = "admin:admin" + auth = "REDACTED" } diff --git a/pkg/generate/testdata/generate/dashboard-filtered/resources.tf b/pkg/generate/testdata/generate/dashboard-filtered/resources.tf index 43c89b738..275f253c9 100644 --- a/pkg/generate/testdata/generate/dashboard-filtered/resources.tf +++ b/pkg/generate/testdata/generate/dashboard-filtered/resources.tf @@ -1,8 +1,8 @@ # __generated__ by Terraform # Please review these resources and move them into your main configuration files. -# __generated__ by Terraform from "1:my-dashboard-uid" -resource "grafana_dashboard" "_1_my-dashboard-uid" { +# __generated__ by Terraform from "my-dashboard-uid" +resource "grafana_dashboard" "my-dashboard-uid" { config_json = jsonencode({ title = "My Dashboard" uid = "my-dashboard-uid" diff --git a/pkg/generate/testdata/generate/dashboard-json/imports.tf.json b/pkg/generate/testdata/generate/dashboard-json/imports.tf.json index 955e368a2..67f170549 100644 --- a/pkg/generate/testdata/generate/dashboard-json/imports.tf.json +++ b/pkg/generate/testdata/generate/dashboard-json/imports.tf.json @@ -1,20 +1,28 @@ { "import": [ { - "id": "1:my-dashboard-uid", - "to": "grafana_dashboard._1_my-dashboard-uid" + "id": "email receiver", + "to": "grafana_contact_point.email_receiver" }, { - "id": "1:my-folder-uid", - "to": "grafana_folder._1_my-folder-uid" + "id": "my-dashboard-uid", + "to": "grafana_dashboard.my-dashboard-uid" }, { - "id": "1:policy", - "to": "grafana_notification_policy._1_policy" + "id": "my-folder-uid", + "to": "grafana_folder.my-folder-uid" + }, + { + "id": "policy", + "to": "grafana_notification_policy.policy" }, { "id": "1", "to": "grafana_organization_preferences._1" + }, + { + "id": "1", + "to": "grafana_user.admin" } ] } diff --git a/pkg/generate/testdata/generate/dashboard-json/provider.tf.json b/pkg/generate/testdata/generate/dashboard-json/provider.tf.json index 3274a219e..3864a2fce 100644 --- a/pkg/generate/testdata/generate/dashboard-json/provider.tf.json +++ b/pkg/generate/testdata/generate/dashboard-json/provider.tf.json @@ -2,7 +2,7 @@ "provider": { "grafana": [ { - "auth": "admin:admin", + "auth": "REDACTED", "url": "http://localhost:3000" } ] @@ -13,7 +13,7 @@ { "grafana": { "source": "grafana/grafana", - "version": "3.0.0" + "version": "999.999.999" } } ] diff --git a/pkg/generate/testdata/generate/dashboard-json/resources.tf.json b/pkg/generate/testdata/generate/dashboard-json/resources.tf.json index e586f2deb..2ab0c2bca 100644 --- a/pkg/generate/testdata/generate/dashboard-json/resources.tf.json +++ b/pkg/generate/testdata/generate/dashboard-json/resources.tf.json @@ -1,15 +1,32 @@ { "resource": { + "grafana_contact_point": { + "email_receiver": [ + { + "disable_provenance": true, + "email": [ + { + "addresses": [ + "\u003cexample@email.com\u003e" + ], + "disable_resolve_message": false, + "single_email": false + } + ], + "name": "email receiver" + } + ] + }, "grafana_dashboard": { - "_1_my-dashboard-uid": [ + "my-dashboard-uid": [ { "config_json": "${jsonencode({\n title = \"My Dashboard\"\n uid = \"my-dashboard-uid\"\n })}", - "folder": "${grafana_folder._1_my-folder-uid.uid}" + "folder": "${grafana_folder.my-folder-uid.uid}" } ] }, "grafana_folder": { - "_1_my-folder-uid": [ + "my-folder-uid": [ { "title": "My Folder", "uid": "my-folder-uid" @@ -17,7 +34,7 @@ ] }, "grafana_notification_policy": { - "_1_policy": [ + "policy": [ { "contact_point": "grafana-default-email", "disable_provenance": true, @@ -32,6 +49,16 @@ "_1": [ {} ] + }, + "grafana_user": { + "admin": [ + { + "email": "admin@localhost", + "is_admin": true, + "login": "admin", + "password": "SENSITIVE_VALUE_TO_REPLACE" + } + ] } } } diff --git a/pkg/generate/testdata/generate/dashboard-large/dashboards/large-dashboard-test.json b/pkg/generate/testdata/generate/dashboard-large/dashboards/large-dashboard-test.json new file mode 100644 index 000000000..945cf044a --- /dev/null +++ b/pkg/generate/testdata/generate/dashboard-large/dashboards/large-dashboard-test.json @@ -0,0 +1,1146 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations and Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "panels": [ + { + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 14, + "x": 0, + "y": 0 + }, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "# SLO Terraform Testing-4848308903255109881 - No Alerting Check SLO\nSLO Terraform Testing-4848308903255109881 - No Alerting Check", + "mode": "markdown" + }, + "pluginVersion": "11.2.0-74459", + "title": "", + "transparent": true, + "type": "text" + }, + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "description": "The time window over which the service level objective is being measured over", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "super-light-blue", + "value": null + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 14, + "y": 0 + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "/.*/", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.2.0-74459", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "builder", + "expr": "label_replace(vector(1), \"time_period\", \"28d\", \"\", \"\")", + "hide": false, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "refId": "time_window" + } + ], + "title": "Time Window", + "transformations": [ + { + "id": "labelsToFields", + "options": { + "mode": "rows" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "label": true + }, + "indexByName": {}, + "renameByName": { + "label": "time_period", + "value": "Time Window" + } + } + } + ], + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "description": "The SLO's Objective value. Always between 0 and 100%", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "super-light-blue", + "value": null + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 19, + "y": 0 + }, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value", + "wideLayout": true + }, + "pluginVersion": "11.2.0-74459", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "builder", + "expr": "vector(0.995)", + "hide": false, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "SLO", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "from": 1e-10, + "result": { + "text": "FIRING" + }, + "to": 1 + }, + "type": "range" + }, + { + "options": { + "from": -1, + "result": { + "text": "OK" + }, + "to": 0 + }, + "type": "range" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 2, + "w": 5, + "x": 0, + "y": 4 + }, + "links": [ + { + "asDropdown": false, + "icon": "", + "includeVars": false, + "keepTime": false, + "targetBlank": true, + "title": "View alert rule", + "tooltip": "", + "type": "link", + "url": "https://tfprovidertests.grafana.net/alerting/list?queryString=grafana_slo_severity%3Dcritical%2Cgrafana_slo_uuid%3Dmcbsi2tzf7nxhnvgm9ke9" + } + ], + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "vertical", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value_and_name", + "wideLayout": true + }, + "pluginVersion": "11.2.0-74459", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "builder", + "expr": "max(ALERTS{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\", grafana_slo_severity=\"critical\"}) OR on() vector(0)", + "hide": false, + "instant": true, + "interval": "", + "legendFormat": "Critical alert", + "range": false, + "refId": "A" + } + ], + "title": "", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "description": "Service level indicator", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMax": 0.05, + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "scheme", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0.995 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 14, + "x": 5, + "y": 4 + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.2.0-74459", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "builder", + "expr": "avg_over_time(grafana_slo_sli_5m{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}[$__rate_interval])", + "hide": false, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "queryType": "linear", + "range": true, + "refId": "recorded_data" + }, + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "code", + "expr": "((sum(rate(apiserver_request_total{code!=\"500\"}[$__rate_interval] offset 2m))) / (sum(rate(apiserver_request_total[$__rate_interval] offset 2m)))) AND timestamp(sum(rate(apiserver_request_total[$__rate_interval] offset 2m))) \u003c 1718292544", + "hide": false, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "queryType": "linear", + "range": true, + "refId": "computed_before_creation_time" + } + ], + "title": "SLI", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "description": "Service level indicator's value over the last 28d", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 2, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0.995 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 19, + "y": 4 + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.2.0-74459", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "builder", + "expr": "sum(sum_over_time(grafana_slo_success_rate_5m{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}[28d]))\n/ sum(sum_over_time(grafana_slo_total_rate_5m{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}[28d]))", + "hide": false, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "refId": "recorded_data" + } + ], + "title": "SLI (last 28d)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "from": 1e-10, + "result": { + "text": "FIRING" + }, + "to": 1 + }, + "type": "range" + }, + { + "options": { + "from": -1, + "result": { + "text": "OK" + }, + "to": 0 + }, + "type": "range" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 2, + "w": 5, + "x": 0, + "y": 6 + }, + "links": [ + { + "asDropdown": false, + "icon": "", + "includeVars": false, + "keepTime": false, + "targetBlank": true, + "title": "View alert rule", + "tooltip": "", + "type": "link", + "url": "https://tfprovidertests.grafana.net/alerting/list?queryString=grafana_slo_severity%3Dwarning%2Cgrafana_slo_uuid%3Dmcbsi2tzf7nxhnvgm9ke9" + } + ], + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "vertical", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value_and_name", + "wideLayout": true + }, + "pluginVersion": "11.2.0-74459", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "builder", + "expr": "max(ALERTS{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\", grafana_slo_severity=\"warning\"}) OR on() vector(0)", + "hide": false, + "instant": true, + "interval": "", + "legendFormat": "Warning alert", + "range": false, + "refId": "A" + } + ], + "title": "", + "type": "stat" + }, + { + "description": "All active alerts for this SLO", + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 17, + "w": 5, + "x": 0, + "y": 8 + }, + "options": { + "alertInstanceLabelFilter": "{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}", + "alertName": "", + "dashboardAlerts": false, + "folder": "", + "groupBy": [], + "groupMode": "default", + "maxItems": 20, + "sortOrder": 1, + "stateFilter": { + "error": true, + "firing": true, + "noData": true, + "normal": true, + "pending": true + }, + "viewMode": "list" + }, + "pluginVersion": "11.2.0-74459", + "title": "Burn Rate Alerts", + "type": "alertlist" + }, + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "description": "If error budget is decreasing over time, it means that your service is spending its error budget faster than it's earning it back.\n\nIf error budget is increasing over time, you're not spending too much of your error budget.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMax": 1, + "axisSoftMin": 0, + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "scheme", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 0 + }, + { + "color": "green", + "value": 0.2 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 14, + "x": 5, + "y": 11 + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.2.0-74459", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "builder", + "expr": "(\n\t\tsum(sum_over_time(grafana_slo_success_rate_5m{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}[28d]))\n\t\t/ sum(sum_over_time(grafana_slo_total_rate_5m{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}[28d]))\n\t\t- on() grafana_slo_objective{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}\n\t)\n\t/ on () (1 - grafana_slo_objective{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"})", + "hide": false, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "refId": "error_budget_trend" + } + ], + "title": "Error Budget Trend", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "description": "The unspent error budget over the last {0.995 28d} Window", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 19, + "y": 11 + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.2.0-74459", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "builder", + "exemplar": false, + "expr": "(\n\t\tsum(sum_over_time(grafana_slo_success_rate_5m{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}[28d]))\n\t\t/ sum(sum_over_time(grafana_slo_total_rate_5m{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}[28d]))\n\t\t- on() grafana_slo_objective{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}\n\t)\n\t/ on () (1 - grafana_slo_objective{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"})", + "hide": false, + "instant": true, + "interval": "", + "legendFormat": "__auto", + "range": false, + "refId": "A" + } + ], + "title": "Remaining Error Budget", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "description": "The burn rate is the rate that this SLO is spending its error budget over last 5 min [0, 1.0]. A 1x burn rate will consume the entire error budget allotted for that period.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMin": 0, + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "scheme", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "dark-red", + "value": 3 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 14, + "x": 5, + "y": 18 + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.2.0-74459", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "builder", + "expr": "(1 - avg_over_time(grafana_slo_sli_5m{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}[$__rate_interval])) / on(grafana_slo_uuid) group_left() (1 - avg_over_time(grafana_slo_objective{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}[$__rate_interval]))", + "hide": false, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "refId": "recorded_data" + }, + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "code", + "expr": "((1 - ((sum(rate(apiserver_request_total{code!=\"500\"}[$__rate_interval] offset 2m))) / (sum(rate(apiserver_request_total[$__rate_interval] offset 2m))))) / (1 - 0.995)) AND timestamp(sum(rate(apiserver_request_total[$__rate_interval] offset 2m))) \u003c 1718292544", + "hide": false, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "refId": "computed_before_creation_time" + } + ], + "title": "Error Budget Burn Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafanacloud-prom" + }, + "description": "The burn rate is the rate that this SLO is spending its error budget over last 5 min [0, 1.0]. A 1x burn rate will consume the entire error budget allotted for that period.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 1 + }, + { + "color": "dark-red", + "value": 3 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 19, + "y": 18 + }, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.2.0-74459", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "builder", + "exemplar": false, + "expr": "(1 - avg_over_time(grafana_slo_sli_5m{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}[$__rate_interval])) / on(grafana_slo_uuid) group_left() (1 - avg_over_time(grafana_slo_objective{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"}[$__rate_interval]))", + "hide": false, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Current Burn Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "description": "Total Rate (for SLIs that compare rate of successful events to rate of total events, this is the latter)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "axisSoftMax": 0.05, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "unit": "reqps" + } + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 25 + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "builder", + "expr": "sum by (grafana_slo_uuid) (grafana_slo_total_rate_5m{grafana_slo_uuid=\"mcbsi2tzf7nxhnvgm9ke9\"})", + "hide": false, + "instant": false, + "interval": "", + "legendFormat": "", + "queryType": "linear", + "range": true, + "refId": "recorded_data" + }, + { + "datasource": { + "type": "prometheus", + "uid": "grafanacloud-prom" + }, + "editorMode": "code", + "expr": "(sum(rate(apiserver_request_total[$__rate_interval] offset 2m))) AND timestamp(sum(rate(apiserver_request_total[$__rate_interval] offset 2m))) \u003c 1718292544", + "hide": false, + "instant": false, + "interval": "", + "legendFormat": "", + "queryType": "linear", + "range": true, + "refId": "computed_before_creation_time" + } + ], + "title": "Event Rate", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 39, + "tags": [ + "slo" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Testing Large Dashboard", + "uid": "large-dashboard-test", + "weekStart": "" +} diff --git a/pkg/generate/testdata/generate/dashboard-large/imports.tf b/pkg/generate/testdata/generate/dashboard-large/imports.tf new file mode 100644 index 000000000..15568164a --- /dev/null +++ b/pkg/generate/testdata/generate/dashboard-large/imports.tf @@ -0,0 +1,9 @@ +import { + to = grafana_dashboard.large-dashboard-test + id = "large-dashboard-test" +} + +import { + to = grafana_folder.folder-with-large-dashboard + id = "folder-with-large-dashboard" +} diff --git a/pkg/generate/testdata/generate/dashboard-large/provider.tf b/pkg/generate/testdata/generate/dashboard-large/provider.tf new file mode 100644 index 000000000..ce3c1d837 --- /dev/null +++ b/pkg/generate/testdata/generate/dashboard-large/provider.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = "999.999.999" + } + } +} + +provider "grafana" { + url = "http://localhost:3000" + auth = "REDACTED" +} diff --git a/pkg/generate/testdata/generate/dashboard-large/resources.tf b/pkg/generate/testdata/generate/dashboard-large/resources.tf new file mode 100644 index 000000000..70c6f7dbe --- /dev/null +++ b/pkg/generate/testdata/generate/dashboard-large/resources.tf @@ -0,0 +1,14 @@ +# __generated__ by Terraform +# Please review these resources and move them into your main configuration files. + +# __generated__ by Terraform from "large-dashboard-test" +resource "grafana_dashboard" "large-dashboard-test" { + config_json = file("${path.module}/dashboards/large-dashboard-test.json") + folder = grafana_folder.folder-with-large-dashboard.uid +} + +# __generated__ by Terraform from "folder-with-large-dashboard" +resource "grafana_folder" "folder-with-large-dashboard" { + title = "Folder with Large Dashboard" + uid = "folder-with-large-dashboard" +} diff --git a/pkg/generate/testdata/generate/dashboard-restricted-permissions/imports.tf b/pkg/generate/testdata/generate/dashboard-restricted-permissions/imports.tf new file mode 100644 index 000000000..e3f28a553 --- /dev/null +++ b/pkg/generate/testdata/generate/dashboard-restricted-permissions/imports.tf @@ -0,0 +1,9 @@ +import { + to = grafana_dashboard.my-dashboard-uid + id = "my-dashboard-uid" +} + +import { + to = grafana_folder.my-folder-uid + id = "my-folder-uid" +} diff --git a/pkg/generate/testdata/generate/dashboard-restricted-permissions/provider.tf b/pkg/generate/testdata/generate/dashboard-restricted-permissions/provider.tf new file mode 100644 index 000000000..ce3c1d837 --- /dev/null +++ b/pkg/generate/testdata/generate/dashboard-restricted-permissions/provider.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = "999.999.999" + } + } +} + +provider "grafana" { + url = "http://localhost:3000" + auth = "REDACTED" +} diff --git a/pkg/generate/testdata/generate/dashboard-restricted-permissions/resources.tf b/pkg/generate/testdata/generate/dashboard-restricted-permissions/resources.tf new file mode 100644 index 000000000..0d4c4ca85 --- /dev/null +++ b/pkg/generate/testdata/generate/dashboard-restricted-permissions/resources.tf @@ -0,0 +1,17 @@ +# __generated__ by Terraform +# Please review these resources and move them into your main configuration files. + +# __generated__ by Terraform from "my-dashboard-uid" +resource "grafana_dashboard" "my-dashboard-uid" { + config_json = jsonencode({ + title = "My Dashboard" + uid = "my-dashboard-uid" + }) + folder = grafana_folder.my-folder-uid.uid +} + +# __generated__ by Terraform from "my-folder-uid" +resource "grafana_folder" "my-folder-uid" { + title = "My Folder" + uid = "my-folder-uid" +} diff --git a/pkg/generate/testdata/generate/dashboard/imports.tf b/pkg/generate/testdata/generate/dashboard/imports.tf index dc453ef7b..48de25e56 100644 --- a/pkg/generate/testdata/generate/dashboard/imports.tf +++ b/pkg/generate/testdata/generate/dashboard/imports.tf @@ -1,19 +1,29 @@ import { - to = grafana_dashboard._1_my-dashboard-uid - id = "1:my-dashboard-uid" + to = grafana_contact_point.email_receiver + id = "email receiver" } import { - to = grafana_folder._1_my-folder-uid - id = "1:my-folder-uid" + to = grafana_dashboard.my-dashboard-uid + id = "my-dashboard-uid" } import { - to = grafana_notification_policy._1_policy - id = "1:policy" + to = grafana_folder.my-folder-uid + id = "my-folder-uid" +} + +import { + to = grafana_notification_policy.policy + id = "policy" } import { to = grafana_organization_preferences._1 id = "1" } + +import { + to = grafana_user.admin + id = "1" +} diff --git a/pkg/generate/testdata/generate/dashboard/provider.tf b/pkg/generate/testdata/generate/dashboard/provider.tf index 0538588ae..ce3c1d837 100644 --- a/pkg/generate/testdata/generate/dashboard/provider.tf +++ b/pkg/generate/testdata/generate/dashboard/provider.tf @@ -2,12 +2,12 @@ terraform { required_providers { grafana = { source = "grafana/grafana" - version = "3.0.0" + version = "999.999.999" } } } provider "grafana" { url = "http://localhost:3000" - auth = "admin:admin" + auth = "REDACTED" } diff --git a/pkg/generate/testdata/generate/dashboard/resources.tf b/pkg/generate/testdata/generate/dashboard/resources.tf index 2556a9c97..1abdaa620 100644 --- a/pkg/generate/testdata/generate/dashboard/resources.tf +++ b/pkg/generate/testdata/generate/dashboard/resources.tf @@ -1,23 +1,34 @@ # __generated__ by Terraform # Please review these resources and move them into your main configuration files. -# __generated__ by Terraform from "1:my-dashboard-uid" -resource "grafana_dashboard" "_1_my-dashboard-uid" { +# __generated__ by Terraform from "email receiver" +resource "grafana_contact_point" "email_receiver" { + disable_provenance = true + name = "email receiver" + email { + addresses = [""] + disable_resolve_message = false + single_email = false + } +} + +# __generated__ by Terraform from "my-dashboard-uid" +resource "grafana_dashboard" "my-dashboard-uid" { config_json = jsonencode({ title = "My Dashboard" uid = "my-dashboard-uid" }) - folder = grafana_folder._1_my-folder-uid.uid + folder = grafana_folder.my-folder-uid.uid } -# __generated__ by Terraform from "1:my-folder-uid" -resource "grafana_folder" "_1_my-folder-uid" { +# __generated__ by Terraform from "my-folder-uid" +resource "grafana_folder" "my-folder-uid" { title = "My Folder" uid = "my-folder-uid" } -# __generated__ by Terraform from "1:policy" -resource "grafana_notification_policy" "_1_policy" { +# __generated__ by Terraform from "policy" +resource "grafana_notification_policy" "policy" { contact_point = "grafana-default-email" disable_provenance = true group_by = ["grafana_folder", "alertname"] @@ -26,3 +37,11 @@ resource "grafana_notification_policy" "_1_policy" { # __generated__ by Terraform from "1" resource "grafana_organization_preferences" "_1" { } + +# __generated__ by Terraform +resource "grafana_user" "admin" { + email = "admin@localhost" + is_admin = true + login = "admin" + password = "SENSITIVE_VALUE_TO_REPLACE" +} diff --git a/pkg/generate/testdata/generate/empty-with-creds/imports.tf b/pkg/generate/testdata/generate/empty-with-creds/imports.tf new file mode 100644 index 000000000..404a262f1 --- /dev/null +++ b/pkg/generate/testdata/generate/empty-with-creds/imports.tf @@ -0,0 +1 @@ +# No resources were found diff --git a/pkg/generate/testdata/generate/empty-with-creds/provider.tf b/pkg/generate/testdata/generate/empty-with-creds/provider.tf new file mode 100644 index 000000000..3fb374d15 --- /dev/null +++ b/pkg/generate/testdata/generate/empty-with-creds/provider.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = "999.999.999" + } + } +} + +provider "grafana" { + url = "http://localhost:3000" + auth = "admin:admin" +} diff --git a/pkg/generate/testdata/generate/empty-with-creds/resources.tf b/pkg/generate/testdata/generate/empty-with-creds/resources.tf new file mode 100644 index 000000000..404a262f1 --- /dev/null +++ b/pkg/generate/testdata/generate/empty-with-creds/resources.tf @@ -0,0 +1 @@ +# No resources were found diff --git a/pkg/generate/testdata/generate/empty/provider.tf b/pkg/generate/testdata/generate/empty/provider.tf index 0538588ae..ce3c1d837 100644 --- a/pkg/generate/testdata/generate/empty/provider.tf +++ b/pkg/generate/testdata/generate/empty/provider.tf @@ -2,12 +2,12 @@ terraform { required_providers { grafana = { source = "grafana/grafana" - version = "3.0.0" + version = "999.999.999" } } } provider "grafana" { url = "http://localhost:3000" - auth = "admin:admin" + auth = "REDACTED" } diff --git a/pkg/generate/testdata/generate/oncall-resources/imports.tf.tmpl b/pkg/generate/testdata/generate/oncall-resources/imports.tf.tmpl new file mode 100644 index 000000000..362d1b1f4 --- /dev/null +++ b/pkg/generate/testdata/generate/oncall-resources/imports.tf.tmpl @@ -0,0 +1,19 @@ +import { + to = grafana_oncall_escalation.{{ .EscalationID }} + id = "{{ .EscalationID }}" +} + +import { + to = grafana_oncall_escalation_chain.{{ .Name }} + id = "{{ .EscalationChainID }}" +} + +import { + to = grafana_oncall_integration.{{ .Name }} + id = "{{ .IntegrationID }}" +} + +import { + to = grafana_oncall_schedule.{{ .Name }} + id = "{{ .ScheduleID }}" +} diff --git a/pkg/generate/testdata/generate/oncall-resources/provider.tf b/pkg/generate/testdata/generate/oncall-resources/provider.tf new file mode 100644 index 000000000..222bbbd30 --- /dev/null +++ b/pkg/generate/testdata/generate/oncall-resources/provider.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = "999.999.999" + } + } +} + +provider "grafana" { + url = "https://tfprovidertests.grafana.net/" + auth = "REDACTED" + oncall_url = "https://oncall-prod-us-central-0.grafana.net/oncall" + oncall_access_token = "REDACTED" +} diff --git a/pkg/generate/testdata/generate/oncall-resources/resources.tf.tmpl b/pkg/generate/testdata/generate/oncall-resources/resources.tf.tmpl new file mode 100644 index 000000000..7fd05582a --- /dev/null +++ b/pkg/generate/testdata/generate/oncall-resources/resources.tf.tmpl @@ -0,0 +1,29 @@ +# __generated__ by Terraform +# Please review these resources and move them into your main configuration files. + +# __generated__ by Terraform from "{{ .EscalationID }}" +resource "grafana_oncall_escalation" "{{ .EscalationID }}" { + duration = 300 + escalation_chain_id = grafana_oncall_escalation_chain.{{ .Name }}.id + position = 0 + type = "wait" +} + +# __generated__ by Terraform from "{{ .EscalationChainID }}" +resource "grafana_oncall_escalation_chain" "{{ .Name }}" { + name = "{{ .Name }}" +} + +# __generated__ by Terraform from "{{ .IntegrationID }}" +resource "grafana_oncall_integration" "{{ .Name }}" { + name = "{{ .Name }}" + type = "grafana" +} + +# __generated__ by Terraform from "{{ .ScheduleID }}" +resource "grafana_oncall_schedule" "{{ .Name }}" { + enable_web_overrides = false + name = "{{ .Name }}" + time_zone = "America/New_York" + type = "calendar" +} diff --git a/pkg/generate/testdata/generate/sm-check/imports.tf.tmpl b/pkg/generate/testdata/generate/sm-check/imports.tf.tmpl new file mode 100644 index 000000000..bb8ac05f8 --- /dev/null +++ b/pkg/generate/testdata/generate/sm-check/imports.tf.tmpl @@ -0,0 +1,4 @@ +import { + to = grafana_synthetic_monitoring_check.{{ .Job }} + id = "{{ .ID }}" +} diff --git a/pkg/generate/testdata/generate/sm-check/provider.tf b/pkg/generate/testdata/generate/sm-check/provider.tf new file mode 100644 index 000000000..74a95f706 --- /dev/null +++ b/pkg/generate/testdata/generate/sm-check/provider.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = "999.999.999" + } + } +} + +provider "grafana" { + url = "https://tfprovidertests.grafana.net/" + auth = "REDACTED" + sm_url = "https://synthetic-monitoring-api-us-east-0.grafana.net" + sm_access_token = "REDACTED" +} diff --git a/pkg/generate/testdata/generate/sm-check/resources.tf.tmpl b/pkg/generate/testdata/generate/sm-check/resources.tf.tmpl new file mode 100644 index 000000000..05655e84e --- /dev/null +++ b/pkg/generate/testdata/generate/sm-check/resources.tf.tmpl @@ -0,0 +1,26 @@ +# __generated__ by Terraform +# Please review these resources and move them into your main configuration files. + +# __generated__ by Terraform from "{{ .ID }}" +resource "grafana_synthetic_monitoring_check" "{{ .Job }}" { + alert_sensitivity = "none" + basic_metrics_only = true + enabled = false + frequency = 60000 + job = "{{ .Job }}" + labels = { + foo = "bar" + } + probes = [7] + target = "https://grafana.com" + timeout = 3000 + settings { + http { + fail_if_not_ssl = false + fail_if_ssl = false + ip_version = "V4" + method = "GET" + no_follow_redirects = false + } + } +} diff --git a/pkg/generate/utils/hcl.go b/pkg/generate/utils/hcl.go new file mode 100644 index 000000000..f45a3b20a --- /dev/null +++ b/pkg/generate/utils/hcl.go @@ -0,0 +1,23 @@ +package utils + +import ( + "errors" + "os" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclwrite" +) + +func ReadHCLFile(fpath string) (*hclwrite.File, error) { + src, err := os.ReadFile(fpath) + if err != nil { + return nil, err + } + + file, diags := hclwrite.ParseConfig(src, fpath, hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + return nil, errors.New(diags.Error()) + } + + return file, nil +} diff --git a/pkg/provider/configure_clients.go b/pkg/provider/configure_clients.go index fa5c77307..3afc2811d 100644 --- a/pkg/provider/configure_clients.go +++ b/pkg/provider/configure_clients.go @@ -9,13 +9,14 @@ import ( "net/url" "os" "strings" + "syscall" "time" onCallAPI "github.com/grafana/amixr-api-go-client" "github.com/grafana/grafana-com-public-clients/go/gcom" goapi "github.com/grafana/grafana-openapi-client-go/client" "github.com/grafana/machine-learning-go-client/mlapi" - slo "github.com/grafana/slo-openapi-client/go" + "github.com/grafana/slo-openapi-client/go/slo" SMAPI "github.com/grafana/synthetic-monitoring-api-go-client" "github.com/go-openapi/strfmt" @@ -74,6 +75,11 @@ func createGrafanaAPIClient(client *common.Client, providerConfig ProviderConfig if err != nil { return fmt.Errorf("failed to parse API url: %v", err.Error()) } + + if client.GrafanaAPIURLParsed.Scheme == "http" && strings.Contains(client.GrafanaAPIURLParsed.Host, "grafana.net") { + return fmt.Errorf("http not supported in Grafana Cloud. Use the https scheme") + } + apiPath, err := url.JoinPath(client.GrafanaAPIURLParsed.Path, "api") if err != nil { return fmt.Errorf("failed to join API path: %v", err.Error()) @@ -115,7 +121,6 @@ func createMLClient(client *common.Client, providerConfig ProviderConfig) error BasicAuth: client.GrafanaAPIConfig.BasicAuth, BearerToken: client.GrafanaAPIConfig.APIKey, Client: getRetryClient(providerConfig), - NumRetries: client.GrafanaAPIConfig.NumRetries, } mlURL := client.GrafanaAPIURL if !strings.HasSuffix(mlURL, "/") { @@ -128,14 +133,17 @@ func createMLClient(client *common.Client, providerConfig ProviderConfig) error } func createSLOClient(client *common.Client, providerConfig ProviderConfig) error { + var err error + sloConfig := slo.NewConfiguration() sloConfig.Host = client.GrafanaAPIURLParsed.Host sloConfig.Scheme = client.GrafanaAPIURLParsed.Scheme + sloConfig.DefaultHeader, err = getHTTPHeadersMap(providerConfig) sloConfig.DefaultHeader["Authorization"] = "Bearer " + providerConfig.Auth.ValueString() - sloConfig.DefaultHeader["Grafana-Terraform-Provider"] = "true" sloConfig.HTTPClient = getRetryClient(providerConfig) client.SLOClient = slo.NewAPIClient(sloConfig) - return nil + + return err } func createCloudClient(client *common.Client, providerConfig ProviderConfig) error { @@ -167,7 +175,10 @@ func createOnCallClient(providerConfig ProviderConfig) (*onCallAPI.Client, error // Sets a custom HTTP Header on all requests coming from the Grafana Terraform Provider to Grafana-Terraform-Provider: true // in addition to any headers set within the `http_headers` field or the `GRAFANA_HTTP_HEADERS` environment variable func getHTTPHeadersMap(providerConfig ProviderConfig) (map[string]string, error) { - headers := map[string]string{"Grafana-Terraform-Provider": "true"} + headers := map[string]string{ + "Grafana-Terraform-Provider": "true", + "Grafana-Terraform-Provider-Version": providerConfig.Version.ValueString(), + } for k, v := range providerConfig.HTTPHeaders.Elements() { if vString, ok := v.(types.String); ok { headers[k] = vString.ValueString() @@ -184,7 +195,7 @@ func createTempFileIfLiteral(value string) (path string, tempFile bool, err erro return "", false, nil } - if _, err := os.Stat(value); errors.Is(err, os.ErrNotExist) { + if _, err := os.Stat(value); errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENAMETOOLONG) { // value is not a file path, assume it's a literal f, err := os.CreateTemp("", "grafana-provider-tls") if err != nil { @@ -207,9 +218,12 @@ func parseAuth(providerConfig ProviderConfig) (*url.Userinfo, int64, string, err var orgID int64 = 1 if len(auth) == 2 { - return url.UserPassword(auth[0], auth[1]), orgID, "", nil + user := strings.TrimSpace(auth[0]) + pass := strings.TrimSpace(auth[1]) + return url.UserPassword(user, pass), orgID, "", nil } else if auth[0] != "anonymous" { - return nil, 0, auth[0], nil + apiKey := strings.TrimSpace(auth[0]) + return nil, 0, apiKey, nil } return nil, 0, "", nil } diff --git a/pkg/provider/configure_clients_test.go b/pkg/provider/configure_clients_test.go new file mode 100644 index 000000000..4f5c1a7b0 --- /dev/null +++ b/pkg/provider/configure_clients_test.go @@ -0,0 +1,144 @@ +package provider + +import ( + "os" + "testing" + + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateTempFileIfLiteral(t *testing.T) { + t.Run("Test with empty string value returns empty and does not create temp file", func(t *testing.T) { + path, tempFile, err := createTempFileIfLiteral("") + require.NoError(t, err) + require.False(t, tempFile, "Expected temp file to not be created") + require.Empty(t, path) + }) + t.Run("Test file path returns given path and does not create a temp file", func(t *testing.T) { + // Create a temporary file to simulate an existing file + tmp, err := os.CreateTemp(t.TempDir(), "existing-file") + require.NoError(t, err) + + path, tempFile, err := createTempFileIfLiteral(tmp.Name()) + require.NoError(t, err) + require.False(t, tempFile, "Expected temp file to not be created") + require.Equal(t, tmp.Name(), path) + }) + + t.Run("Test with short literal creates temp file and path", func(t *testing.T) { + caCert := "certTest" + + path, tempFile, err := createTempFileIfLiteral(caCert) + require.NoError(t, err) + require.True(t, tempFile, "Expected temp file to be created") + require.NotEmpty(t, path) + + // Validate the file was created and has the correct content + content, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, caCert, string(content)) + + // Clean up the temporary file + require.NoError(t, os.Remove(path)) + }) + + t.Run("Test with a certificate literal creates temp file and path", func(t *testing.T) { + caCert := ` -----BEGIN CERTIFICATE----- + MIIDXTCCAkWgAwIBAgIJAMW9UJtz1MoNMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV + BAYTAkFVMRMwEQYDVQQIDApxdWVlbnNsYW5kMRAwDgYDVQQHDAdicmlzYmFuZTEN + MAsGA1UECgwEVGVzdDAeFw0xODA2MTAwNzU1NDJaFw0xOTA2MTAwNzU1NDJaMEUx + CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApxdWVlbnNsYW5kMRAwDgYDVQQHDAdicmlz + YmFuZTENMAsGA1UECgwEVGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC + ggEBAK1lpt+lPZJbG7yMYYWzjk8FwGbM3vlUJlC2aQHJ18T2aTtsOaZC1deKtwGR + qBZMyel3hG0XayZmFQO2DAnOScgn4j+jPEFLWswg+U4MgH80+PA4wHzm+E0v68qD + S+cA9If1D2I0gtT6jKPm3WYwZ/r0GUn8/JjgiIhCZGfXArH39V2D2KNhJ3W0b7T6 + isfbsHvSKWs/49q8w5J/yN8GOh/n/rThBfhM3FQ2eDdVR1QfvvX5KT69aXhtJlD9 + Z5H8z9DnD8BZxBrzE5hEO74KK13CvAFeKbVp7KvXf6NOy4W31lUd6lmzZ+lR+IxO + NHElgJoaJ2F2y4XcFXY1cQFhKjkCAwEAAaNQME4wHQYDVR0OBBYEFLPkkSMxs/PR + 1E7VwDhRu5DTHwrNMB8GA1UdIwQYMBaAFLPkkSMxs/PR1E7VwDhRu5DTHwrNMAwG + A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAK1lpt+lPZJbG7yMYYWzjk8F + wGbM3vlUJlC2aQHJ18T2aTtsOaZC1deKtwGRqBZMyel3hG0XayZmFQO2DAnOScgn + 4j+jPEFLWswg+U4MgH80+PA4wHzm+E0v68qDS+cA9If1D2I0gtT6jKPm3WYwZ/r0 + GUn8/JjgiIhCZGfXArH39V2D2KNhJ3W0b7T6isfbsHvSKWs/49q8w5J/yN8GOh/n + /rThBfhM3FQ2eDdVR1QfvvX5KT69aXhtJlD9Z5H8z9DnD8BZxBrzE5hEO74KK13C + vAFeKbVp7KvXf6NOy4W31lUd6lmzZ+lR+IxONHElgJoaJ2F2y4XcFXY1cQFhKjkC + AwEAAaNQME4wHQYDVR0OBBYEFLPkkSMxs/PR1E7VwDhRu5DTHwrNMB8GA1UdIwQY + MBaAFLPkkSMxs/PR1E7VwDhRu5DTHwrNMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcN + AQELBQADggEBAK1lpt+lPZJbG7yMYYWzjk8FwGbM3vlUJlC2aQHJ18T2aTtsOaZC + 1deKtwGRqBZMyel3hG0XayZmFQO2DAnOScgn4j+jPEFLWswg+U4MgH80+PA4wHzm + +E0v68qDS+cA9If1D2I0gtT6jKPm3WYwZ/r0GUn8/JjgiIhCZGfXArH39V2D2KNh + J3W0b7T6isfbsHvSKWs/49q8w5J/yN8GOh/n/rThBfhM3FQ2eDdVR1QfvvX5KT69 + aXhtJlD9Z5H8z9DnD8BZxBrzE5hEO74KK13CvAFeKbVp7KvXf6NOy4W31lUd6lmz + Z+lR+IxONHElgJoaJ2F2y4XcFXY1cQFhKjkCAwEAAaNQME4wHQYDVR0OBBYEFLPk + kSMxs/PR1E7VwDhRu5DTHwrNMB8GA1UdIwQYMBaAFLPkkSMxs/PR1E7VwDhRu5DT + HwrNMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAK1lpt+lPZJbG7yM + YYWzjk8FwGbM3vlUJl= + -----END CERTIFICATE-----` + + path, tempFile, err := createTempFileIfLiteral(caCert) + require.NoError(t, err) + require.True(t, tempFile, "Expected temp file to be created") + require.NotEmpty(t, path) + + // Check if the file exists and has the correct content + content, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, caCert, string(content)) + + // Clean up the temporary file + require.NoError(t, os.Remove(path)) + }) +} + +func TestCreateClients(t *testing.T) { + testCases := []struct { + name string + config ProviderConfig + expected func(c *common.Client, err error) + }{ + { + name: "http with Grafana Cloud", + config: ProviderConfig{ + URL: types.StringValue("http://myinstance.grafana.net"), + Auth: types.StringValue("myapikey"), + }, + expected: func(c *common.Client, err error) { + assert.EqualError(t, err, "http not supported in Grafana Cloud. Use the https scheme") + }, + }, + { + name: "https with Grafana Cloud", + config: ProviderConfig{ + URL: types.StringValue("https://myinstance.grafana.net"), + Auth: types.StringValue("myapikey"), + }, + expected: func(c *common.Client, err error) { + assert.Nil(t, err) + assert.NotNil(t, c.GrafanaAPI) + assert.NotNil(t, c.MLAPI) + assert.NotNil(t, c.SLOClient) + }, + }, + { + name: "http with Grafana OSS", + config: ProviderConfig{ + URL: types.StringValue("http://localhost:3000"), + Auth: types.StringValue("admin:admin"), + }, + expected: func(c *common.Client, err error) { + assert.Nil(t, err) + assert.NotNil(t, c.GrafanaAPI) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + c, err := CreateClients(tc.config) + tc.expected(c, err) + }) + } +} diff --git a/pkg/provider/framework_provider.go b/pkg/provider/framework_provider.go index b86b767ac..bb412a501 100644 --- a/pkg/provider/framework_provider.go +++ b/pkg/provider/framework_provider.go @@ -41,6 +41,7 @@ type ProviderConfig struct { OncallURL types.String `tfsdk:"oncall_url"` UserAgent types.String `tfsdk:"-"` + Version types.String `tfsdk:"-"` } func (c *ProviderConfig) SetDefaults() error { @@ -206,6 +207,7 @@ func (p *frameworkProvider) Configure(ctx context.Context, req provider.Configur resp.Diagnostics.AddError("failed to set defaults", err.Error()) return } + cfg.Version = types.StringValue(p.version) cfg.UserAgent = types.StringValue(fmt.Sprintf("Terraform/%s (+https://www.terraform.io) terraform-provider-grafana/%s", req.TerraformVersion, p.version)) clients, err := CreateClients(cfg) diff --git a/pkg/provider/legacy_provider.go b/pkg/provider/legacy_provider.go index c04617266..627dc3b67 100644 --- a/pkg/provider/legacy_provider.go +++ b/pkg/provider/legacy_provider.go @@ -2,6 +2,8 @@ package provider import ( "context" + "os" + "path/filepath" "fmt" "strings" @@ -14,6 +16,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) +var ( + EnableGenerateEnvVar = "TF_GENERATE_UNSENSITIVE" + EnableGenerateMarkerFile = ".generate-make-all-fields-unsensitive" +) + func init() { schema.DescriptionKind = schema.StringMarkdown schema.SchemaDescriptionBuilder = func(s *schema.Schema) string { @@ -135,6 +142,29 @@ func Provider(version string) *schema.Provider { DataSourcesMap: legacySDKDataSources(), } + if os.Getenv(EnableGenerateEnvVar) != "" { + // If TF_GENERATE_UNSENSITIVE envvar is set and there's the "marker file" in the current directory, + // generate the provider with all fields marked as non-sensitive. + // The Terraform generation feature is overly-aggressive in redacting sensitive fields, it redacts all blocks at the root level. + // Security note: + // Setting an envvar + creating a marker file in the TF dir means that the user has full control over the TF context. + // This means that the user could also read sensitive data from the state, or use the `unsensitive` TF function to read sensitive data. + // So, this feature doesn't introduce a new way to extract sensitive data. + + wd, err := os.Getwd() + if err != nil { + panic(err) // It's ok to panic, this is only meant to be used in the context of the generator. + } + _, err = os.Stat(filepath.Join(wd, EnableGenerateMarkerFile)) + if err == nil { + for k := range p.ResourcesMap { + unsensitive(p.ResourcesMap[k]) + } + } else { + fmt.Println("The marker file for generating unsensitive fields is not present, skipping.") + } + } + p.ConfigureContextFunc = configure(version, p) return p @@ -180,6 +210,7 @@ func configure(version string, p *schema.Provider) func(context.Context, *schema RetryStatusCodes: statusCodes, RetryWait: types.Int64Value(int64(d.Get("retry_wait").(int))), UserAgent: types.StringValue(p.UserAgent("terraform-provider-grafana", version)), + Version: types.StringValue(version), } if err := cfg.SetDefaults(); err != nil { return nil, diag.FromErr(err) @@ -210,3 +241,15 @@ func int64ValueOrNull(d *schema.ResourceData, key string) types.Int64 { } return types.Int64Null() } + +func unsensitive(r *schema.Resource) { + for _, s := range r.Schema { + s.Sensitive = false + if r, ok := s.Elem.(*schema.Resource); ok { + unsensitive(r) + } + if _, ok := s.Elem.(*schema.Schema); ok { + s.Elem.(*schema.Schema).Sensitive = false + } + } +} diff --git a/pkg/provider/legacy_provider_test.go b/pkg/provider/legacy_provider_test.go index 984f9cf90..f0868230f 100644 --- a/pkg/provider/legacy_provider_test.go +++ b/pkg/provider/legacy_provider_test.go @@ -29,8 +29,8 @@ func TestProviderConfigure(t *testing.T) { // Helper for header tests checkHeaders := func(t *testing.T, provider *schema.Provider) { gotHeaders := provider.Meta().(*common.Client).GrafanaAPIConfig.HTTPHeaders - if len(gotHeaders) != 3 { - t.Errorf("expected 3 HTTP header, got %d", len(gotHeaders)) + if len(gotHeaders) != 4 { + t.Errorf("expected 4 HTTP header, got %d", len(gotHeaders)) } if gotHeaders["Authorization"] != "Bearer test" { t.Errorf("expected HTTP header Authorization to be \"Bearer test\", got %q", gotHeaders["Authorization"]) diff --git a/pkg/provider/resources.go b/pkg/provider/resources.go index 8167f4cb1..30ddc219b 100644 --- a/pkg/provider/resources.go +++ b/pkg/provider/resources.go @@ -61,6 +61,14 @@ func Resources() []*common.Resource { return resources } +func ResourcesMap() map[string]*common.Resource { + result := make(map[string]*common.Resource) + for _, r := range Resources() { + result[r.Name] = r + } + return result +} + func legacySDKResources() map[string]*schema.Resource { result := make(map[string]*schema.Resource) for _, r := range Resources() { diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl index de8125cab..3ff35711d 100644 --- a/templates/index.md.tmpl +++ b/templates/index.md.tmpl @@ -39,11 +39,11 @@ One, or many, of the following authentication settings must be set. Each authent ### `auth` This can be a Grafana API key, basic auth `username:password`, or a -[Grafana Service Account token](https://grafana.com/docs/grafana/latest/developers/http_api/create-api-tokens-for-org/). +[Grafana Service Account token](https://grafana.com/docs/grafana/latest/developers/http_api/examples/create-api-tokens-for-org/). ### `cloud_access_policy_token` -An access policy token created on the [Grafana Cloud Portal](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/create-api-key/). +An access policy token created on the [Grafana Cloud Portal](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/authorize-services/). ### `sm_access_token` diff --git a/templates/resources/machine_learning_alert.md.tmpl b/templates/resources/machine_learning_alert.md.tmpl new file mode 100644 index 000000000..7aaa63b1d --- /dev/null +++ b/templates/resources/machine_learning_alert.md.tmpl @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +## Example Usage + +### Forecast Alert + +This alert uses a forecast. + +{{ tffile "examples/resources/grafana_machine_learning_alert/forecast_alert.tf" }} + +### Outlier Alert + +This alert uses an outlier detector. + +{{ tffile "examples/resources/grafana_machine_learning_alert/outlier_alert.tf" }} + +## Import + +Import is supported using the following syntax: + +{{ codefile "shell" "examples/resources/grafana_machine_learning_alert/import.sh" }} diff --git a/templates/resources/machine_learning_holiday.md.tmpl b/templates/resources/machine_learning_holiday.md.tmpl new file mode 100644 index 000000000..585008d8f --- /dev/null +++ b/templates/resources/machine_learning_holiday.md.tmpl @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +## Example Usage + +### iCal Holiday + +This holiday uses an iCal file to define the holidays. + +{{ tffile "examples/resources/grafana_machine_learning_holiday/ical_holiday.tf" }} + +### Custom Periods Holiday + +This holiday uses custom periods to define the holidays. + +{{ tffile "examples/resources/grafana_machine_learning_holiday/custom_periods_holiday.tf" }} + +## Import + +Import is supported using the following syntax: + +{{ codefile "shell" "examples/resources/grafana_machine_learning_holiday/import.sh" }} diff --git a/templates/resources/machine_learning_job.md.tmpl b/templates/resources/machine_learning_job.md.tmpl new file mode 100644 index 000000000..c0981260c --- /dev/null +++ b/templates/resources/machine_learning_job.md.tmpl @@ -0,0 +1,39 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +## Example Usage + +### Basic Forecast + +This forecast uses a Prometheus datasource, where the source query is defined in the `expr` field of the `query_params` attribute. + +Other datasources are supported, but the structure `query_params` may differ. + +{{ tffile "examples/resources/grafana_machine_learning_job/job.tf" }} + +### Tuned Forecast + +This forecast has tuned hyperparameters to improve the accuracy of the model. + +{{ tffile "examples/resources/grafana_machine_learning_job/tuned_job.tf" }} + +### Forecast with Holidays + +This forecast has holidays which will be taken into account when training the model. + +{{ tffile "examples/resources/grafana_machine_learning_job/holidays_job.tf" }} + +## Import + +Import is supported using the following syntax: + +{{ codefile "shell" "examples/resources/grafana_machine_learning_job/import.sh" }} diff --git a/templates/resources/machine_learning_outlier_detector.md.tmpl b/templates/resources/machine_learning_outlier_detector.md.tmpl new file mode 100644 index 000000000..620506460 --- /dev/null +++ b/templates/resources/machine_learning_outlier_detector.md.tmpl @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +## Example Usage + +### DBSCAN Outlier Detector + +This outlier detector uses the DBSCAN algorithm to detect outliers. + +{{ tffile "examples/resources/grafana_machine_learning_outlier_detector/dbscan.tf" }} + +### MAD Outlier Detector + +This outlier detector uses the Median Absolute Deviation (MAD) algorithm to detect outliers. + +{{ tffile "examples/resources/grafana_machine_learning_outlier_detector/mad.tf" }} + +## Import + +Import is supported using the following syntax: + +{{ codefile "shell" "examples/resources/grafana_machine_learning_outlier_detector/import.sh" }} diff --git a/testdata/integration/test.sh b/testdata/integration/test.sh index d3e507ade..a35d578db 100755 --- a/testdata/integration/test.sh +++ b/testdata/integration/test.sh @@ -53,7 +53,10 @@ ${REPO_ROOT}/terraform-provider-grafana-generate \ --grafana-url ${GRAFANA_URL} \ --grafana-auth "admin:admin" \ --clobber \ - --output-dir ${SCRIPT_DIR}/generated + --output-dir ${SCRIPT_DIR}/generated \ + --include-resources "grafana_folder.*" \ + --include-resources "grafana_team.*" \ + --output-credentials ${REPO_ROOT}/terraform-provider-grafana-generate \ --terraform-provider-version "v3.0.0" \ @@ -61,12 +64,15 @@ ${REPO_ROOT}/terraform-provider-grafana-generate \ --grafana-auth "admin:admin" \ --clobber \ --output-dir ${SCRIPT_DIR}/generated-json \ - --output-format json + --output-format json \ + --include-resources "grafana_folder.*" \ + --include-resources "grafana_team.*" \ + --output-credentials # Test the generated code for dir in "generated" "generated-json" ; do cd ${SCRIPT_DIR}/${dir} - terraform plan > plan.out + terraform plan | tee plan.out # Expect a folder called "My Folder" and no changes in the plan grep "My Folder" plan.out || (echo "Expected a folder called 'My Folder'" && exit 1) grep ' to import, 0 to add, 0 to change, 0 to destroy' plan.out || (echo "Expected no changes in the plan" && exit 1)