From 683e6c1f63519e778de85db500fd43bdaf5598bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johanna=20=C3=96jeling?= <51084516+johannaojeling@users.noreply.github.com> Date: Thu, 13 Feb 2025 09:50:36 +0100 Subject: [PATCH] Add support for Grafana Cloud Fleet Management (#1989) --- .github/CODEOWNERS | 1 + .github/workflows/acc-tests.yml | 2 + docs/data-sources/cloud_stack.md | 4 + docs/index.md | 94 ++++++ docs/resources/cloud_stack.md | 4 + docs/resources/fleet_management_collector.md | 58 ++++ docs/resources/fleet_management_pipeline.md | 64 ++++ .../provider/provider-fleet-management.tf | 80 +++++ .../import.sh | 1 + .../resource.tf | 8 + .../import.sh | 1 + .../resource.tf | 9 + go.mod | 4 + go.sum | 8 + internal/common/client.go | 17 +- internal/common/fleetmanagementapi/client.go | 68 ++++ .../common/fleetmanagementapi/client_test.go | 120 ++++++++ internal/common/resource.go | 1 + .../cloud/data_source_cloud_stack_test.go | 4 + .../resources/cloud/resource_cloud_stack.go | 11 + .../cloud/resource_cloud_stack_test.go | 4 + internal/resources/examples_test.go | 7 + .../fleetmanagement/alloy_config_type.go | 65 ++++ .../fleetmanagement/alloy_config_type_test.go | 49 +++ .../fleetmanagement/alloy_config_value.go | 112 +++++++ .../alloy_config_value_test.go | 101 ++++++ internal/resources/fleetmanagement/common.go | 32 ++ .../fleetmanagement/generic_list_type.go | 81 +++++ .../fleetmanagement/generic_list_type_test.go | 64 ++++ .../fleetmanagement/generic_list_value.go | 80 +++++ .../generic_list_value_test.go | 66 ++++ .../fleetmanagement/model_collector.go | 70 +++++ .../fleetmanagement/model_collector_test.go | 172 +++++++++++ .../fleetmanagement/model_pipeline.go | 76 +++++ .../fleetmanagement/model_pipeline_test.go | 175 +++++++++++ .../prometheus_matcher_type.go | 65 ++++ .../prometheus_matcher_type_test.go | 49 +++ .../prometheus_matcher_value.go | 96 ++++++ .../prometheus_matcher_value_test.go | 85 +++++ .../fleetmanagement/resource_collector.go | 269 ++++++++++++++++ .../resource_collector_test.go | 160 ++++++++++ .../fleetmanagement/resource_pipeline.go | 291 ++++++++++++++++++ .../fleetmanagement/resource_pipeline_test.go | 217 +++++++++++++ .../resources/fleetmanagement/resources.go | 8 + internal/resources/fleetmanagement/utils.go | 23 ++ .../resources/fleetmanagement/utils_test.go | 80 +++++ internal/testutils/provider.go | 2 + .../postprocessing/replace_references.go | 1 + pkg/provider/configure_clients.go | 23 ++ pkg/provider/framework_provider.go | 15 + pkg/provider/legacy_provider.go | 20 ++ pkg/provider/resources.go | 2 + templates/index.md.tmpl | 11 + 53 files changed, 3123 insertions(+), 7 deletions(-) create mode 100644 docs/resources/fleet_management_collector.md create mode 100644 docs/resources/fleet_management_pipeline.md create mode 100644 examples/provider/provider-fleet-management.tf create mode 100644 examples/resources/grafana_fleet_management_collector/import.sh create mode 100644 examples/resources/grafana_fleet_management_collector/resource.tf create mode 100644 examples/resources/grafana_fleet_management_pipeline/import.sh create mode 100644 examples/resources/grafana_fleet_management_pipeline/resource.tf create mode 100644 internal/common/fleetmanagementapi/client.go create mode 100644 internal/common/fleetmanagementapi/client_test.go create mode 100644 internal/resources/fleetmanagement/alloy_config_type.go create mode 100644 internal/resources/fleetmanagement/alloy_config_type_test.go create mode 100644 internal/resources/fleetmanagement/alloy_config_value.go create mode 100644 internal/resources/fleetmanagement/alloy_config_value_test.go create mode 100644 internal/resources/fleetmanagement/common.go create mode 100644 internal/resources/fleetmanagement/generic_list_type.go create mode 100644 internal/resources/fleetmanagement/generic_list_type_test.go create mode 100644 internal/resources/fleetmanagement/generic_list_value.go create mode 100644 internal/resources/fleetmanagement/generic_list_value_test.go create mode 100644 internal/resources/fleetmanagement/model_collector.go create mode 100644 internal/resources/fleetmanagement/model_collector_test.go create mode 100644 internal/resources/fleetmanagement/model_pipeline.go create mode 100644 internal/resources/fleetmanagement/model_pipeline_test.go create mode 100644 internal/resources/fleetmanagement/prometheus_matcher_type.go create mode 100644 internal/resources/fleetmanagement/prometheus_matcher_type_test.go create mode 100644 internal/resources/fleetmanagement/prometheus_matcher_value.go create mode 100644 internal/resources/fleetmanagement/prometheus_matcher_value_test.go create mode 100644 internal/resources/fleetmanagement/resource_collector.go create mode 100644 internal/resources/fleetmanagement/resource_collector_test.go create mode 100644 internal/resources/fleetmanagement/resource_pipeline.go create mode 100644 internal/resources/fleetmanagement/resource_pipeline_test.go create mode 100644 internal/resources/fleetmanagement/resources.go create mode 100644 internal/resources/fleetmanagement/utils.go create mode 100644 internal/resources/fleetmanagement/utils_test.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d2072d62e..9f785d84a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,6 +4,7 @@ /internal/resources/cloud/* @grafana/platform-monitoring @grafana/grafana-com-maintainers /internal/resources/cloudprovider/* @grafana/platform-monitoring @grafana/middleware-apps /internal/resources/connections/* @grafana/platform-monitoring @grafana/middleware-apps +/internal/resources/fleetmanagement/* @grafana/platform-monitoring @grafana/fleet-management-backend /internal/resources/machinelearning/* @grafana/platform-monitoring @grafana/machine-learning /internal/resources/oncall/* @grafana/platform-monitoring @grafana/grafana-irm-backend /internal/resources/slo/* @grafana/platform-monitoring @grafana/slo-squad diff --git a/.github/workflows/acc-tests.yml b/.github/workflows/acc-tests.yml index 5e4ef2a7e..cf80ada3c 100644 --- a/.github/workflows/acc-tests.yml +++ b/.github/workflows/acc-tests.yml @@ -48,6 +48,8 @@ jobs: GRAFANA_CLOUD_PROVIDER_ACCESS_TOKEN=cloudprovider-tests:access-token GRAFANA_CLOUD_PROVIDER_AWS_ROLE_ARN=cloudprovider-tests:aws-role-arn GRAFANA_CLOUD_PROVIDER_TEST_STACK_ID=cloudprovider-tests:test-stack-id + GRAFANA_FLEET_MANAGEMENT_AUTH=cloud-instance-tests:fleet-management-auth + GRAFANA_FLEET_MANAGEMENT_URL=cloud-instance-tests:fleet-management-url - uses: iFaxity/wait-on-action@a7d13170ec542bdca4ef8ac4b15e9c6aa00a6866 # v1.2.1 with: resource: ${{ env.GRAFANA_URL }} diff --git a/docs/data-sources/cloud_stack.md b/docs/data-sources/cloud_stack.md index 91592e2fb..be7a5284f 100644 --- a/docs/data-sources/cloud_stack.md +++ b/docs/data-sources/cloud_stack.md @@ -41,6 +41,10 @@ available at “https://.grafana.net". - `alertmanager_user_id` (Number) User ID of the Alertmanager instance configured for this stack. - `cluster_slug` (String) Slug of the cluster where this stack resides. - `description` (String) Description of stack. +- `fleet_management_name` (String) Name of the Fleet Management instance configured for this stack. +- `fleet_management_status` (String) Status of the Fleet Management instance configured for this stack. +- `fleet_management_url` (String) Base URL of the Fleet Management instance configured for this stack. +- `fleet_management_user_id` (Number) User ID of the Fleet Management instance configured for this stack. - `graphite_name` (String) - `graphite_status` (String) - `graphite_url` (String) diff --git a/docs/index.md b/docs/index.md index 53415500f..8154f9d78 100644 --- a/docs/index.md +++ b/docs/index.md @@ -255,6 +255,8 @@ resource "grafana_oncall_escalation" "example_notify_step" { - `cloud_provider_url` (String) A Grafana Cloud Provider backend address. May alternatively be set via the `GRAFANA_CLOUD_PROVIDER_URL` environment variable. - `connections_api_access_token` (String, Sensitive) A Grafana Connections API access token. May alternatively be set via the `GRAFANA_CONNECTIONS_API_ACCESS_TOKEN` environment variable. - `connections_api_url` (String) A Grafana Connections API address. May alternatively be set via the `GRAFANA_CONNECTIONS_API_URL` environment variable. +- `fleet_management_auth` (String, Sensitive) A Grafana Fleet Management basic auth in the `username:password` format. May alternatively be set via the `GRAFANA_FLEET_MANAGEMENT_AUTH` environment variable. +- `fleet_management_url` (String) A Grafana Fleet Management API address. May alternatively be set via the `GRAFANA_FLEET_MANAGEMENT_URL` environment variable. - `http_headers` (Map of String, Sensitive) Optional. HTTP headers mapping keys to values used for accessing the Grafana and Grafana Cloud APIs. May alternatively be set via the `GRAFANA_HTTP_HEADERS` environment variable in JSON format. - `insecure_skip_verify` (Boolean) Skip TLS certificate verification. May alternatively be set via the `GRAFANA_INSECURE_SKIP_VERIFY` environment variable. - `oncall_access_token` (String, Sensitive) A Grafana OnCall access token. May alternatively be set via the `GRAFANA_ONCALL_ACCESS_TOKEN` environment variable. @@ -450,6 +452,91 @@ provider "grafana" { } ``` +### Managing Grafana Fleet Management + +```terraform +// Variables +variable "cloud_access_policy_token" { + type = string + description = "Cloud access policy token with scopes: accesspolicies:read|write|delete, stacks:read" +} + +variable "stack_slug" { + type = string + description = "Subdomain that the Grafana Cloud instance is available at: https://.grafana.net" +} + +// Step 1: Retrieve stack details +provider "grafana" { + alias = "cloud" + + cloud_access_policy_token = var.cloud_access_policy_token +} + +data "grafana_cloud_stack" "stack" { + provider = grafana.cloud + + slug = var.stack_slug +} + +// Step 2: Create an access policy and token for Fleet Management +resource "grafana_cloud_access_policy" "policy" { + provider = grafana.cloud + + name = "fleet-management-policy" + region = data.grafana_cloud_stack.stack.region_slug + + scopes = [ + "fleet-management:read", + "fleet-management:write" + ] + + realm { + type = "stack" + identifier = data.grafana_cloud_stack.stack.id + } +} + +resource "grafana_cloud_access_policy_token" "token" { + provider = grafana.cloud + + name = "fleet-management-token" + region = grafana_cloud_access_policy.policy.region + access_policy_id = grafana_cloud_access_policy.policy.policy_id +} + +// Step 3: Interact with Fleet Management +provider "grafana" { + alias = "fm" + + fleet_management_auth = "${data.grafana_cloud_stack.stack.fleet_management_user_id}:${grafana_cloud_access_policy_token.token.token}" + fleet_management_url = data.grafana_cloud_stack.stack.fleet_management_url +} + +resource "grafana_fleet_management_collector" "collector" { + provider = grafana.fm + + id = "my_collector" + remote_attributes = { + "env" = "PROD", + "owner" = "TEAM-A" + } + enabled = true +} + +resource "grafana_fleet_management_pipeline" "pipeline" { + provider = grafana.fm + + name = "my_pipeline" + contents = file("config.alloy") + matchers = [ + "collector.os=\"linux\"", + "env=\"PROD\"" + ] + enabled = true +} +``` + ## Authentication One, or many, of the following authentication settings must be set. Each authentication setting allows a subset of resources to be used @@ -483,3 +570,10 @@ To create one, follow the instructions in the [obtaining cloud provider access t An access policy token created on the [Grafana Cloud Portal](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/using-an-access-policy-token/) to manage connections resources, such as Metrics Endpoint jobs. For guidance on creating one, see section [obtaining connections access token](#obtaining-connections-access-token). + +### `fleet_management_auth` + +[Grafana Fleet Management](https://grafana.com/docs/grafana-cloud/send-data/fleet-management/api-reference/) +uses basic auth to allow access to the API, where the username is the Fleet Management instance ID and the +password is the API token. You can access the instance ID and request a new Fleet Management API token on the +Connections -> Collector -> Fleet Management page, in the API tab. diff --git a/docs/resources/cloud_stack.md b/docs/resources/cloud_stack.md index 718e5e999..5e878645c 100644 --- a/docs/resources/cloud_stack.md +++ b/docs/resources/cloud_stack.md @@ -53,6 +53,10 @@ resource "grafana_cloud_stack" "test" { - `alertmanager_url` (String) Base URL of the Alertmanager instance configured for this stack. - `alertmanager_user_id` (Number) User ID of the Alertmanager instance configured for this stack. - `cluster_slug` (String) Slug of the cluster where this stack resides. +- `fleet_management_name` (String) Name of the Fleet Management instance configured for this stack. +- `fleet_management_status` (String) Status of the Fleet Management instance configured for this stack. +- `fleet_management_url` (String) Base URL of the Fleet Management instance configured for this stack. +- `fleet_management_user_id` (Number) User ID of the Fleet Management instance configured for this stack. - `graphite_name` (String) - `graphite_status` (String) - `graphite_url` (String) diff --git a/docs/resources/fleet_management_collector.md b/docs/resources/fleet_management_collector.md new file mode 100644 index 000000000..d6b9cdde2 --- /dev/null +++ b/docs/resources/fleet_management_collector.md @@ -0,0 +1,58 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "grafana_fleet_management_collector Resource - terraform-provider-grafana" +subcategory: "Fleet Management" +description: |- + Manages Grafana Fleet Management collectors. + Official documentation https://grafana.com/docs/grafana-cloud/send-data/fleet-management/API documentation https://grafana.com/docs/grafana-cloud/send-data/fleet-management/api-reference/collector-api/ + Note: Fleet Management is in public preview https://grafana.com/docs/release-life-cycle/#public-preview and this resource is experimental. Grafana Labs offers limited support, and breaking changes might occur. + Required access policy scopes: + fleet-management:readfleet-management:write +--- + +# grafana_fleet_management_collector (Resource) + +Manages Grafana Fleet Management collectors. + +* [Official documentation](https://grafana.com/docs/grafana-cloud/send-data/fleet-management/) +* [API documentation](https://grafana.com/docs/grafana-cloud/send-data/fleet-management/api-reference/collector-api/) + +**Note:** Fleet Management is in [public preview](https://grafana.com/docs/release-life-cycle/#public-preview) and this resource is experimental. Grafana Labs offers limited support, and breaking changes might occur. + +Required access policy scopes: + +* fleet-management:read +* fleet-management:write + +## Example Usage + +```terraform +resource "grafana_fleet_management_collector" "test" { + id = "my_collector" + remote_attributes = { + "env" = "PROD", + "owner" = "TEAM-A" + } + enabled = true +} +``` + + +## Schema + +### Required + +- `id` (String) ID of the collector + +### Optional + +- `enabled` (Boolean) Whether the collector is enabled or not +- `remote_attributes` (Map of String) Remote attributes for the collector + +## Import + +Import is supported using the following syntax: + +```shell +terraform import grafana_fleet_management_collector.name "{{ id }}" +``` diff --git a/docs/resources/fleet_management_pipeline.md b/docs/resources/fleet_management_pipeline.md new file mode 100644 index 000000000..677819ea8 --- /dev/null +++ b/docs/resources/fleet_management_pipeline.md @@ -0,0 +1,64 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "grafana_fleet_management_pipeline Resource - terraform-provider-grafana" +subcategory: "Fleet Management" +description: |- + Manages Grafana Fleet Management pipelines. + Official documentation https://grafana.com/docs/grafana-cloud/send-data/fleet-management/API documentation https://grafana.com/docs/grafana-cloud/send-data/fleet-management/api-reference/pipeline-api/ + Note: Fleet Management is in public preview https://grafana.com/docs/release-life-cycle/#public-preview and this resource is experimental. Grafana Labs offers limited support, and breaking changes might occur. + Required access policy scopes: + fleet-management:readfleet-management:write +--- + +# grafana_fleet_management_pipeline (Resource) + +Manages Grafana Fleet Management pipelines. + +* [Official documentation](https://grafana.com/docs/grafana-cloud/send-data/fleet-management/) +* [API documentation](https://grafana.com/docs/grafana-cloud/send-data/fleet-management/api-reference/pipeline-api/) + +**Note:** Fleet Management is in [public preview](https://grafana.com/docs/release-life-cycle/#public-preview) and this resource is experimental. Grafana Labs offers limited support, and breaking changes might occur. + +Required access policy scopes: + +* fleet-management:read +* fleet-management:write + +## Example Usage + +```terraform +resource "grafana_fleet_management_pipeline" "test" { + name = "my_pipeline" + contents = file("config.alloy") + matchers = [ + "collector.os=~\".*\"", + "env=\"PROD\"" + ] + enabled = true +} +``` + + +## Schema + +### Required + +- `contents` (String) Configuration contents of the pipeline to be used by collectors +- `name` (String) Name of the pipeline which is the unique identifier for the pipeline + +### Optional + +- `enabled` (Boolean) Whether the pipeline is enabled for collectors +- `matchers` (List of String) Used to match against collectors and assign pipelines to them; follows the syntax of Prometheus Alertmanager matchers + +### Read-Only + +- `id` (String) Server-assigned ID of the pipeline + +## Import + +Import is supported using the following syntax: + +```shell +terraform import grafana_fleet_management_pipeline.name "{{ name }}" +``` diff --git a/examples/provider/provider-fleet-management.tf b/examples/provider/provider-fleet-management.tf new file mode 100644 index 000000000..702a56e3e --- /dev/null +++ b/examples/provider/provider-fleet-management.tf @@ -0,0 +1,80 @@ +// Variables +variable "cloud_access_policy_token" { + type = string + description = "Cloud access policy token with scopes: accesspolicies:read|write|delete, stacks:read" +} + +variable "stack_slug" { + type = string + description = "Subdomain that the Grafana Cloud instance is available at: https://.grafana.net" +} + +// Step 1: Retrieve stack details +provider "grafana" { + alias = "cloud" + + cloud_access_policy_token = var.cloud_access_policy_token +} + +data "grafana_cloud_stack" "stack" { + provider = grafana.cloud + + slug = var.stack_slug +} + +// Step 2: Create an access policy and token for Fleet Management +resource "grafana_cloud_access_policy" "policy" { + provider = grafana.cloud + + name = "fleet-management-policy" + region = data.grafana_cloud_stack.stack.region_slug + + scopes = [ + "fleet-management:read", + "fleet-management:write" + ] + + realm { + type = "stack" + identifier = data.grafana_cloud_stack.stack.id + } +} + +resource "grafana_cloud_access_policy_token" "token" { + provider = grafana.cloud + + name = "fleet-management-token" + region = grafana_cloud_access_policy.policy.region + access_policy_id = grafana_cloud_access_policy.policy.policy_id +} + +// Step 3: Interact with Fleet Management +provider "grafana" { + alias = "fm" + + fleet_management_auth = "${data.grafana_cloud_stack.stack.fleet_management_user_id}:${grafana_cloud_access_policy_token.token.token}" + fleet_management_url = data.grafana_cloud_stack.stack.fleet_management_url +} + +resource "grafana_fleet_management_collector" "collector" { + provider = grafana.fm + + id = "my_collector" + remote_attributes = { + "env" = "PROD", + "owner" = "TEAM-A" + } + enabled = true +} + +resource "grafana_fleet_management_pipeline" "pipeline" { + provider = grafana.fm + + name = "my_pipeline" + contents = file("config.alloy") + matchers = [ + "collector.os=\"linux\"", + "env=\"PROD\"" + ] + enabled = true +} diff --git a/examples/resources/grafana_fleet_management_collector/import.sh b/examples/resources/grafana_fleet_management_collector/import.sh new file mode 100644 index 000000000..7e456347d --- /dev/null +++ b/examples/resources/grafana_fleet_management_collector/import.sh @@ -0,0 +1 @@ +terraform import grafana_fleet_management_collector.name "{{ id }}" diff --git a/examples/resources/grafana_fleet_management_collector/resource.tf b/examples/resources/grafana_fleet_management_collector/resource.tf new file mode 100644 index 000000000..1b3646ccb --- /dev/null +++ b/examples/resources/grafana_fleet_management_collector/resource.tf @@ -0,0 +1,8 @@ +resource "grafana_fleet_management_collector" "test" { + id = "my_collector" + remote_attributes = { + "env" = "PROD", + "owner" = "TEAM-A" + } + enabled = true +} diff --git a/examples/resources/grafana_fleet_management_pipeline/import.sh b/examples/resources/grafana_fleet_management_pipeline/import.sh new file mode 100644 index 000000000..b7240054a --- /dev/null +++ b/examples/resources/grafana_fleet_management_pipeline/import.sh @@ -0,0 +1 @@ +terraform import grafana_fleet_management_pipeline.name "{{ name }}" diff --git a/examples/resources/grafana_fleet_management_pipeline/resource.tf b/examples/resources/grafana_fleet_management_pipeline/resource.tf new file mode 100644 index 000000000..b1eadc4d8 --- /dev/null +++ b/examples/resources/grafana_fleet_management_pipeline/resource.tf @@ -0,0 +1,9 @@ +resource "grafana_fleet_management_pipeline" "test" { + name = "my_pipeline" + contents = file("config.alloy") + matchers = [ + "collector.os=~\".*\"", + "env=\"PROD\"" + ] + enabled = true +} diff --git a/go.mod b/go.mod index d6c5b444c..6a71f7c15 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,17 @@ go 1.23 toolchain go1.23.2 require ( + connectrpc.com/connect v1.16.1 github.com/Masterminds/semver/v3 v3.3.1 github.com/fatih/color v1.18.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.19 // main branch + github.com/grafana/fleet-management-api v0.1.2 github.com/grafana/grafana-com-public-clients/go/gcom v0.0.0-20240807172819-ac10800522a3 github.com/grafana/grafana-openapi-client-go v0.0.0-20241113095943-9cb2bbfeb8a3 github.com/grafana/machine-learning-go-client v0.8.2 + github.com/grafana/river v0.3.0 github.com/grafana/slo-openapi-client/go/slo v0.0.0-20240807172758-1b7d00838fc7 github.com/grafana/synthetic-monitoring-agent v0.30.2 github.com/grafana/synthetic-monitoring-api-go-client v0.9.2 @@ -30,6 +33,7 @@ require ( github.com/hashicorp/terraform-plugin-go v0.25.0 github.com/hashicorp/terraform-plugin-mux v0.17.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.35.0 + github.com/prometheus/alertmanager v0.27.0 github.com/prometheus/common v0.61.0 github.com/stretchr/testify v1.10.0 github.com/tmccombs/hcl2json v0.6.5 diff --git a/go.sum b/go.sum index 9c3a04538..efaff1626 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +connectrpc.com/connect v1.16.1 h1:rOdrK/RTI/7TVnn3JsVxt3n028MlTRwmK5Q4heSpjis= +connectrpc.com/connect v1.16.1/go.mod h1:XpZAduBQUySsb4/KO5JffORVkDI4B6/EYPi7N8xpNZw= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -139,6 +141,8 @@ 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.19 h1:09FxpE9CXR7YpjDvq/ionA6UlmV4G1r48HFR4noG9Dw= github.com/grafana/amixr-api-go-client v0.0.19/go.mod h1:u53FF0WSBMx6XvZK58fply91KBl6X+OtIu0aJC07amY= +github.com/grafana/fleet-management-api v0.1.2 h1:ADBRtK7zACm+Qh/0rA0JtxnYrmCldmdbzy+J/4v9mDE= +github.com/grafana/fleet-management-api v0.1.2/go.mod h1:6iJjhjWhHZ8iwkyuDXFVTuay57JILnE3kaOPk8Nzorw= 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-20241113095943-9cb2bbfeb8a3 h1:poKxGlUaEYVp2DMofC/I2GHw/vvtHAZ20c48I8rFB6M= @@ -151,6 +155,8 @@ github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= 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/river v0.3.0 h1:6TsaR/vkkcppUM9I0muGbPIUedCtpPu6OWreE5+CE6g= +github.com/grafana/river v0.3.0/go.mod h1:icSidCSHYXJUYy6TjGAi/D+X7FsP7Gc7cxvBUIwYMmY= 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.30.2 h1:wredFHXxXXO5Mg/ucEe3+Yud1OSL51LwMneC28nAGbg= @@ -310,6 +316,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/alertmanager v0.27.0 h1:V6nTa2J5V4s8TG4C4HtrBP/WNSebCCTYGGv4qecA/+I= +github.com/prometheus/alertmanager v0.27.0/go.mod h1:8Ia/R3urPmbzJ8OsdvmZvIprDwvwmYCmUbwBL+jlPOE= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= diff --git a/internal/common/client.go b/internal/common/client.go index 2c2342dbd..4a4ede7b8 100644 --- a/internal/common/client.go +++ b/internal/common/client.go @@ -13,6 +13,8 @@ import ( "github.com/grafana/slo-openapi-client/go/slo" SMAPI "github.com/grafana/synthetic-monitoring-api-go-client" "github.com/grafana/terraform-provider-grafana/v3/internal/common/cloudproviderapi" + "github.com/grafana/terraform-provider-grafana/v3/internal/common/fleetmanagementapi" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -25,13 +27,14 @@ type Client struct { GrafanaAPI *goapi.GrafanaHTTPAPI GrafanaAPIConfig *goapi.TransportConfig - GrafanaCloudAPI *gcom.APIClient - SMAPI *SMAPI.Client - MLAPI *mlapi.Client - OnCallClient *onCallAPI.Client - SLOClient *slo.APIClient - CloudProviderAPI *cloudproviderapi.Client - ConnectionsAPIClient *connectionsapi.Client + GrafanaCloudAPI *gcom.APIClient + SMAPI *SMAPI.Client + MLAPI *mlapi.Client + OnCallClient *onCallAPI.Client + SLOClient *slo.APIClient + CloudProviderAPI *cloudproviderapi.Client + ConnectionsAPIClient *connectionsapi.Client + FleetManagementClient *fleetmanagementapi.Client alertingMutex sync.Mutex } diff --git a/internal/common/fleetmanagementapi/client.go b/internal/common/fleetmanagementapi/client.go new file mode 100644 index 000000000..3d1b976b8 --- /dev/null +++ b/internal/common/fleetmanagementapi/client.go @@ -0,0 +1,68 @@ +package fleetmanagementapi + +import ( + "encoding/base64" + "net/http" + + "github.com/grafana/fleet-management-api/api/gen/proto/go/collector/v1/collectorv1connect" + "github.com/grafana/fleet-management-api/api/gen/proto/go/pipeline/v1/pipelinev1connect" +) + +type Client struct { + CollectorServiceClient collectorv1connect.CollectorServiceClient + PipelineServiceClient pipelinev1connect.PipelineServiceClient +} + +func NewClient(auth string, url string, client *http.Client, userAgent string, headers map[string]string) *Client { + httpClient := newHTTPClient(client, auth, userAgent, headers) + + collectorClient := collectorv1connect.NewCollectorServiceClient(httpClient, url) + pipelineClient := pipelinev1connect.NewPipelineServiceClient(httpClient, url) + + return &Client{ + CollectorServiceClient: collectorClient, + PipelineServiceClient: pipelineClient, + } +} + +func newHTTPClient(client *http.Client, auth string, userAgent string, headers map[string]string) *http.Client { + baseTransport := client.Transport + if baseTransport == nil { + baseTransport = http.DefaultTransport + } + + return &http.Client{ + Transport: &transport{ + auth: auth, + userAgent: userAgent, + headers: headers, + baseTransport: baseTransport, + }, + CheckRedirect: client.CheckRedirect, + Jar: client.Jar, + Timeout: client.Timeout, + } +} + +type transport struct { + auth string + userAgent string + headers map[string]string + baseTransport http.RoundTripper +} + +func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + encoded := base64.StdEncoding.EncodeToString([]byte(t.auth)) + clone.Header.Set("Authorization", "Basic "+encoded) + + if t.userAgent != "" { + clone.Header.Set("User-Agent", t.userAgent) + } + + for key, value := range t.headers { + clone.Header.Set(key, value) + } + + return t.baseTransport.RoundTrip(clone) +} diff --git a/internal/common/fleetmanagementapi/client_test.go b/internal/common/fleetmanagementapi/client_test.go new file mode 100644 index 000000000..c6ed2e4bb --- /dev/null +++ b/internal/common/fleetmanagementapi/client_test.go @@ -0,0 +1,120 @@ +package fleetmanagementapi + +import ( + "crypto/tls" + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_NewClient(t *testing.T) { + t.Run("successfully creates a new client", func(t *testing.T) { + actualClient := NewClient( + "test:auth", + "https://test.url", + &http.Client{}, + "test-user-agent", + map[string]string{"key": "value"}, + ) + + assert.NotNil(t, actualClient) + assert.NotNil(t, actualClient.CollectorServiceClient) + assert.NotNil(t, actualClient.PipelineServiceClient) + }) +} + +func Test_newHTTPClient(t *testing.T) { + t.Run("creates a new client with default transport", func(t *testing.T) { + client := &http.Client{} + auth := "test:auth" + userAgent := "test-user-agent" + headers := map[string]string{"key": "value"} + + actualClient := newHTTPClient(client, auth, userAgent, headers) + + expectedClient := &http.Client{ + Transport: &transport{ + auth: auth, + headers: headers, + userAgent: userAgent, + baseTransport: http.DefaultTransport, + }, + CheckRedirect: client.CheckRedirect, + Jar: client.Jar, + Timeout: client.Timeout, + } + + assert.NotNil(t, actualClient) + assert.Equal(t, expectedClient, actualClient) + assert.Same(t, http.DefaultTransport, actualClient.Transport.(*transport).baseTransport) + }) + + t.Run("uses existing transport if provided", func(t *testing.T) { + existingTransport := &http.Transport{ + DisableCompression: true, + } + client := &http.Client{Transport: existingTransport} + auth := "test:auth" + userAgent := "test-user-agent" + headers := map[string]string{"key": "value"} + + actualClient := newHTTPClient(client, auth, userAgent, headers) + + expectedClient := &http.Client{ + Transport: &transport{ + auth: auth, + userAgent: userAgent, + headers: headers, + baseTransport: existingTransport, + }, + CheckRedirect: client.CheckRedirect, + Jar: client.Jar, + Timeout: client.Timeout, + } + + assert.NotNil(t, actualClient) + assert.Equal(t, expectedClient, actualClient) + assert.Same(t, existingTransport, actualClient.Transport.(*transport).baseTransport) + }) +} + +func Test_transport_RoundTrip(t *testing.T) { + t.Run("sets headers correctly", func(t *testing.T) { + svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + encoded := base64.StdEncoding.EncodeToString([]byte("test:auth")) + assert.Equal(t, "Basic "+encoded, r.Header.Get("Authorization")) + assert.Equal(t, "test-user-agent", r.Header.Get("User-Agent")) + assert.Equal(t, "value1", r.Header.Get("key1")) + assert.Equal(t, "value2", r.Header.Get("key2")) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(svr.Close) + + client := &http.Client{ + Transport: &transport{ + auth: "test:auth", + userAgent: "test-user-agent", + headers: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + baseTransport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // #nosec G402 + }, + }, + }, + } + + req, err := http.NewRequest(http.MethodGet, svr.URL, nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) +} diff --git a/internal/common/resource.go b/internal/common/resource.go index d2d532df4..2abc3289f 100644 --- a/internal/common/resource.go +++ b/internal/common/resource.go @@ -23,6 +23,7 @@ var ( CategorySyntheticMonitoring ResourceCategory = "Synthetic Monitoring" CategoryCloudProvider ResourceCategory = "Cloud Provider" CategoryConnections ResourceCategory = "Connections" + CategoryFleetManagement ResourceCategory = "Fleet Management" ) type ResourceCommon struct { diff --git a/internal/resources/cloud/data_source_cloud_stack_test.go b/internal/resources/cloud/data_source_cloud_stack_test.go index d2c785186..e8694341f 100644 --- a/internal/resources/cloud/data_source_cloud_stack_test.go +++ b/internal/resources/cloud/data_source_cloud_stack_test.go @@ -36,6 +36,10 @@ func TestAccDataSourceStack_Basic(t *testing.T) { resource.TestCheckResourceAttrSet("data.grafana_cloud_stack.test", "prometheus_user_id"), resource.TestCheckResourceAttrSet("data.grafana_cloud_stack.test", "alertmanager_user_id"), resource.TestCheckResourceAttrSet("data.grafana_cloud_stack.test", "cluster_slug"), + resource.TestCheckResourceAttrSet("data.grafana_cloud_stack.test", "fleet_management_user_id"), + resource.TestCheckResourceAttrSet("data.grafana_cloud_stack.test", "fleet_management_name"), + resource.TestCheckResourceAttrSet("data.grafana_cloud_stack.test", "fleet_management_url"), + resource.TestCheckResourceAttrSet("data.grafana_cloud_stack.test", "fleet_management_status"), ), }, }, diff --git a/internal/resources/cloud/resource_cloud_stack.go b/internal/resources/cloud/resource_cloud_stack.go index aaa85e094..f31c232a4 100644 --- a/internal/resources/cloud/resource_cloud_stack.go +++ b/internal/resources/cloud/resource_cloud_stack.go @@ -177,6 +177,12 @@ Required access policy scopes: "graphite_url": common.ComputedString(), "graphite_status": common.ComputedString(), + // Fleet Management + "fleet_management_user_id": common.ComputedIntWithDescription("User ID of the Fleet Management instance configured for this stack."), + "fleet_management_name": common.ComputedStringWithDescription("Name of the Fleet Management instance configured for this stack."), + "fleet_management_url": common.ComputedStringWithDescription("Base URL of the Fleet Management instance configured for this stack."), + "fleet_management_status": common.ComputedStringWithDescription("Status of the Fleet Management instance configured for this stack."), + // Connections "influx_url": common.ComputedStringWithDescription("Base URL of the InfluxDB instance configured for this stack. The username is the same as the metrics' (`prometheus_user_id` attribute of this resource). See https://grafana.com/docs/grafana-cloud/send-data/metrics/metrics-influxdb/push-from-telegraf/ for docs on how to use this."), "otlp_url": common.ComputedStringWithDescription("Base URL of the OTLP instance configured for this stack. The username is the stack's ID (`id` attribute of this resource). See https://grafana.com/docs/grafana-cloud/send-data/otlp/send-data-otlp/ for docs on how to use this."), @@ -418,6 +424,11 @@ func flattenStack(d *schema.ResourceData, stack *gcom.FormattedApiInstance, conn d.Set("graphite_url", stack.HmInstanceGraphiteUrl) d.Set("graphite_status", stack.HmInstanceGraphiteStatus) + d.Set("fleet_management_user_id", stack.AgentManagementInstanceId) + d.Set("fleet_management_name", stack.AgentManagementInstanceName) + d.Set("fleet_management_url", stack.AgentManagementInstanceUrl) + d.Set("fleet_management_status", stack.AgentManagementInstanceStatus) + if otlpURL := connections.OtlpHttpUrl; otlpURL.IsSet() { d.Set("otlp_url", otlpURL.Get()) } diff --git a/internal/resources/cloud/resource_cloud_stack_test.go b/internal/resources/cloud/resource_cloud_stack_test.go index 96ddc75a1..7e6988458 100644 --- a/internal/resources/cloud/resource_cloud_stack_test.go +++ b/internal/resources/cloud/resource_cloud_stack_test.go @@ -50,6 +50,10 @@ func TestResourceStack_Basic(t *testing.T) { resource.TestCheckResourceAttrSet("grafana_cloud_stack.test", "profiles_name"), resource.TestCheckResourceAttrSet("grafana_cloud_stack.test", "profiles_url"), resource.TestCheckResourceAttrSet("grafana_cloud_stack.test", "profiles_status"), + resource.TestCheckResourceAttrSet("grafana_cloud_stack.test", "fleet_management_user_id"), + resource.TestCheckResourceAttrSet("grafana_cloud_stack.test", "fleet_management_name"), + resource.TestCheckResourceAttrSet("grafana_cloud_stack.test", "fleet_management_url"), + resource.TestCheckResourceAttrSet("grafana_cloud_stack.test", "fleet_management_status"), resource.TestCheckResourceAttrSet("grafana_cloud_stack.test", "otlp_url"), resource.TestCheckResourceAttrSet("grafana_cloud_stack.test", "influx_url"), resource.TestCheckResourceAttrSet("grafana_cloud_stack.test", "cluster_slug"), diff --git a/internal/resources/examples_test.go b/internal/resources/examples_test.go index 82964248e..d37636187 100644 --- a/internal/resources/examples_test.go +++ b/internal/resources/examples_test.go @@ -99,6 +99,13 @@ func TestAccExamples(t *testing.T) { t.Skip() }, }, + { + category: "Fleet Management", + testCheck: func(t *testing.T, filename string) { + t.Skip() + testutils.CheckCloudInstanceTestsEnabled(t) + }, + }, } { // Get all the filenames for all resource examples for this category filenames := []string{} diff --git a/internal/resources/fleetmanagement/alloy_config_type.go b/internal/resources/fleetmanagement/alloy_config_type.go new file mode 100644 index 000000000..bcb0be6ea --- /dev/null +++ b/internal/resources/fleetmanagement/alloy_config_type.go @@ -0,0 +1,65 @@ +package fleetmanagement + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +var ( + _ basetypes.StringTypable = alloyConfigType{} +) + +var ( + AlloyConfigType = alloyConfigType{} +) + +type alloyConfigType struct { + basetypes.StringType +} + +func (t alloyConfigType) Equal(o attr.Type) bool { + other, ok := o.(alloyConfigType) + if !ok { + return false + } + + return t.StringType.Equal(other.StringType) +} + +func (t alloyConfigType) String() string { + return "AlloyConfigType" +} + +func (t alloyConfigType) ValueFromString(ctx context.Context, in basetypes.StringValue) (basetypes.StringValuable, diag.Diagnostics) { + return AlloyConfigValue{ + StringValue: in, + }, nil +} + +func (t alloyConfigType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + attrValue, err := t.StringType.ValueFromTerraform(ctx, in) + if err != nil { + return nil, err + } + + stringValue, ok := attrValue.(basetypes.StringValue) + if !ok { + return nil, fmt.Errorf("unexpected value type of %T", attrValue) + } + + stringValuable, diags := t.ValueFromString(ctx, stringValue) + if diags.HasError() { + return nil, fmt.Errorf("unexpected error converting StringValue to StringValuable: %v", diags) + } + + return stringValuable, nil +} + +func (t alloyConfigType) ValueType(ctx context.Context) attr.Value { + return AlloyConfigValue{} +} diff --git a/internal/resources/fleetmanagement/alloy_config_type_test.go b/internal/resources/fleetmanagement/alloy_config_type_test.go new file mode 100644 index 000000000..6a7664b47 --- /dev/null +++ b/internal/resources/fleetmanagement/alloy_config_type_test.go @@ -0,0 +1,49 @@ +package fleetmanagement + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/stretchr/testify/assert" +) + +func TestAlloyConfigType_Equal(t *testing.T) { + type1 := AlloyConfigType + type2 := AlloyConfigType + type3 := types.StringType + + assert.True(t, type1.Equal(type2)) + assert.False(t, type1.Equal(type3)) +} + +func TestAlloyConfigType_String(t *testing.T) { + assert.Equal(t, "AlloyConfigType", AlloyConfigType.String()) +} + +func TestAlloyConfigType_ValueFromString(t *testing.T) { + ctx := context.Background() + stringValue := types.StringValue("test") + + alloyCfgValue, diags := AlloyConfigType.ValueFromString(ctx, stringValue) + assert.False(t, diags.HasError()) + expected := AlloyConfigValue{StringValue: stringValue} + assert.Equal(t, expected, alloyCfgValue) +} + +func TestAlloyConfigType_ValueFromTerraform(t *testing.T) { + ctx := context.Background() + tfValue := tftypes.NewValue(tftypes.String, "test") + + alloyCfgValue, err := AlloyConfigType.ValueFromTerraform(ctx, tfValue) + assert.NoError(t, err) + expected := AlloyConfigValue{StringValue: types.StringValue("test")} + assert.Equal(t, expected, alloyCfgValue) +} + +func TestAlloyConfigType_ValueType(t *testing.T) { + ctx := context.Background() + alloyCfgValue := AlloyConfigType.ValueType(ctx) + assert.IsType(t, AlloyConfigValue{}, alloyCfgValue) +} diff --git a/internal/resources/fleetmanagement/alloy_config_value.go b/internal/resources/fleetmanagement/alloy_config_value.go new file mode 100644 index 000000000..7639a8932 --- /dev/null +++ b/internal/resources/fleetmanagement/alloy_config_value.go @@ -0,0 +1,112 @@ +package fleetmanagement + +import ( + "bytes" + "context" + "fmt" + + "github.com/grafana/river/parser" + "github.com/grafana/river/printer" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/attr/xattr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +var ( + _ basetypes.StringValuable = AlloyConfigValue{} + _ basetypes.StringValuableWithSemanticEquals = AlloyConfigValue{} + _ xattr.ValidateableAttribute = AlloyConfigValue{} +) + +type AlloyConfigValue struct { + basetypes.StringValue +} + +func NewAlloyConfigValue(value string) AlloyConfigValue { + return AlloyConfigValue{ + StringValue: basetypes.NewStringValue(value), + } +} + +func (v AlloyConfigValue) Equal(o attr.Value) bool { + other, ok := o.(AlloyConfigValue) + if !ok { + return false + } + + return v.StringValue.Equal(other.StringValue) +} + +func (v AlloyConfigValue) Type(ctx context.Context) attr.Type { + return AlloyConfigType +} + +func (v AlloyConfigValue) StringSemanticEquals(ctx context.Context, newValuable basetypes.StringValuable) (bool, diag.Diagnostics) { + var diags diag.Diagnostics + + newValue, ok := newValuable.(AlloyConfigValue) + if !ok { + diags.AddError( + "Semantic Equality Check Error", + "An unexpected value type was received while performing semantic equality checks. "+ + "Please report this to the provider developers.\n\n"+ + "Expected Value Type: "+fmt.Sprintf("%T", v)+"\n"+ + "Got Value Type: "+fmt.Sprintf("%T", newValuable), + ) + + return false, diags + } + + // Values are already validated at this point, ignoring errors + result, _ := riverEqual(v.ValueString(), newValue.ValueString()) + return result, diags +} + +func (v AlloyConfigValue) ValidateAttribute(ctx context.Context, req xattr.ValidateAttributeRequest, resp *xattr.ValidateAttributeResponse) { + if v.IsNull() || v.IsUnknown() { + return + } + + _, err := parseRiver(v.ValueString()) + if err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Alloy configuration", + "A string value was provided that is not valid Alloy configuration format.\n\n"+ + "Path: "+req.Path.String()+"\n"+ + "Given Value: "+v.ValueString()+"\n"+ + "Error: "+err.Error(), + ) + + return + } +} + +func riverEqual(contents1 string, contents2 string) (bool, error) { + parsed1, err := parseRiver(contents1) + if err != nil { + return false, err + } + + parsed2, err := parseRiver(contents2) + if err != nil { + return false, err + } + + return parsed1 == parsed2, nil +} + +func parseRiver(contents string) (string, error) { + file, err := parser.ParseFile("", []byte(contents)) + if err != nil { + return "", err + } + + var buf bytes.Buffer + if err := printer.Fprint(&buf, file); err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/internal/resources/fleetmanagement/alloy_config_value_test.go b/internal/resources/fleetmanagement/alloy_config_value_test.go new file mode 100644 index 000000000..fc673992a --- /dev/null +++ b/internal/resources/fleetmanagement/alloy_config_value_test.go @@ -0,0 +1,101 @@ +package fleetmanagement + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr/xattr" + "github.com/stretchr/testify/assert" +) + +func TestNewAlloyConfigValue(t *testing.T) { + rawValue := "logging {}" + value := NewAlloyConfigValue(rawValue) + assert.Equal(t, rawValue, value.ValueString()) +} + +func TestAlloyConfigValue_Equal(t *testing.T) { + value1 := NewAlloyConfigValue("logging {}") + value2 := NewAlloyConfigValue("logging {}") + value3 := NewAlloyConfigValue("logging {}\n") + + assert.True(t, value1.Equal(value2)) + assert.False(t, value1.Equal(value3)) +} + +func TestAlloyConfigValue_Type(t *testing.T) { + ctx := context.Background() + value := NewAlloyConfigValue("logging {}") + assert.IsType(t, AlloyConfigType, value.Type(ctx)) +} + +func TestAlloyConfigValue_StringSemanticEquals(t *testing.T) { + ctx := context.Background() + value1 := NewAlloyConfigValue("logging {}") + value2 := NewAlloyConfigValue("logging {}\n") + value3 := NewAlloyConfigValue("// test") + + t.Run("semantically equal Alloy Config value", func(t *testing.T) { + equal, diags := value1.StringSemanticEquals(ctx, value2) + assert.False(t, diags.HasError()) + assert.True(t, equal) + }) + + t.Run("semantically not equal Alloy Config value", func(t *testing.T) { + equal, diags := value1.StringSemanticEquals(ctx, value3) + assert.False(t, diags.HasError()) + assert.False(t, equal) + }) +} + +func TestAlloyConfigValue_ValidateAttribute(t *testing.T) { + ctx := context.Background() + req := xattr.ValidateAttributeRequest{} + resp := &xattr.ValidateAttributeResponse{} + + t.Run("valid attribute", func(t *testing.T) { + value := NewAlloyConfigValue("// valid") + value.ValidateAttribute(ctx, req, resp) + assert.False(t, resp.Diagnostics.HasError()) + }) + + t.Run("invalid attribute", func(t *testing.T) { + invalidValue := NewAlloyConfigValue("invalid") + invalidValue.ValidateAttribute(ctx, req, resp) + assert.True(t, resp.Diagnostics.HasError()) + }) +} + +func TestRiverEqual(t *testing.T) { + contents1 := "logging {}" + contents2 := "logging {}\n" + contents3 := "// test" + + t.Run("equal river contents", func(t *testing.T) { + equal, err := riverEqual(contents1, contents2) + assert.NoError(t, err) + assert.True(t, equal) + }) + + t.Run("not equal river contents", func(t *testing.T) { + equal, err := riverEqual(contents1, contents3) + assert.NoError(t, err) + assert.False(t, equal) + }) +} + +func TestParseRiver(t *testing.T) { + t.Run("valid river contents", func(t *testing.T) { + contents := "// valid" + parsed, err := parseRiver(contents) + assert.NoError(t, err) + assert.NotEmpty(t, parsed) + }) + + t.Run("invalid river contents", func(t *testing.T) { + contents := "invalid" + parsed, err := parseRiver(contents) + assert.Error(t, err) + assert.Empty(t, parsed) + }) +} diff --git a/internal/resources/fleetmanagement/common.go b/internal/resources/fleetmanagement/common.go new file mode 100644 index 000000000..e0436fa01 --- /dev/null +++ b/internal/resources/fleetmanagement/common.go @@ -0,0 +1,32 @@ +package fleetmanagement + +import ( + "fmt" + + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/grafana/terraform-provider-grafana/v3/internal/common/fleetmanagementapi" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func withClientForResource(req resource.ConfigureRequest, resp *resource.ConfigureResponse) (*fleetmanagementapi.Client, error) { + 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 nil, fmt.Errorf("unexpected Resource Configure Type: %T, expected *common.Client", req.ProviderData) + } + + if client.FleetManagementClient == nil { + resp.Diagnostics.AddError( + "The Grafana Provider is missing a configuration for the Fleet Management API.", + "Please ensure that fleet_management_auth and fleet_management_url are set in the provider configuration.", + ) + + return nil, fmt.Errorf("the Fleet Management client is nil") + } + + return client.FleetManagementClient, nil +} diff --git a/internal/resources/fleetmanagement/generic_list_type.go b/internal/resources/fleetmanagement/generic_list_type.go new file mode 100644 index 000000000..59834ddd3 --- /dev/null +++ b/internal/resources/fleetmanagement/generic_list_type.go @@ -0,0 +1,81 @@ +package fleetmanagement + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +var ( + _ basetypes.ListTypable = genericListType[basetypes.StringValue]{} +) + +var ( + ListOfPrometheusMatcherType = genericListType[PrometheusMatcherValue]{basetypes.ListType{ElemType: PrometheusMatcherType}} +) + +type genericListType[T attr.Value] struct { + basetypes.ListType +} + +func NewGenericListType[T attr.Value](ctx context.Context) genericListType[T] { + var zero T + return genericListType[T]{ + basetypes.ListType{ + ElemType: zero.Type(ctx), + }, + } +} + +func (t genericListType[T]) Equal(o attr.Type) bool { + other, ok := o.(genericListType[T]) + if !ok { + return false + } + + return t.ListType.Equal(other.ListType) +} + +func (t genericListType[T]) String() string { + var zero T + return fmt.Sprintf("GenericListType[%T]", zero) +} + +func (t genericListType[T]) ValueFromList(ctx context.Context, in basetypes.ListValue) (basetypes.ListValuable, diag.Diagnostics) { + if in.IsNull() { + return NewGenericListValueNull[T](ctx), nil + } + + if in.IsUnknown() { + return NewGenericListValueUnknown[T](ctx), nil + } + + return NewGenericListValue[T](ctx, in.Elements()) +} + +func (t genericListType[T]) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + attrValue, err := t.ListType.ValueFromTerraform(ctx, in) + if err != nil { + return nil, err + } + + listValue, ok := attrValue.(basetypes.ListValue) + if !ok { + return nil, fmt.Errorf("unexpected value type of %T", attrValue) + } + + listValuable, diags := t.ValueFromList(ctx, listValue) + if diags.HasError() { + return nil, fmt.Errorf("unexpected error converting ListValue to ListValuable: %v", diags) + } + + return listValuable, nil +} + +func (t genericListType[T]) ValueType(ctx context.Context) attr.Value { + return GenericListValue[T]{} +} diff --git a/internal/resources/fleetmanagement/generic_list_type_test.go b/internal/resources/fleetmanagement/generic_list_type_test.go new file mode 100644 index 000000000..ae043b64d --- /dev/null +++ b/internal/resources/fleetmanagement/generic_list_type_test.go @@ -0,0 +1,64 @@ +package fleetmanagement + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/stretchr/testify/assert" +) + +func TestNewGenericListType(t *testing.T) { + ctx := context.Background() + genListType := NewGenericListType[types.String](ctx) + assert.IsType(t, genericListType[types.String]{}, genListType) +} + +func TestGenericListType_Equal(t *testing.T) { + ctx := context.Background() + genListType1 := NewGenericListType[types.String](ctx) + genListType2 := NewGenericListType[types.String](ctx) + genListType3 := NewGenericListType[types.Number](ctx) + + assert.True(t, genListType1.Equal(genListType2)) + assert.False(t, genListType1.Equal(genListType3)) +} + +func TestGenericListType_String(t *testing.T) { + ctx := context.Background() + genListType := NewGenericListType[types.String](ctx) + assert.Equal(t, "GenericListType[basetypes.StringValue]", genListType.String()) +} + +func TestGenericListType_ValueFromList(t *testing.T) { + ctx := context.Background() + attrElements := []attr.Value{basetypes.NewStringValue("test")} + listValue := basetypes.NewListValueMust(types.StringType, attrElements) + genListType := NewGenericListType[types.String](ctx) + + genListValue, diags := genListType.ValueFromList(ctx, listValue) + assert.False(t, diags.HasError()) + genListElements := genListValue.(GenericListValue[types.String]).Elements() + assert.ElementsMatch(t, attrElements, genListElements) +} + +func TestGenericListType_ValueFromTerraform(t *testing.T) { + ctx := context.Background() + tfValue := tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{tftypes.NewValue(tftypes.String, "test")}) + genListType := NewGenericListType[types.String](ctx) + + genListValue, err := genListType.ValueFromTerraform(ctx, tfValue) + assert.NoError(t, err) + genListElements := genListValue.(GenericListValue[types.String]).Elements() + expected := []attr.Value{basetypes.NewStringValue("test")} + assert.ElementsMatch(t, expected, genListElements) +} + +func TestGenericListType_ValueType(t *testing.T) { + ctx := context.Background() + genListType := NewGenericListType[types.String](ctx) + assert.IsType(t, GenericListValue[types.String]{}, genListType.ValueType(ctx)) +} diff --git a/internal/resources/fleetmanagement/generic_list_value.go b/internal/resources/fleetmanagement/generic_list_value.go new file mode 100644 index 000000000..17310faf6 --- /dev/null +++ b/internal/resources/fleetmanagement/generic_list_value.go @@ -0,0 +1,80 @@ +package fleetmanagement + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +var ( + _ basetypes.ListValuable = GenericListValue[basetypes.StringValue]{} +) + +type GenericListValue[T attr.Value] struct { + basetypes.ListValue +} + +func NewGenericListValueNull[T attr.Value](ctx context.Context) GenericListValue[T] { + var zero T + return GenericListValue[T]{ + ListValue: basetypes.NewListNull( + zero.Type(ctx), + ), + } +} + +func NewGenericListValueUnknown[T attr.Value](ctx context.Context) GenericListValue[T] { + var zero T + return GenericListValue[T]{ + ListValue: basetypes.NewListUnknown( + zero.Type(ctx), + ), + } +} + +func NewGenericListValue[T attr.Value](ctx context.Context, elements []attr.Value) (GenericListValue[T], diag.Diagnostics) { + var zero T + value, diags := basetypes.NewListValue(zero.Type(ctx), elements) + if diags.HasError() { + return NewGenericListValueUnknown[T](ctx), diags + } + + return GenericListValue[T]{ + ListValue: value, + }, nil +} + +func NewGenericListValueFrom[T attr.Value](ctx context.Context, elementType attr.Type, elements any) (GenericListValue[T], diag.Diagnostics) { + var zero T + value, diags := basetypes.NewListValueFrom(ctx, zero.Type(ctx), elements) + if diags.HasError() { + return NewGenericListValueUnknown[T](ctx), diags + } + + return GenericListValue[T]{ + ListValue: value, + }, nil +} + +func NewGenericListValueMust[T attr.Value](ctx context.Context, elements []attr.Value) GenericListValue[T] { + var zero T + value := basetypes.NewListValueMust(zero.Type(ctx), elements) + return GenericListValue[T]{ + ListValue: value, + } +} + +func (v GenericListValue[T]) Equal(o attr.Value) bool { + other, ok := o.(GenericListValue[T]) + if !ok { + return false + } + + return v.ListValue.Equal(other.ListValue) +} + +func (v GenericListValue[T]) Type(ctx context.Context) attr.Type { + return NewGenericListType[T](ctx) +} diff --git a/internal/resources/fleetmanagement/generic_list_value_test.go b/internal/resources/fleetmanagement/generic_list_value_test.go new file mode 100644 index 000000000..b1a1f4655 --- /dev/null +++ b/internal/resources/fleetmanagement/generic_list_value_test.go @@ -0,0 +1,66 @@ +package fleetmanagement + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/stretchr/testify/assert" +) + +func TestNewGenericListValueNull(t *testing.T) { + ctx := context.Background() + genListValue := NewGenericListValueNull[types.String](ctx) + assert.True(t, genListValue.IsNull()) +} + +func TestNewGenericListValueUnknown(t *testing.T) { + ctx := context.Background() + genListValue := NewGenericListValueUnknown[types.String](ctx) + assert.True(t, genListValue.IsUnknown()) +} + +func TestNewGenericListValue(t *testing.T) { + ctx := context.Background() + attrElements := []attr.Value{basetypes.NewStringValue("test")} + + genListValue, diags := NewGenericListValue[types.String](ctx, attrElements) + assert.False(t, diags.HasError()) + assert.ElementsMatch(t, attrElements, genListValue.Elements()) +} + +func TestNewGenericListValueFrom(t *testing.T) { + ctx := context.Background() + stringElements := []string{"test"} + + genListValue, diags := NewGenericListValueFrom[types.String](ctx, types.StringType, stringElements) + assert.False(t, diags.HasError()) + expected := []attr.Value{basetypes.NewStringValue("test")} + assert.ElementsMatch(t, expected, genListValue.Elements()) +} + +func TestNewGenericListValueMust(t *testing.T) { + ctx := context.Background() + attrElements := []attr.Value{basetypes.NewStringValue("test")} + genListValue := NewGenericListValueMust[types.String](ctx, attrElements) + assert.ElementsMatch(t, attrElements, genListValue.Elements()) +} + +func TestGenericListValue_Equal(t *testing.T) { + ctx := context.Background() + genListValue1 := NewGenericListValueMust[types.String](ctx, []attr.Value{basetypes.NewStringValue("test")}) + genListValue2 := NewGenericListValueMust[types.String](ctx, []attr.Value{basetypes.NewStringValue("test")}) + genListValue3 := NewGenericListValueMust[types.String](ctx, []attr.Value{basetypes.NewStringValue("different")}) + + assert.True(t, genListValue1.Equal(genListValue2)) + assert.False(t, genListValue1.Equal(genListValue3)) +} + +func TestGenericListValue_Type(t *testing.T) { + ctx := context.Background() + attrElements := []attr.Value{basetypes.NewStringValue("test")} + genListValue := NewGenericListValueMust[types.String](ctx, attrElements) + assert.IsType(t, genericListType[types.String]{}, genListValue.Type(ctx)) +} diff --git a/internal/resources/fleetmanagement/model_collector.go b/internal/resources/fleetmanagement/model_collector.go new file mode 100644 index 000000000..147c9d4f2 --- /dev/null +++ b/internal/resources/fleetmanagement/model_collector.go @@ -0,0 +1,70 @@ +package fleetmanagement + +import ( + "context" + + collectorv1 "github.com/grafana/fleet-management-api/api/gen/proto/go/collector/v1" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type collectorModel struct { + ID types.String `tfsdk:"id"` + RemoteAttributes types.Map `tfsdk:"remote_attributes"` + Enabled types.Bool `tfsdk:"enabled"` +} + +func collectorMessageToModel(ctx context.Context, msg *collectorv1.Collector) (*collectorModel, diag.Diagnostics) { + remoteAttributes, diags := nativeStringMapToTFStringMap(ctx, msg.RemoteAttributes) + if diags.HasError() { + return nil, diags + } + + return &collectorModel{ + ID: types.StringValue(msg.Id), + RemoteAttributes: remoteAttributes, + Enabled: types.BoolPointerValue(msg.Enabled), + }, nil +} + +func collectorModelToMessage(ctx context.Context, model *collectorModel) (*collectorv1.Collector, diag.Diagnostics) { + remoteAttributes, diags := tfStringMapToNativeStringMap(ctx, model.RemoteAttributes) + if diags.HasError() { + return nil, diags + } + + return &collectorv1.Collector{ + Id: model.ID.ValueString(), + RemoteAttributes: remoteAttributes, + Enabled: tfBoolToNativeBoolPtr(model.Enabled), + }, nil +} + +func nativeStringMapToTFStringMap(ctx context.Context, nativeMap map[string]string) (types.Map, diag.Diagnostics) { + if len(nativeMap) == 0 { + return types.MapValueMust(types.StringType, map[string]attr.Value{}), nil + } + + return types.MapValueFrom(ctx, types.StringType, nativeMap) +} + +func tfStringMapToNativeStringMap(ctx context.Context, tfMap types.Map) (map[string]string, diag.Diagnostics) { + if tfMap.IsNull() || tfMap.IsUnknown() { + return map[string]string{}, nil + } + + length := len(tfMap.Elements()) + elements := make(map[string]types.String, length) + diags := tfMap.ElementsAs(ctx, &elements, false) + if diags.HasError() { + return nil, diags + } + + nativeMap := make(map[string]string, length) + for key, val := range elements { + nativeMap[key] = val.ValueString() + } + + return nativeMap, nil +} diff --git a/internal/resources/fleetmanagement/model_collector_test.go b/internal/resources/fleetmanagement/model_collector_test.go new file mode 100644 index 000000000..3a7f71405 --- /dev/null +++ b/internal/resources/fleetmanagement/model_collector_test.go @@ -0,0 +1,172 @@ +package fleetmanagement + +import ( + "context" + "testing" + + collectorv1 "github.com/grafana/fleet-management-api/api/gen/proto/go/collector/v1" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/stretchr/testify/assert" +) + +func TestCollectorMessageToModel(t *testing.T) { + id := "test_id" + enabled := true + + msg := &collectorv1.Collector{ + Id: id, + RemoteAttributes: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + Enabled: &enabled, + } + + expectedModel := &collectorModel{ + ID: types.StringValue(id), + RemoteAttributes: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("value1"), + "key2": types.StringValue("value2"), + }, + ), + Enabled: types.BoolPointerValue(&enabled), + } + + ctx := context.Background() + actualModel, diags := collectorMessageToModel(ctx, msg) + assert.False(t, diags.HasError()) + assert.Equal(t, expectedModel, actualModel) +} + +func TestCollectorModelToMessage(t *testing.T) { + id := "test_id" + enabled := true + + model := &collectorModel{ + ID: types.StringValue(id), + RemoteAttributes: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("value1"), + "key2": types.StringValue("value2"), + }, + ), + Enabled: types.BoolPointerValue(&enabled), + } + + expectedMsg := &collectorv1.Collector{ + Id: id, + RemoteAttributes: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + Enabled: &enabled, + } + + ctx := context.Background() + actualMsg, diags := collectorModelToMessage(ctx, model) + assert.False(t, diags.HasError()) + assert.Equal(t, expectedMsg, actualMsg) +} + +func TestNativeStringMapToTFStringMap(t *testing.T) { + tests := []struct { + name string + nativeMap map[string]string + expected types.Map + }{ + { + "nil map", + nil, + types.MapValueMust( + types.StringType, + map[string]attr.Value{}, + ), + }, + { + "empty map", + map[string]string{}, + types.MapValueMust( + types.StringType, + map[string]attr.Value{}, + ), + }, + { + "non-empty map", + map[string]string{ + "key1": "value1", + "key2": "value2", + }, + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("value1"), + "key2": types.StringValue("value2"), + }, + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + actual, diags := nativeStringMapToTFStringMap(ctx, tt.nativeMap) + assert.False(t, diags.HasError()) + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestTfStringMapToNativeStringMap(t *testing.T) { + tests := []struct { + name string + tfMap types.Map + expected map[string]string + }{ + { + "null map", + basetypes.NewMapNull(types.StringType), + map[string]string{}, + }, + { + "unknown map", + basetypes.NewMapUnknown(types.StringType), + map[string]string{}, + }, + { + "empty map", + types.MapValueMust( + types.StringType, + map[string]attr.Value{}, + ), + map[string]string{}, + }, + { + "non-empty map", + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("value1"), + "key2": types.StringValue("value2"), + }, + ), + map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + actual, diags := tfStringMapToNativeStringMap(ctx, tt.tfMap) + assert.False(t, diags.HasError()) + assert.Equal(t, tt.expected, actual) + }) + } +} diff --git a/internal/resources/fleetmanagement/model_pipeline.go b/internal/resources/fleetmanagement/model_pipeline.go new file mode 100644 index 000000000..93bb22287 --- /dev/null +++ b/internal/resources/fleetmanagement/model_pipeline.go @@ -0,0 +1,76 @@ +package fleetmanagement + +import ( + "context" + + pipelinev1 "github.com/grafana/fleet-management-api/api/gen/proto/go/pipeline/v1" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type pipelineModel struct { + Name types.String `tfsdk:"name"` + Contents AlloyConfigValue `tfsdk:"contents"` + Matchers GenericListValue[PrometheusMatcherValue] `tfsdk:"matchers"` + Enabled types.Bool `tfsdk:"enabled"` + ID types.String `tfsdk:"id"` +} + +func pipelineMessageToModel(ctx context.Context, msg *pipelinev1.Pipeline) (*pipelineModel, diag.Diagnostics) { + matcherValues, diags := stringSliceToMatcherValues(ctx, msg.Matchers) + if diags.HasError() { + return nil, diags + } + + return &pipelineModel{ + Name: types.StringValue(msg.Name), + Contents: NewAlloyConfigValue(msg.Contents), + Matchers: matcherValues, + Enabled: types.BoolPointerValue(msg.Enabled), + ID: types.StringPointerValue(msg.Id), + }, nil +} + +func pipelineModelToMessage(ctx context.Context, model *pipelineModel) (*pipelinev1.Pipeline, diag.Diagnostics) { + matchers, diags := matcherValuesToStringSlice(ctx, model.Matchers) + if diags.HasError() { + return nil, diags + } + + return &pipelinev1.Pipeline{ + Name: model.Name.ValueString(), + Contents: model.Contents.ValueString(), + Matchers: matchers, + Enabled: tfBoolToNativeBoolPtr(model.Enabled), + Id: tfStringToNativeStringPtr(model.ID), + }, nil +} + +func stringSliceToMatcherValues(ctx context.Context, matchers []string) (GenericListValue[PrometheusMatcherValue], diag.Diagnostics) { + if len(matchers) == 0 { + return NewGenericListValueMust[PrometheusMatcherValue](ctx, []attr.Value{}), nil + } + + return NewGenericListValueFrom[PrometheusMatcherValue](ctx, PrometheusMatcherType, matchers) +} + +func matcherValuesToStringSlice(ctx context.Context, matcherValues GenericListValue[PrometheusMatcherValue]) ([]string, diag.Diagnostics) { + if matcherValues.IsNull() || matcherValues.IsUnknown() { + return []string{}, nil + } + + length := len(matcherValues.Elements()) + elements := make([]PrometheusMatcherValue, length) + diags := matcherValues.ElementsAs(ctx, &elements, false) + if diags.HasError() { + return nil, diags + } + + matchers := make([]string, length) + for i, element := range elements { + matchers[i] = element.ValueString() + } + + return matchers, nil +} diff --git a/internal/resources/fleetmanagement/model_pipeline_test.go b/internal/resources/fleetmanagement/model_pipeline_test.go new file mode 100644 index 000000000..bc464043b --- /dev/null +++ b/internal/resources/fleetmanagement/model_pipeline_test.go @@ -0,0 +1,175 @@ +package fleetmanagement + +import ( + "context" + "testing" + + pipelinev1 "github.com/grafana/fleet-management-api/api/gen/proto/go/pipeline/v1" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/assert" +) + +func TestPipelineMessageToModel(t *testing.T) { + name := "test_name" + contents := "logging {}" + matcher1 := "collector.os=\"linux\"" + matcher2 := "owner=\"TEAM-A\"" + enabled := true + id := "123" + + msg := &pipelinev1.Pipeline{ + Name: name, + Contents: contents, + Matchers: []string{ + matcher1, + matcher2, + }, + Enabled: &enabled, + Id: &id, + } + + expectedModel := &pipelineModel{ + Name: types.StringValue(name), + Contents: NewAlloyConfigValue(contents), + Matchers: NewGenericListValueMust[PrometheusMatcherValue]( + context.Background(), + []attr.Value{ + NewPrometheusMatcherValue(matcher1), + NewPrometheusMatcherValue(matcher2), + }, + ), + Enabled: types.BoolPointerValue(&enabled), + ID: types.StringPointerValue(&id), + } + + ctx := context.Background() + actualModel, diags := pipelineMessageToModel(ctx, msg) + assert.False(t, diags.HasError()) + assert.Equal(t, expectedModel, actualModel) +} + +func TestPipelineModelToMessage(t *testing.T) { + name := "test_name" + contents := "logging {}" + matcher1 := "collector.os=\"linux\"" + matcher2 := "owner=\"TEAM-A\"" + enabled := true + id := "123" + + model := &pipelineModel{ + Name: types.StringValue(name), + Contents: NewAlloyConfigValue(contents), + Matchers: NewGenericListValueMust[PrometheusMatcherValue]( + context.Background(), + []attr.Value{ + NewPrometheusMatcherValue(matcher1), + NewPrometheusMatcherValue(matcher2), + }, + ), + Enabled: types.BoolPointerValue(&enabled), + ID: types.StringPointerValue(&id), + } + + expectedMsg := &pipelinev1.Pipeline{ + Name: name, + Contents: contents, + Matchers: []string{matcher1, matcher2}, + Enabled: &enabled, + Id: &id, + } + + ctx := context.Background() + actualMsg, diags := pipelineModelToMessage(ctx, model) + assert.False(t, diags.HasError()) + assert.Equal(t, expectedMsg, actualMsg) +} + +func TestStringSliceToMatcherValues(t *testing.T) { + tests := []struct { + name string + nativeSlice []string + expected GenericListValue[PrometheusMatcherValue] + }{ + { + "nil slice", + nil, + NewGenericListValueMust[PrometheusMatcherValue](context.Background(), []attr.Value{}), + }, + { + "empty slice", + []string{}, + NewGenericListValueMust[PrometheusMatcherValue](context.Background(), []attr.Value{}), + }, + { + "non-empty slice", + []string{ + "collector.os=linux", + "collector.os=darwin", + }, + NewGenericListValueMust[PrometheusMatcherValue]( + context.Background(), + []attr.Value{ + NewPrometheusMatcherValue("collector.os=linux"), + NewPrometheusMatcherValue("collector.os=darwin"), + }, + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + actual, diags := stringSliceToMatcherValues(ctx, tt.nativeSlice) + assert.False(t, diags.HasError()) + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestMatcherValuesToStringSlice(t *testing.T) { + tests := []struct { + name string + genericList GenericListValue[PrometheusMatcherValue] + expected []string + }{ + { + "null list", + NewGenericListValueNull[PrometheusMatcherValue](context.Background()), + []string{}, + }, + { + "unknown list", + NewGenericListValueUnknown[PrometheusMatcherValue](context.Background()), + []string{}, + }, + { + "empty list", + NewGenericListValueMust[PrometheusMatcherValue](context.Background(), []attr.Value{}), + []string{}, + }, + { + "non-empty list", + NewGenericListValueMust[PrometheusMatcherValue]( + context.Background(), + []attr.Value{ + NewPrometheusMatcherValue("collector.os=linux"), + NewPrometheusMatcherValue("collector.os=darwin"), + }, + ), + []string{ + "collector.os=linux", + "collector.os=darwin", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + actual, diags := matcherValuesToStringSlice(ctx, tt.genericList) + assert.False(t, diags.HasError()) + assert.Equal(t, tt.expected, actual) + }) + } +} diff --git a/internal/resources/fleetmanagement/prometheus_matcher_type.go b/internal/resources/fleetmanagement/prometheus_matcher_type.go new file mode 100644 index 000000000..49d51a422 --- /dev/null +++ b/internal/resources/fleetmanagement/prometheus_matcher_type.go @@ -0,0 +1,65 @@ +package fleetmanagement + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +var ( + _ basetypes.StringTypable = prometheusMatcherType{} +) + +var ( + PrometheusMatcherType = prometheusMatcherType{} +) + +type prometheusMatcherType struct { + basetypes.StringType +} + +func (t prometheusMatcherType) Equal(o attr.Type) bool { + other, ok := o.(prometheusMatcherType) + if !ok { + return false + } + + return t.StringType.Equal(other.StringType) +} + +func (t prometheusMatcherType) String() string { + return "PrometheusMatcherType" +} + +func (t prometheusMatcherType) ValueFromString(ctx context.Context, in basetypes.StringValue) (basetypes.StringValuable, diag.Diagnostics) { + return PrometheusMatcherValue{ + StringValue: in, + }, nil +} + +func (t prometheusMatcherType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + attrValue, err := t.StringType.ValueFromTerraform(ctx, in) + if err != nil { + return nil, err + } + + stringValue, ok := attrValue.(basetypes.StringValue) + if !ok { + return nil, fmt.Errorf("unexpected value type of %T", attrValue) + } + + stringValuable, diags := t.ValueFromString(ctx, stringValue) + if diags.HasError() { + return nil, fmt.Errorf("unexpected error converting StringValue to StringValuable: %v", diags) + } + + return stringValuable, nil +} + +func (t prometheusMatcherType) ValueType(ctx context.Context) attr.Value { + return PrometheusMatcherValue{} +} diff --git a/internal/resources/fleetmanagement/prometheus_matcher_type_test.go b/internal/resources/fleetmanagement/prometheus_matcher_type_test.go new file mode 100644 index 000000000..22a62c76b --- /dev/null +++ b/internal/resources/fleetmanagement/prometheus_matcher_type_test.go @@ -0,0 +1,49 @@ +package fleetmanagement + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/stretchr/testify/assert" +) + +func TestPrometheusMatcherType_Equal(t *testing.T) { + type1 := PrometheusMatcherType + type2 := PrometheusMatcherType + type3 := types.StringType + + assert.True(t, type1.Equal(type2)) + assert.False(t, type1.Equal(type3)) +} + +func TestPrometheusMatcherType_String(t *testing.T) { + assert.Equal(t, "PrometheusMatcherType", PrometheusMatcherType.String()) +} + +func TestPrometheusMatcherType_ValueFromString(t *testing.T) { + ctx := context.Background() + stringValue := types.StringValue("test") + + promMatcherValue, diags := PrometheusMatcherType.ValueFromString(ctx, stringValue) + assert.False(t, diags.HasError()) + expected := PrometheusMatcherValue{StringValue: stringValue} + assert.Equal(t, expected, promMatcherValue) +} + +func TestPrometheusMatcherType_ValueFromTerraform(t *testing.T) { + ctx := context.Background() + tfValue := tftypes.NewValue(tftypes.String, "test") + + promMatcherValue, err := PrometheusMatcherType.ValueFromTerraform(ctx, tfValue) + assert.NoError(t, err) + expected := PrometheusMatcherValue{StringValue: types.StringValue("test")} + assert.Equal(t, expected, promMatcherValue) +} + +func TestPrometheusMatcherType_ValueType(t *testing.T) { + ctx := context.Background() + promMatcherValue := PrometheusMatcherType.ValueType(ctx) + assert.IsType(t, PrometheusMatcherValue{}, promMatcherValue) +} diff --git a/internal/resources/fleetmanagement/prometheus_matcher_value.go b/internal/resources/fleetmanagement/prometheus_matcher_value.go new file mode 100644 index 000000000..fd21cfb87 --- /dev/null +++ b/internal/resources/fleetmanagement/prometheus_matcher_value.go @@ -0,0 +1,96 @@ +package fleetmanagement + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/attr/xattr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/prometheus/alertmanager/matchers/parse" +) + +var ( + _ basetypes.StringValuable = PrometheusMatcherValue{} + _ basetypes.StringValuableWithSemanticEquals = PrometheusMatcherValue{} + _ xattr.ValidateableAttribute = PrometheusMatcherValue{} +) + +type PrometheusMatcherValue struct { + basetypes.StringValue +} + +func NewPrometheusMatcherValue(value string) PrometheusMatcherValue { + return PrometheusMatcherValue{ + StringValue: basetypes.NewStringValue(value), + } +} + +func (v PrometheusMatcherValue) Equal(o attr.Value) bool { + other, ok := o.(PrometheusMatcherValue) + if !ok { + return false + } + + return v.StringValue.Equal(other.StringValue) +} + +func (v PrometheusMatcherValue) Type(ctx context.Context) attr.Type { + return PrometheusMatcherType +} + +func (v PrometheusMatcherValue) StringSemanticEquals(ctx context.Context, newValuable basetypes.StringValuable) (bool, diag.Diagnostics) { + var diags diag.Diagnostics + + newValue, ok := newValuable.(PrometheusMatcherValue) + if !ok { + diags.AddError( + "Semantic Equality Check Error", + "An unexpected value type was received while performing semantic equality checks. "+ + "Please report this to the provider developers.\n\n"+ + "Expected Value Type: "+fmt.Sprintf("%T", v)+"\n"+ + "Got Value Type: "+fmt.Sprintf("%T", newValuable), + ) + + return false, diags + } + + // Values are already validated at this point, ignoring errors + result, _ := matcherEqual(v.ValueString(), newValue.ValueString()) + return result, diags +} + +func (v PrometheusMatcherValue) ValidateAttribute(ctx context.Context, req xattr.ValidateAttributeRequest, resp *xattr.ValidateAttributeResponse) { + if v.IsNull() || v.IsUnknown() { + return + } + + _, err := parse.Matcher(v.ValueString()) + if err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Prometheus matcher", + "A string value was provided that is not valid Prometheus matcher format.\n\n"+ + "Path: "+req.Path.String()+"\n"+ + "Given Value: "+v.ValueString()+"\n"+ + "Error: "+err.Error(), + ) + + return + } +} + +func matcherEqual(matcher1 string, matcher2 string) (bool, error) { + parsed1, err := parse.Matcher(matcher1) + if err != nil { + return false, err + } + + parsed2, err := parse.Matcher(matcher2) + if err != nil { + return false, err + } + + return parsed1.String() == parsed2.String(), nil +} diff --git a/internal/resources/fleetmanagement/prometheus_matcher_value_test.go b/internal/resources/fleetmanagement/prometheus_matcher_value_test.go new file mode 100644 index 000000000..29c5cc6c0 --- /dev/null +++ b/internal/resources/fleetmanagement/prometheus_matcher_value_test.go @@ -0,0 +1,85 @@ +package fleetmanagement + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr/xattr" + "github.com/stretchr/testify/assert" +) + +func TestNewPrometheusMatcherValue(t *testing.T) { + rawValue := "os=\"linux\"" + value := NewPrometheusMatcherValue(rawValue) + assert.Equal(t, rawValue, value.ValueString()) +} + +func TestPrometheusMatcherValue_Equal(t *testing.T) { + value1 := NewPrometheusMatcherValue("os=\"linux\"") + value2 := NewPrometheusMatcherValue("os=\"linux\"") + value3 := NewPrometheusMatcherValue("os=linux") + + assert.True(t, value1.Equal(value2)) + assert.False(t, value1.Equal(value3)) +} + +func TestPrometheusMatcherValue_Type(t *testing.T) { + value := NewPrometheusMatcherValue("collector.os=\"linux\"") + ctx := context.Background() + assert.IsType(t, PrometheusMatcherType, value.Type(ctx)) +} + +func TestPrometheusMatcherValue_StringSemanticEquals(t *testing.T) { + ctx := context.Background() + value1 := NewPrometheusMatcherValue("collector.os=\"linux\"") + value2 := NewPrometheusMatcherValue("collector.os=linux") + value3 := NewPrometheusMatcherValue("collector.os=\"darwin\"") + + t.Run("semantically equal Prometheus matcher value", func(t *testing.T) { + equal, diags := value1.StringSemanticEquals(ctx, value2) + assert.False(t, diags.HasError()) + assert.True(t, equal) + }) + + t.Run("semantically not equal Prometheus matcher value", func(t *testing.T) { + equal, diags := value1.StringSemanticEquals(ctx, value3) + assert.False(t, diags.HasError()) + assert.False(t, equal) + }) +} + +func TestPrometheusMatcherValue_ValidateAttribute(t *testing.T) { + ctx := context.Background() + req := xattr.ValidateAttributeRequest{} + resp := &xattr.ValidateAttributeResponse{} + + t.Run("valid attribute", func(t *testing.T) { + value := NewPrometheusMatcherValue("collector.os=~.*") + value.ValidateAttribute(ctx, req, resp) + assert.False(t, resp.Diagnostics.HasError()) + }) + + t.Run("invalid attribute", func(t *testing.T) { + invalidValue := NewPrometheusMatcherValue("collector.os~=.*") + invalidValue.ValidateAttribute(ctx, req, resp) + assert.True(t, resp.Diagnostics.HasError()) + }) +} + +func TestMatcherEqual(t *testing.T) { + matcher1 := "collector.os=\"linux\"" + matcher2 := "collector.os=linux" + matcher3 := "collector.os=\"darwin\"" + + t.Run("equal matchers", func(t *testing.T) { + equal, err := matcherEqual(matcher1, matcher2) + assert.NoError(t, err) + assert.True(t, equal) + }) + + t.Run("not equal matchers", func(t *testing.T) { + equal, err := matcherEqual(matcher1, matcher3) + assert.NoError(t, err) + assert.False(t, equal) + }) +} diff --git a/internal/resources/fleetmanagement/resource_collector.go b/internal/resources/fleetmanagement/resource_collector.go new file mode 100644 index 000000000..15aeed555 --- /dev/null +++ b/internal/resources/fleetmanagement/resource_collector.go @@ -0,0 +1,269 @@ +package fleetmanagement + +import ( + "context" + + "connectrpc.com/connect" + collectorv1 "github.com/grafana/fleet-management-api/api/gen/proto/go/collector/v1" + "github.com/grafana/fleet-management-api/api/gen/proto/go/collector/v1/collectorv1connect" + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +const ( + collectorIDField = "id" + collectorTypeName = "grafana_fleet_management_collector" +) + +var ( + collectorResourceID = common.NewResourceID(common.StringIDField(collectorIDField)) +) + +var ( + _ resource.Resource = &collectorResource{} + _ resource.ResourceWithConfigure = &collectorResource{} + _ resource.ResourceWithImportState = &collectorResource{} +) + +type collectorResource struct { + client collectorv1connect.CollectorServiceClient +} + +func newCollectorResource() *common.Resource { + return common.NewResource( + common.CategoryFleetManagement, + collectorTypeName, + collectorResourceID, + &collectorResource{}, + ) +} + +func (r *collectorResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil || r.client != nil { + return + } + + client, err := withClientForResource(req, resp) + if err != nil { + return + } + + r.client = client.CollectorServiceClient +} + +func (r *collectorResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = collectorTypeName +} + +func (r *collectorResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Manages Grafana Fleet Management collectors. + +* [Official documentation](https://grafana.com/docs/grafana-cloud/send-data/fleet-management/) +* [API documentation](https://grafana.com/docs/grafana-cloud/send-data/fleet-management/api-reference/collector-api/) + +**Note:** Fleet Management is in [public preview](https://grafana.com/docs/release-life-cycle/#public-preview) and this resource is experimental. Grafana Labs offers limited support, and breaking changes might occur. + +Required access policy scopes: + +* fleet-management:read +* fleet-management:write +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "ID of the collector", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "remote_attributes": schema.MapAttribute{ + Description: "Remote attributes for the collector", + Optional: true, + Computed: true, + ElementType: types.StringType, + Default: mapdefault.StaticValue(types.MapValueMust(types.StringType, map[string]attr.Value{})), + }, + "enabled": schema.BoolAttribute{ + Description: "Whether the collector is enabled or not", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + }, + } +} + +func (r *collectorResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + getReq := &collectorv1.GetCollectorRequest{ + Id: req.ID, + } + getResp, err := r.client.GetCollector(ctx, connect.NewRequest(getReq)) + if err != nil { + resp.Diagnostics.AddError("Failed to get collector", err.Error()) + return + } + + state, diags := collectorMessageToModel(ctx, getResp.Msg) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *collectorResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + data := &collectorModel{} + diags := req.Plan.Get(ctx, data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + collector, diags := collectorModelToMessage(ctx, data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + createReq := &collectorv1.CreateCollectorRequest{ + Collector: collector, + } + _, err := r.client.CreateCollector(ctx, connect.NewRequest(createReq)) + if err != nil { + resp.Diagnostics.AddError("Failed to create collector", err.Error()) + return + } + + getReq := &collectorv1.GetCollectorRequest{ + Id: collector.Id, + } + getResp, err := r.client.GetCollector(ctx, connect.NewRequest(getReq)) + if err != nil { + resp.Diagnostics.AddError("Failed to get collector", err.Error()) + return + } + + state, diags := collectorMessageToModel(ctx, getResp.Msg) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *collectorResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + data := &collectorModel{} + diags := req.State.Get(ctx, data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + getReq := &collectorv1.GetCollectorRequest{ + Id: data.ID.ValueString(), + } + getResp, err := r.client.GetCollector(ctx, connect.NewRequest(getReq)) + if connect.CodeOf(err) == connect.CodeNotFound { + resp.Diagnostics.AddWarning( + "Collector not found during refresh", + "Automatically removing resource from Terraform state. Original error: "+err.Error(), + ) + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError("Failed to get collector", err.Error()) + return + } + + state, diags := collectorMessageToModel(ctx, getResp.Msg) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) +} + +func (r *collectorResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + data := &collectorModel{} + diags := req.Plan.Get(ctx, data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + collector, diags := collectorModelToMessage(ctx, data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + updateReq := &collectorv1.UpdateCollectorRequest{ + Collector: collector, + } + _, err := r.client.UpdateCollector(ctx, connect.NewRequest(updateReq)) + if err != nil { + resp.Diagnostics.AddError("Failed to update collector", err.Error()) + return + } + + getReq := &collectorv1.GetCollectorRequest{ + Id: collector.Id, + } + getResp, err := r.client.GetCollector(ctx, connect.NewRequest(getReq)) + if err != nil { + resp.Diagnostics.AddError("Failed to get collector", err.Error()) + return + } + + state, diags := collectorMessageToModel(ctx, getResp.Msg) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *collectorResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + data := &collectorModel{} + diags := req.State.Get(ctx, data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + deleteReq := &collectorv1.DeleteCollectorRequest{ + Id: data.ID.ValueString(), + } + _, err := r.client.DeleteCollector(ctx, connect.NewRequest(deleteReq)) + if err != nil { + resp.Diagnostics.AddError("Failed to delete collector", err.Error()) + return + } +} diff --git a/internal/resources/fleetmanagement/resource_collector_test.go b/internal/resources/fleetmanagement/resource_collector_test.go new file mode 100644 index 000000000..55d4fc78e --- /dev/null +++ b/internal/resources/fleetmanagement/resource_collector_test.go @@ -0,0 +1,160 @@ +package fleetmanagement_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "connectrpc.com/connect" + collectorv1 "github.com/grafana/fleet-management-api/api/gen/proto/go/collector/v1" + "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" +) + +var ( + collectorResourceRequiredConfig = ` +resource "grafana_fleet_management_collector" "test" { + id = "%s" +} +` + + collectorResourceOptionalConfig = ` +resource "grafana_fleet_management_collector" "test" { + id = "%s" + remote_attributes = { + "env" = "PROD", + "owner" = "TEAM-A" + } + enabled = false +} +` + + collectorResourceEmptyRemoteAttributesConfig = ` +resource "grafana_fleet_management_collector" "test" { + id = "%s" + remote_attributes = {} +} +` +) + +func TestAccCollectorResource(t *testing.T) { + testutils.CheckCloudInstanceTestsEnabled(t) + + ctx := context.Background() + resourceName := "grafana_fleet_management_collector.test" + collectorID := fmt.Sprintf("testacc_%s", acctest.RandString(8)) + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + // Create with only required fields + { + Config: fmt.Sprintf(collectorResourceRequiredConfig, collectorID), + Check: resource.ComposeTestCheckFunc( + testAccCollectorResourceExists(ctx, resourceName), + resource.TestCheckResourceAttr(resourceName, "id", collectorID), + resource.TestCheckResourceAttrSet(resourceName, "remote_attributes.%"), + resource.TestCheckResourceAttr(resourceName, "remote_attributes.%", "0"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + ), + }, + // Import state with only required fields + { + ResourceName: resourceName, + ImportState: true, + ImportStateId: collectorID, + ImportStateVerify: true, + }, + // Update with all optional fields + { + Config: fmt.Sprintf(collectorResourceOptionalConfig, collectorID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "id", collectorID), + resource.TestCheckResourceAttr(resourceName, "remote_attributes.%", "2"), + resource.TestCheckResourceAttr(resourceName, "remote_attributes.env", "PROD"), + resource.TestCheckResourceAttr(resourceName, "remote_attributes.owner", "TEAM-A"), + resource.TestCheckResourceAttr(resourceName, "enabled", "false"), + ), + }, + // Import state with all optional fields + { + ResourceName: resourceName, + ImportState: true, + ImportStateId: collectorID, + ImportStateVerify: true, + }, + // Update with empty remote_attributes field + { + Config: fmt.Sprintf(collectorResourceEmptyRemoteAttributesConfig, collectorID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "id", collectorID), + resource.TestCheckResourceAttrSet(resourceName, "remote_attributes.%"), + resource.TestCheckResourceAttr(resourceName, "remote_attributes.%", "0"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + ), + }, + // Update with only required fields + { + Config: fmt.Sprintf(collectorResourceRequiredConfig, collectorID), + Check: resource.ComposeTestCheckFunc( + testAccCollectorResourceExists(ctx, resourceName), + resource.TestCheckResourceAttr(resourceName, "id", collectorID), + resource.TestCheckResourceAttrSet(resourceName, "remote_attributes.%"), + resource.TestCheckResourceAttr(resourceName, "remote_attributes.%", "0"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + ), + }, + }, + // Delete + CheckDestroy: testAccCollectorResourceCheckDestroy(ctx, collectorID), + }) +} + +func testAccCollectorResourceExists(ctx context.Context, resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + resourceState, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("resource not found: %s\n %#v", resourceName, s.RootModule().Resources) + } + + collectorID, ok := resourceState.Primary.Attributes["id"] + if !ok { + return fmt.Errorf("collector ID not set") + } + + client := testutils.Provider.Meta().(*common.Client).FleetManagementClient.CollectorServiceClient + + getReq := &collectorv1.GetCollectorRequest{ + Id: collectorID, + } + _, err := client.GetCollector(ctx, connect.NewRequest(getReq)) + if err != nil { + return fmt.Errorf("error getting collector: %v", err) + } + + return nil + } +} + +func testAccCollectorResourceCheckDestroy(ctx context.Context, collectorID string) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testutils.Provider.Meta().(*common.Client).FleetManagementClient.CollectorServiceClient + + getReq := &collectorv1.GetCollectorRequest{ + Id: collectorID, + } + _, err := client.GetCollector(ctx, connect.NewRequest(getReq)) + if err == nil { + return errors.New("collector still exists") + } + if connect.CodeOf(err) != connect.CodeNotFound { + return fmt.Errorf("unexpected error retrieving collector: %s", err) + } + + return nil + } +} diff --git a/internal/resources/fleetmanagement/resource_pipeline.go b/internal/resources/fleetmanagement/resource_pipeline.go new file mode 100644 index 000000000..74f574661 --- /dev/null +++ b/internal/resources/fleetmanagement/resource_pipeline.go @@ -0,0 +1,291 @@ +package fleetmanagement + +import ( + "context" + + "connectrpc.com/connect" + pipelinev1 "github.com/grafana/fleet-management-api/api/gen/proto/go/pipeline/v1" + "github.com/grafana/fleet-management-api/api/gen/proto/go/pipeline/v1/pipelinev1connect" + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +const ( + pipelineIDField = "name" + pipelineTypeName = "grafana_fleet_management_pipeline" +) + +var ( + pipelineResourceID = common.NewResourceID(common.StringIDField(pipelineIDField)) +) + +var ( + _ resource.Resource = &pipelineResource{} + _ resource.ResourceWithConfigure = &pipelineResource{} + _ resource.ResourceWithImportState = &pipelineResource{} +) + +type pipelineResource struct { + client pipelinev1connect.PipelineServiceClient +} + +func newPipelineResource() *common.Resource { + return common.NewResource( + common.CategoryFleetManagement, + pipelineTypeName, + pipelineResourceID, + &pipelineResource{}, + ) +} + +func (r *pipelineResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil || r.client != nil { + return + } + + client, err := withClientForResource(req, resp) + if err != nil { + return + } + + r.client = client.PipelineServiceClient +} + +func (r *pipelineResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = pipelineTypeName +} + +func (r *pipelineResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Manages Grafana Fleet Management pipelines. + +* [Official documentation](https://grafana.com/docs/grafana-cloud/send-data/fleet-management/) +* [API documentation](https://grafana.com/docs/grafana-cloud/send-data/fleet-management/api-reference/pipeline-api/) + +**Note:** Fleet Management is in [public preview](https://grafana.com/docs/release-life-cycle/#public-preview) and this resource is experimental. Grafana Labs offers limited support, and breaking changes might occur. + +Required access policy scopes: + +* fleet-management:read +* fleet-management:write +`, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Name of the pipeline which is the unique identifier for the pipeline", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "contents": schema.StringAttribute{ + CustomType: AlloyConfigType, + Description: "Configuration contents of the pipeline to be used by collectors", + Required: true, + }, + "matchers": schema.ListAttribute{ + CustomType: ListOfPrometheusMatcherType, + Description: "Used to match against collectors and assign pipelines to them; follows the syntax of Prometheus Alertmanager matchers", + Optional: true, + Computed: true, + ElementType: PrometheusMatcherType, + Default: listdefault.StaticValue(types.ListValueMust(PrometheusMatcherType, []attr.Value{})), + }, + "enabled": schema.BoolAttribute{ + Description: "Whether the pipeline is enabled for collectors", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "id": schema.StringAttribute{ + Description: "Server-assigned ID of the pipeline", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *pipelineResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + getIDReq := &pipelinev1.GetPipelineIDRequest{ + Name: req.ID, + } + getIDResp, err := r.client.GetPipelineID(ctx, connect.NewRequest(getIDReq)) + if err != nil { + resp.Diagnostics.AddError("Failed to get pipeline ID", err.Error()) + return + } + + getReq := &pipelinev1.GetPipelineRequest{ + Id: getIDResp.Msg.Id, + } + getResp, err := r.client.GetPipeline(ctx, connect.NewRequest(getReq)) + if err != nil { + resp.Diagnostics.AddError("Failed to get pipeline", err.Error()) + return + } + + state, diags := pipelineMessageToModel(ctx, getResp.Msg) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *pipelineResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + data := &pipelineModel{} + diags := req.Plan.Get(ctx, data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + pipeline, diags := pipelineModelToMessage(ctx, data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + createReq := &pipelinev1.CreatePipelineRequest{ + Pipeline: pipeline, + } + createResp, err := r.client.CreatePipeline(ctx, connect.NewRequest(createReq)) + if err != nil { + resp.Diagnostics.AddError("Failed to create pipeline", err.Error()) + return + } + + getReq := &pipelinev1.GetPipelineRequest{ + Id: *createResp.Msg.Id, + } + getResp, err := r.client.GetPipeline(ctx, connect.NewRequest(getReq)) + if err != nil { + resp.Diagnostics.AddError("Failed to get pipeline", err.Error()) + return + } + + state, diags := pipelineMessageToModel(ctx, getResp.Msg) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *pipelineResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + data := &pipelineModel{} + diags := req.State.Get(ctx, data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + getReq := &pipelinev1.GetPipelineRequest{ + Id: data.ID.ValueString(), + } + getResp, err := r.client.GetPipeline(ctx, connect.NewRequest(getReq)) + if connect.CodeOf(err) == connect.CodeNotFound { + resp.Diagnostics.AddWarning( + "Pipeline not found during refresh", + "Automatically removing resource from Terraform state. Original error: "+err.Error(), + ) + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError("Failed to get pipeline", err.Error()) + return + } + + state, diags := pipelineMessageToModel(ctx, getResp.Msg) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) +} + +func (r *pipelineResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + data := &pipelineModel{} + diags := req.Plan.Get(ctx, data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + pipeline, diags := pipelineModelToMessage(ctx, data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + updateReq := &pipelinev1.UpdatePipelineRequest{ + Pipeline: pipeline, + } + _, err := r.client.UpdatePipeline(ctx, connect.NewRequest(updateReq)) + if err != nil { + resp.Diagnostics.AddError("Failed to update pipeline", err.Error()) + return + } + + getReq := &pipelinev1.GetPipelineRequest{ + Id: *pipeline.Id, + } + getResp, err := r.client.GetPipeline(ctx, connect.NewRequest(getReq)) + if err != nil { + resp.Diagnostics.AddError("Failed to get pipeline", err.Error()) + return + } + + state, diags := pipelineMessageToModel(ctx, getResp.Msg) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *pipelineResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + data := &pipelineModel{} + diags := req.State.Get(ctx, data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + deleteReq := &pipelinev1.DeletePipelineRequest{ + Id: data.ID.ValueString(), + } + _, err := r.client.DeletePipeline(ctx, connect.NewRequest(deleteReq)) + if err != nil { + resp.Diagnostics.AddError("Failed to delete pipeline", err.Error()) + return + } +} diff --git a/internal/resources/fleetmanagement/resource_pipeline_test.go b/internal/resources/fleetmanagement/resource_pipeline_test.go new file mode 100644 index 000000000..23e9a65d7 --- /dev/null +++ b/internal/resources/fleetmanagement/resource_pipeline_test.go @@ -0,0 +1,217 @@ +package fleetmanagement_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "connectrpc.com/connect" + pipelinev1 "github.com/grafana/fleet-management-api/api/gen/proto/go/pipeline/v1" + "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" +) + +var ( + pipelineResourceRequiredConfig = ` +resource "grafana_fleet_management_pipeline" "test" { + name = "%s" + contents = "prometheus.exporter.self \"alloy\" { }" +} +` + + pipelineResourceOptionalConfig = ` +resource "grafana_fleet_management_pipeline" "test" { + name = "%s" + contents = "prometheus.exporter.self \"alloy\" { }" + matchers = [ + "collector.os=\"linux\"", + "owner=\"TEAM-A\"", + ] + enabled = false +} +` + + pipelineResourceSemanticallyEqualContentsConfig = ` +resource "grafana_fleet_management_pipeline" "test" { + name = "%s" + contents = "prometheus.exporter.self \"alloy\" { }\n" +} +` + + pipelineResourceSemanticallyEqualMatchersConfig = ` +resource "grafana_fleet_management_pipeline" "test" { + name = "%s" + contents = "prometheus.exporter.self \"alloy\" { }" + matchers = [ + "collector.os=linux", + "owner=TEAM-A", + ] +} +` + + pipelineResourceEmptyMatchersConfig = ` +resource "grafana_fleet_management_pipeline" "test" { + name = "%s" + contents = "prometheus.exporter.self \"alloy\" { }" + matchers = [] +} +` +) + +func TestAccPipelineResource(t *testing.T) { + testutils.CheckCloudInstanceTestsEnabled(t) + + ctx := context.Background() + resourceName := "grafana_fleet_management_pipeline.test" + pipelineName := fmt.Sprintf("testacc_%s", acctest.RandString(8)) + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + // Create with only required fields + { + Config: fmt.Sprintf(pipelineResourceRequiredConfig, pipelineName), + Check: resource.ComposeTestCheckFunc( + testAccPipelineResourceExists(ctx, resourceName), + resource.TestCheckResourceAttr(resourceName, "name", pipelineName), + resource.TestCheckResourceAttr(resourceName, "contents", "prometheus.exporter.self \"alloy\" { }"), + resource.TestCheckResourceAttrSet(resourceName, "matchers.#"), + resource.TestCheckResourceAttr(resourceName, "matchers.#", "0"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + resource.TestCheckResourceAttrSet(resourceName, "id"), + ), + }, + // Import state with only required fields + { + ResourceName: resourceName, + ImportState: true, + ImportStateId: pipelineName, + ImportStateVerify: true, + }, + // Update with all optional fields + { + Config: fmt.Sprintf(pipelineResourceOptionalConfig, pipelineName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", pipelineName), + resource.TestCheckResourceAttr(resourceName, "contents", "prometheus.exporter.self \"alloy\" { }"), + resource.TestCheckResourceAttr(resourceName, "matchers.#", "2"), + resource.TestCheckResourceAttr(resourceName, "matchers.0", "collector.os=\"linux\""), + resource.TestCheckResourceAttr(resourceName, "matchers.1", "owner=\"TEAM-A\""), + resource.TestCheckResourceAttr(resourceName, "enabled", "false"), + resource.TestCheckResourceAttrSet(resourceName, "id"), + ), + }, + // Import state with all optional fields + { + ResourceName: resourceName, + ImportState: true, + ImportStateId: pipelineName, + ImportStateVerify: true, + }, + // Update with semantically equal contents field + { + Config: fmt.Sprintf(pipelineResourceSemanticallyEqualContentsConfig, pipelineName), + Check: resource.ComposeTestCheckFunc( + testAccPipelineResourceExists(ctx, resourceName), + resource.TestCheckResourceAttr(resourceName, "name", pipelineName), + resource.TestCheckResourceAttr(resourceName, "contents", "prometheus.exporter.self \"alloy\" { }\n"), + resource.TestCheckResourceAttrSet(resourceName, "matchers.#"), + resource.TestCheckResourceAttr(resourceName, "matchers.#", "0"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + resource.TestCheckResourceAttrSet(resourceName, "id"), + ), + }, + // Update with semantically equal matchers field + { + Config: fmt.Sprintf(pipelineResourceSemanticallyEqualMatchersConfig, pipelineName), + Check: resource.ComposeTestCheckFunc( + testAccPipelineResourceExists(ctx, resourceName), + resource.TestCheckResourceAttr(resourceName, "name", pipelineName), + resource.TestCheckResourceAttr(resourceName, "contents", "prometheus.exporter.self \"alloy\" { }"), + resource.TestCheckResourceAttr(resourceName, "matchers.#", "2"), + resource.TestCheckResourceAttr(resourceName, "matchers.0", "collector.os=linux"), + resource.TestCheckResourceAttr(resourceName, "matchers.1", "owner=TEAM-A"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + resource.TestCheckResourceAttrSet(resourceName, "id"), + ), + }, + // Update with empty matchers field + { + Config: fmt.Sprintf(pipelineResourceEmptyMatchersConfig, pipelineName), + Check: resource.ComposeTestCheckFunc( + testAccPipelineResourceExists(ctx, resourceName), + resource.TestCheckResourceAttr(resourceName, "name", pipelineName), + resource.TestCheckResourceAttr(resourceName, "contents", "prometheus.exporter.self \"alloy\" { }"), + resource.TestCheckResourceAttrSet(resourceName, "matchers.#"), + resource.TestCheckResourceAttr(resourceName, "matchers.#", "0"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + resource.TestCheckResourceAttrSet(resourceName, "id"), + ), + }, + // Update with only required fields + { + Config: fmt.Sprintf(pipelineResourceRequiredConfig, pipelineName), + Check: resource.ComposeTestCheckFunc( + testAccPipelineResourceExists(ctx, resourceName), + resource.TestCheckResourceAttr(resourceName, "name", pipelineName), + resource.TestCheckResourceAttr(resourceName, "contents", "prometheus.exporter.self \"alloy\" { }"), + resource.TestCheckResourceAttrSet(resourceName, "matchers.#"), + resource.TestCheckResourceAttr(resourceName, "matchers.#", "0"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + resource.TestCheckResourceAttrSet(resourceName, "id"), + ), + }, + }, + // Delete + CheckDestroy: testAccPipelineResourceCheckDestroy(ctx, pipelineName), + }) +} + +func testAccPipelineResourceExists(ctx context.Context, resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + resourceState, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("resource not found: %s\n %#v", resourceName, s.RootModule().Resources) + } + + pipelineID, ok := resourceState.Primary.Attributes["id"] + if !ok { + return fmt.Errorf("pipeline ID not set") + } + + client := testutils.Provider.Meta().(*common.Client).FleetManagementClient.PipelineServiceClient + + getReq := &pipelinev1.GetPipelineRequest{ + Id: pipelineID, + } + _, err := client.GetPipeline(ctx, connect.NewRequest(getReq)) + if err != nil { + return fmt.Errorf("error getting pipeline: %v", err) + } + + return nil + } +} + +func testAccPipelineResourceCheckDestroy(ctx context.Context, pipelineName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testutils.Provider.Meta().(*common.Client).FleetManagementClient.PipelineServiceClient + + getIDReq := &pipelinev1.GetPipelineIDRequest{ + Name: pipelineName, + } + _, err := client.GetPipelineID(ctx, connect.NewRequest(getIDReq)) + if err == nil { + return errors.New("pipeline still exists") + } + if connect.CodeOf(err) != connect.CodeNotFound { + return fmt.Errorf("unexpected error retrieving pipeline: %s", err) + } + + return nil + } +} diff --git a/internal/resources/fleetmanagement/resources.go b/internal/resources/fleetmanagement/resources.go new file mode 100644 index 000000000..8e6824aeb --- /dev/null +++ b/internal/resources/fleetmanagement/resources.go @@ -0,0 +1,8 @@ +package fleetmanagement + +import "github.com/grafana/terraform-provider-grafana/v3/internal/common" + +var Resources = []*common.Resource{ + newCollectorResource(), + newPipelineResource(), +} diff --git a/internal/resources/fleetmanagement/utils.go b/internal/resources/fleetmanagement/utils.go new file mode 100644 index 000000000..f2c28662a --- /dev/null +++ b/internal/resources/fleetmanagement/utils.go @@ -0,0 +1,23 @@ +package fleetmanagement + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func tfBoolToNativeBoolPtr(tfBool types.Bool) *bool { + var boolPtr *bool + if !(tfBool.IsNull() || tfBool.IsUnknown()) { + val := tfBool.ValueBool() + boolPtr = &val + } + return boolPtr +} + +func tfStringToNativeStringPtr(tfString types.String) *string { + var stringPtr *string + if !(tfString.IsNull() || tfString.IsUnknown()) { + val := tfString.ValueString() + stringPtr = &val + } + return stringPtr +} diff --git a/internal/resources/fleetmanagement/utils_test.go b/internal/resources/fleetmanagement/utils_test.go new file mode 100644 index 000000000..6fae9f3a2 --- /dev/null +++ b/internal/resources/fleetmanagement/utils_test.go @@ -0,0 +1,80 @@ +package fleetmanagement + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/assert" +) + +func TestTfBoolToNativeBoolPtr(t *testing.T) { + truev := true + falsev := false + + tests := []struct { + name string + tfBool types.Bool + expected *bool + }{ + { + "null bool", + types.BoolNull(), + nil, + }, + { + "unknown bool", + types.BoolUnknown(), + nil, + }, + { + "true bool", + types.BoolValue(true), + &truev, + }, + { + "false bool", + types.BoolValue(false), + &falsev, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := tfBoolToNativeBoolPtr(tt.tfBool) + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestTfStringToNativeStringPtr(t *testing.T) { + testStr := "test" + + tests := []struct { + name string + tfString types.String + expected *string + }{ + { + "null string", + types.StringNull(), + nil, + }, + { + "unknown string", + types.StringUnknown(), + nil, + }, + { + "non-empty string", + types.StringValue(testStr), + &testStr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := tfStringToNativeStringPtr(tt.tfString) + assert.Equal(t, tt.expected, actual) + }) + } +} diff --git a/internal/testutils/provider.go b/internal/testutils/provider.go index b45280dc2..4faeca657 100644 --- a/internal/testutils/provider.go +++ b/internal/testutils/provider.go @@ -194,6 +194,8 @@ func CheckCloudInstanceTestsEnabled(t *testing.T) { "GRAFANA_CLOUD_PROVIDER_ACCESS_TOKEN", "GRAFANA_CLOUD_PROVIDER_AWS_ROLE_ARN", "GRAFANA_CLOUD_PROVIDER_TEST_STACK_ID", + "GRAFANA_FLEET_MANAGEMENT_AUTH", + "GRAFANA_FLEET_MANAGEMENT_URL", ) } diff --git a/pkg/generate/postprocessing/replace_references.go b/pkg/generate/postprocessing/replace_references.go index 801d061eb..7d9fa56a7 100644 --- a/pkg/generate/postprocessing/replace_references.go +++ b/pkg/generate/postprocessing/replace_references.go @@ -17,6 +17,7 @@ var knownReferences = []string{ "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_access_policy_token.region=grafana_cloud_access_policy.region", "grafana_cloud_plugin_installation.stack_slug=grafana_cloud_stack.slug", "grafana_cloud_private_data_source_connect_network.stack_identifier=grafana_cloud_stack.id", "grafana_cloud_private_data_source_connect_network_token.pdc_network_id=grafana_cloud_private_data_source_connect_network.pdc_network_id", diff --git a/pkg/provider/configure_clients.go b/pkg/provider/configure_clients.go index 87bc57c34..274a71584 100644 --- a/pkg/provider/configure_clients.go +++ b/pkg/provider/configure_clients.go @@ -27,6 +27,7 @@ import ( "github.com/grafana/terraform-provider-grafana/v3/internal/common" "github.com/grafana/terraform-provider-grafana/v3/internal/common/cloudproviderapi" "github.com/grafana/terraform-provider-grafana/v3/internal/common/connectionsapi" + "github.com/grafana/terraform-provider-grafana/v3/internal/common/fleetmanagementapi" "github.com/grafana/terraform-provider-grafana/v3/internal/resources/grafana" ) @@ -71,6 +72,11 @@ func CreateClients(providerConfig ProviderConfig) (*common.Client, error) { return nil, err } } + if !providerConfig.FleetManagementAuth.IsNull() { + if err := createFleetManagementClient(c, providerConfig); err != nil { + return nil, err + } + } grafana.StoreDashboardSHA256 = providerConfig.StoreDashboardSha256.ValueBool() @@ -229,6 +235,23 @@ func createConnectionsClient(client *common.Client, providerConfig ProviderConfi return nil } +func createFleetManagementClient(client *common.Client, providerConfig ProviderConfig) error { + providerHeaders, err := getHTTPHeadersMap(providerConfig) + if err != nil { + return fmt.Errorf("failed to get provider default HTTP headers: %w", err) + } + + client.FleetManagementClient = fleetmanagementapi.NewClient( + providerConfig.FleetManagementAuth.ValueString(), + providerConfig.FleetManagementURL.ValueString(), + getRetryClient(providerConfig), + providerConfig.UserAgent.ValueString(), + providerHeaders, + ) + + return nil +} + // 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) { diff --git a/pkg/provider/framework_provider.go b/pkg/provider/framework_provider.go index c11886849..4bf04bcd0 100644 --- a/pkg/provider/framework_provider.go +++ b/pkg/provider/framework_provider.go @@ -46,6 +46,9 @@ type ProviderConfig struct { ConnectionsAPIAccessToken types.String `tfsdk:"connections_api_access_token"` ConnectionsAPIURL types.String `tfsdk:"connections_api_url"` + FleetManagementAuth types.String `tfsdk:"fleet_management_auth"` + FleetManagementURL types.String `tfsdk:"fleet_management_url"` + UserAgent types.String `tfsdk:"-"` Version types.String `tfsdk:"-"` } @@ -68,6 +71,8 @@ func (c *ProviderConfig) SetDefaults() error { c.CloudProviderURL = envDefaultFuncString(c.CloudProviderURL, "GRAFANA_CLOUD_PROVIDER_URL") c.ConnectionsAPIAccessToken = envDefaultFuncString(c.ConnectionsAPIAccessToken, "GRAFANA_CONNECTIONS_API_ACCESS_TOKEN") c.ConnectionsAPIURL = envDefaultFuncString(c.ConnectionsAPIURL, "GRAFANA_CONNECTIONS_API_URL", "https://connections-api.grafana.net") + c.FleetManagementAuth = envDefaultFuncString(c.FleetManagementAuth, "GRAFANA_FLEET_MANAGEMENT_AUTH") + c.FleetManagementURL = envDefaultFuncString(c.FleetManagementURL, "GRAFANA_FLEET_MANAGEMENT_URL") if c.StoreDashboardSha256, err = envDefaultFuncBool(c.StoreDashboardSha256, "GRAFANA_STORE_DASHBOARD_SHA256", false); err != nil { return fmt.Errorf("failed to parse GRAFANA_STORE_DASHBOARD_SHA256: %w", err) } @@ -221,6 +226,16 @@ func (p *frameworkProvider) Schema(_ context.Context, _ provider.SchemaRequest, Optional: true, MarkdownDescription: "A Grafana Connections API address. May alternatively be set via the `GRAFANA_CONNECTIONS_API_URL` environment variable.", }, + + "fleet_management_auth": schema.StringAttribute{ + Optional: true, + Sensitive: true, + MarkdownDescription: "A Grafana Fleet Management basic auth in the `username:password` format. May alternatively be set via the `GRAFANA_FLEET_MANAGEMENT_AUTH` environment variable.", + }, + "fleet_management_url": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "A Grafana Fleet Management API address. May alternatively be set via the `GRAFANA_FLEET_MANAGEMENT_URL` environment variable.", + }, }, } } diff --git a/pkg/provider/legacy_provider.go b/pkg/provider/legacy_provider.go index fb6af2ef5..bb2997288 100644 --- a/pkg/provider/legacy_provider.go +++ b/pkg/provider/legacy_provider.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -160,6 +161,23 @@ func Provider(version string) *schema.Provider { Description: "A Grafana Connections API address. May alternatively be set via the `GRAFANA_CONNECTIONS_API_URL` environment variable.", ValidateFunc: validation.IsURLWithHTTPorHTTPS, }, + + "fleet_management_auth": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + Description: "A Grafana Fleet Management basic auth in the `username:password` format. May alternatively be set via the `GRAFANA_FLEET_MANAGEMENT_AUTH` environment variable.", + ValidateFunc: validation.StringMatch( + regexp.MustCompile(`^[^:]+:[^:]+$`), + "must be in the format `username:password`", + ), + }, + "fleet_management_url": { + Type: schema.TypeString, + Optional: true, + Description: "A Grafana Fleet Management API address. May alternatively be set via the `GRAFANA_FLEET_MANAGEMENT_URL` environment variable.", + ValidateFunc: validation.IsURLWithHTTPorHTTPS, + }, }, ResourcesMap: legacySDKResources(), @@ -232,6 +250,8 @@ func configure(version string, p *schema.Provider) func(context.Context, *schema CloudProviderURL: stringValueOrNull(d, "cloud_provider_url"), ConnectionsAPIAccessToken: stringValueOrNull(d, "connections_api_access_token"), ConnectionsAPIURL: stringValueOrNull(d, "connections_api_url"), + FleetManagementAuth: stringValueOrNull(d, "fleet_management_auth"), + FleetManagementURL: stringValueOrNull(d, "fleet_management_url"), StoreDashboardSha256: boolValueOrNull(d, "store_dashboard_sha256"), HTTPHeaders: headers, Retries: int64ValueOrNull(d, "retries"), diff --git a/pkg/provider/resources.go b/pkg/provider/resources.go index e01ac0dfc..d8570bf92 100644 --- a/pkg/provider/resources.go +++ b/pkg/provider/resources.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/terraform-provider-grafana/v3/internal/resources/cloud" "github.com/grafana/terraform-provider-grafana/v3/internal/resources/cloudprovider" "github.com/grafana/terraform-provider-grafana/v3/internal/resources/connections" + "github.com/grafana/terraform-provider-grafana/v3/internal/resources/fleetmanagement" "github.com/grafana/terraform-provider-grafana/v3/internal/resources/grafana" "github.com/grafana/terraform-provider-grafana/v3/internal/resources/machinelearning" "github.com/grafana/terraform-provider-grafana/v3/internal/resources/oncall" @@ -64,6 +65,7 @@ func Resources() []*common.Resource { resources = append(resources, syntheticmonitoring.Resources...) resources = append(resources, cloudprovider.Resources...) resources = append(resources, connections.Resources...) + resources = append(resources, fleetmanagement.Resources...) return resources } diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl index 52a39a6df..08d7472a8 100644 --- a/templates/index.md.tmpl +++ b/templates/index.md.tmpl @@ -143,6 +143,10 @@ provider "grafana" { } ``` +### Managing Grafana Fleet Management + +{{ tffile "examples/provider/provider-fleet-management.tf" }} + ## Authentication One, or many, of the following authentication settings must be set. Each authentication setting allows a subset of resources to be used @@ -176,3 +180,10 @@ To create one, follow the instructions in the [obtaining cloud provider access t An access policy token created on the [Grafana Cloud Portal](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/using-an-access-policy-token/) to manage connections resources, such as Metrics Endpoint jobs. For guidance on creating one, see section [obtaining connections access token](#obtaining-connections-access-token). + +### `fleet_management_auth` + +[Grafana Fleet Management](https://grafana.com/docs/grafana-cloud/send-data/fleet-management/api-reference/) +uses basic auth to allow access to the API, where the username is the Fleet Management instance ID and the +password is the API token. You can access the instance ID and request a new Fleet Management API token on the +Connections -> Collector -> Fleet Management page, in the API tab.