diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 12474cd63..443a14076 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -63,7 +63,6 @@ jobs: xpack.security.enabled: true xpack.security.authc.api_key.enabled: true xpack.security.authc.token.enabled: true - xpack.security.http.ssl.enabled: false xpack.watcher.enabled: true xpack.license.self_generated.type: trial repositories.url.allowed_urls: https://example.com/* @@ -80,10 +79,28 @@ jobs: ELASTICSEARCH_USERNAME: ${{ env.KIBANA_SYSTEM_USERNAME }} ELASTICSEARCH_PASSWORD: ${{ env.KIBANA_SYSTEM_PASSWORD }} XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY: a7a6311933d3503b89bc2dbc36572c33a6c10925682e591bffcab6911c06786d -# LOGGING_ROOT_LEVEL: debug + # LOGGING_ROOT_LEVEL: debug ports: - 5601:5601 options: --health-cmd="curl http://localhost:5601/api/status" --health-interval=10s --health-timeout=5s --health-retries=10 + fleet: + image: docker.elastic.co/beats/elastic-agent:${{ matrix.version }} + env: + SERVER_NAME: fleet + FLEET_ENROLL: "1" + FLEET_URL: https://fleet:8220 + FLEET_INSECURE: "true" + FLEET_SERVER_ENABLE: "1" + FLEET_SERVER_POLICY_ID: fleet-server + FLEET_SERVER_ELASTICSEARCH_HOST: http://elasticsearch:9200 + FLEET_SERVER_ELASTICSEARCH_INSECURE: "true" + FLEET_SERVER_INSECURE_HTTP: "true" + KIBANA_HOST: http://kibana:5601 + KIBANA_FLEET_SETUP: "1" + KIBANA_FLEET_PASSWORD: ${{ env.ELASTIC_PASSWORD }} + ports: + - 8220:8220 + options: --restart="unless-stopped" timeout-minutes: 15 strategy: @@ -123,8 +140,6 @@ jobs: - name: Setup Kibana user run: make set-kibana-password env: - ELASTICSEARCH_ENDPOINTS: "http://localhost:9200" - ELASTICSEARCH_USERNAME: "elastic" ELASTICSEARCH_PASSWORD: ${{ env.ELASTIC_PASSWORD }} KIBANA_SYSTEM_USERNAME: ${{ env.KIBANA_SYSTEM_USERNAME }} KIBANA_SYSTEM_PASSWORD: ${{ env.KIBANA_SYSTEM_PASSWORD }} @@ -134,10 +149,17 @@ jobs: run: |- echo "apikey=$(make create-es-api-key | jq -r .encoded)" >> "$GITHUB_OUTPUT" env: - ELASTICSEARCH_ENDPOINTS: "http://localhost:9200" - ELASTICSEARCH_USERNAME: "elastic" ELASTICSEARCH_PASSWORD: ${{ env.ELASTIC_PASSWORD }} + - id: setup-fleet + name: Setup Fleet + if: matrix.version == '8.10.3' || matrix.version == '8.11.4' || matrix.version == '8.12.2' || matrix.version == '8.13.4' || matrix.version == '8.14.3' || matrix.version == '8.15.0' + run: |- + make setup-kibana-fleet + env: + ELASTICSEARCH_PASSWORD: ${{ env.ELASTIC_PASSWORD }} + FLEET_NAME: "fleet" + - id: force-install-synthetics name: Force install synthetics if: matrix.version == '8.14.3' || matrix.version == '8.15.0' diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c5da485b..aba9bbad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Fix handling of `sys_monitoring` in `elasticstack_fleet_agent_policy` ([#792](https://github.com/elastic/terraform-provider-elasticstack/pull/792)) - Migrate `elasticstack_fleet_agent_policy`, `elasticstack_fleet_integration` (both), and `elasticstack_fleet_server_host` to terraform-plugin-framework ([#785](https://github.com/elastic/terraform-provider-elasticstack/pull/785)) - Fix for synthetics http/tcp monitor produces inconsistent result after apply ([#801](https://github.com/elastic/terraform-provider-elasticstack/pull/801)) +- Migrate `elasticstack_fleet_integration_policy` to terraform-plugin-framework. Fix drift in integration policy secrets. ([#797](https://github.com/elastic/terraform-provider-elasticstack/pull/797)) ## [0.11.7] - 2024-09-20 diff --git a/Makefile b/Makefile index 7c10e7835..9b2eccb07 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,11 @@ KIBANA_SYSTEM_USERNAME ?= kibana_system KIBANA_SYSTEM_PASSWORD ?= password KIBANA_API_KEY_NAME ?= kibana-api-key +FLEET_NAME ?= terraform-elasticstack-fleet +FLEET_ENDPOINT ?= https://$(FLEET_NAME):8220 + SOURCE_LOCATION ?= $(shell pwd) +, := , export GOBIN = $(shell pwd)/bin @@ -72,7 +76,7 @@ retry = until [ $$(if [ -z "$$attempt" ]; then echo -n "0"; else echo -n "$$atte # To run specific test (e.g. TestAccResourceActionConnector) execute `make docker-testacc TESTARGS='-run ^TestAccResourceActionConnector$$'` # To enable tracing (or debugging), execute `make docker-testacc TF_LOG=TRACE` .PHONY: docker-testacc -docker-testacc: docker-elasticsearch docker-kibana ## Run acceptance tests in the docker container +docker-testacc: docker-elasticsearch docker-kibana docker-fleet ## Run acceptance tests in the docker container @ docker run --rm \ -e ELASTICSEARCH_ENDPOINTS="$(ELASTICSEARCH_ENDPOINTS)" \ -e KIBANA_ENDPOINT="$(KIBANA_ENDPOINT)" \ @@ -163,6 +167,30 @@ docker-kibana-with-tls: docker-network docker-elasticsearch set-kibana-password docker.elastic.co/kibana/kibana:$(STACK_VERSION); \ fi) +.PHONY: docker-fleet +docker-fleet: docker-network docker-elasticsearch docker-kibana setup-kibana-fleet ## Start Fleet node in docker container + @ docker rm -f $(FLEET_NAME) &> /dev/null || true + @ $(call retry, 5, if ! docker ps --format '{{.Names}}' | grep -w $(FLEET_NAME) > /dev/null 2>&1 ; then \ + docker run -d \ + -p 8220:8220 \ + -e SERVER_NAME=fleet \ + -e FLEET_ENROLL=1 \ + -e FLEET_URL=$(FLEET_ENDPOINT) \ + -e FLEET_INSECURE=true \ + -e FLEET_SERVER_ENABLE=1 \ + -e FLEET_SERVER_POLICY_ID=fleet-server \ + -e FLEET_SERVER_ELASTICSEARCH_HOST=$(ELASTICSEARCH_ENDPOINTS) \ + -e FLEET_SERVER_ELASTICSEARCH_INSECURE=true \ + -e FLEET_SERVER_INSECURE_HTTP=true \ + -e KIBANA_HOST=$(KIBANA_ENDPOINT) \ + -e KIBANA_FLEET_SETUP=1 \ + -e KIBANA_FLEET_USERNAME=$(ELASTICSEARCH_USERNAME) \ + -e KIBANA_FLEET_PASSWORD=$(ELASTICSEARCH_PASSWORD) \ + --name $(FLEET_NAME) \ + --network $(ELASTICSEARCH_NETWORK) \ + docker.elastic.co/beats/elastic-agent:$(STACK_VERSION); \ + fi) + .PHONY: docker-network docker-network: ## Create a dedicated network for ES and test runs @@ -172,19 +200,25 @@ docker-network: ## Create a dedicated network for ES and test runs .PHONY: set-kibana-password set-kibana-password: ## Sets the ES KIBANA_SYSTEM_USERNAME's password to KIBANA_SYSTEM_PASSWORD. This expects Elasticsearch to be available at localhost:9200 - @ $(call retry, 10, curl -X POST -u $(ELASTICSEARCH_USERNAME):$(ELASTICSEARCH_PASSWORD) -H "Content-Type: application/json" http://localhost:9200/_security/user/$(KIBANA_SYSTEM_USERNAME)/_password -d "{\"password\":\"$(KIBANA_SYSTEM_PASSWORD)\"}" | grep -q "^{}") + @ $(call retry, 10, curl -sS -X POST -u $(ELASTICSEARCH_USERNAME):$(ELASTICSEARCH_PASSWORD) -H "Content-Type: application/json" http://localhost:9200/_security/user/$(KIBANA_SYSTEM_USERNAME)/_password -d '{"password":"$(KIBANA_SYSTEM_PASSWORD)"}' | grep -q "^{}") .PHONY: create-es-api-key create-es-api-key: ## Creates and outputs a new API Key. This expects Elasticsearch to be available at localhost:9200 - @ $(call retry, 10, curl -X POST -u $(ELASTICSEARCH_USERNAME):$(ELASTICSEARCH_PASSWORD) -H "Content-Type: application/json" http://localhost:9200/_security/api_key -d "{\"name\":\"$(KIBANA_API_KEY_NAME)\"}") + @ $(call retry, 10, curl -sS -X POST -u $(ELASTICSEARCH_USERNAME):$(ELASTICSEARCH_PASSWORD) -H "Content-Type: application/json" http://localhost:9200/_security/api_key -d '{"name":"$(KIBANA_API_KEY_NAME)"}') .PHONY: create-es-bearer-token -create-es-bearer-token: - @ $(call retry, 10, curl -X POST -u $(ELASTICSEARCH_USERNAME):$(ELASTICSEARCH_PASSWORD) -H "Content-Type: application/json" http://localhost:9200/_security/oauth2/token -d "{\"grant_type\": \"client_credentials\"}") +create-es-bearer-token: ## Creates and outputs a new OAuth bearer token. This expects Elasticsearch to be available at localhost:9200 + @ $(call retry, 10, curl -sS -X POST -u $(ELASTICSEARCH_USERNAME):$(ELASTICSEARCH_PASSWORD) -H "Content-Type: application/json" http://localhost:9200/_security/oauth2/token -d '{"grant_type":"client_credentials"}') + +.PHONY: setup-kibana-fleet +setup-kibana-fleet: ## Creates the agent and integration policies required to run Fleet. This expects Kibana to be available at localhost:5601 + @ $(call retry, 10, curl -sS --fail-with-body -X POST -u $(ELASTICSEARCH_USERNAME):$(ELASTICSEARCH_PASSWORD) -H "Content-Type: application/json" -H "kbn-xsrf: true" http://localhost:5601/api/fleet/fleet_server_hosts -d '{"name":"default"$(,)"host_urls":["$(FLEET_ENDPOINT)"]$(,)"is_default":true}') + @ $(call retry, 10, curl -sS --fail-with-body -X POST -u $(ELASTICSEARCH_USERNAME):$(ELASTICSEARCH_PASSWORD) -H "Content-Type: application/json" -H "kbn-xsrf: true" http://localhost:5601/api/fleet/agent_policies -d '{"id":"fleet-server"$(,)"name":"Fleet Server"$(,)"namespace":"default"$(,)"monitoring_enabled":["logs"$(,)"metrics"]}') + @ $(call retry, 10, curl -sS --fail-with-body -X POST -u $(ELASTICSEARCH_USERNAME):$(ELASTICSEARCH_PASSWORD) -H "Content-Type: application/json" -H "kbn-xsrf: true" http://localhost:5601/api/fleet/package_policies -d '{"name":"fleet-server"$(,)"namespace":"default"$(,)"policy_id":"fleet-server"$(,)"enabled":true$(,)"inputs":[{"type":"fleet-server"$(,)"enabled":true$(,)"streams":[]$(,)"vars":{}}]$(,)"package":{"name":"fleet_server"$(,)"version":"1.5.0"}}') .PHONY: docker-clean docker-clean: ## Try to remove provisioned nodes and assigned network - @ docker rm -f $(ELASTICSEARCH_NAME) $(KIBANA_NAME) || true + @ docker rm -f $(ELASTICSEARCH_NAME) $(KIBANA_NAME) $(FLEET_NAME) || true @ docker network rm $(ELASTICSEARCH_NETWORK) || true diff --git a/docs/resources/fleet_integration_policy.md b/docs/resources/fleet_integration_policy.md index 3b098e529..351cd8e5e 100644 --- a/docs/resources/fleet_integration_policy.md +++ b/docs/resources/fleet_integration_policy.md @@ -93,7 +93,7 @@ resource "elasticstack_fleet_integration_policy" "sample" { - `description` (String) The description of the integration policy. - `enabled` (Boolean) Enable the integration policy. - `force` (Boolean) Force operations, such as creation and deletion, to occur. -- `input` (Block List) (see [below for nested schema](#nestedblock--input)) +- `input` (Block List) Integration inputs. (see [below for nested schema](#nestedblock--input)) - `policy_id` (String) Unique identifier of the integration policy. - `vars_json` (String, Sensitive) Integration-level variables as JSON. diff --git a/generated/fleet/fleet.gen.go b/generated/fleet/fleet.gen.go index 990ba1b7b..37fc86c65 100644 --- a/generated/fleet/fleet.gen.go +++ b/generated/fleet/fleet.gen.go @@ -1,6 +1,6 @@ // Package fleet provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/deepmap/oapi-codegen/v2 version v2.1.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.0 DO NOT EDIT. package fleet import ( @@ -299,10 +299,13 @@ type NewPackagePolicy struct { Name string `json:"name"` Namespace *string `json:"namespace,omitempty"` // Deprecated: - OutputId *string `json:"output_id,omitempty"` - Package *PackagePolicyPackageInfo `json:"package,omitempty"` - PolicyId *string `json:"policy_id,omitempty"` - Vars *map[string]interface{} `json:"vars,omitempty"` + OutputId *string `json:"output_id,omitempty"` + Package *PackagePolicyPackageInfo `json:"package,omitempty"` + PolicyId *string `json:"policy_id,omitempty"` + SecretReferences *[]struct { + Id *string `json:"id,omitempty"` + } `json:"secret_references,omitempty"` + Vars *map[string]interface{} `json:"vars,omitempty"` } // OutputCreateRequest defines model for output_create_request. @@ -669,11 +672,14 @@ type PackagePolicy struct { Name string `json:"name"` Namespace *string `json:"namespace,omitempty"` // Deprecated: - OutputId *string `json:"output_id,omitempty"` - Package *PackagePolicyPackageInfo `json:"package,omitempty"` - PolicyId *string `json:"policy_id,omitempty"` - Revision float32 `json:"revision"` - Vars *map[string]interface{} `json:"vars,omitempty"` + OutputId *string `json:"output_id,omitempty"` + Package *PackagePolicyPackageInfo `json:"package,omitempty"` + PolicyId *string `json:"policy_id,omitempty"` + Revision float32 `json:"revision"` + SecretReferences *[]struct { + Id *string `json:"id,omitempty"` + } `json:"secret_references,omitempty"` + Vars *map[string]interface{} `json:"vars,omitempty"` } // PackagePolicyInput defines model for package_policy_input. diff --git a/generated/fleet/getschema.go b/generated/fleet/getschema.go index dd49fa7e9..b08753c35 100644 --- a/generated/fleet/getschema.go +++ b/generated/fleet/getschema.go @@ -71,6 +71,7 @@ var transformers = []TransformFunc{ transformSchemasInputsType, transformInlinePackageDefinitions, transformAddPackagePolicyVars, + transformAddPackagePolicySecretReferences, transformFixPackageSearchResult, } @@ -333,6 +334,30 @@ func transformAddPackagePolicyVars(schema *Schema) { } } +// transformAddPackagePolicySecretReferences adds the missing 'secretReferences' +// field to the PackagePolicy schema struct. +func transformAddPackagePolicySecretReferences(schema *Schema) { + inputs, ok := schema.Components.GetFields("schemas.new_package_policy.properties") + if !ok { + panic("properties not found") + } + + // Only add it if it doesn't exist. + if _, ok = inputs.Get("secret_references"); !ok { + inputs.Set("secret_references", map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{ + "type": "string", + }, + }, + }, + }) + } +} + // transformFixPackageSearchResult removes unneeded fields from the // SearchResult struct. These fields are also causing parsing errors. func transformFixPackageSearchResult(schema *Schema) { diff --git a/internal/clients/fleet/fleet.go b/internal/clients/fleet/fleet.go index 778e326f6..6c3cc2c29 100644 --- a/internal/clients/fleet/fleet.go +++ b/internal/clients/fleet/fleet.go @@ -253,7 +253,7 @@ func DeleteFleetServerHost(ctx context.Context, client *Client, id string) fwdia } // ReadPackagePolicy reads a specific package policy from the API. -func ReadPackagePolicy(ctx context.Context, client *Client, id string) (*fleetapi.PackagePolicy, diag.Diagnostics) { +func ReadPackagePolicy(ctx context.Context, client *Client, id string) (*fleetapi.PackagePolicy, fwdiag.Diagnostics) { format := fleetapi.GetPackagePolicyParamsFormatSimplified params := fleetapi.GetPackagePolicyParams{ Format: &format, @@ -261,7 +261,7 @@ func ReadPackagePolicy(ctx context.Context, client *Client, id string) (*fleetap resp, err := client.API.GetPackagePolicyWithResponse(ctx, id, ¶ms) if err != nil { - return nil, diag.FromErr(err) + return nil, fromErr(err) } switch resp.StatusCode() { @@ -270,12 +270,12 @@ func ReadPackagePolicy(ctx context.Context, client *Client, id string) (*fleetap case http.StatusNotFound: return nil, nil default: - return nil, reportUnknownError(resp.StatusCode(), resp.Body) + return nil, reportUnknownErrorFw(resp.StatusCode(), resp.Body) } } // CreatePackagePolicy creates a new package policy. -func CreatePackagePolicy(ctx context.Context, client *Client, req fleetapi.CreatePackagePolicyJSONRequestBody) (*fleetapi.PackagePolicy, diag.Diagnostics) { +func CreatePackagePolicy(ctx context.Context, client *Client, req fleetapi.CreatePackagePolicyJSONRequestBody) (*fleetapi.PackagePolicy, fwdiag.Diagnostics) { format := fleetapi.CreatePackagePolicyParamsFormatSimplified params := fleetapi.CreatePackagePolicyParams{ Format: &format, @@ -283,19 +283,19 @@ func CreatePackagePolicy(ctx context.Context, client *Client, req fleetapi.Creat resp, err := client.API.CreatePackagePolicyWithResponse(ctx, ¶ms, req) if err != nil { - return nil, diag.FromErr(err) + return nil, fromErr(err) } switch resp.StatusCode() { case http.StatusOK: return &resp.JSON200.Item, nil default: - return nil, reportUnknownError(resp.StatusCode(), resp.Body) + return nil, reportUnknownErrorFw(resp.StatusCode(), resp.Body) } } // UpdatePackagePolicy updates an existing package policy. -func UpdatePackagePolicy(ctx context.Context, client *Client, id string, req fleetapi.UpdatePackagePolicyJSONRequestBody) (*fleetapi.PackagePolicy, diag.Diagnostics) { +func UpdatePackagePolicy(ctx context.Context, client *Client, id string, req fleetapi.UpdatePackagePolicyJSONRequestBody) (*fleetapi.PackagePolicy, fwdiag.Diagnostics) { format := fleetapi.UpdatePackagePolicyParamsFormatSimplified params := fleetapi.UpdatePackagePolicyParams{ Format: &format, @@ -303,23 +303,23 @@ func UpdatePackagePolicy(ctx context.Context, client *Client, id string, req fle resp, err := client.API.UpdatePackagePolicyWithResponse(ctx, id, ¶ms, req) if err != nil { - return nil, diag.FromErr(err) + return nil, fromErr(err) } switch resp.StatusCode() { case http.StatusOK: return &resp.JSON200.Item, nil default: - return nil, reportUnknownError(resp.StatusCode(), resp.Body) + return nil, reportUnknownErrorFw(resp.StatusCode(), resp.Body) } } // DeletePackagePolicy deletes an existing package policy. -func DeletePackagePolicy(ctx context.Context, client *Client, id string, force bool) diag.Diagnostics { +func DeletePackagePolicy(ctx context.Context, client *Client, id string, force bool) fwdiag.Diagnostics { params := fleetapi.DeletePackagePolicyParams{Force: &force} resp, err := client.API.DeletePackagePolicyWithResponse(ctx, id, ¶ms) if err != nil { - return diag.FromErr(err) + return fromErr(err) } switch resp.StatusCode() { @@ -328,7 +328,7 @@ func DeletePackagePolicy(ctx context.Context, client *Client, id string, force b case http.StatusNotFound: return nil default: - return reportUnknownError(resp.StatusCode(), resp.Body) + return reportUnknownErrorFw(resp.StatusCode(), resp.Body) } } diff --git a/internal/fleet/agent_policy/resource_test.go b/internal/fleet/agent_policy/resource_test.go index 3f8ab7c0d..3f827da47 100644 --- a/internal/fleet/agent_policy/resource_test.go +++ b/internal/fleet/agent_policy/resource_test.go @@ -2,7 +2,6 @@ package agent_policy_test import ( "context" - "errors" "fmt" "testing" @@ -224,7 +223,7 @@ func checkResourceAgentPolicySkipDestroy(s *terraform.State) error { } if diags = fleet.DeleteAgentPolicy(context.Background(), fleetClient, rs.Primary.ID); diags.HasError() { - return errors.New(diags.Errors()[0].Summary()) + return utils.FwDiagsAsError(diags) } } return nil diff --git a/internal/fleet/enrollment_tokens/models.go b/internal/fleet/enrollment_tokens/models.go index 847d9d00b..79fc4d250 100644 --- a/internal/fleet/enrollment_tokens/models.go +++ b/internal/fleet/enrollment_tokens/models.go @@ -31,7 +31,7 @@ func (model *enrollmentTokensModel) populateFromAPI(ctx context.Context, data [] return } -func newEnrollmentTokenModel(data fleetapi.EnrollmentApiKey) enrollmentTokenModel { +func newEnrollmentTokenModel(data fleetapi.EnrollmentApiKey, meta utils.ListMeta) enrollmentTokenModel { return enrollmentTokenModel{ KeyID: types.StringValue(data.Id), Active: types.BoolValue(data.Active), diff --git a/internal/fleet/integration_policy/create.go b/internal/fleet/integration_policy/create.go new file mode 100644 index 000000000..9f320a507 --- /dev/null +++ b/internal/fleet/integration_policy/create.go @@ -0,0 +1,51 @@ +package integration_policy + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients/fleet" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *integrationPolicyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var planModel integrationPolicyModel + + diags := req.Plan.Get(ctx, &planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetFleetClient() + if err != nil { + resp.Diagnostics.AddError(err.Error(), "") + return + } + + body, diags := planModel.toAPIModel(ctx, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + policy, diags := fleet.CreatePackagePolicy(ctx, client, body) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = handleReqRespSecrets(ctx, body, policy, resp.Private) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = planModel.populateFromAPI(ctx, policy) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, planModel) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/fleet/integration_policy/delete.go b/internal/fleet/integration_policy/delete.go new file mode 100644 index 000000000..24b6caf2f --- /dev/null +++ b/internal/fleet/integration_policy/delete.go @@ -0,0 +1,29 @@ +package integration_policy + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients/fleet" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *integrationPolicyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var stateModel integrationPolicyModel + + diags := req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetFleetClient() + if err != nil { + resp.Diagnostics.AddError(err.Error(), "") + return + } + + policyID := stateModel.PolicyID.ValueString() + force := stateModel.Force.ValueBool() + diags = fleet.DeletePackagePolicy(ctx, client, policyID, force) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/fleet/integration_policy/models.go b/internal/fleet/integration_policy/models.go new file mode 100644 index 000000000..5dfe88c73 --- /dev/null +++ b/internal/fleet/integration_policy/models.go @@ -0,0 +1,151 @@ +package integration_policy + +import ( + "context" + "sort" + + fleetapi "github.com/elastic/terraform-provider-elasticstack/generated/fleet" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type integrationPolicyModel struct { + ID types.String `tfsdk:"id"` + PolicyID types.String `tfsdk:"policy_id"` + Name types.String `tfsdk:"name"` + Namespace types.String `tfsdk:"namespace"` + AgentPolicyID types.String `tfsdk:"agent_policy_id"` + Description types.String `tfsdk:"description"` + Enabled types.Bool `tfsdk:"enabled"` + Force types.Bool `tfsdk:"force"` + IntegrationName types.String `tfsdk:"integration_name"` + IntegrationVersion types.String `tfsdk:"integration_version"` + Input types.List `tfsdk:"input"` //> integrationPolicyInputModel + VarsJson jsontypes.Normalized `tfsdk:"vars_json"` +} + +type integrationPolicyInputModel struct { + InputID types.String `tfsdk:"input_id"` + Enabled types.Bool `tfsdk:"enabled"` + StreamsJson jsontypes.Normalized `tfsdk:"streams_json"` + VarsJson jsontypes.Normalized `tfsdk:"vars_json"` +} + +func (model *integrationPolicyModel) populateFromAPI(ctx context.Context, data *fleetapi.PackagePolicy) diag.Diagnostics { + if data == nil { + return nil + } + + var diags diag.Diagnostics + + model.ID = types.StringValue(data.Id) + model.PolicyID = types.StringValue(data.Id) + model.Name = types.StringValue(data.Name) + model.Namespace = types.StringPointerValue(data.Namespace) + model.AgentPolicyID = types.StringPointerValue(data.PolicyId) + model.Description = types.StringPointerValue(data.Description) + model.Enabled = types.BoolPointerValue(data.Enabled) + model.IntegrationName = types.StringValue(data.Package.Name) + model.IntegrationVersion = types.StringValue(data.Package.Version) + model.VarsJson = utils.MapToNormalizedType(utils.Deref(data.Vars), path.Root("vars_json"), diags) + + model.populateInputFromAPI(ctx, data.Inputs, diags) + + return diags +} + +func (model *integrationPolicyModel) populateInputFromAPI(ctx context.Context, inputs map[string]fleetapi.PackagePolicyInput, diags diag.Diagnostics) { + newInputs := utils.TransformMapToSlice(inputs, path.Root("input"), diags, + func(inputData fleetapi.PackagePolicyInput, meta utils.MapMeta) integrationPolicyInputModel { + return integrationPolicyInputModel{ + InputID: types.StringValue(meta.Key), + Enabled: types.BoolValue(inputData.Enabled), + StreamsJson: utils.MapToNormalizedType(utils.Deref(inputData.Streams), meta.Path.AtName("streams_json"), diags), + VarsJson: utils.MapToNormalizedType(utils.Deref(inputData.Vars), meta.Path.AtName("vars_json"), diags), + } + }) + if newInputs == nil { + model.Input = types.ListNull(getInputType()) + } else { + oldInputs := utils.ListTypeAs[integrationPolicyInputModel](ctx, model.Input, path.Root("input"), diags) + sortInputs(newInputs, oldInputs) + + inputList, d := types.ListValueFrom(ctx, getInputType(), newInputs) + diags.Append(d...) + + model.Input = inputList + } +} + +func (model integrationPolicyModel) toAPIModel(ctx context.Context, isUpdate bool) (fleetapi.PackagePolicyRequest, diag.Diagnostics) { + var diags diag.Diagnostics + + body := fleetapi.PackagePolicyRequest{ + Description: model.Description.ValueStringPointer(), + Force: model.Force.ValueBoolPointer(), + Name: model.Name.ValueString(), + Namespace: model.Namespace.ValueStringPointer(), + Package: struct { + Name string `json:"name"` + Version string `json:"version"` + }{ + Name: model.IntegrationName.ValueString(), + Version: model.IntegrationVersion.ValueString(), + }, + PolicyId: model.AgentPolicyID.ValueString(), + Vars: utils.MapRef(utils.NormalizedTypeToMap[any](model.VarsJson, path.Root("vars_json"), diags)), + } + + if isUpdate { + body.Id = model.ID.ValueStringPointer() + } + + body.Inputs = utils.MapRef(utils.ListTypeToMap(ctx, model.Input, path.Root("input"), diags, + func(inputModel integrationPolicyInputModel, meta utils.ListMeta) (string, fleetapi.PackagePolicyRequestInput) { + return inputModel.InputID.ValueString(), fleetapi.PackagePolicyRequestInput{ + Enabled: inputModel.Enabled.ValueBoolPointer(), + Streams: utils.MapRef(utils.NormalizedTypeToMap[fleetapi.PackagePolicyRequestInputStream](inputModel.StreamsJson, meta.Path.AtName("streams_json"), diags)), + Vars: utils.MapRef(utils.NormalizedTypeToMap[any](inputModel.VarsJson, meta.Path.AtName("vars_json"), diags)), + } + })) + + return body, diags +} + +// sortInputs will sort the 'incoming' list of input definitions based on +// the order of inputs defined in the 'existing' list. Inputs not present in +// 'existing' will be placed at the end of the list. Inputs are identified by +// their ID ('input_id'). The 'incoming' slice will be sorted in-place. +func sortInputs(incoming []integrationPolicyInputModel, existing []integrationPolicyInputModel) { + if len(existing) == 0 { + sort.Slice(incoming, func(i, j int) bool { + return incoming[i].InputID.ValueString() < incoming[j].InputID.ValueString() + }) + return + } + + idToIndex := make(map[string]int, len(existing)) + for index, inputData := range existing { + inputID := inputData.InputID.ValueString() + idToIndex[inputID] = index + } + + sort.Slice(incoming, func(i, j int) bool { + iID := incoming[i].InputID.ValueString() + iIdx, ok := idToIndex[iID] + if !ok { + return false + } + + jID := incoming[j].InputID.ValueString() + jIdx, ok := idToIndex[jID] + if !ok { + return true + } + + return iIdx < jIdx + }) +} diff --git a/internal/fleet/integration_policy/models_test.go b/internal/fleet/integration_policy/models_test.go new file mode 100644 index 000000000..b8362f11f --- /dev/null +++ b/internal/fleet/integration_policy/models_test.go @@ -0,0 +1,64 @@ +package integration_policy + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/require" +) + +func Test_SortInputs(t *testing.T) { + t.Run("WithExisting", func(t *testing.T) { + existing := []integrationPolicyInputModel{ + {InputID: types.StringValue("A"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("B"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("C"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("D"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("E"), Enabled: types.BoolValue(true)}, + } + + incoming := []integrationPolicyInputModel{ + {InputID: types.StringValue("G"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("F"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("B"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("E"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("C"), Enabled: types.BoolValue(true)}, + } + + want := []integrationPolicyInputModel{ + {InputID: types.StringValue("B"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("C"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("E"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("G"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("F"), Enabled: types.BoolValue(true)}, + } + + sortInputs(incoming, existing) + + require.Equal(t, want, incoming) + }) + + t.Run("WithEmpty", func(t *testing.T) { + var existing []integrationPolicyInputModel + + incoming := []integrationPolicyInputModel{ + {InputID: types.StringValue("G"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("F"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("B"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("E"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("C"), Enabled: types.BoolValue(true)}, + } + + want := []integrationPolicyInputModel{ + {InputID: types.StringValue("B"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("C"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("E"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("F"), Enabled: types.BoolValue(true)}, + {InputID: types.StringValue("G"), Enabled: types.BoolValue(true)}, + } + + sortInputs(incoming, existing) + + require.Equal(t, want, incoming) + }) +} diff --git a/internal/fleet/integration_policy/read.go b/internal/fleet/integration_policy/read.go new file mode 100644 index 000000000..9f0590a47 --- /dev/null +++ b/internal/fleet/integration_policy/read.go @@ -0,0 +1,51 @@ +package integration_policy + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients/fleet" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *integrationPolicyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var stateModel integrationPolicyModel + + diags := req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetFleetClient() + if err != nil { + resp.Diagnostics.AddError(err.Error(), "") + return + } + + policyID := stateModel.PolicyID.ValueString() + policy, diags := fleet.ReadPackagePolicy(ctx, client, policyID) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if policy == nil { + resp.State.RemoveResource(ctx) + return + } + + diags = handleRespSecrets(ctx, policy, resp.Private) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = stateModel.populateFromAPI(ctx, policy) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, stateModel) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/fleet/integration_policy/resource.go b/internal/fleet/integration_policy/resource.go new file mode 100644 index 000000000..e1cc39be1 --- /dev/null +++ b/internal/fleet/integration_policy/resource.go @@ -0,0 +1,39 @@ +package integration_policy + +import ( + "context" + "fmt" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +var ( + _ resource.Resource = &integrationPolicyResource{} + _ resource.ResourceWithConfigure = &integrationPolicyResource{} + _ resource.ResourceWithImportState = &integrationPolicyResource{} +) + +// NewResource is a helper function to simplify the provider implementation. +func NewResource() resource.Resource { + return &integrationPolicyResource{} +} + +type integrationPolicyResource struct { + client *clients.ApiClient +} + +func (r *integrationPolicyResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + client, diags := clients.ConvertProviderData(req.ProviderData) + resp.Diagnostics.Append(diags...) + r.client = client +} + +func (r *integrationPolicyResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = fmt.Sprintf("%s_%s", req.ProviderTypeName, "fleet_integration_policy") +} + +func (r *integrationPolicyResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("policy_id"), req, resp) +} diff --git a/internal/fleet/integration_policy/resource_test.go b/internal/fleet/integration_policy/resource_test.go new file mode 100644 index 000000000..6d4d3c41d --- /dev/null +++ b/internal/fleet/integration_policy/resource_test.go @@ -0,0 +1,439 @@ +package integration_policy_test + +import ( + "context" + "fmt" + "regexp" + "testing" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/fleet" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/elastic/terraform-provider-elasticstack/internal/versionutils" + "github.com/hashicorp/go-version" + sdkacctest "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 minVersionIntegrationPolicy = version.Must(version.NewVersion("8.10.0")) + +func TestAccResourceIntegrationPolicy(t *testing.T) { + policyName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceIntegrationPolicyDestroy, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionIntegrationPolicy), + Config: testAccResourceIntegrationPolicyCreate(policyName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "name", policyName), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "description", "IntegrationPolicyTest Policy"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_name", "tcp"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_version", "1.16.0"), + resource.TestCheckNoResourceAttr("elasticstack_fleet_integration_policy.test_policy", "vars_json"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.input_id", "tcp-tcp"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.enabled", "true"), + resource.TestCheckNoResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.vars_json"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.streams_json", `{"tcp.generic":{"enabled":true,"vars":{"custom":"","data_stream.dataset":"tcp.generic","listen_address":"localhost","listen_port":8080,"ssl":"","syslog_options":"field: message","tags":[]}}}`), + ), + }, + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionIntegrationPolicy), + Config: testAccResourceIntegrationPolicyUpdate(policyName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "name", policyName), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "description", "Updated Integration Policy"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_name", "tcp"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_version", "1.16.0"), + resource.TestCheckNoResourceAttr("elasticstack_fleet_integration_policy.test_policy", "vars_json"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.input_id", "tcp-tcp"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.enabled", "false"), + resource.TestCheckNoResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.vars_json"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.streams_json", `{"tcp.generic":{"enabled":false,"vars":{"custom":"","data_stream.dataset":"tcp.generic","listen_address":"localhost","listen_port":8085,"ssl":"","syslog_options":"field: message","tags":[]}}}`), + ), + }, + }, + }) +} + +func TestAccResourceIntegrationPolicySecretsFromSDK(t *testing.T) { + policyName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceIntegrationPolicyDestroy, + Steps: []resource.TestStep{ + { + ExternalProviders: map[string]resource.ExternalProvider{ + "elasticstack": { + Source: "elastic/elasticstack", + VersionConstraint: "0.11.7", + }, + }, + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionIntegrationPolicy), + Config: testAccResourceIntegrationPolicySecretsCreate(policyName, "created"), + ExpectNonEmptyPlan: true, // secret churn + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "name", policyName), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "description", "IntegrationPolicyTest Policy"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_name", "aws_logs"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_version", "1.4.0"), + resource.TestMatchResourceAttr("elasticstack_fleet_integration_policy.test_policy", "vars_json", regexp.MustCompile(`{"access_key_id":{"id":"\S+","isSecretRef":true},"default_region":"us-east-1","endpoint":"endpoint","secret_access_key":{"id":"\S+","isSecretRef":true},"session_token":{"id":"\S+","isSecretRef":true}}`)), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.input_id", "aws_logs-aws-cloudwatch"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.enabled", "true"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.vars_json", ""), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.streams_json", `{"aws_logs.generic":{"enabled":true,"vars":{"api_sleep":"200ms","api_timeput":"120s","custom":"","data_stream.dataset":"aws_logs.generic","log_streams":[],"number_of_workers":1,"preserve_original_event":false,"scan_frequency":"1m","start_position":"beginning","tags":["forwarded"]}}}`), + ), + }, + { + ProtoV6ProviderFactories: acctest.Providers, + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionIntegrationPolicy), + Config: testAccResourceIntegrationPolicySecretsCreate(policyName, "created"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "name", policyName), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "description", "IntegrationPolicyTest Policy"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_name", "aws_logs"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_version", "1.4.0"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "vars_json", fmt.Sprintf(`{"access_key_id":"placeholder","default_region":"us-east-1","endpoint":"endpoint","secret_access_key":"created %s","session_token":"placeholder"}`, policyName)), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.input_id", "aws_logs-aws-cloudwatch"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.enabled", "true"), + resource.TestCheckNoResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.vars_json"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.streams_json", `{"aws_logs.generic":{"enabled":true,"vars":{"api_sleep":"200ms","api_timeput":"120s","custom":"","data_stream.dataset":"aws_logs.generic","log_streams":[],"number_of_workers":1,"preserve_original_event":false,"scan_frequency":"1m","start_position":"beginning","tags":["forwarded"]}}}`), + ), + }, + }, + }) +} + +func TestAccResourceIntegrationPolicySecrets(t *testing.T) { + policyName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceIntegrationPolicyDestroy, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionIntegrationPolicy), + Config: testAccResourceIntegrationPolicySecretsCreate(policyName, "created"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "name", policyName), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "description", "IntegrationPolicyTest Policy"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_name", "aws_logs"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_version", "1.4.0"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "vars_json", fmt.Sprintf(`{"access_key_id":"placeholder","default_region":"us-east-1","endpoint":"endpoint","secret_access_key":"created %s","session_token":"placeholder"}`, policyName)), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.input_id", "aws_logs-aws-cloudwatch"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.enabled", "true"), + resource.TestCheckNoResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.vars_json"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.streams_json", `{"aws_logs.generic":{"enabled":true,"vars":{"api_sleep":"200ms","api_timeput":"120s","custom":"","data_stream.dataset":"aws_logs.generic","log_streams":[],"number_of_workers":1,"preserve_original_event":false,"scan_frequency":"1m","start_position":"beginning","tags":["forwarded"]}}}`), + ), + }, + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionIntegrationPolicy), + Config: testAccResourceIntegrationPolicySecretsUpdate(policyName, "updated"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "name", policyName), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "description", "Updated Integration Policy"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_name", "aws_logs"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_version", "1.4.0"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "vars_json", fmt.Sprintf(`{"access_key_id":"placeholder","default_region":"us-east-2","endpoint":"endpoint","secret_access_key":"updated %s","session_token":"placeholder"}`, policyName)), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.input_id", "aws_logs-aws-cloudwatch"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.enabled", "false"), + resource.TestCheckNoResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.vars_json"), + resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.streams_json", `{"aws_logs.generic":{"enabled":false,"vars":{"api_sleep":"200ms","api_timeput":"120s","custom":"","data_stream.dataset":"aws_logs.generic","log_streams":[],"number_of_workers":1,"preserve_original_event":false,"scan_frequency":"2m","start_position":"beginning","tags":["forwarded"]}}}`), + ), + }, + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionIntegrationPolicy), + ResourceName: "elasticstack_fleet_integration_policy.test_policy", + Config: testAccResourceIntegrationPolicyUpdate(policyName), + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"vars_json"}, + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr("elasticstack_fleet_integration_policy.test_policy", "vars_json", regexp.MustCompile(`{"access_key_id":{"id":"\S+","isSecretRef":true},"default_region":"us-east-2","endpoint":"endpoint","secret_access_key":{"id":"\S+","isSecretRef":true},"session_token":{"id":"\S+","isSecretRef":true}}`)), + ), + }, + }, + }) +} + +func checkResourceIntegrationPolicyDestroy(s *terraform.State) error { + client, err := clients.NewAcceptanceTestingClient() + if err != nil { + return err + } + + fleetClient, err := client.GetFleetClient() + if err != nil { + return err + } + + for _, rs := range s.RootModule().Resources { + switch rs.Type { + case "elasticstack_fleet_agent_policy": + policy, diags := fleet.ReadAgentPolicy(context.Background(), fleetClient, rs.Primary.ID) + if diags.HasError() { + return utils.FwDiagsAsError(diags) + } + if policy != nil { + return fmt.Errorf("agent policy id=%v still exists, but it should have been removed", rs.Primary.ID) + } + case "elasticstack_fleet_integration_policy": + policy, diags := fleet.ReadPackagePolicy(context.Background(), fleetClient, rs.Primary.ID) + if diags.HasError() { + return utils.FwDiagsAsError(diags) + } + if policy != nil { + return fmt.Errorf("integration policy id=%v still exists, but it should have been removed", rs.Primary.ID) + } + default: + continue + } + + } + return nil +} + +func testAccResourceIntegrationPolicyCommon(name string, integrationName string, integrationVersion string) string { + return fmt.Sprintf(` +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +resource "elasticstack_fleet_integration" "test_policy" { + name = "%s" + version = "%s" + force = true +} + +resource "elasticstack_fleet_agent_policy" "test_policy" { + name = "%s Agent Policy" + namespace = "default" + description = "IntegrationPolicyTest Agent Policy" + monitor_logs = true + monitor_metrics = true + skip_destroy = false +} + +data "elasticstack_fleet_enrollment_tokens" "test_policy" { + policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id +} +`, integrationName, integrationVersion, name) +} + +func testAccResourceIntegrationPolicyCreate(id string) string { + common := testAccResourceIntegrationPolicyCommon(id, "tcp", "1.16.0") + return fmt.Sprintf(` +%s + +resource "elasticstack_fleet_integration_policy" "test_policy" { + name = "%s" + namespace = "default" + description = "IntegrationPolicyTest Policy" + agent_policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id + integration_name = elasticstack_fleet_integration.test_policy.name + integration_version = elasticstack_fleet_integration.test_policy.version + + input { + input_id = "tcp-tcp" + enabled = true + streams_json = jsonencode({ + "tcp.generic": { + "enabled": true + "vars": { + "listen_address": "localhost" + "listen_port": 8080 + "data_stream.dataset": "tcp.generic" + "tags": [] + "syslog_options": "field: message" + "ssl": "" + "custom": "" + } + } + }) + } +} +`, common, id) +} + +func testAccResourceIntegrationPolicyUpdate(id string) string { + common := testAccResourceIntegrationPolicyCommon(id, "tcp", "1.16.0") + return fmt.Sprintf(` +%s + +resource "elasticstack_fleet_integration_policy" "test_policy" { + name = "%s" + namespace = "default" + description = "Updated Integration Policy" + agent_policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id + integration_name = elasticstack_fleet_integration.test_policy.name + integration_version = elasticstack_fleet_integration.test_policy.version + + input { + input_id = "tcp-tcp" + enabled = false + streams_json = jsonencode({ + "tcp.generic": { + "enabled": false + "vars": { + "listen_address": "localhost" + "listen_port": 8085 + "data_stream.dataset": "tcp.generic" + "tags": [] + "syslog_options": "field: message" + "ssl": "" + "custom": "" + } + } + }) + } +} +`, common, id) +} + +func testAccResourceIntegrationPolicySecretsCreate(id string, key string) string { + common := testAccResourceIntegrationPolicyCommon(id, "aws_logs", "1.4.0") + return fmt.Sprintf(` +%s + +resource "elasticstack_fleet_integration_policy" "test_policy" { + name = "%s" + namespace = "default" + description = "IntegrationPolicyTest Policy" + agent_policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id + integration_name = elasticstack_fleet_integration.test_policy.name + integration_version = elasticstack_fleet_integration.test_policy.version + + vars_json = jsonencode({ + "access_key_id": "placeholder" + "secret_access_key": "%s %s" + "session_token": "placeholder" + "endpoint": "endpoint" + "default_region": "us-east-1" + }) + input { + input_id = "aws_logs-aws-cloudwatch" + enabled = true + streams_json = jsonencode({ + "aws_logs.generic" = { + enabled = true + vars = { + "number_of_workers": 1 + "log_streams": [] + "start_position": "beginning" + "scan_frequency": "1m" + "api_timeput": "120s" + "api_sleep": "200ms" + "tags": ["forwarded"] + "preserve_original_event": false + "data_stream.dataset": "aws_logs.generic" + "custom": "" + } + } + }) + } + input { + input_id = "aws_logs-aws-s3" + enabled = true + streams_json = jsonencode({ + "aws_logs.generic" = { + enabled = true + vars = { + "number_of_workers": 1 + "bucket_list_interval": "120s" + "file_selectors": "" + "fips_enabled": false + "include_s3_metadata": [] + "max_bytes": "10MiB" + "max_number_of_messages": 5 + "parsers": "" + "sqs.max_receive_count": 5 + "sqs.wait_time": "20s" + "tags": ["forwarded"] + "preserve_original_event": false + "data_stream.dataset": "aws_logs.generic" + "custom": "" + } + } + }) + } +} +`, common, id, key, id) +} + +func testAccResourceIntegrationPolicySecretsUpdate(id string, key string) string { + common := testAccResourceIntegrationPolicyCommon(id, "aws_logs", "1.4.0") + return fmt.Sprintf(` +%s + +resource "elasticstack_fleet_integration_policy" "test_policy" { + name = "%s" + namespace = "default" + description = "Updated Integration Policy" + agent_policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id + integration_name = elasticstack_fleet_integration.test_policy.name + integration_version = elasticstack_fleet_integration.test_policy.version + + vars_json = jsonencode({ + "access_key_id": "placeholder" + "secret_access_key": "%s %s" + "session_token": "placeholder" + "endpoint": "endpoint" + "default_region": "us-east-2" + }) + input { + input_id = "aws_logs-aws-cloudwatch" + enabled = false + streams_json = jsonencode({ + "aws_logs.generic" = { + enabled = false + vars = { + "number_of_workers": 1, + "log_streams": [], + "start_position": "beginning", + "scan_frequency": "2m", + "api_timeput": "120s", + "api_sleep": "200ms", + "tags": ["forwarded"], + "preserve_original_event": false, + "data_stream.dataset": "aws_logs.generic", + "custom": "", + } + } + }) + } + input { + input_id = "aws_logs-aws-s3" + enabled = false + streams_json = jsonencode({ + "aws_logs.generic" = { + enabled = false + vars = { + "number_of_workers": 1, + "bucket_list_interval": "120s", + "file_selectors": "", + "fips_enabled": false, + "include_s3_metadata": [], + "max_bytes": "20MiB", + "max_number_of_messages": 5, + "parsers": "", + "sqs.max_receive_count": 5, + "sqs.wait_time": "20s", + "tags": ["forwarded"], + "preserve_original_event": false, + "data_stream.dataset": "aws_logs.generic", + "custom": "", + } + } + }) + } +} +`, common, id, key, id) +} diff --git a/internal/fleet/integration_policy/schema.go b/internal/fleet/integration_policy/schema.go new file mode 100644 index 000000000..80afc60fd --- /dev/null +++ b/internal/fleet/integration_policy/schema.go @@ -0,0 +1,119 @@ +package integration_policy + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "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/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" +) + +func (r *integrationPolicyResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = getSchema() +} + +func getSchema() schema.Schema { + return schema.Schema{ + Description: "Creates a new Fleet Integration Policy. See https://www.elastic.co/guide/en/fleet/current/add-integration-to-policy.html", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of this resource.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "policy_id": schema.StringAttribute{ + Description: "Unique identifier of the integration policy.", + Computed: true, + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the integration policy.", + Required: true, + }, + "namespace": schema.StringAttribute{ + Description: "The namespace of the integration policy.", + Required: true, + }, + "agent_policy_id": schema.StringAttribute{ + Description: "ID of the agent policy.", + Required: true, + }, + "description": schema.StringAttribute{ + Description: "The description of the integration policy.", + Optional: true, + }, + "enabled": schema.BoolAttribute{ + Description: "Enable the integration policy.", + Computed: true, + Optional: true, + Default: booldefault.StaticBool(true), + }, + "force": schema.BoolAttribute{ + Description: "Force operations, such as creation and deletion, to occur.", + Optional: true, + }, + "integration_name": schema.StringAttribute{ + Description: "The name of the integration package.", + Required: true, + }, + "integration_version": schema.StringAttribute{ + Description: "The version of the integration package.", + Required: true, + }, + "vars_json": schema.StringAttribute{ + Description: "Integration-level variables as JSON.", + CustomType: jsontypes.NormalizedType{}, + Computed: true, + Optional: true, + Sensitive: true, + }, + }, + Blocks: map[string]schema.Block{ + "input": schema.ListNestedBlock{ + Description: "Integration inputs.", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "input_id": schema.StringAttribute{ + Description: "The identifier of the input.", + Required: true, + }, + "enabled": schema.BoolAttribute{ + Description: "Enable the input.", + Computed: true, + Optional: true, + Default: booldefault.StaticBool(true), + }, + "streams_json": schema.StringAttribute{ + Description: "Input streams as JSON.", + CustomType: jsontypes.NormalizedType{}, + Computed: true, + Optional: true, + Sensitive: true, + }, + "vars_json": schema.StringAttribute{ + Description: "Input variables as JSON.", + CustomType: jsontypes.NormalizedType{}, + Computed: true, + Optional: true, + Sensitive: true, + }, + }, + }, + }, + }, + } +} + +func getInputType() attr.Type { + return getSchema().Blocks["input"].Type().(attr.TypeWithElementType).ElementType() +} diff --git a/internal/fleet/integration_policy/secrets.go b/internal/fleet/integration_policy/secrets.go new file mode 100644 index 000000000..ba2dca688 --- /dev/null +++ b/internal/fleet/integration_policy/secrets.go @@ -0,0 +1,189 @@ +package integration_policy + +import ( + "context" + "encoding/json" + + fleetapi "github.com/elastic/terraform-provider-elasticstack/generated/fleet" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/diag" +) + +// The secret store is a map of policy secret reference IDs to the +// original value at time of creation. By replacing the ref when +// marshaling the state back to Terraform, we can prevent resource +// drift. +type secretStore map[string]any + +// newSecretStore creates a new secretStore from the resource privateData. +// If the store already exists, it is filtered by any references in the resp policy. +func newSecretStore(ctx context.Context, resp *fleetapi.PackagePolicy, private privateData) (store secretStore, diags diag.Diagnostics) { + bytes, nd := private.GetKey(ctx, "secrets") + diags.Append(nd...) + if diags.HasError() { + return + } + + if len(bytes) == 0 { + store = secretStore{} + return + } + + err := json.Unmarshal(bytes, &store) + if err != nil { + diags.AddError("could not unmarshal secret store", err.Error()) + return + } + + // Remove any saved secret refs not present in the API response. + refs := make(map[string]any) + for _, r := range utils.Deref(resp.SecretReferences) { + refs[*r.Id] = nil + } + + for id := range store { + if _, ok := refs[id]; !ok { + delete(store, id) + } + } + + return +} + +// Save marshals the secretStore back to the provider. +func (s secretStore) Save(ctx context.Context, private privateData) (diags diag.Diagnostics) { + bytes, err := json.Marshal(s) + if err != nil { + diags.AddError("could not marshal secret store", err.Error()) + return + } + + return private.SetKey(ctx, "secrets", bytes) +} + +// handleRespSecrets extracts the wrapped value from each response var, then +// replaces any secret refs with the original value from secrets if available. +func handleRespSecrets(ctx context.Context, resp *fleetapi.PackagePolicy, private privateData) (diags diag.Diagnostics) { + secrets, nd := newSecretStore(ctx, resp, private) + diags.Append(nd...) + if diags.HasError() { + return + } + + handleVars := func(vars map[string]any) { + for key, val := range vars { + if mval, ok := val.(map[string]any); ok { + if wrapped, ok := mval["value"]; ok { + vars[key] = wrapped + val = wrapped + } else { + // Don't keep null (missing) values + delete(vars, key) + continue + } + + if mval, ok := val.(map[string]any); ok { + if v, ok := mval["isSecretRef"]; ok && v == true { + refID := mval["id"].(string) + if original, ok := secrets[refID]; ok { + vars[key] = original + } + } + } + } + } + } + + handleVars(utils.Deref(resp.Vars)) + for _, input := range resp.Inputs { + handleVars(utils.Deref(input.Vars)) + for _, _stream := range utils.Deref(input.Streams) { + stream := _stream.(map[string]any) + streamVars := stream["vars"].(map[string]any) + handleVars(streamVars) + } + } + + nd = secrets.Save(ctx, private) + diags.Append(nd...) + + return +} + +// handleReqRespSecrets extracts the wrapped value from each response var, then +// maps any secret refs to the original request value. +func handleReqRespSecrets(ctx context.Context, req fleetapi.PackagePolicyRequest, resp *fleetapi.PackagePolicy, private privateData) (diags diag.Diagnostics) { + secrets, nd := newSecretStore(ctx, resp, private) + diags.Append(nd...) + if diags.HasError() { + return + } + + handleVars := func(reqVars map[string]any, respVars map[string]any) { + for key, val := range respVars { + if mval, ok := val.(map[string]any); ok { + if wrapped, ok := mval["value"]; ok { + respVars[key] = wrapped + val = wrapped + } else { + // Don't keep null (missing) values + delete(respVars, key) + continue + } + + if mval, ok := val.(map[string]any); ok { + if v, ok := mval["isSecretRef"]; ok && v == true { + refID := mval["id"].(string) + original := reqVars[key] + secrets[refID] = original + respVars[key] = original + } + } + } + } + } + + handleVars(utils.Deref(req.Vars), utils.Deref(resp.Vars)) + for inputID, inputReq := range utils.Deref(req.Inputs) { + inputResp := resp.Inputs[inputID] + handleVars(utils.Deref(inputReq.Vars), utils.Deref(inputResp.Vars)) + streamsResp := utils.Deref(inputResp.Streams) + for streamID, streamReq := range utils.Deref(inputReq.Streams) { + streamResp := streamsResp[streamID].(map[string]any) + streamRespVars := streamResp["vars"].(map[string]any) + handleVars(utils.Deref(streamReq.Vars), streamRespVars) + } + } + + nd = secrets.Save(ctx, private) + diags.Append(nd...) + + return +} + +// Equivalent to privatestate.ProviderData +type privateData interface { + // GetKey returns the private state data associated with the given key. + // + // If the key is reserved for framework usage, an error diagnostic + // is returned. If the key is valid, but private state data is not found, + // nil is returned. + // + // The naming of keys only matters in context of a single resource, + // however care should be taken that any historical keys are not reused + // without accounting for older resource instances that may still have + // older data at the key. + GetKey(ctx context.Context, key string) ([]byte, diag.Diagnostics) + + // SetKey sets the private state data at the given key. + // + // If the key is reserved for framework usage, an error diagnostic + // is returned. The data must be valid JSON and UTF-8 safe or an error + // diagnostic is returned. + // + // The naming of keys only matters in context of a single resource, + // however care should be taken that any historical keys are not reused + // without accounting for older resource instances that may still have + // older data at the key. + SetKey(ctx context.Context, key string, value []byte) diag.Diagnostics +} diff --git a/internal/fleet/integration_policy/update.go b/internal/fleet/integration_policy/update.go new file mode 100644 index 000000000..9fe62fafb --- /dev/null +++ b/internal/fleet/integration_policy/update.go @@ -0,0 +1,52 @@ +package integration_policy + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients/fleet" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *integrationPolicyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var planModel integrationPolicyModel + + diags := req.Plan.Get(ctx, &planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.client.GetFleetClient() + if err != nil { + resp.Diagnostics.AddError(err.Error(), "") + return + } + + body, diags := planModel.toAPIModel(ctx, true) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + policyID := planModel.PolicyID.ValueString() + policy, diags := fleet.UpdatePackagePolicy(ctx, client, policyID, body) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = handleReqRespSecrets(ctx, body, policy, resp.Private) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = planModel.populateFromAPI(ctx, policy) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, planModel) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/fleet/integration_policy_resource.go b/internal/fleet/integration_policy_resource.go deleted file mode 100644 index 86f2f7ea0..000000000 --- a/internal/fleet/integration_policy_resource.go +++ /dev/null @@ -1,427 +0,0 @@ -package fleet - -import ( - "context" - "encoding/json" - "sort" - - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" - - fleetapi "github.com/elastic/terraform-provider-elasticstack/generated/fleet" - "github.com/elastic/terraform-provider-elasticstack/internal/clients/fleet" -) - -func ResourceIntegrationPolicy() *schema.Resource { - packagePolicySchema := map[string]*schema.Schema{ - "policy_id": { - Description: "Unique identifier of the integration policy.", - Type: schema.TypeString, - Computed: true, - Optional: true, - ForceNew: true, - }, - "name": { - Description: "The name of the integration policy.", - Type: schema.TypeString, - Required: true, - }, - "namespace": { - Description: "The namespace of the integration policy.", - Type: schema.TypeString, - Required: true, - }, - "agent_policy_id": { - Description: "ID of the agent policy.", - Type: schema.TypeString, - Required: true, - }, - "description": { - Description: "The description of the integration policy.", - Type: schema.TypeString, - Optional: true, - }, - "enabled": { - Description: "Enable the integration policy.", - Type: schema.TypeBool, - Optional: true, - Computed: true, - }, - "force": { - Description: "Force operations, such as creation and deletion, to occur.", - Type: schema.TypeBool, - Optional: true, - }, - "integration_name": { - Description: "The name of the integration package.", - Type: schema.TypeString, - Required: true, - }, - "integration_version": { - Description: "The version of the integration package.", - Type: schema.TypeString, - Required: true, - }, - "input": { - Type: schema.TypeList, - Optional: true, - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "input_id": { - Description: "The identifier of the input.", - Type: schema.TypeString, - Required: true, - }, - "enabled": { - Description: "Enable the input.", - Type: schema.TypeBool, - Default: true, - Optional: true, - }, - "streams_json": { - Description: "Input streams as JSON.", - Type: schema.TypeString, - ValidateFunc: validation.StringIsJSON, - Optional: true, - Computed: true, - Sensitive: true, - }, - "vars_json": { - Description: "Input variables as JSON.", - Type: schema.TypeString, - ValidateFunc: validation.StringIsJSON, - Computed: true, - Optional: true, - Sensitive: true, - }, - }, - }, - }, - "vars_json": { - Description: "Integration-level variables as JSON.", - Type: schema.TypeString, - ValidateFunc: validation.StringIsJSON, - Computed: true, - Optional: true, - Sensitive: true, - }, - } - - return &schema.Resource{ - Description: "Creates a new Fleet Integration Policy. See https://www.elastic.co/guide/en/fleet/current/add-integration-to-policy.html", - - CreateContext: resourceIntegrationPolicyCreate, - ReadContext: resourceIntegrationPolicyRead, - UpdateContext: resourceIntegrationPolicyUpdate, - DeleteContext: resourceIntegrationPolicyDelete, - - Importer: &schema.ResourceImporter{ - StateContext: schema.ImportStatePassthroughContext, - }, - - Schema: packagePolicySchema, - } -} - -func resourceIntegrationPolicyCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - fleetClient, diags := getFleetClient(d, meta) - if diags.HasError() { - return diags - } - - if id := d.Get("policy_id").(string); id != "" { - d.SetId(id) - } - - req := fleetapi.CreatePackagePolicyJSONRequestBody{ - PolicyId: d.Get("agent_policy_id").(string), - Name: d.Get("name").(string), - } - req.Package.Name = d.Get("integration_name").(string) - req.Package.Version = d.Get("integration_version").(string) - - if value := d.Get("policy_id").(string); value != "" { - req.Id = &value - } - if value := d.Get("namespace").(string); value != "" { - req.Namespace = &value - } - if value := d.Get("description").(string); value != "" { - req.Description = &value - } - if value := d.Get("force").(bool); value { - req.Force = &value - } - if varsRaw, _ := d.Get("vars_json").(string); varsRaw != "" { - vars := map[string]interface{}{} - if err := json.Unmarshal([]byte(varsRaw), &vars); err != nil { - panic(err) - } - req.Vars = &vars - } - - values := d.Get("input").([]interface{}) - if len(values) > 0 { - inputMap := map[string]fleetapi.PackagePolicyRequestInput{} - - for _, v := range values { - var input fleetapi.PackagePolicyRequestInput - - inputData := v.(map[string]interface{}) - inputID := inputData["input_id"].(string) - - enabled, _ := inputData["enabled"].(bool) - input.Enabled = &enabled - - if streamsRaw, _ := inputData["streams_json"].(string); streamsRaw != "" { - streams := map[string]fleetapi.PackagePolicyRequestInputStream{} - if err := json.Unmarshal([]byte(streamsRaw), &streams); err != nil { - panic(err) - } - input.Streams = &streams - } - if varsRaw, _ := inputData["vars_json"].(string); varsRaw != "" { - vars := map[string]interface{}{} - if err := json.Unmarshal([]byte(varsRaw), &vars); err != nil { - panic(err) - } - input.Vars = &vars - } - - inputMap[inputID] = input - } - - req.Inputs = &inputMap - } - - obj, diags := fleet.CreatePackagePolicy(ctx, fleetClient, req) - if diags.HasError() { - return diags - } - - d.SetId(obj.Id) - if err := d.Set("policy_id", obj.Id); err != nil { - return diag.FromErr(err) - } - - return resourceIntegrationPolicyRead(ctx, d, meta) -} - -func resourceIntegrationPolicyUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - fleetClient, diags := getFleetClient(d, meta) - if diags.HasError() { - return diags - } - - req := fleetapi.UpdatePackagePolicyJSONRequestBody{ - PolicyId: d.Get("agent_policy_id").(string), - Name: d.Get("name").(string), - } - req.Package.Name = d.Get("integration_name").(string) - req.Package.Version = d.Get("integration_version").(string) - - if value := d.Get("policy_id").(string); value != "" { - req.Id = &value - } - if value := d.Get("namespace").(string); value != "" { - req.Namespace = &value - } - if value := d.Get("description").(string); value != "" { - req.Description = &value - } - if value := d.Get("force").(bool); value { - req.Force = &value - } - if varsRaw, _ := d.Get("vars_json").(string); varsRaw != "" { - vars := map[string]interface{}{} - if err := json.Unmarshal([]byte(varsRaw), &vars); err != nil { - panic(err) - } - req.Vars = &vars - } - - if values := d.Get("input").([]interface{}); len(values) > 0 { - inputMap := map[string]fleetapi.PackagePolicyRequestInput{} - - for _, v := range values { - var input fleetapi.PackagePolicyRequestInput - - inputData := v.(map[string]interface{}) - inputID := inputData["input_id"].(string) - - enabled, _ := inputData["enabled"].(bool) - input.Enabled = &enabled - - if streamsRaw, _ := inputData["streams_json"].(string); streamsRaw != "" { - streams := map[string]fleetapi.PackagePolicyRequestInputStream{} - if err := json.Unmarshal([]byte(streamsRaw), &streams); err != nil { - panic(err) - } - input.Streams = &streams - } - if varsRaw, _ := inputData["vars_json"].(string); varsRaw != "" { - vars := map[string]interface{}{} - if err := json.Unmarshal([]byte(varsRaw), &vars); err != nil { - panic(err) - } - input.Vars = &vars - } - - inputMap[inputID] = input - } - - req.Inputs = &inputMap - } - - _, diags = fleet.UpdatePackagePolicy(ctx, fleetClient, d.Id(), req) - if diags.HasError() { - return diags - } - - return resourceIntegrationPolicyRead(ctx, d, meta) -} - -func resourceIntegrationPolicyRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - fleetClient, diags := getFleetClient(d, meta) - if diags.HasError() { - return diags - } - - pkgPolicy, diags := fleet.ReadPackagePolicy(ctx, fleetClient, d.Id()) - if diags.HasError() { - return diags - } - - // Not found. - if pkgPolicy == nil { - d.SetId("") - return nil - } - - if err := d.Set("name", pkgPolicy.Name); err != nil { - return diag.FromErr(err) - } - if err := d.Set("namespace", pkgPolicy.Namespace); err != nil { - return diag.FromErr(err) - } - if err := d.Set("policy_id", pkgPolicy.Id); err != nil { - return diag.FromErr(err) - } - if err := d.Set("integration_name", pkgPolicy.Package.Name); err != nil { - return diag.FromErr(err) - } - if err := d.Set("integration_version", pkgPolicy.Package.Version); err != nil { - return diag.FromErr(err) - } - if err := d.Set("agent_policy_id", pkgPolicy.PolicyId); err != nil { - return diag.FromErr(err) - } - if pkgPolicy.Description != nil { - if err := d.Set("description", *pkgPolicy.Description); err != nil { - return diag.FromErr(err) - } - } - if pkgPolicy.Vars != nil { - - vars := make(map[string]any, len(*pkgPolicy.Vars)) - // Var values are wrapped in a type/value struct and need - // to be extracted. The only applies to reading values back - // from the API, sending var values does not use this format. - for k, v := range *pkgPolicy.Vars { - wrappedTypeValue, _ := v.(map[string]any) - if wrappedValue, ok := wrappedTypeValue["value"]; ok { - vars[k] = wrappedValue - } - } - - data, err := json.Marshal(vars) - if err != nil { - return diag.FromErr(err) - } - if err = d.Set("vars_json", string(data)); err != nil { - return diag.FromErr(err) - } - } - - newInputs := make([]any, 0, len(pkgPolicy.Inputs)) - for inputID, input := range pkgPolicy.Inputs { - inputData := map[string]any{ - "input_id": inputID, - "enabled": input.Enabled, - } - - if input.Streams != nil { - data, err := json.Marshal(*input.Streams) - if err != nil { - return diag.FromErr(err) - } - inputData["streams_json"] = string(data) - } - if input.Vars != nil { - data, err := json.Marshal(*input.Vars) - if err != nil { - return diag.FromErr(err) - } - inputData["vars_json"] = string(data) - } - - newInputs = append(newInputs, inputData) - } - - existingInputs, _ := d.Get("input").([]any) - sortInputs(newInputs, existingInputs) - - if err := d.Set("input", newInputs); err != nil { - return diag.FromErr(err) - } - - return nil -} - -func resourceIntegrationPolicyDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - fleetClient, diags := getFleetClient(d, meta) - if diags.HasError() { - return diags - } - - force := d.Get("force").(bool) - - if diags = fleet.DeletePackagePolicy(ctx, fleetClient, d.Id(), force); diags.HasError() { - return diags - } - d.SetId("") - - return diags -} - -// sortInputs will sort the 'incoming' list of input definitions based on -// the order of inputs defined in the 'existing' list. Inputs not present in -// 'existing' will be placed at the end of the list. Inputs are identified by -// their ID ('input_id'). The 'incoming' slice will be sorted in-place. -func sortInputs(incoming []any, existing []any) { - idToIndex := make(map[string]int, len(existing)) - for i, v := range existing { - inputData, _ := v.(map[string]any) - inputID, _ := inputData["input_id"].(string) - idToIndex[inputID] = i - } - - sort.Slice(incoming, func(i, j int) bool { - iInput, _ := incoming[i].(map[string]any) - iID, _ := iInput["input_id"].(string) - iIdx, ok := idToIndex[iID] - if !ok { - return false - } - - jInput, _ := incoming[j].(map[string]any) - jID, _ := jInput["input_id"].(string) - jIdx, ok := idToIndex[jID] - if !ok { - return true - } - - return iIdx < jIdx - }) -} diff --git a/internal/fleet/integration_policy_resource_test.go b/internal/fleet/integration_policy_resource_test.go deleted file mode 100644 index beb9fe7f8..000000000 --- a/internal/fleet/integration_policy_resource_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package fleet_test - -import ( - "context" - "errors" - "fmt" - "testing" - - "github.com/hashicorp/go-version" - sdkacctest "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" - - "github.com/elastic/terraform-provider-elasticstack/internal/acctest" - "github.com/elastic/terraform-provider-elasticstack/internal/clients" - "github.com/elastic/terraform-provider-elasticstack/internal/clients/fleet" - "github.com/elastic/terraform-provider-elasticstack/internal/utils" - "github.com/elastic/terraform-provider-elasticstack/internal/versionutils" -) - -var minVersionIntegrationPolicy = version.Must(version.NewVersion("8.10.0")) - -func TestAccResourceIntegrationPolicy(t *testing.T) { - policyName := sdkacctest.RandStringFromCharSet(22, sdkacctest.CharSetAlphaNum) - - resource.Test(t, resource.TestCase{ - PreCheck: func() { acctest.PreCheck(t) }, - CheckDestroy: checkResourceIntegrationPolicyDestroy, - ProtoV6ProviderFactories: acctest.Providers, - Steps: []resource.TestStep{ - { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionIntegrationPolicy), - Config: testAccResourceIntegrationPolicyCreate(policyName), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "name", policyName), - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "description", "IntegrationPolicyTest Policy"), - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_name", "tcp"), - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_version", "1.16.0"), - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.input_id", "tcp-tcp"), - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.enabled", "true"), - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.streams_json", "{\"tcp.generic\":{\"enabled\":true,\"vars\":{\"custom\":\"\",\"data_stream.dataset\":\"tcp.generic\",\"listen_address\":\"localhost\",\"listen_port\":8080,\"ssl\":\"#certificate: |\\n# -----BEGIN CERTIFICATE-----\\n# ...\\n# -----END CERTIFICATE-----\\n#key: |\\n# -----BEGIN PRIVATE KEY-----\\n# ...\\n# -----END PRIVATE KEY-----\\n\",\"syslog_options\":\"field: message\\n#format: auto\\n#timezone: Local\\n\",\"tags\":[]}}}"), - ), - }, - { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionIntegrationPolicy), - Config: testAccResourceIntegrationPolicyUpdate(policyName), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "name", policyName), - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "description", "Updated Integration Policy"), - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_name", "tcp"), - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "integration_version", "1.16.0"), - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.input_id", "tcp-tcp"), - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.enabled", "true"), - resource.TestCheckResourceAttr("elasticstack_fleet_integration_policy.test_policy", "input.0.streams_json", "{\"tcp.generic\":{\"enabled\":true,\"vars\":{\"custom\":\"\",\"data_stream.dataset\":\"tcp.generic\",\"listen_address\":\"localhost\",\"listen_port\":8085,\"ssl\":\"#certificate: |\\n# -----BEGIN CERTIFICATE-----\\n# ...\\n# -----END CERTIFICATE-----\\n#key: |\\n# -----BEGIN PRIVATE KEY-----\\n# ...\\n# -----END PRIVATE KEY-----\\n\",\"syslog_options\":\"field: message\\n#format: auto\\n#timezone: Local\\n\",\"tags\":[]}}}"), - ), - }, - }, - }) -} - -func checkResourceIntegrationPolicyDestroy(s *terraform.State) error { - client, err := clients.NewAcceptanceTestingClient() - if err != nil { - return err - } - - fleetClient, err := client.GetFleetClient() - if err != nil { - return err - } - - for _, rs := range s.RootModule().Resources { - switch rs.Type { - case "elasticstack_fleet_integration_policy": - integrationPolicy, diag := fleet.ReadPackagePolicy(context.Background(), fleetClient, rs.Primary.ID) - if diag.HasError() { - return errors.New(diag[0].Summary) - } - if integrationPolicy != nil { - return fmt.Errorf("integration policy id=%v still exists, but it should have been removed", rs.Primary.ID) - } - case "elasticstack_fleet_agent_policy": - agentPolicy, diag := fleet.ReadAgentPolicy(context.Background(), fleetClient, rs.Primary.ID) - if diag.HasError() { - return utils.FwDiagsAsError(diag) - } - if agentPolicy != nil { - return fmt.Errorf("agent policy id=%v still exists, but it should have been removed", rs.Primary.ID) - } - default: - continue - } - - } - return nil -} - -func testAccResourceIntegrationPolicyCreate(id string) string { - return fmt.Sprintf(` -provider "elasticstack" { - elasticsearch {} - kibana {} -} - -resource "elasticstack_fleet_integration" "test_policy" { - name = "tcp" - version = "1.16.0" - force = true -} - -resource "elasticstack_fleet_agent_policy" "test_policy" { - name = "%s Agent Policy" - namespace = "default" - description = "IntegrationPolicyTest Agent Policy" - monitor_logs = true - monitor_metrics = true - skip_destroy = false -} - -data "elasticstack_fleet_enrollment_tokens" "test_policy" { - policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id -} - -resource "elasticstack_fleet_integration_policy" "test_policy" { - name = "%s" - namespace = "default" - description = "IntegrationPolicyTest Policy" - agent_policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id - integration_name = elasticstack_fleet_integration.test_policy.name - integration_version = elasticstack_fleet_integration.test_policy.version - - input { - input_id = "tcp-tcp" - streams_json = jsonencode({ - "tcp.generic": { - "enabled": true, - "vars": { - "listen_address": "localhost", - "listen_port": 8080, - "data_stream.dataset": "tcp.generic", - "tags": [], - "syslog_options": "field: message\n#format: auto\n#timezone: Local\n", - "ssl": "#certificate: |\n# -----BEGIN CERTIFICATE-----\n# ...\n# -----END CERTIFICATE-----\n#key: |\n# -----BEGIN PRIVATE KEY-----\n# ...\n# -----END PRIVATE KEY-----\n", - "custom": "" - } - } - }) - } -} -`, id, id) -} - -func testAccResourceIntegrationPolicyUpdate(id string) string { - return fmt.Sprintf(` -provider "elasticstack" { - elasticsearch {} - kibana {} -} - -resource "elasticstack_fleet_integration" "test_policy" { - name = "tcp" - version = "1.16.0" - force = true -} - -resource "elasticstack_fleet_agent_policy" "test_policy" { - name = "%s Agent Policy" - namespace = "default" - description = "IntegrationPolicyTest Agent Policy" - monitor_logs = true - monitor_metrics = true - skip_destroy = false -} - -data "elasticstack_fleet_enrollment_tokens" "test_policy" { - policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id -} - -resource "elasticstack_fleet_integration_policy" "test_policy" { - name = "%s" - namespace = "default" - description = "Updated Integration Policy" - agent_policy_id = elasticstack_fleet_agent_policy.test_policy.policy_id - integration_name = elasticstack_fleet_integration.test_policy.name - integration_version = elasticstack_fleet_integration.test_policy.version - - input { - input_id = "tcp-tcp" - streams_json = jsonencode({ - "tcp.generic": { - "enabled": true, - "vars": { - "listen_address": "localhost", - "listen_port": 8085, - "data_stream.dataset": "tcp.generic", - "tags": [], - "syslog_options": "field: message\n#format: auto\n#timezone: Local\n", - "ssl": "#certificate: |\n# -----BEGIN CERTIFICATE-----\n# ...\n# -----END CERTIFICATE-----\n#key: |\n# -----BEGIN PRIVATE KEY-----\n# ...\n# -----END PRIVATE KEY-----\n", - "custom": "" - } - } - }) - } -} -`, id, id) -} diff --git a/internal/fleet/server_host/models.go b/internal/fleet/server_host/models.go index 6817ad6f4..cd2ce6e51 100644 --- a/internal/fleet/server_host/models.go +++ b/internal/fleet/server_host/models.go @@ -44,7 +44,7 @@ func (model serverHostModel) toAPICreateModel(ctx context.Context) (body fleetap func (model serverHostModel) toAPIUpdateModel(ctx context.Context) (body fleetapi.UpdateFleetServerHostsJSONRequestBody, diags diag.Diagnostics) { body = fleetapi.UpdateFleetServerHostsJSONRequestBody{ - HostUrls: utils.Pointer(utils.ListTypeToSlice_String(ctx, model.Hosts, path.Root("hosts"), diags)), + HostUrls: utils.SliceRef(utils.ListTypeToSlice_String(ctx, model.Hosts, path.Root("hosts"), diags)), IsDefault: model.Default.ValueBoolPointer(), Name: model.Name.ValueStringPointer(), } diff --git a/internal/fleet/shared_test.go b/internal/fleet/shared_test.go deleted file mode 100644 index 2b767880c..000000000 --- a/internal/fleet/shared_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package fleet - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func Test_SortInputs(t *testing.T) { - t.Run("WithExisting", func(t *testing.T) { - existing := []any{ - map[string]any{"input_id": "A", "enabled": true}, - map[string]any{"input_id": "B", "enabled": true}, - map[string]any{"input_id": "C", "enabled": true}, - map[string]any{"input_id": "D", "enabled": true}, - map[string]any{"input_id": "E", "enabled": true}, - } - - incoming := []any{ - map[string]any{"input_id": "G", "enabled": true}, - map[string]any{"input_id": "F", "enabled": true}, - map[string]any{"input_id": "B", "enabled": true}, - map[string]any{"input_id": "E", "enabled": true}, - map[string]any{"input_id": "C", "enabled": true}, - } - - want := []any{ - map[string]any{"input_id": "B", "enabled": true}, - map[string]any{"input_id": "C", "enabled": true}, - map[string]any{"input_id": "E", "enabled": true}, - map[string]any{"input_id": "G", "enabled": true}, - map[string]any{"input_id": "F", "enabled": true}, - } - - sortInputs(incoming, existing) - - require.Equal(t, want, incoming) - }) - - t.Run("WithEmpty", func(t *testing.T) { - var existing []any - - incoming := []any{ - map[string]any{"input_id": "G", "enabled": true}, - map[string]any{"input_id": "F", "enabled": true}, - map[string]any{"input_id": "B", "enabled": true}, - map[string]any{"input_id": "E", "enabled": true}, - map[string]any{"input_id": "C", "enabled": true}, - } - - want := []any{ - map[string]any{"input_id": "G", "enabled": true}, - map[string]any{"input_id": "F", "enabled": true}, - map[string]any{"input_id": "B", "enabled": true}, - map[string]any{"input_id": "E", "enabled": true}, - map[string]any{"input_id": "C", "enabled": true}, - } - - sortInputs(incoming, existing) - - require.Equal(t, want, incoming) - }) -} diff --git a/internal/utils/tfsdk.go b/internal/utils/tfsdk.go index 3f52577d1..cdb63c9de 100644 --- a/internal/utils/tfsdk.go +++ b/internal/utils/tfsdk.go @@ -2,77 +2,165 @@ package utils import ( "context" + "encoding/json" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/types" ) +type ListMeta struct { + Index int + Path path.Path + Diags diag.Diagnostics +} + +type MapMeta struct { + Key string + Path path.Path + Diags diag.Diagnostics +} + +// MapToNormalizedType marshals a map into a jsontypes.Normalized. +func MapToNormalizedType[T any](value map[string]T, p path.Path, diags diag.Diagnostics) jsontypes.Normalized { + if value == nil { + return jsontypes.NewNormalizedNull() + } + + bytes, err := json.Marshal(value) + if err != nil { + diags.AddAttributeError(p, "marshal failure", err.Error()) + } + + return jsontypes.NewNormalizedValue(string(bytes)) +} + // SliceToListType converts a tfsdk naive []T1 into an types.List of []T2. // This handles both structs and simple types to attr.Values. -func SliceToListType[T1 any, T2 any](ctx context.Context, value []T1, elemType attr.Type, path path.Path, diags diag.Diagnostics, iteratee func(item T1) T2) types.List { +func SliceToListType[T1 any, T2 any](ctx context.Context, value []T1, elemType attr.Type, p path.Path, diags diag.Diagnostics, iteratee func(item T1, meta ListMeta) T2) types.List { if value == nil { return types.ListNull(elemType) } - elems := TransformSlice(value, iteratee) + elems := TransformSlice(value, p, diags, iteratee) list, nd := types.ListValueFrom(ctx, elemType, elems) - diags.Append(ConvertToAttrDiags(nd, path)...) + diags.Append(ConvertToAttrDiags(nd, p)...) return list } // SliceToListType_String converts a tfsdk naive []string into a types.List. // This is a shorthand SliceToListType helper for strings. -func SliceToListType_String(ctx context.Context, value []string, path path.Path, diags diag.Diagnostics) types.List { - return SliceToListType(ctx, value, types.StringType, path, diags, types.StringValue) +func SliceToListType_String(ctx context.Context, value []string, p path.Path, diags diag.Diagnostics) types.List { + return SliceToListType(ctx, value, types.StringType, p, diags, + func(item string, meta ListMeta) types.String { + return types.StringValue(item) + }) +} + +// ListTypeToMap converts a types.List first into a tfsdk aware map[string]T1 +// and transforms the result into a map[string]T2. +func ListTypeToMap[T1 any, T2 any](ctx context.Context, value types.List, p path.Path, diags diag.Diagnostics, iteratee func(item T1, meta ListMeta) (key string, elem T2)) map[string]T2 { + if !IsKnown(value) { + return nil + } + + items := ListTypeAs[T1](ctx, value, p, diags) + if diags.HasError() { + return nil + } + + return TransformSliceToMap(items, p, diags, iteratee) } // ListTypeToSlice converts a types.List first into a tfsdk aware []T1 and transforms // the result into a []T2. -func ListTypeToSlice[T1 any, T2 any](ctx context.Context, value types.List, path path.Path, diags diag.Diagnostics, iteratee func(item T1) T2) []T2 { +func ListTypeToSlice[T1 any, T2 any](ctx context.Context, value types.List, p path.Path, diags diag.Diagnostics, iteratee func(item T1, meta ListMeta) T2) []T2 { if !IsKnown(value) { return nil } - elems := ListTypeAs[T1](ctx, value, path, diags) + elems := ListTypeAs[T1](ctx, value, p, diags) if diags.HasError() { return nil } - return TransformSlice(elems, iteratee) + return TransformSlice(elems, p, diags, iteratee) } // ListTypeToSlice_String converts a types.List into a []string. // This is a shorthand ListTypeToSlice helper for strings. -func ListTypeToSlice_String(ctx context.Context, value types.List, path path.Path, diags diag.Diagnostics) []string { - return ListTypeToSlice(ctx, value, path, diags, func(item types.String) string { +func ListTypeToSlice_String(ctx context.Context, value types.List, p path.Path, diags diag.Diagnostics) []string { + return ListTypeToSlice(ctx, value, p, diags, func(item types.String, meta ListMeta) string { return item.ValueString() }) } // ListTypeAs converts a types.List into a tfsdk aware []T. -func ListTypeAs[T any](ctx context.Context, value types.List, path path.Path, diags diag.Diagnostics) []T { +func ListTypeAs[T any](ctx context.Context, value types.List, p path.Path, diags diag.Diagnostics) []T { if !IsKnown(value) { return nil } var items []T nd := value.ElementsAs(ctx, &items, false) - diags.Append(ConvertToAttrDiags(nd, path)...) + diags.Append(ConvertToAttrDiags(nd, p)...) return items } +// NormalizedTypeToMap unmarshals a jsontypes.Normalized to a map[string]T. +func NormalizedTypeToMap[T any](value jsontypes.Normalized, p path.Path, diags diag.Diagnostics) map[string]T { + if !IsKnown(value) { + return nil + } + + var dest map[string]T + d := value.Unmarshal(&dest) + diags.Append(ConvertToAttrDiags(d, p)...) + return dest +} + // TransformSlice converts []T1 to []T2 via the iteratee. -func TransformSlice[T1 any, T2 any](value []T1, iteratee func(item T1) T2) []T2 { +func TransformSlice[T1 any, T2 any](value []T1, p path.Path, diags diag.Diagnostics, iteratee func(item T1, meta ListMeta) T2) []T2 { if value == nil { return nil } elems := make([]T2, len(value)) for i, v := range value { - elems[i] = iteratee(v) + elems[i] = iteratee(v, ListMeta{Index: i, Path: p.AtListIndex(i), Diags: diags}) + } + + return elems +} + +// TransformSliceToMap converts []T1 to map[string]]T2 via the iteratee. +func TransformSliceToMap[T1 any, T2 any](value []T1, p path.Path, diags diag.Diagnostics, iteratee func(item T1, meta ListMeta) (key string, elem T2)) map[string]T2 { + if value == nil { + return nil + } + + elems := make(map[string]T2, len(value)) + for i, v := range value { + k, v := iteratee(v, ListMeta{Index: i, Path: p.AtListIndex(i), Diags: diags}) + elems[k] = v + } + + return elems +} + +// TransformSliceToMap converts []T1 to map[string]]T2 via the iteratee. +func TransformMapToSlice[T1 any, T2 any](value map[string]T1, p path.Path, diags diag.Diagnostics, iteratee func(item T1, meta MapMeta) T2) []T2 { + if value == nil { + return nil + } + + elems := make([]T2, 0, len(value)) + for k, v := range value { + v := iteratee(v, MapMeta{Key: k, Path: p.AtMapKey(k), Diags: diags}) + elems = append(elems, v) } return elems diff --git a/internal/utils/tfsdk_test.go b/internal/utils/tfsdk_test.go index 329c590ee..c62b5f9c4 100644 --- a/internal/utils/tfsdk_test.go +++ b/internal/utils/tfsdk_test.go @@ -3,9 +3,11 @@ package utils_test import ( "context" "reflect" + "sort" "testing" "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" @@ -15,6 +17,7 @@ import ( type naive struct { ID string `json:"id"` } + type aware struct { ID types.String `tfsdk:"id"` } @@ -61,8 +64,47 @@ var ( types.StringValue("v2"), types.StringValue("v3"), }) + + mapNil = (map[string]naive)(nil) + mapEmpty = map[string]naive{} + mapFull = map[string]naive{ + "k1": {ID: "id1"}, + "k2": {ID: "id2"}, + "k3": {ID: "id3"}, + } + normUnk = jsontypes.NewNormalizedUnknown() + normNil = jsontypes.NewNormalizedNull() + normEmpty = jsontypes.NewNormalizedValue(`{}`) + normFull = jsontypes.NewNormalizedValue(`{"k1":{"id":"id1"},"k2":{"id":"id2"},"k3":{"id":"id3"}}`) ) +func TestMapToNormalizedType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input map[string]naive + want jsontypes.Normalized + }{ + {name: "converts nil", input: mapNil, want: normNil}, + {name: "converts empty", input: mapEmpty, want: normEmpty}, + {name: "converts struct", input: mapFull, want: normFull}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var diags diag.Diagnostics + got := utils.MapToNormalizedType(tt.input, path.Empty(), diags) + if !got.Equal(tt.want) { + t.Errorf("MapToNormalizedType() = %v, want %v", got, tt.want) + } + for _, d := range diags.Errors() { + t.Errorf("MapToNormalizedType() diagnostic: %s: %s", d.Summary(), d.Detail()) + } + }) + } +} + func TestSliceToListType(t *testing.T) { t.Parallel() @@ -81,7 +123,11 @@ func TestSliceToListType(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var diags diag.Diagnostics - got := utils.SliceToListType(ctx, tt.input, awareType, path.Empty(), diags, toAware) + got := utils.SliceToListType(ctx, tt.input, awareType, path.Empty(), diags, + func(item naive, meta utils.ListMeta) aware { + return aware{ID: types.StringValue(item.ID)} + }, + ) if !got.Equal(tt.want) { t.Errorf("SliceToListType() = %v, want %v", got, tt.want) } @@ -121,13 +167,46 @@ func TestSliceToListType_String(t *testing.T) { } } +func TestListTypeToMap(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input types.List + want map[string]naive + }{ + {name: "converts unknown", input: awareListUnk, want: mapNil}, + {name: "converts nil", input: awareListNil, want: mapNil}, + {name: "converts empty", input: awareListEmpty, want: mapEmpty}, + {name: "converts struct", input: awareListFull, want: mapFull}, + } + + ctx := context.Background() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var diags diag.Diagnostics + got := utils.ListTypeToMap(ctx, tt.input, path.Empty(), diags, + func(item aware, meta utils.ListMeta) (string, naive) { + return "k" + item.ID.ValueString()[2:], toNaive(item) + }) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ListTypeToMap() = %v, want %v", got, tt.want) + } + for _, d := range diags.Errors() { + t.Errorf("ListTypeToMap() diagnostic: %s: %s", d.Summary(), d.Detail()) + } + }) + } +} + func TestListTypeToSlice(t *testing.T) { t.Parallel() tests := []struct { name string - want []naive input types.List + want []naive }{ {name: "converts unknown", input: awareListUnk, want: naiveNil}, {name: "converts nil", input: awareListNil, want: naiveNil}, @@ -140,7 +219,10 @@ func TestListTypeToSlice(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var diags diag.Diagnostics - got := utils.ListTypeToSlice(ctx, tt.input, path.Empty(), diags, toNaive) + got := utils.ListTypeToSlice(ctx, tt.input, path.Empty(), diags, + func(item aware, meta utils.ListMeta) naive { + return toNaive(item) + }) if !reflect.DeepEqual(got, tt.want) { t.Errorf("ListTypeToSlice() = %v, want %v", got, tt.want) } @@ -211,6 +293,34 @@ func TestListTypeAs(t *testing.T) { } } +func TestNormalizedTypeToMap(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input jsontypes.Normalized + want map[string]naive + }{ + {name: "converts unknown", input: normUnk, want: mapNil}, + {name: "converts nil", input: normNil, want: mapNil}, + {name: "converts empty", input: normEmpty, want: mapEmpty}, + {name: "converts struct", input: normFull, want: mapFull}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var diags diag.Diagnostics + got := utils.NormalizedTypeToMap[naive](tt.input, path.Empty(), diags) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("MapToNormalizedType() = %v, want %v", got, tt.want) + } + for _, d := range diags.Errors() { + t.Errorf("MapToNormalizedType() diagnostic: %s: %s", d.Summary(), d.Detail()) + } + }) + } +} + func TestTransformSlice(t *testing.T) { t.Parallel() @@ -227,7 +337,10 @@ func TestTransformSlice(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var diags diag.Diagnostics - got := utils.TransformSlice(tt.input, toAware) + got := utils.TransformSlice(tt.input, path.Empty(), diags, + func(item naive, meta utils.ListMeta) aware { + return toAware(item) + }) if !reflect.DeepEqual(got, tt.want) { t.Errorf("TransformSlice() = %v, want %v", got, tt.want) } @@ -237,3 +350,73 @@ func TestTransformSlice(t *testing.T) { }) } } + +func TestTransformSliceToMap(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []aware + want map[string]naive + }{ + {name: "converts nil", input: awareNil, want: mapNil}, + {name: "converts empty", input: awareEmpty, want: mapEmpty}, + {name: "converts struct", input: awareFull, want: mapFull}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var diags diag.Diagnostics + got := utils.TransformSliceToMap(tt.input, path.Empty(), diags, + func(item aware, meta utils.ListMeta) (string, naive) { + return "k" + item.ID.ValueString()[2:], toNaive(item) + }) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("TransformSliceToMap() = %v, want %v", got, tt.want) + } + for _, d := range diags.Errors() { + t.Errorf("TransformSliceToMap() diagnostic: %s: %s", d.Summary(), d.Detail()) + } + }) + } +} + +func TestTransformMapToSlice(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input map[string]naive + want []naive + }{ + {name: "converts nil", input: mapNil, want: naiveNil}, + {name: "converts empty", input: mapEmpty, want: naiveEmpty}, + {name: "converts struct", input: mapFull, want: naiveFull}, + } + + sortFn := func(s []naive) func(i, j int) bool { + return func(i, j int) bool { + return s[i].ID < s[j].ID + } + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var diags diag.Diagnostics + got := utils.TransformMapToSlice(tt.input, path.Empty(), diags, + func(item naive, meta utils.MapMeta) naive { + return item + }) + + sort.Slice(got, sortFn(got)) + sort.Slice(tt.want, sortFn(tt.want)) + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("TransformMapToSlice() = %v, want %v", got, tt.want) + } + for _, d := range diags.Errors() { + t.Errorf("TransformMapToSlice() diagnostic: %s: %s", d.Summary(), d.Detail()) + } + }) + } +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index da514a6a1..99d77365e 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -239,6 +239,37 @@ func Pointer[T any](value T) *T { return &value } +// MapRef is similar to Pointer, in that it takes the reference of +// the given value, however if the value is already nil then it returns +// nil rather than a pointer to nil. +func MapRef[T any](value map[string]T) *map[string]T { + if value == nil { + return nil + } + return &value +} + +// SliceRef is similar to Pointer, in that it takes the reference of +// the given value, however if the value is already nil then it returns +// nil rather than a pointer to nil. +func SliceRef[T any](value []T) *[]T { + if value == nil { + return nil + } + return &value +} + +// Deref returns the value referenced by the given pointer. If the value is +// nil, a zero value is returned. +func Deref[T any](value *T) T { + if value == nil { + var zero T + return zero + } else { + return *value + } +} + func FlipMap[K comparable, V comparable](m map[K]V) map[V]K { inv := make(map[V]K) for k, v := range m { diff --git a/provider/plugin_framework.go b/provider/plugin_framework.go index d997ade43..a8bbb5f2a 100644 --- a/provider/plugin_framework.go +++ b/provider/plugin_framework.go @@ -11,6 +11,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/fleet/enrollment_tokens" "github.com/elastic/terraform-provider-elasticstack/internal/fleet/integration" "github.com/elastic/terraform-provider-elasticstack/internal/fleet/integration_ds" + "github.com/elastic/terraform-provider-elasticstack/internal/fleet/integration_policy" "github.com/elastic/terraform-provider-elasticstack/internal/fleet/server_host" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/data_view" "github.com/elastic/terraform-provider-elasticstack/internal/kibana/import_saved_objects" @@ -91,6 +92,7 @@ func (p *Provider) Resources(ctx context.Context) []func() resource.Resource { func() resource.Resource { return &synthetics.Resource{} }, agent_policy.NewResource, integration.NewResource, + integration_policy.NewResource, server_host.NewResource, } } diff --git a/provider/provider.go b/provider/provider.go index 07adff0c9..835e0a7d4 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -108,8 +108,7 @@ func New(version string) *schema.Provider { "elasticstack_kibana_security_role": kibana.ResourceRole(), "elasticstack_kibana_slo": kibana.ResourceSlo(), - "elasticstack_fleet_output": fleet.ResourceOutput(), - "elasticstack_fleet_integration_policy": fleet.ResourceIntegrationPolicy(), + "elasticstack_fleet_output": fleet.ResourceOutput(), }, }