diff --git a/.go-version b/.go-version index d28b1eb8..53cc1a6f 100644 --- a/.go-version +++ b/.go-version @@ -1 +1 @@ -1.22.9 +1.24.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2ba1d800..756676a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: - id: gofmts - repo: https://github.com/golangci/golangci-lint - rev: v1.51.2 + rev: v1.63.2 hooks: - id: golangci-lint diff --git a/docs/data-sources/environment.md b/docs/data-sources/environment.md index 5db13906..e7fbe851 100644 --- a/docs/data-sources/environment.md +++ b/docs/data-sources/environment.md @@ -61,5 +61,6 @@ Read-Only: - `min_num_approvals` (Number) - `required` (Boolean) - `required_approval_tags` (List of String) +- `resource_kind` (String) - `service_config` (Map of String) - `service_kind` (String) diff --git a/docs/guides/approval-settings-migration.md b/docs/guides/approval-settings-migration.md new file mode 100644 index 00000000..be384549 --- /dev/null +++ b/docs/guides/approval-settings-migration.md @@ -0,0 +1,270 @@ +--- +page_title: "Migrating to multi-resource approval settings" +description: |- + This guide explains how to use the approval_settings block to configure approval requirements for flags, segments, and AI configs within the same environment. Learn about the new resource_kind attribute, validation rules, and how to migrate existing configurations. +--- + +# Approval Settings Migration Guide + +## Overview + +The `approval_settings` block now supports multiple resource kinds (flags, segments, and AI configs) while maintaining full backwards compatibility with existing configurations. + +## What's New + +### Multiple Approval Settings Blocks + +You can now specify approval settings for different resource types within the same environment: + +**Important**: `service_kind` and `service_config` are **only supported for flag approval settings** (`resource_kind = "flag"`). Using these fields with `resource_kind = "segment"` or `resource_kind = "aiconfig"` will result in a validation error. + +```hcl +resource "launchdarkly_environment" "example" { + name = "Production" + key = "production" + color = "FF0000" + project_key = "my-project" + + # Flag approval settings + approval_settings { + resource_kind = "flag" + required = true + min_num_approvals = 2 + can_review_own_request = false + can_apply_declined_changes = false + } + + # Segment approval settings + # Note: service_kind and service_config are not supported for segment approvals + approval_settings { + resource_kind = "segment" + required = true + min_num_approvals = 1 + can_apply_declined_changes = true + } + + # AI Config approval settings + # Note: service_kind and service_config are not supported for aiconfig approvals + approval_settings { + resource_kind = "aiconfig" + required = false + min_num_approvals = 1 + } +} +``` + +### New Attribute: `resource_kind` + +- **Type**: `string` +- **Optional**: Yes (defaults to `"flag"`) +- **Valid values**: `"flag"`, `"segment"`, `"aiconfig"` +- **Description**: Specifies which resource type the approval settings apply to + +### Validation + +- Each `resource_kind` can only be specified once per environment +- Attempting to configure duplicate `resource_kind` values will result in a validation error + +## Backwards Compatibility + +Existing configurations without the `resource_kind` attribute will continue to work exactly as before. The attribute defaults to `"flag"` when not specified: + +```hcl +# This configuration is still valid and equivalent to resource_kind = "flag" +resource "launchdarkly_environment" "example" { + name = "Production" + key = "production" + color = "FF0000" + project_key = "my-project" + + approval_settings { + required = true + min_num_approvals = 1 + } +} +``` + +## API Mapping + +### Reading from API +- `approvalSettings` (root level) → `approval_settings` with `resource_kind = "flag"` +- `resourceApprovalSettings.segment` → `approval_settings` with `resource_kind = "segment"` +- `resourceApprovalSettings.aiconfig` → `approval_settings` with `resource_kind = "aiconfig"` + +### Writing to API +- `resource_kind = "flag"` → PATCH operations to `/approvalSettings/*` +- `resource_kind = "segment"` → PATCH operations to `/resourceApprovalSettings/segment/*` +- `resource_kind = "aiconfig"` → PATCH operations to `/resourceApprovalSettings/aiconfig/*` + +## Migration Examples + +### Before: Flag Approvals Only +```hcl +resource "launchdarkly_environment" "prod" { + name = "Production" + key = "production" + project_key = "my-project" + + approval_settings { + required = true + min_num_approvals = 2 + } +} +``` + +### After: Adding Segment Approvals +```hcl +resource "launchdarkly_environment" "prod" { + name = "Production" + key = "production" + project_key = "my-project" + + # Existing flag approvals (add resource_kind for clarity, but optional) + approval_settings { + resource_kind = "flag" + required = true + min_num_approvals = 2 + } + + # New segment approvals + approval_settings { + resource_kind = "segment" + required = true + min_num_approvals = 1 + } +} +``` + +## Implementation Details + +### Code Changes + +1. **New constant**: `RESOURCE_KIND` added to `keys.go` +2. **Schema updated**: `approval_settings` block now supports `resource_kind` attribute +3. **Validation added**: `validateUniqueResourceKinds` ensures no duplicate resource kinds +4. **Patch generation**: `approvalPatchFromSettings` generates correct API paths based on resource kind +5. **Data reading**: `environmentApprovalSettingsToResourceData` handles both API structures + +### Testing + +Comprehensive unit tests ensure: +- Multiple resource kinds can be configured simultaneously +- Correct API paths are generated for each resource kind +- Backwards compatibility with existing configurations +- Proper handling of adding/removing resource kinds + +Run tests with: +```bash +go test -v ./launchdarkly -run TestApproval +``` + +## Troubleshooting + +### Error: "duplicate resource_kind found" + +**Cause**: Multiple `approval_settings` blocks with the same `resource_kind` + +**Solution**: Ensure each `approval_settings` block has a unique `resource_kind` value + +```hcl +# ❌ Invalid - duplicate "flag" resource_kind +approval_settings { + resource_kind = "flag" + required = true +} +approval_settings { + resource_kind = "flag" # ERROR: duplicate + required = false +} + +# ✅ Valid - unique resource_kind values +approval_settings { + resource_kind = "flag" + required = true +} +approval_settings { + resource_kind = "segment" + required = false +} +``` + +### Error: "service_kind cannot be set for resource_kind 'segment'" + +**Cause**: Attempting to use `service_kind` with a non-default value (anything other than `"launchdarkly"`) for segment or aiconfig approval settings + +**Solution**: Remove the `service_kind` field or set it to the default value `"launchdarkly"` (or omit it entirely) + +```hcl +# ❌ Invalid - service_kind not supported for segment +approval_settings { + resource_kind = "segment" + service_kind = "servicenow" # ERROR: not supported for segments + required = true +} + +# ✅ Valid - service_kind only used with flag resource_kind +approval_settings { + resource_kind = "flag" + service_kind = "servicenow" + required = true +} + +approval_settings { + resource_kind = "segment" + required = true + # service_kind omitted or set to default "launchdarkly" +} +``` + +### Error: "service_config cannot be set for resource_kind 'segment'" + +**Cause**: Attempting to use `service_config` for segment or aiconfig approval settings + +**Solution**: Remove the `service_config` field from non-flag approval settings + +```hcl +# ❌ Invalid - service_config not supported for segment +approval_settings { + resource_kind = "segment" + service_config = { + template = "template-id" + detail_column = "justification" + } # ERROR: not supported for segments + required = true +} + +# ✅ Valid - service_config only used with flag resource_kind +approval_settings { + resource_kind = "flag" + service_kind = "servicenow" + service_config = { + template = "template-id" + detail_column = "justification" + } + required = true +} + +approval_settings { + resource_kind = "segment" + required = true + # service_config omitted +} +``` + + +### Import Considerations + +When you import an existing environment (using `terraform import`), Terraform will read all configured approval settings from the API and represent them as separate `approval_settings` blocks in the state, each with the appropriate `resource_kind` value. + +For example, if you import an environment that has both flag and segment approvals configured: + +```bash +terraform import launchdarkly_environment.example project-key/env-key +``` + +The resulting state will contain multiple `approval_settings` blocks. You'll need to add corresponding blocks to your Terraform configuration to match the imported state. + +## Additional Resources + +- [LaunchDarkly Approval Settings Documentation](https://docs.launchdarkly.com/home/feature-workflows/approvals) +- [Terraform Provider Documentation](https://registry.terraform.io/providers/launchdarkly/launchdarkly/latest/docs) diff --git a/docs/resources/environment.md b/docs/resources/environment.md index 13e4a6c2..70035755 100644 --- a/docs/resources/environment.md +++ b/docs/resources/environment.md @@ -78,17 +78,18 @@ resource "launchdarkly_environment" "approvals_example" { Optional: -- `auto_apply_approved_changes` (Boolean) Automatically apply changes that have been approved by all reviewers. This field is only applicable for approval service kinds other than `launchdarkly`. +- `auto_apply_approved_changes` (Boolean) Automatically apply changes that have been approved by all reviewers. **Note:** This field is only supported for `resource_kind = "flag"` and is only applicable for approval service kinds other than `launchdarkly`. - `can_apply_declined_changes` (Boolean) Set to `true` if changes can be applied as long as the `min_num_approvals` is met, regardless of whether any reviewers have declined a request. Defaults to `true`. - `can_review_own_request` (Boolean) Set to `true` if requesters can approve or decline their own request. They may always comment. Defaults to `false`. - `min_num_approvals` (Number) The number of approvals required before an approval request can be applied. This number must be between 1 and 5. Defaults to 1. -- `required` (Boolean) Set to `true` for changes to flags in this environment to require approval. You may only set `required` to true if `required_approval_tags` is not set and vice versa. Defaults to `false`. +- `required` (Boolean) Set to `true` for changes to resources of this kind in this environment to require approval. You may only set `required` to true if `required_approval_tags` is not set and vice versa. Defaults to `false`. - `required_approval_tags` (List of String) An array of tags used to specify which flags with those tags require approval. You may only set `required_approval_tags` if `required` is set to `false` and vice versa. -- `service_config` (Map of String) The configuration for the service associated with this approval. This is specific to each approval service. For a `service_kind` of `servicenow`, the following fields apply: +- `resource_kind` (String) The kind of resource for which approval settings should apply. Valid values are `flag`, `segment`, and `aiconfig`. Defaults to `flag`. +- `service_config` (Map of String) The configuration for the service associated with this approval. **Note:** This field is only supported for `resource_kind = "flag"`. Using this field with `resource_kind = "segment"` or `resource_kind = "aiconfig"` will result in an error. For a `service_kind` of `servicenow`, the following fields apply: - `template` (String) The sys_id of the Standard Change Request Template in ServiceNow that LaunchDarkly will use when creating the change request. - `detail_column` (String) The name of the ServiceNow Change Request column LaunchDarkly uses to populate detailed approval request information. This is most commonly "justification". -- `service_kind` (String) The kind of service associated with this approval. This determines which platform is used for requesting approval. Valid values are `servicenow`, `launchdarkly`. If you use a value other than `launchdarkly`, you must have already configured the integration in the LaunchDarkly UI or your apply will fail. +- `service_kind` (String) The kind of service associated with this approval. This determines which platform is used for requesting approval. Valid values are `servicenow`, `launchdarkly`. **Note:** This field is only supported for `resource_kind = "flag"`. Using this field with `resource_kind = "segment"` or `resource_kind = "aiconfig"` will result in an error. If you use a value other than `launchdarkly`, you must have already configured the integration in the LaunchDarkly UI or your apply will fail. ## Import diff --git a/docs/resources/project.md b/docs/resources/project.md index 697775bc..c60eb355 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -97,17 +97,18 @@ Read-Only: Optional: -- `auto_apply_approved_changes` (Boolean) Automatically apply changes that have been approved by all reviewers. This field is only applicable for approval service kinds other than `launchdarkly`. +- `auto_apply_approved_changes` (Boolean) Automatically apply changes that have been approved by all reviewers. **Note:** This field is only supported for `resource_kind = "flag"` and is only applicable for approval service kinds other than `launchdarkly`. - `can_apply_declined_changes` (Boolean) Set to `true` if changes can be applied as long as the `min_num_approvals` is met, regardless of whether any reviewers have declined a request. Defaults to `true`. - `can_review_own_request` (Boolean) Set to `true` if requesters can approve or decline their own request. They may always comment. Defaults to `false`. - `min_num_approvals` (Number) The number of approvals required before an approval request can be applied. This number must be between 1 and 5. Defaults to 1. -- `required` (Boolean) Set to `true` for changes to flags in this environment to require approval. You may only set `required` to true if `required_approval_tags` is not set and vice versa. Defaults to `false`. +- `required` (Boolean) Set to `true` for changes to resources of this kind in this environment to require approval. You may only set `required` to true if `required_approval_tags` is not set and vice versa. Defaults to `false`. - `required_approval_tags` (List of String) An array of tags used to specify which flags with those tags require approval. You may only set `required_approval_tags` if `required` is set to `false` and vice versa. -- `service_config` (Map of String) The configuration for the service associated with this approval. This is specific to each approval service. For a `service_kind` of `servicenow`, the following fields apply: +- `resource_kind` (String) The kind of resource for which approval settings should apply. Valid values are `flag`, `segment`, and `aiconfig`. Defaults to `flag`. +- `service_config` (Map of String) The configuration for the service associated with this approval. **Note:** This field is only supported for `resource_kind = "flag"`. Using this field with `resource_kind = "segment"` or `resource_kind = "aiconfig"` will result in an error. For a `service_kind` of `servicenow`, the following fields apply: - `template` (String) The sys_id of the Standard Change Request Template in ServiceNow that LaunchDarkly will use when creating the change request. - `detail_column` (String) The name of the ServiceNow Change Request column LaunchDarkly uses to populate detailed approval request information. This is most commonly "justification". -- `service_kind` (String) The kind of service associated with this approval. This determines which platform is used for requesting approval. Valid values are `servicenow`, `launchdarkly`. If you use a value other than `launchdarkly`, you must have already configured the integration in the LaunchDarkly UI or your apply will fail. +- `service_kind` (String) The kind of service associated with this approval. This determines which platform is used for requesting approval. Valid values are `servicenow`, `launchdarkly`. **Note:** This field is only supported for `resource_kind = "flag"`. Using this field with `resource_kind = "segment"` or `resource_kind = "aiconfig"` will result in an error. If you use a value other than `launchdarkly`, you must have already configured the integration in the LaunchDarkly UI or your apply will fail. diff --git a/examples/environment_approval_settings_example.tf b/examples/environment_approval_settings_example.tf new file mode 100644 index 00000000..561f6e9e --- /dev/null +++ b/examples/environment_approval_settings_example.tf @@ -0,0 +1,56 @@ +# Example of environment with approval settings for both flags and segments + +resource "launchdarkly_environment" "example" { + name = "Example Environment" + key = "example-env" + color = "FFFFFF" + project_key = "example-project" + + # Flag approval settings (resource_kind defaults to "flag") + approval_settings { + resource_kind = "flag" # Optional, defaults to "flag" + required = true + can_review_own_request = false + min_num_approvals = 2 + can_apply_declined_changes = false + service_kind = "launchdarkly" + } + + # Segment approval settings + approval_settings { + resource_kind = "segment" + required = true + can_review_own_request = false + min_num_approvals = 1 + can_apply_declined_changes = true + service_kind = "launchdarkly" + } + + # AI Config approval settings (if needed) + # approval_settings { + # resource_kind = "aiconfig" + # required = false + # can_review_own_request = true + # min_num_approvals = 1 + # can_apply_declined_changes = true + # service_kind = "launchdarkly" + # } +} + +# Example maintaining backwards compatibility (without resource_kind) +# This will default to flag approval settings +resource "launchdarkly_environment" "backwards_compatible" { + name = "Backwards Compatible Environment" + key = "backwards-compat" + color = "000000" + project_key = "example-project" + + approval_settings { + # No resource_kind specified - defaults to "flag" + required = true + can_review_own_request = false + min_num_approvals = 1 + can_apply_declined_changes = true + service_kind = "launchdarkly" + } +} diff --git a/launchdarkly/approvals_helper.go b/launchdarkly/approvals_helper.go index 27eafb80..ab3ebcc8 100644 --- a/launchdarkly/approvals_helper.go +++ b/launchdarkly/approvals_helper.go @@ -14,11 +14,19 @@ type approvalSchemaOptions struct { func approvalSchema(options approvalSchemaOptions) *schema.Schema { elemSchema := map[string]*schema.Schema{ + RESOURCE_KIND: { + Type: schema.TypeString, + Optional: !options.isDataSource, + Computed: options.isDataSource, + Description: "The kind of resource for which approval settings should apply. Valid values are `flag`, `segment`, and `aiconfig`. Defaults to `flag`.", + Default: "flag", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"flag", "segment", "aiconfig"}, false)), + }, REQUIRED: { Type: schema.TypeBool, Optional: !options.isDataSource, Computed: options.isDataSource, - Description: "Set to `true` for changes to flags in this environment to require approval. You may only set `required` to true if `required_approval_tags` is not set and vice versa. Defaults to `false`.", + Description: "Set to `true` for changes to resources of this kind in this environment to require approval. You may only set `required` to true if `required_approval_tags` is not set and vice versa. Defaults to `false`.", Default: false, }, CAN_REVIEW_OWN_REQUEST: { @@ -59,7 +67,7 @@ func approvalSchema(options approvalSchemaOptions) *schema.Schema { Type: schema.TypeString, Optional: !options.isDataSource, Computed: options.isDataSource, - Description: "The kind of service associated with this approval. This determines which platform is used for requesting approval. Valid values are `servicenow`, `launchdarkly`. If you use a value other than `launchdarkly`, you must have already configured the integration in the LaunchDarkly UI or your apply will fail.", + Description: "The kind of service associated with this approval. This determines which platform is used for requesting approval. Valid values are `servicenow`, `launchdarkly`. **Note:** This field is only supported for `resource_kind = \"flag\"`. Using this field with `resource_kind = \"segment\"` or `resource_kind = \"aiconfig\"` will result in an error. If you use a value other than `launchdarkly`, you must have already configured the integration in the LaunchDarkly UI or your apply will fail.", Default: "launchdarkly", ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"servicenow", "launchdarkly"}, false)), }, @@ -67,12 +75,12 @@ func approvalSchema(options approvalSchemaOptions) *schema.Schema { Type: schema.TypeMap, Optional: !options.isDataSource, Computed: options.isDataSource, - Description: "The configuration for the service associated with this approval. This is specific to each approval service. For a `service_kind` of `servicenow`, the following fields apply:\n\n\t - `template` (String) The sys_id of the Standard Change Request Template in ServiceNow that LaunchDarkly will use when creating the change request.\n\t - `detail_column` (String) The name of the ServiceNow Change Request column LaunchDarkly uses to populate detailed approval request information. This is most commonly \"justification\".", + Description: "The configuration for the service associated with this approval. **Note:** This field is only supported for `resource_kind = \"flag\"`. Using this field with `resource_kind = \"segment\"` or `resource_kind = \"aiconfig\"` will result in an error. For a `service_kind` of `servicenow`, the following fields apply:\n\n\t - `template` (String) The sys_id of the Standard Change Request Template in ServiceNow that LaunchDarkly will use when creating the change request.\n\t - `detail_column` (String) The name of the ServiceNow Change Request column LaunchDarkly uses to populate detailed approval request information. This is most commonly \"justification\".", }, AUTO_APPLY_APPROVED_CHANGES: { Type: schema.TypeBool, Optional: true, - Description: "Automatically apply changes that have been approved by all reviewers. This field is only applicable for approval service kinds other than `launchdarkly`.", + Description: "Automatically apply changes that have been approved by all reviewers. **Note:** This field is only supported for `resource_kind = \"flag\"` and is only applicable for approval service kinds other than `launchdarkly`.", Default: false, }, } @@ -81,7 +89,7 @@ func approvalSchema(options approvalSchemaOptions) *schema.Schema { elemSchema = removeInvalidFieldsForDataSource(elemSchema) } - return &schema.Schema{ + approvalSettingsSchema := &schema.Schema{ Type: schema.TypeList, Optional: !options.isDataSource, Computed: true, @@ -89,14 +97,22 @@ func approvalSchema(options approvalSchemaOptions) *schema.Schema { Schema: elemSchema, }, } + + return approvalSettingsSchema +} + +// approvalSettingWithKind wraps approval settings with its resource kind +type approvalSettingWithKind struct { + ResourceKind string + Settings ldapi.ApprovalSettings } -func approvalSettingsFromResourceData(val interface{}) (ldapi.ApprovalSettings, error) { - raw := val.([]interface{}) - if len(raw) == 0 { - return ldapi.ApprovalSettings{}, nil +func approvalSettingFromMap(approvalSettingsMap map[string]interface{}) (approvalSettingWithKind, error) { + resourceKind, ok := approvalSettingsMap[RESOURCE_KIND].(string) + if !ok || resourceKind == "" { + resourceKind = "flag" // default value } - approvalSettingsMap := raw[0].(map[string]interface{}) + autoApply := approvalSettingsMap[AUTO_APPLY_APPROVED_CHANGES].(bool) settings := ldapi.ApprovalSettings{ CanReviewOwnRequest: approvalSettingsMap[CAN_REVIEW_OWN_REQUEST].(bool), @@ -113,7 +129,7 @@ func approvalSettingsFromResourceData(val interface{}) (ldapi.ApprovalSettings, tags := approvalSettingsMap[REQUIRED_APPROVAL_TAGS].([]interface{}) if len(tags) > 0 { if required { - return ldapi.ApprovalSettings{}, fmt.Errorf("invalid approval_settings config: required and required_approval_tags cannot be set simultaneously") + return approvalSettingWithKind{}, fmt.Errorf("invalid approval_settings config: required and required_approval_tags cannot be set simultaneously") } stringTags := make([]string, len(tags)) for i := range tags { @@ -126,14 +142,32 @@ func approvalSettingsFromResourceData(val interface{}) (ldapi.ApprovalSettings, settings.ServiceKind = approvalSettingsMap[SERVICE_KIND].(string) settings.ServiceConfig = approvalSettingsMap[SERVICE_CONFIG].(map[string]interface{}) + + // Validate that service_kind and service_config are only used with flag resource kinds + if resourceKind != "flag" { + // Error if service_kind is set to non-default value + serviceKind := settings.ServiceKind + if serviceKind != "launchdarkly" { + return approvalSettingWithKind{}, fmt.Errorf("invalid approval_settings config: service_kind cannot be set for resource_kind '%s'. This field is only supported for resource_kind 'flag'", resourceKind) + } + + // Error if service_config has any values + if len(settings.ServiceConfig) > 0 { + return approvalSettingWithKind{}, fmt.Errorf("invalid approval_settings config: service_config cannot be set for resource_kind '%s'. This field is only supported for resource_kind 'flag'", resourceKind) + } + } + + // Validate that auto_apply cannot be used with launchdarkly service_kind (existing validation) if settings.ServiceKind == "launchdarkly" && settings.AutoApplyApprovedChanges != nil && *settings.AutoApplyApprovedChanges { - return ldapi.ApprovalSettings{}, fmt.Errorf("invalid approval_settings config: auto_apply_approved_changes cannot be set to true for service_kind of launchdarkly") + return approvalSettingWithKind{}, fmt.Errorf("invalid approval_settings config: auto_apply_approved_changes cannot be set to true for service_kind of launchdarkly") } - return settings, nil + + return approvalSettingWithKind{ResourceKind: resourceKind, Settings: settings}, nil } -func approvalSettingsToResourceData(settings ldapi.ApprovalSettings) interface{} { - transformed := map[string]interface{}{ +func approvalSettingToResourceData(settings ldapi.ApprovalSettings, resourceKind string) map[string]interface{} { + return map[string]interface{}{ + RESOURCE_KIND: resourceKind, CAN_REVIEW_OWN_REQUEST: settings.CanReviewOwnRequest, MIN_NUM_APPROVALS: settings.MinNumApprovals, CAN_APPLY_DECLINED_CHANGES: settings.CanApplyDeclinedChanges, @@ -143,36 +177,161 @@ func approvalSettingsToResourceData(settings ldapi.ApprovalSettings) interface{} SERVICE_CONFIG: settings.ServiceConfig, AUTO_APPLY_APPROVED_CHANGES: settings.AutoApplyApprovedChanges, } - return []map[string]interface{}{transformed} } -func approvalPatchFromSettings(oldApprovalSettings, newApprovalSettings interface{}) ([]ldapi.PatchOperation, error) { - settings, err := approvalSettingsFromResourceData(newApprovalSettings) - if err != nil { - return []ldapi.PatchOperation{}, err +// environmentApprovalSettingsToResourceData converts environment approval settings from API response +// It handles both approvalSettings (flags) and resourceApprovalSettings (segment, aiconfig, etc.) +func environmentApprovalSettingsToResourceData(env ldapi.Environment) []map[string]interface{} { + result := make([]map[string]interface{}, 0) + + // Handle flag approval settings (from root approvalSettings) + // Always include flag approval settings if present for backwards compatibility + if env.ApprovalSettings != nil { + result = append(result, approvalSettingToResourceData(*env.ApprovalSettings, "flag")) } - new := newApprovalSettings.([]interface{}) - old := oldApprovalSettings.([]interface{}) + + // Handle resource approval settings (from resourceApprovalSettings) + if env.ResourceApprovalSettings != nil { + resourceApprovalSettings := *env.ResourceApprovalSettings + + // Handle segment approval settings - only include if actually configured + if segmentSettings, ok := resourceApprovalSettings["segment"]; ok { + // Skip if not configured (just defaults from API) + if segmentSettings.Required || len(segmentSettings.RequiredApprovalTags) > 0 { + result = append(result, approvalSettingToResourceData(segmentSettings, "segment")) + } + } + + // Handle aiconfig approval settings - only include if actually configured + if aiconfigSettings, ok := resourceApprovalSettings["aiconfig"]; ok { + // Skip if not configured (just defaults from API) + if aiconfigSettings.Required || len(aiconfigSettings.RequiredApprovalTags) > 0 { + result = append(result, approvalSettingToResourceData(aiconfigSettings, "aiconfig")) + } + } + } + + return result +} + +// approvalPatchForResourceKind generates patch operations for a specific resource kind +func approvalPatchForResourceKind(settings approvalSettingWithKind) []ldapi.PatchOperation { + // Determine the base path based on resource kind + var basePath string + if settings.ResourceKind == "flag" { + basePath = "/approvalSettings" + } else { + basePath = fmt.Sprintf("/resourceApprovalSettings/%s", settings.ResourceKind) + } + + patch := []ldapi.PatchOperation{ + patchReplace(basePath+"/required", settings.Settings.Required), + patchReplace(basePath+"/canReviewOwnRequest", settings.Settings.CanReviewOwnRequest), + patchReplace(basePath+"/minNumApprovals", settings.Settings.MinNumApprovals), + patchReplace(basePath+"/canApplyDeclinedChanges", settings.Settings.CanApplyDeclinedChanges), + patchReplace(basePath+"/requiredApprovalTags", settings.Settings.RequiredApprovalTags), + } + + // serviceKind, serviceConfig, and autoApplyApprovedChanges are only supported for flag approval settings + if settings.ResourceKind == "flag" { + patch = append(patch, + patchReplace(basePath+"/serviceKind", settings.Settings.ServiceKind), + patchReplace(basePath+"/serviceConfig", settings.Settings.ServiceConfig), + ) + if settings.Settings.AutoApplyApprovedChanges != nil { + patch = append(patch, patchReplace(basePath+"/autoApplyApprovedChanges", *settings.Settings.AutoApplyApprovedChanges)) + } + } + + return patch +} + +func approvalPatchFromSettings(oldApprovalSettings, newApprovalSettings []interface{}) ([]ldapi.PatchOperation, error) { + new := newApprovalSettings + old := oldApprovalSettings + if len(new) == 0 && len(old) == 0 { return []ldapi.PatchOperation{}, nil } - if len(new) == 0 && len(old) > 0 { - return []ldapi.PatchOperation{ - patchRemove("/approvalSettings/required"), - patchRemove("/approvalSettings/requiredApprovalTags"), - }, nil + + // Validate that there are no duplicate resource_kind values + seenKinds := make(map[string]bool) + for _, rawSetting := range new { + if rawSetting == nil { + continue + } + setting := rawSetting.(map[string]interface{}) + kind, ok := setting[RESOURCE_KIND].(string) + if !ok || kind == "" { + kind = "flag" + } + if seenKinds[kind] { + return []ldapi.PatchOperation{}, fmt.Errorf("duplicate resource_kind '%s' found in approval_settings. Each resource_kind can only be specified once", kind) + } + seenKinds[kind] = true } - patch := []ldapi.PatchOperation{ - patchReplace("/approvalSettings/required", settings.Required), - patchReplace("/approvalSettings/canReviewOwnRequest", settings.CanReviewOwnRequest), - patchReplace("/approvalSettings/minNumApprovals", settings.MinNumApprovals), - patchReplace("/approvalSettings/canApplyDeclinedChanges", settings.CanApplyDeclinedChanges), - patchReplace("/approvalSettings/requiredApprovalTags", settings.RequiredApprovalTags), - patchReplace("/approvalSettings/serviceKind", settings.ServiceKind), - patchReplace("/approvalSettings/serviceConfig", settings.ServiceConfig), - } - if settings.AutoApplyApprovedChanges != nil { - patch = append(patch, patchReplace("/approvalSettings/autoApplyApprovedChanges", *settings.AutoApplyApprovedChanges)) - } - return patch, nil + + patches := []ldapi.PatchOperation{} + + // Track which resource kinds exist in old and new + oldKinds := make(map[string]bool) + newKinds := make(map[string]bool) + + for _, rawSetting := range old { + if rawSetting == nil { + continue + } + setting := rawSetting.(map[string]interface{}) + kind, ok := setting[RESOURCE_KIND].(string) + if !ok || kind == "" { + kind = "flag" + } + oldKinds[kind] = true + } + + for _, rawSetting := range new { + if rawSetting == nil { + continue + } + setting := rawSetting.(map[string]interface{}) + kind, ok := setting[RESOURCE_KIND].(string) + if !ok || kind == "" { + kind = "flag" + } + newKinds[kind] = true + } + + // Handle removals - resource kinds that were in old but not in new + for kind := range oldKinds { + if !newKinds[kind] { + if kind == "flag" { + patches = append(patches, + patchRemove("/approvalSettings/required"), + patchRemove("/approvalSettings/requiredApprovalTags"), + ) + } else { + basePath := fmt.Sprintf("/resourceApprovalSettings/%s", kind) + patches = append(patches, + patchRemove(basePath+"/required"), + patchRemove(basePath+"/requiredApprovalTags"), + ) + } + } + } + + // Handle additions and updates - resource kinds that are in new + for _, rawSetting := range new { + if rawSetting == nil { + continue + } + settingMap := rawSetting.(map[string]interface{}) + setting, err := approvalSettingFromMap(settingMap) + if err != nil { + return []ldapi.PatchOperation{}, err + } + + patches = append(patches, approvalPatchForResourceKind(setting)...) + } + + return patches, nil } diff --git a/launchdarkly/approvals_helper_test.go b/launchdarkly/approvals_helper_test.go new file mode 100644 index 00000000..f05a5897 --- /dev/null +++ b/launchdarkly/approvals_helper_test.go @@ -0,0 +1,385 @@ +package launchdarkly + +import ( + "testing" + + ldapi "github.com/launchdarkly/api-client-go/v17" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApprovalPatchFromSettings_MultipleResourceKinds(t *testing.T) { + // Test creating patches for both flag and segment approval settings + oldSettings := []interface{}{} + newSettings := []interface{}{ + map[string]interface{}{ + RESOURCE_KIND: "flag", + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 2, + CAN_APPLY_DECLINED_CHANGES: false, + SERVICE_KIND: "launchdarkly", + SERVICE_CONFIG: map[string]interface{}{}, + AUTO_APPLY_APPROVED_CHANGES: false, + REQUIRED_APPROVAL_TAGS: []interface{}{}, + }, + map[string]interface{}{ + RESOURCE_KIND: "segment", + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 1, + CAN_APPLY_DECLINED_CHANGES: true, + SERVICE_KIND: "launchdarkly", + SERVICE_CONFIG: map[string]interface{}{}, + AUTO_APPLY_APPROVED_CHANGES: false, + REQUIRED_APPROVAL_TAGS: []interface{}{}, + }, + } + + patches, err := approvalPatchFromSettings(oldSettings, newSettings) + require.NoError(t, err) + + // Should have patches for both flag and segment + // Flag has 8 fields (required, canReviewOwnRequest, minNumApprovals, + // canApplyDeclinedChanges, requiredApprovalTags, serviceKind, serviceConfig, autoApplyApprovedChanges) + // Segment has 5 fields (required, canReviewOwnRequest, minNumApprovals, + // canApplyDeclinedChanges, requiredApprovalTags) - service_kind, service_config, auto_apply not supported + assert.Equal(t, 13, len(patches), "Expected 13 patches (8 for flag + 5 for segment)") + + // Verify flag patches have /approvalSettings path + flagPatchCount := 0 + segmentPatchCount := 0 + for _, patch := range patches { + if len(patch.Path) >= len("/approvalSettings") && patch.Path[:len("/approvalSettings")] == "/approvalSettings" { + flagPatchCount++ + } + if len(patch.Path) >= len("/resourceApprovalSettings/segment") && patch.Path[:len("/resourceApprovalSettings/segment")] == "/resourceApprovalSettings/segment" { + segmentPatchCount++ + } + } + + assert.Equal(t, 8, flagPatchCount, "Expected 8 patches for flag approval settings") + assert.Equal(t, 5, segmentPatchCount, "Expected 5 patches for segment approval settings") +} + +func TestApprovalPatchFromSettings_RemoveResourceKind(t *testing.T) { + // Test removing segment approval settings while keeping flag settings + oldSettings := []interface{}{ + map[string]interface{}{ + RESOURCE_KIND: "flag", + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 1, + CAN_APPLY_DECLINED_CHANGES: true, + SERVICE_KIND: "launchdarkly", + SERVICE_CONFIG: map[string]interface{}{}, + AUTO_APPLY_APPROVED_CHANGES: false, + REQUIRED_APPROVAL_TAGS: []interface{}{}, + }, + map[string]interface{}{ + RESOURCE_KIND: "segment", + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 1, + CAN_APPLY_DECLINED_CHANGES: true, + SERVICE_KIND: "launchdarkly", + SERVICE_CONFIG: map[string]interface{}{}, + AUTO_APPLY_APPROVED_CHANGES: false, + REQUIRED_APPROVAL_TAGS: []interface{}{}, + }, + } + newSettings := []interface{}{ + map[string]interface{}{ + RESOURCE_KIND: "flag", + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 1, + CAN_APPLY_DECLINED_CHANGES: true, + SERVICE_KIND: "launchdarkly", + SERVICE_CONFIG: map[string]interface{}{}, + AUTO_APPLY_APPROVED_CHANGES: false, + REQUIRED_APPROVAL_TAGS: []interface{}{}, + }, + } + + patches, err := approvalPatchFromSettings(oldSettings, newSettings) + require.NoError(t, err) + + // Should have removal patches for segment and update patches for flag + // 2 remove patches for segment + 8 update patches for flag = 10 total + assert.GreaterOrEqual(t, len(patches), 10, "Expected at least 10 patches") + + // Verify we have remove operations for segment + hasSegmentRemove := false + for _, patch := range patches { + if patch.Op == "remove" && len(patch.Path) >= len("/resourceApprovalSettings/segment") { + if patch.Path[:len("/resourceApprovalSettings/segment")] == "/resourceApprovalSettings/segment" { + hasSegmentRemove = true + break + } + } + } + + assert.True(t, hasSegmentRemove, "Expected remove operation for segment approval settings") +} + +func TestEnvironmentApprovalSettingsToResourceData(t *testing.T) { + // Test converting API response to Terraform resource data + flagSettings := ldapi.ApprovalSettings{ + Required: true, + CanReviewOwnRequest: false, + MinNumApprovals: 2, + CanApplyDeclinedChanges: false, + ServiceKind: "launchdarkly", + ServiceConfig: map[string]interface{}{}, + RequiredApprovalTags: []string{}, + } + autoApply := false + flagSettings.AutoApplyApprovedChanges = &autoApply + + segmentSettings := ldapi.ApprovalSettings{ + Required: true, + CanReviewOwnRequest: false, + MinNumApprovals: 1, + CanApplyDeclinedChanges: true, + ServiceKind: "launchdarkly", + ServiceConfig: map[string]interface{}{}, + RequiredApprovalTags: []string{}, + } + segmentSettings.AutoApplyApprovedChanges = &autoApply + + resourceApprovalSettings := map[string]ldapi.ApprovalSettings{ + "segment": segmentSettings, + } + + env := ldapi.Environment{ + ApprovalSettings: &flagSettings, + ResourceApprovalSettings: &resourceApprovalSettings, + } + + result := environmentApprovalSettingsToResourceData(env) + + // Should have 2 approval settings blocks + assert.Equal(t, 2, len(result), "Expected 2 approval settings blocks") + + // Verify flag settings + var flagBlock map[string]interface{} + var segmentBlock map[string]interface{} + for _, block := range result { + if block[RESOURCE_KIND] == "flag" { + flagBlock = block + } else if block[RESOURCE_KIND] == "segment" { + segmentBlock = block + } + } + + assert.NotNil(t, flagBlock, "Expected flag approval settings block") + assert.Equal(t, true, flagBlock[REQUIRED]) + assert.Equal(t, int32(2), flagBlock[MIN_NUM_APPROVALS]) + + assert.NotNil(t, segmentBlock, "Expected segment approval settings block") + assert.Equal(t, true, segmentBlock[REQUIRED]) + assert.Equal(t, int32(1), segmentBlock[MIN_NUM_APPROVALS]) + assert.Equal(t, true, segmentBlock[CAN_APPLY_DECLINED_CHANGES]) +} + +func TestApprovalPatchFromSettings_BackwardsCompatibility(t *testing.T) { + // Test that omitting resource_kind defaults to "flag" + oldSettings := []interface{}{} + newSettings := []interface{}{ + map[string]interface{}{ + // No RESOURCE_KIND specified - should default to "flag" + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 1, + CAN_APPLY_DECLINED_CHANGES: true, + SERVICE_KIND: "launchdarkly", + SERVICE_CONFIG: map[string]interface{}{}, + AUTO_APPLY_APPROVED_CHANGES: false, + REQUIRED_APPROVAL_TAGS: []interface{}{}, + }, + } + + patches, err := approvalPatchFromSettings(oldSettings, newSettings) + require.NoError(t, err) + + // Verify all patches use /approvalSettings path (flag path) + for _, patch := range patches { + assert.Contains(t, patch.Path, "/approvalSettings", "Expected /approvalSettings path for backwards compatibility") + assert.NotContains(t, patch.Path, "/resourceApprovalSettings", "Should not use /resourceApprovalSettings path when resource_kind is omitted") + } +} + +func TestApprovalSettingFromMap_ServiceKindErrorWithSegment(t *testing.T) { + // Test that setting service_kind to non-default value with segment resource_kind produces an error + settingsMap := map[string]interface{}{ + RESOURCE_KIND: "segment", + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 1, + CAN_APPLY_DECLINED_CHANGES: true, + SERVICE_KIND: "servicenow", // Non-default value + SERVICE_CONFIG: map[string]interface{}{}, + AUTO_APPLY_APPROVED_CHANGES: false, + REQUIRED_APPROVAL_TAGS: []interface{}{}, + } + + _, err := approvalSettingFromMap(settingsMap) + require.Error(t, err) + assert.Contains(t, err.Error(), "service_kind cannot be set for resource_kind 'segment'") +} + +func TestApprovalSettingFromMap_ServiceKindErrorWithAiconfig(t *testing.T) { + // Test that setting service_kind to non-default value with aiconfig resource_kind produces an error + settingsMap := map[string]interface{}{ + RESOURCE_KIND: "aiconfig", + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 1, + CAN_APPLY_DECLINED_CHANGES: true, + SERVICE_KIND: "servicenow", // Non-default value + SERVICE_CONFIG: map[string]interface{}{}, + AUTO_APPLY_APPROVED_CHANGES: false, + REQUIRED_APPROVAL_TAGS: []interface{}{}, + } + + _, err := approvalSettingFromMap(settingsMap) + require.Error(t, err) + assert.Contains(t, err.Error(), "service_kind cannot be set for resource_kind 'aiconfig'") +} + +func TestApprovalSettingFromMap_ServiceConfigErrorWithSegment(t *testing.T) { + // Test that setting service_config with segment resource_kind produces an error + settingsMap := map[string]interface{}{ + RESOURCE_KIND: "segment", + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 1, + CAN_APPLY_DECLINED_CHANGES: true, + SERVICE_KIND: "launchdarkly", + SERVICE_CONFIG: map[string]interface{}{ + "template": "some-template-id", + "detail_column": "justification", + }, + AUTO_APPLY_APPROVED_CHANGES: false, + REQUIRED_APPROVAL_TAGS: []interface{}{}, + } + + _, err := approvalSettingFromMap(settingsMap) + require.Error(t, err) + assert.Contains(t, err.Error(), "service_config cannot be set for resource_kind 'segment'") +} + +func TestApprovalSettingFromMap_ServiceConfigErrorWithAiconfig(t *testing.T) { + // Test that setting service_config with aiconfig resource_kind produces an error + settingsMap := map[string]interface{}{ + RESOURCE_KIND: "aiconfig", + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 1, + CAN_APPLY_DECLINED_CHANGES: true, + SERVICE_KIND: "launchdarkly", + SERVICE_CONFIG: map[string]interface{}{ + "template": "some-template-id", + }, + AUTO_APPLY_APPROVED_CHANGES: false, + REQUIRED_APPROVAL_TAGS: []interface{}{}, + } + + _, err := approvalSettingFromMap(settingsMap) + require.Error(t, err) + assert.Contains(t, err.Error(), "service_config cannot be set for resource_kind 'aiconfig'") +} + +func TestApprovalSettingFromMap_DefaultValuesSuccessWithSegment(t *testing.T) { + // Test that using default values (service_kind="launchdarkly", empty service_config, auto_apply=false) + // succeeds with segment resource_kind + settingsMap := map[string]interface{}{ + RESOURCE_KIND: "segment", + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 1, + CAN_APPLY_DECLINED_CHANGES: true, + SERVICE_KIND: "launchdarkly", // Default value + SERVICE_CONFIG: map[string]interface{}{}, // Empty + AUTO_APPLY_APPROVED_CHANGES: false, // Default value + REQUIRED_APPROVAL_TAGS: []interface{}{}, + } + + result, err := approvalSettingFromMap(settingsMap) + require.NoError(t, err) + assert.Equal(t, "segment", result.ResourceKind) + assert.Equal(t, "launchdarkly", result.Settings.ServiceKind) + assert.Equal(t, 0, len(result.Settings.ServiceConfig)) + assert.False(t, *result.Settings.AutoApplyApprovedChanges) +} + +func TestApprovalSettingFromMap_DefaultValuesSuccessWithAiconfig(t *testing.T) { + // Test that using default values succeeds with aiconfig resource_kind + settingsMap := map[string]interface{}{ + RESOURCE_KIND: "aiconfig", + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 1, + CAN_APPLY_DECLINED_CHANGES: true, + SERVICE_KIND: "launchdarkly", // Default value + SERVICE_CONFIG: map[string]interface{}{}, // Empty + AUTO_APPLY_APPROVED_CHANGES: false, // Default value + REQUIRED_APPROVAL_TAGS: []interface{}{}, + } + + result, err := approvalSettingFromMap(settingsMap) + require.NoError(t, err) + assert.Equal(t, "aiconfig", result.ResourceKind) + assert.Equal(t, "launchdarkly", result.Settings.ServiceKind) + assert.Equal(t, 0, len(result.Settings.ServiceConfig)) + assert.False(t, *result.Settings.AutoApplyApprovedChanges) +} + +func TestApprovalSettingFromMap_FlagResourceKindSupportsAllFields(t *testing.T) { + // Test that flag resource_kind continues to support all fields (backwards compatibility) + settingsMap := map[string]interface{}{ + RESOURCE_KIND: "flag", + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 2, + CAN_APPLY_DECLINED_CHANGES: false, + SERVICE_KIND: "servicenow", // Non-default value + SERVICE_CONFIG: map[string]interface{}{ + "template": "some-template-id", + "detail_column": "justification", + }, + AUTO_APPLY_APPROVED_CHANGES: true, // Set to true + REQUIRED_APPROVAL_TAGS: []interface{}{}, + } + + result, err := approvalSettingFromMap(settingsMap) + require.NoError(t, err) + assert.Equal(t, "flag", result.ResourceKind) + assert.Equal(t, "servicenow", result.Settings.ServiceKind) + assert.Equal(t, 2, len(result.Settings.ServiceConfig)) + assert.True(t, *result.Settings.AutoApplyApprovedChanges) +} + +func TestApprovalSettingFromMap_MultipleFieldErrorsWithSegment(t *testing.T) { + // Test that when multiple invalid fields are set, we get an error for the first one checked + // (service_kind is checked first in the validation logic) + settingsMap := map[string]interface{}{ + RESOURCE_KIND: "segment", + REQUIRED: true, + CAN_REVIEW_OWN_REQUEST: false, + MIN_NUM_APPROVALS: 1, + CAN_APPLY_DECLINED_CHANGES: true, + SERVICE_KIND: "servicenow", // Invalid for segment + SERVICE_CONFIG: map[string]interface{}{ // Also invalid for segment + "template": "some-template-id", + }, + AUTO_APPLY_APPROVED_CHANGES: false, + REQUIRED_APPROVAL_TAGS: []interface{}{}, + } + + _, err := approvalSettingFromMap(settingsMap) + require.Error(t, err) + // Should get error for service_kind since it's checked first + assert.Contains(t, err.Error(), "service_kind cannot be set for resource_kind 'segment'") +} diff --git a/launchdarkly/environments_helper.go b/launchdarkly/environments_helper.go index ad9478dd..984470ce 100644 --- a/launchdarkly/environments_helper.go +++ b/launchdarkly/environments_helper.go @@ -154,10 +154,13 @@ func getEnvironmentUpdatePatches(oldConfig, config map[string]interface{}) ([]ld } var oldApprovalSettings []interface{} - if oldSettings, ok := oldConfig[APPROVAL_SETTINGS]; ok { + if oldSettings, ok := oldConfig[APPROVAL_SETTINGS]; ok && oldSettings != nil { oldApprovalSettings = oldSettings.([]interface{}) } - newApprovalSettings := config[APPROVAL_SETTINGS] + var newApprovalSettings []interface{} + if settings, ok := config[APPROVAL_SETTINGS]; ok && settings != nil { + newApprovalSettings = settings.([]interface{}) + } approvalPatches, err := approvalPatchFromSettings(oldApprovalSettings, newApprovalSettings) if err != nil { return []ldapi.PatchOperation{}, err @@ -244,8 +247,10 @@ func environmentToResourceData(env ldapi.Environment) envResourceData { TAGS: env.Tags, CRITICAL: env.Critical, } - if env.ApprovalSettings != nil { - envData[APPROVAL_SETTINGS] = approvalSettingsToResourceData(*env.ApprovalSettings) + // Handle approval settings for all resource kinds (flag, segment, aiconfig) + approvalSettings := environmentApprovalSettingsToResourceData(env) + if len(approvalSettings) > 0 { + envData[APPROVAL_SETTINGS] = approvalSettings } return envData } @@ -302,8 +307,10 @@ func environmentRead(ctx context.Context, d *schema.ResourceData, meta interface _ = d.Set(REQUIRE_COMMENTS, env.RequireComments) _ = d.Set(CONFIRM_CHANGES, env.ConfirmChanges) - if env.ApprovalSettings != nil { - err = d.Set(APPROVAL_SETTINGS, approvalSettingsToResourceData(*env.ApprovalSettings)) + // Handle approval settings for all resource kinds (flag, segment, aiconfig) + approvalSettings := environmentApprovalSettingsToResourceData(*env) + if len(approvalSettings) > 0 { + err = d.Set(APPROVAL_SETTINGS, approvalSettings) if err != nil { return diag.FromErr(err) } diff --git a/launchdarkly/environments_helper_test.go b/launchdarkly/environments_helper_test.go index 8117e9e3..e755f49e 100644 --- a/launchdarkly/environments_helper_test.go +++ b/launchdarkly/environments_helper_test.go @@ -99,13 +99,15 @@ func TestEnvironmentToResourceData(t *testing.T) { TAGS: []string{"test"}, APPROVAL_SETTINGS: []map[string]interface{}{ { - CAN_REVIEW_OWN_REQUEST: true, - MIN_NUM_APPROVALS: int32(3), - CAN_APPLY_DECLINED_CHANGES: true, - REQUIRED_APPROVAL_TAGS: []string{"approval"}, - REQUIRED: true, - SERVICE_KIND: "launchdarkly", - SERVICE_CONFIG: map[string]interface{}(nil), + RESOURCE_KIND: "flag", + CAN_REVIEW_OWN_REQUEST: true, + MIN_NUM_APPROVALS: int32(3), + CAN_APPLY_DECLINED_CHANGES: true, + REQUIRED_APPROVAL_TAGS: []string{"approval"}, + REQUIRED: true, + SERVICE_KIND: "launchdarkly", + SERVICE_CONFIG: map[string]interface{}(nil), + AUTO_APPLY_APPROVED_CHANGES: (*bool)(nil), }, }, }, diff --git a/launchdarkly/keys.go b/launchdarkly/keys.go index d9d8b486..bfc6d1e9 100644 --- a/launchdarkly/keys.go +++ b/launchdarkly/keys.go @@ -94,6 +94,7 @@ const ( REQUIRE_COMMENTS = "require_comments" RESOURCE = "resource" RESOURCES = "resources" + RESOURCE_KIND = "resource_kind" ROLE = "role" ROLE_ATTRIBUTES = "role_attributes" ROLLOUT_CONTEXT_KIND = "rollout_context_kind" diff --git a/launchdarkly/resource_launchdarkly_environment.go b/launchdarkly/resource_launchdarkly_environment.go index 51f9f134..fe567a2b 100644 --- a/launchdarkly/resource_launchdarkly_environment.go +++ b/launchdarkly/resource_launchdarkly_environment.go @@ -129,7 +129,14 @@ func resourceEnvironmentUpdate(ctx context.Context, d *schema.ResourceData, meta } oldApprovalSettings, newApprovalSettings := d.GetChange(APPROVAL_SETTINGS) - approvalPatch, err := approvalPatchFromSettings(oldApprovalSettings, newApprovalSettings) + var oldSettings, newSettings []interface{} + if oldApprovalSettings != nil { + oldSettings = oldApprovalSettings.([]interface{}) + } + if newApprovalSettings != nil { + newSettings = newApprovalSettings.([]interface{}) + } + approvalPatch, err := approvalPatchFromSettings(oldSettings, newSettings) if err != nil { return diag.FromErr(err) } diff --git a/launchdarkly/resource_launchdarkly_environment_test.go b/launchdarkly/resource_launchdarkly_environment_test.go index d96c4ca2..896dbcbb 100644 --- a/launchdarkly/resource_launchdarkly_environment_test.go +++ b/launchdarkly/resource_launchdarkly_environment_test.go @@ -170,6 +170,69 @@ resource "launchdarkly_environment" "critical_env" { can_apply_declined_changes = true } } +` + + testAccEnvironmentWithMultiResourceApprovals = ` +resource "launchdarkly_environment" "multi_approvals_test" { + name = "Multi Resource Approvals Test" + key = "multi-approvals-test" + color = "ababab" + project_key = launchdarkly_project.test.key + + # Flag approval settings + approval_settings { + resource_kind = "flag" + required = true + min_num_approvals = 2 + can_review_own_request = false + can_apply_declined_changes = false + } + + # Segment approval settings + approval_settings { + resource_kind = "segment" + required = true + min_num_approvals = 1 + can_review_own_request = true + can_apply_declined_changes = true + } +} +` + + // Negative test: service_kind with segment should error + testAccEnvironmentWithInvalidServiceKindSegment = ` +resource "launchdarkly_environment" "invalid_test" { + name = "Invalid Test" + key = "invalid-test" + color = "ababab" + project_key = launchdarkly_project.test.key + + approval_settings { + resource_kind = "segment" + service_kind = "servicenow" # This should error + required = true + min_num_approvals = 1 + } +} +` + + // Negative test: service_config with segment should error + testAccEnvironmentWithInvalidServiceConfigSegment = ` +resource "launchdarkly_environment" "invalid_test" { + name = "Invalid Test" + key = "invalid-test" + color = "ababab" + project_key = launchdarkly_project.test.key + + approval_settings { + resource_kind = "segment" + service_config = { + template = "some-template" + } + required = true + min_num_approvals = 1 + } +} ` ) @@ -564,6 +627,78 @@ func TestAccEnvironment_Critical(t *testing.T) { }) } +func TestAccEnvironment_WithMultiResourceApprovals(t *testing.T) { + projectKey := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + resourceName := "launchdarkly_environment.multi_approvals_test" + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: withRandomProject(projectKey, testAccEnvironmentWithMultiResourceApprovals), + Check: resource.ComposeTestCheckFunc( + testAccCheckProjectExists("launchdarkly_project.test"), + testAccCheckEnvironmentExists(resourceName), + resource.TestCheckResourceAttr(resourceName, NAME, "Multi Resource Approvals Test"), + resource.TestCheckResourceAttr(resourceName, KEY, "multi-approvals-test"), + resource.TestCheckResourceAttr(resourceName, PROJECT_KEY, projectKey), + // Check flag approval settings (first block) + resource.TestCheckResourceAttr(resourceName, "approval_settings.0.resource_kind", "flag"), + resource.TestCheckResourceAttr(resourceName, "approval_settings.0.required", "true"), + resource.TestCheckResourceAttr(resourceName, "approval_settings.0.min_num_approvals", "2"), + resource.TestCheckResourceAttr(resourceName, "approval_settings.0.can_review_own_request", "false"), + resource.TestCheckResourceAttr(resourceName, "approval_settings.0.can_apply_declined_changes", "false"), + // Check segment approval settings (second block) + resource.TestCheckResourceAttr(resourceName, "approval_settings.1.resource_kind", "segment"), + resource.TestCheckResourceAttr(resourceName, "approval_settings.1.required", "true"), + resource.TestCheckResourceAttr(resourceName, "approval_settings.1.min_num_approvals", "1"), + resource.TestCheckResourceAttr(resourceName, "approval_settings.1.can_review_own_request", "true"), + resource.TestCheckResourceAttr(resourceName, "approval_settings.1.can_apply_declined_changes", "true"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccEnvironment_WithInvalidServiceKindSegment(t *testing.T) { + projectKey := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: withRandomProject(projectKey, testAccEnvironmentWithInvalidServiceKindSegment), + ExpectError: regexp.MustCompile("service_kind cannot be set for resource_kind 'segment'"), + }, + }, + }) +} + +func TestAccEnvironment_WithInvalidServiceConfigSegment(t *testing.T) { + projectKey := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: withRandomProject(projectKey, testAccEnvironmentWithInvalidServiceConfigSegment), + ExpectError: regexp.MustCompile("service_config cannot be set for resource_kind 'segment'"), + }, + }, + }) +} + func testAccCheckEnvironmentExists(resourceName string) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[resourceName]