diff --git a/ec/acc/security_project_test.go b/ec/acc/security_project_test.go index cc39c1b49..ef420d635 100644 --- a/ec/acc/security_project_test.go +++ b/ec/acc/security_project_test.go @@ -92,17 +92,6 @@ func testAccBasicSecurityProject(id string, name string, region string) string { resource ec_security_project "%s" { name = "%s" region_id = "%s" - admin_features_package = "standard" - product_types = [{ - product_line = "security" - product_tier = "essentials" - }, { - product_line = "cloud" - product_tier = "essentials" - }, { - product_line = "endpoint" - product_tier = "essentials" - }] } `, id, name, region) } @@ -113,17 +102,6 @@ resource ec_security_project "%s" { name = "%s" region_id = "%s" alias = "%s" - admin_features_package = "standard" - product_types = [{ - product_line = "security" - product_tier = "essentials" - }, { - product_line = "cloud" - product_tier = "essentials" - }, { - product_line = "endpoint" - product_tier = "essentials" - }] } `, id, name, region, alias) } diff --git a/ec/ecresource/projectresource/security.go b/ec/ecresource/projectresource/security.go index ffd0a750d..74c3722a3 100644 --- a/ec/ecresource/projectresource/security.go +++ b/ec/ecresource/projectresource/security.go @@ -29,6 +29,10 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -43,8 +47,77 @@ func NewSecurityProjectResource() *Resource[resource_security_project.SecurityPr type securityModelReader struct{} +// productTypesOrderInsensitivePlanModifier ignores order differences in product_types list +type productTypesOrderInsensitivePlanModifier struct{} + +func (m productTypesOrderInsensitivePlanModifier) Description(ctx context.Context) string { + return "Ignores order differences in product_types list when semantically equivalent" +} + +func (m productTypesOrderInsensitivePlanModifier) MarkdownDescription(ctx context.Context) string { + return "Ignores order differences in product_types list when semantically equivalent" +} + +func (m productTypesOrderInsensitivePlanModifier) PlanModifyList(ctx context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) { + // If either value is null or unknown, don't modify + if req.PlanValue.IsNull() || req.PlanValue.IsUnknown() || req.StateValue.IsNull() || req.StateValue.IsUnknown() { + return + } + + // Get both lists + var planItems, stateItems []resource_security_project.ProductTypesValue + req.PlanValue.ElementsAs(ctx, &planItems, false) + req.StateValue.ElementsAs(ctx, &stateItems, false) + + // If different lengths, they're actually different + if len(planItems) != len(stateItems) { + return + } + + // Create maps of product_line -> product_tier for comparison + planMap := make(map[string]string) + for _, item := range planItems { + planMap[item.ProductLine.ValueString()] = item.ProductTier.ValueString() + } + + stateMap := make(map[string]string) + for _, item := range stateItems { + stateMap[item.ProductLine.ValueString()] = item.ProductTier.ValueString() + } + + // If maps are equal, use state value (same content, different order) + mapsEqual := len(planMap) == len(stateMap) + if mapsEqual { + for k, v := range planMap { + if stateMap[k] != v { + mapsEqual = false + break + } + } + } + + if mapsEqual { + resp.PlanValue = req.StateValue + } +} + func (sec securityModelReader) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = resource_security_project.SecurityProjectResourceSchema(ctx) + + // Add plan modifiers to admin_features_package and product_types to preserve state values + // when these fields are not configured. The API returns these values, and they may change + // over time (e.g., tier upgrades), but if not explicitly configured we should keep the + // current state value rather than forcing a recomputation. + adminFeaturesAttr := resp.Schema.Attributes["admin_features_package"].(schema.StringAttribute) + adminFeaturesAttr.PlanModifiers = append(adminFeaturesAttr.PlanModifiers, stringplanmodifier.UseStateForUnknown()) + resp.Schema.Attributes["admin_features_package"] = adminFeaturesAttr + + productTypesAttr := resp.Schema.Attributes["product_types"].(schema.ListNestedAttribute) + productTypesAttr.PlanModifiers = append(productTypesAttr.PlanModifiers, + listplanmodifier.UseStateForUnknown(), + productTypesOrderInsensitivePlanModifier{}, + ) + resp.Schema.Attributes["product_types"] = productTypesAttr } func (sec securityModelReader) ReadFrom(ctx context.Context, getter modelGetter) (*resource_security_project.SecurityProjectModel, diag.Diagnostics) { @@ -288,6 +361,102 @@ func (sec securityApi) Read(ctx context.Context, id string, model resource_secur model.RegionId = basetypes.NewStringValue(resp.JSON200.RegionId) model.Type = basetypes.NewStringValue(string(resp.JSON200.Type)) + // Populate admin_features_package from API response when available + // If API doesn't return it, preserve the configured/state value + if resp.JSON200.AdminFeaturesPackage != nil { + pkgStr := string(*resp.JSON200.AdminFeaturesPackage) + model.AdminFeaturesPackage = basetypes.NewStringValue(pkgStr) + } else if model.AdminFeaturesPackage.IsNull() || model.AdminFeaturesPackage.IsUnknown() { + // Only set to null if it wasn't already configured + model.AdminFeaturesPackage = basetypes.NewStringNull() + } + // Otherwise, preserve the existing configured value + + // Populate product_types from API response when available + if resp.JSON200.ProductTypes != nil { + // If we have product_types in the state/config, we want to preserve that ordering + // to avoid inconsistent results. Otherwise, use API ordering. + var sourceProductTypes []resource_security_project.ProductTypesValue + if !model.ProductTypes.IsNull() && !model.ProductTypes.IsUnknown() { + model.ProductTypes.ElementsAs(ctx, &sourceProductTypes, false) + } + + productTypeValues := []attr.Value{} + + if len(sourceProductTypes) > 0 { + // Use the ordering from state/config, but with values from API + apiProductTypesMap := make(map[string]serverless.SecurityProductType) + for _, pt := range *resp.JSON200.ProductTypes { + apiProductTypesMap[string(pt.ProductLine)] = pt + } + + // Build result in the same order as source + for _, sourcePt := range sourceProductTypes { + productLine := sourcePt.ProductLine.ValueString() + if apiPt, exists := apiProductTypesMap[productLine]; exists { + productTypeValue, diags := resource_security_project.NewProductTypesValue( + resource_security_project.ProductTypesValue{}.AttributeTypes(ctx), + map[string]attr.Value{ + "product_line": basetypes.NewStringValue(string(apiPt.ProductLine)), + "product_tier": basetypes.NewStringValue(string(apiPt.ProductTier)), + }, + ) + if diags.HasError() { + return false, model, diags + } + productTypeValues = append(productTypeValues, productTypeValue) + delete(apiProductTypesMap, productLine) + } + } + + // Add any new product types from API that weren't in source + for _, apiPt := range apiProductTypesMap { + productTypeValue, diags := resource_security_project.NewProductTypesValue( + resource_security_project.ProductTypesValue{}.AttributeTypes(ctx), + map[string]attr.Value{ + "product_line": basetypes.NewStringValue(string(apiPt.ProductLine)), + "product_tier": basetypes.NewStringValue(string(apiPt.ProductTier)), + }, + ) + if diags.HasError() { + return false, model, diags + } + productTypeValues = append(productTypeValues, productTypeValue) + } + } else { + // No source ordering, use API ordering + for _, pt := range *resp.JSON200.ProductTypes { + productTypeValue, diags := resource_security_project.NewProductTypesValue( + resource_security_project.ProductTypesValue{}.AttributeTypes(ctx), + map[string]attr.Value{ + "product_line": basetypes.NewStringValue(string(pt.ProductLine)), + "product_tier": basetypes.NewStringValue(string(pt.ProductTier)), + }, + ) + if diags.HasError() { + return false, model, diags + } + productTypeValues = append(productTypeValues, productTypeValue) + } + } + + productTypesList, diags := types.ListValue( + resource_security_project.ProductTypesValue{}.Type(ctx), + productTypeValues, + ) + if diags.HasError() { + return false, model, diags + } + model.ProductTypes = productTypesList + } else { + // If API doesn't return product_types, preserve the configured/state value + if model.ProductTypes.IsNull() || model.ProductTypes.IsUnknown() { + // Only set to null if it wasn't already configured + model.ProductTypes = types.ListNull(resource_security_project.ProductTypesValue{}.Type(ctx)) + } + // Otherwise, preserve the existing configured value + } + return true, model, nil } diff --git a/ec/ecresource/projectresource/security_test.go b/ec/ecresource/projectresource/security_test.go index e3a37505f..2297236b6 100644 --- a/ec/ecresource/projectresource/security_test.go +++ b/ec/ecresource/projectresource/security_test.go @@ -32,6 +32,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -47,7 +50,17 @@ func TestSecurityModelReader_Schema(t *testing.T) { mr.Schema(context.Background(), resource.SchemaRequest{}, &resp) require.False(t, resp.Diagnostics.HasError()) - require.Equal(t, resource_security_project.SecurityProjectResourceSchema(context.Background()), resp.Schema) + + // Verify that plan modifiers are added to admin_features_package + adminFeaturesAttr := resp.Schema.Attributes["admin_features_package"].(schema.StringAttribute) + require.Len(t, adminFeaturesAttr.PlanModifiers, 1) + require.IsType(t, stringplanmodifier.UseStateForUnknown(), adminFeaturesAttr.PlanModifiers[0]) + + // Verify that plan modifiers are added to product_types + productTypesAttr := resp.Schema.Attributes["product_types"].(schema.ListNestedAttribute) + require.Len(t, productTypesAttr.PlanModifiers, 2) + require.IsType(t, listplanmodifier.UseStateForUnknown(), productTypesAttr.PlanModifiers[0]) + require.IsType(t, productTypesOrderInsensitivePlanModifier{}, productTypesAttr.PlanModifiers[1]) } func TestSecurityModelReader_ReadFrom(t *testing.T) { @@ -904,9 +917,11 @@ func TestSecurityApi_Read(t *testing.T) { "suspended_reason": basetypes.NewStringNull(), }, ), - Name: types.StringValue(readModel.Name), - RegionId: types.StringValue(readModel.RegionId), - Type: types.StringValue(string(readModel.Type)), + Name: types.StringValue(readModel.Name), + RegionId: types.StringValue(readModel.RegionId), + Type: types.StringValue(string(readModel.Type)), + AdminFeaturesPackage: basetypes.NewStringNull(), + ProductTypes: types.ListNull(resource_security_project.ProductTypesValue{}.Type(ctx)), } mockApiClient := mocks.NewMockClientWithResponsesInterface(ctrl) @@ -977,9 +992,211 @@ func TestSecurityApi_Read(t *testing.T) { "suspended_reason": basetypes.NewStringValue(*readModel.Metadata.SuspendedReason), }, ), - Name: types.StringValue(readModel.Name), - RegionId: types.StringValue(readModel.RegionId), - Type: types.StringValue(string(readModel.Type)), + Name: types.StringValue(readModel.Name), + RegionId: types.StringValue(readModel.RegionId), + Type: types.StringValue(string(readModel.Type)), + AdminFeaturesPackage: basetypes.NewStringNull(), + ProductTypes: types.ListNull(resource_security_project.ProductTypesValue{}.Type(ctx)), + } + + mockApiClient := mocks.NewMockClientWithResponsesInterface(ctrl) + mockApiClient.EXPECT(). + GetSecurityProjectWithResponse(ctx, id). + Return(&serverless.GetSecurityProjectResponse{ + JSON200: readModel, + }, nil) + + return testData{ + client: mockApiClient, + id: id, + initialModel: initialModel, + expectedModel: expectedModel, + expectedFound: true, + } + }, + }, + { + name: "should populate admin_features_package and product_types when provided in response", + testData: func(ctx context.Context) testData { + id := "project id" + initialModel := resource_security_project.SecurityProjectModel{ + Id: types.StringValue(id), + } + + adminFeaturesPackage := serverless.SecurityAdminFeaturesPackage("enterprise") + productTypes := []serverless.SecurityProductType{ + { + ProductLine: "security", + ProductTier: "complete", + }, + { + ProductLine: "cloud", + ProductTier: "complete", + }, + } + + readModel := &serverless.SecurityProject{ + Id: id, + Alias: "expected-alias-" + id[0:6], + CloudId: "cloud-id", + Endpoints: serverless.SecurityProjectEndpoints{ + Elasticsearch: "es-endpoint", + Kibana: "kib-endpoint", + Ingest: "ingest-endpoint", + }, + Metadata: serverless.ProjectMetadata{ + CreatedAt: time.Now(), + CreatedBy: "me", + OrganizationId: "1", + }, + Name: "project-name", + RegionId: "nether", + Type: "security", + AdminFeaturesPackage: &adminFeaturesPackage, + ProductTypes: &productTypes, + } + + expectedProductTypes := []attr.Value{ + resource_security_project.NewProductTypesValueMust( + resource_security_project.ProductTypesValue{}.AttributeTypes(ctx), + map[string]attr.Value{ + "product_line": basetypes.NewStringValue("security"), + "product_tier": basetypes.NewStringValue("complete"), + }, + ), + resource_security_project.NewProductTypesValueMust( + resource_security_project.ProductTypesValue{}.AttributeTypes(ctx), + map[string]attr.Value{ + "product_line": basetypes.NewStringValue("cloud"), + "product_tier": basetypes.NewStringValue("complete"), + }, + ), + } + + expectedModel := resource_security_project.SecurityProjectModel{ + Id: types.StringValue(id), + Alias: types.StringValue("expected-alias"), + CloudId: types.StringValue(readModel.CloudId), + Endpoints: resource_security_project.NewEndpointsValueMust( + initialModel.Endpoints.AttributeTypes(ctx), + map[string]attr.Value{ + "elasticsearch": basetypes.NewStringValue(readModel.Endpoints.Elasticsearch), + "kibana": basetypes.NewStringValue(readModel.Endpoints.Kibana), + "ingest": basetypes.NewStringValue(readModel.Endpoints.Ingest), + }, + ), + Metadata: resource_security_project.NewMetadataValueMust( + initialModel.Metadata.AttributeTypes(ctx), + map[string]attr.Value{ + "created_at": basetypes.NewStringValue(readModel.Metadata.CreatedAt.String()), + "created_by": basetypes.NewStringValue(readModel.Metadata.CreatedBy), + "organization_id": basetypes.NewStringValue(readModel.Metadata.OrganizationId), + "suspended_at": basetypes.NewStringNull(), + "suspended_reason": basetypes.NewStringNull(), + }, + ), + Name: types.StringValue(readModel.Name), + RegionId: types.StringValue(readModel.RegionId), + Type: types.StringValue(string(readModel.Type)), + AdminFeaturesPackage: basetypes.NewStringValue("enterprise"), + ProductTypes: types.ListValueMust(resource_security_project.ProductTypesValue{}.Type(ctx), expectedProductTypes), + } + + mockApiClient := mocks.NewMockClientWithResponsesInterface(ctrl) + mockApiClient.EXPECT(). + GetSecurityProjectWithResponse(ctx, id). + Return(&serverless.GetSecurityProjectResponse{ + JSON200: readModel, + }, nil) + + return testData{ + client: mockApiClient, + id: id, + initialModel: initialModel, + expectedModel: expectedModel, + expectedFound: true, + } + }, + }, + { + name: "should preserve configured admin_features_package and product_types when API doesn't return them", + testData: func(ctx context.Context) testData { + id := "project id" + + // Initial model has configured values (simulating what comes from the plan/config) + configuredProductTypes := []attr.Value{ + resource_security_project.NewProductTypesValueMust( + resource_security_project.ProductTypesValue{}.AttributeTypes(ctx), + map[string]attr.Value{ + "product_line": basetypes.NewStringValue("security"), + "product_tier": basetypes.NewStringValue("essentials"), + }, + ), + resource_security_project.NewProductTypesValueMust( + resource_security_project.ProductTypesValue{}.AttributeTypes(ctx), + map[string]attr.Value{ + "product_line": basetypes.NewStringValue("cloud"), + "product_tier": basetypes.NewStringValue("essentials"), + }, + ), + } + + initialModel := resource_security_project.SecurityProjectModel{ + Id: types.StringValue(id), + AdminFeaturesPackage: basetypes.NewStringValue("standard"), + ProductTypes: types.ListValueMust(resource_security_project.ProductTypesValue{}.Type(ctx), configuredProductTypes), + } + + // API response doesn't include admin_features_package or product_types + readModel := &serverless.SecurityProject{ + Id: id, + Alias: "expected-alias-" + id[0:6], + CloudId: "cloud-id", + Endpoints: serverless.SecurityProjectEndpoints{ + Elasticsearch: "es-endpoint", + Kibana: "kib-endpoint", + Ingest: "ingest-endpoint", + }, + Metadata: serverless.ProjectMetadata{ + CreatedAt: time.Now(), + CreatedBy: "me", + OrganizationId: "1", + }, + Name: "project-name", + RegionId: "nether", + Type: "security", + AdminFeaturesPackage: nil, // API doesn't return this + ProductTypes: nil, // API doesn't return this + } + + // Expected model should preserve the configured values + expectedModel := resource_security_project.SecurityProjectModel{ + Id: types.StringValue(id), + Alias: types.StringValue("expected-alias"), + CloudId: types.StringValue(readModel.CloudId), + Endpoints: resource_security_project.NewEndpointsValueMust( + initialModel.Endpoints.AttributeTypes(ctx), + map[string]attr.Value{ + "elasticsearch": basetypes.NewStringValue(readModel.Endpoints.Elasticsearch), + "kibana": basetypes.NewStringValue(readModel.Endpoints.Kibana), + "ingest": basetypes.NewStringValue(readModel.Endpoints.Ingest), + }, + ), + Metadata: resource_security_project.NewMetadataValueMust( + initialModel.Metadata.AttributeTypes(ctx), + map[string]attr.Value{ + "created_at": basetypes.NewStringValue(readModel.Metadata.CreatedAt.String()), + "created_by": basetypes.NewStringValue(readModel.Metadata.CreatedBy), + "organization_id": basetypes.NewStringValue(readModel.Metadata.OrganizationId), + "suspended_at": basetypes.NewStringNull(), + "suspended_reason": basetypes.NewStringNull(), + }, + ), + Name: types.StringValue(readModel.Name), + RegionId: types.StringValue(readModel.RegionId), + Type: types.StringValue(string(readModel.Type)), + AdminFeaturesPackage: basetypes.NewStringValue("standard"), + ProductTypes: types.ListValueMust(resource_security_project.ProductTypesValue{}.Type(ctx), configuredProductTypes), } mockApiClient := mocks.NewMockClientWithResponsesInterface(ctrl)