From b7b616aec2ebe3b38f2b850a2240526530b42331 Mon Sep 17 00:00:00 2001 From: Christopher Tarquini Date: Wed, 18 Feb 2026 14:03:04 -0500 Subject: [PATCH 01/12] feat: add support for segment and aiconfig approvals --- .../environment_approval_settings_example.tf | 56 ++++ launchdarkly/approvals_helper.go | 246 +++++++++++++++--- launchdarkly/approvals_helper_test.go | 209 +++++++++++++++ launchdarkly/environments_helper.go | 12 +- launchdarkly/keys.go | 1 + 5 files changed, 482 insertions(+), 42 deletions(-) create mode 100644 examples/environment_approval_settings_example.tf create mode 100644 launchdarkly/approvals_helper_test.go diff --git a/examples/environment_approval_settings_example.tf b/examples/environment_approval_settings_example.tf new file mode 100644 index 00000000..35f4015c --- /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..6ad87daa 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\"`. 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\"`. 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 } -func approvalSettingsFromResourceData(val interface{}) (ldapi.ApprovalSettings, error) { - raw := val.([]interface{}) - if len(raw) == 0 { - return ldapi.ApprovalSettings{}, nil +// approvalSettingWithKind wraps approval settings with its resource kind +type approvalSettingWithKind struct { + ResourceKind string + Settings ldapi.ApprovalSettings +} + +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 { @@ -127,13 +143,24 @@ func approvalSettingsFromResourceData(val interface{}) (ldapi.ApprovalSettings, settings.ServiceKind = approvalSettingsMap[SERVICE_KIND].(string) settings.ServiceConfig = approvalSettingsMap[SERVICE_CONFIG].(map[string]interface{}) 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 approvalSettingsFromResourceData(val interface{}) (ldapi.ApprovalSettings, error) { + raw := val.([]interface{}) + if len(raw) == 0 { + return ldapi.ApprovalSettings{}, nil + } + approvalSettingsMap := raw[0].(map[string]interface{}) + setting, err := approvalSettingFromMap(approvalSettingsMap) + return setting.Settings, err +} + +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 +170,179 @@ 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 +// approvalSettingsToResourceData converts approval settings from API to Terraform format +// It handles both the root approvalSettings (for flags) and nested resourceApprovalSettings +func approvalSettingsToResourceData(settings ldapi.ApprovalSettings) interface{} { + // For backwards compatibility, this function treats input as flag approval settings + return []map[string]interface{}{approvalSettingToResourceData(settings, "flag")} +} + +// 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) + if env.ApprovalSettings != nil { + result = append(result, approvalSettingToResourceData(*env.ApprovalSettings, "flag")) + } + + // Handle resource approval settings (from resourceApprovalSettings) + if env.ResourceApprovalSettings != nil { + resourceApprovalSettings := *env.ResourceApprovalSettings + + // Handle segment approval settings + if segmentSettings, ok := resourceApprovalSettings["segment"]; ok { + result = append(result, approvalSettingToResourceData(segmentSettings, "segment")) + } + + // Handle aiconfig approval settings + if aiconfigSettings, ok := resourceApprovalSettings["aiconfig"]; ok { + result = append(result, approvalSettingToResourceData(aiconfigSettings, "aiconfig")) + } + } + + return result +} + +// getApprovalSettingByKind finds an approval setting by resource_kind in a list +func getApprovalSettingByKind(settings []interface{}, kind string) (map[string]interface{}, bool) { + for _, rawSetting := range settings { + if rawSetting == nil { + continue + } + setting := rawSetting.(map[string]interface{}) + settingKind, ok := setting[RESOURCE_KIND].(string) + if !ok || settingKind == "" { + settingKind = "flag" + } + if settingKind == kind { + return setting, true + } } + return nil, false +} + +// 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.([]interface{}) old := oldApprovalSettings.([]interface{}) + 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..6f1c3c09 --- /dev/null +++ b/launchdarkly/approvals_helper_test.go @@ -0,0 +1,209 @@ +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 + // Each resource kind has 8 fields (required, canReviewOwnRequest, minNumApprovals, + // canApplyDeclinedChanges, requiredApprovalTags, serviceKind, serviceConfig, autoApplyApprovedChanges) + assert.Equal(t, 16, len(patches), "Expected 16 patches (8 for flag + 8 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, 8, segmentPatchCount, "Expected 8 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") + } +} diff --git a/launchdarkly/environments_helper.go b/launchdarkly/environments_helper.go index ad9478dd..4d799329 100644 --- a/launchdarkly/environments_helper.go +++ b/launchdarkly/environments_helper.go @@ -244,8 +244,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 +304,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/keys.go b/launchdarkly/keys.go index d9d8b486..ab3262ad 100644 --- a/launchdarkly/keys.go +++ b/launchdarkly/keys.go @@ -93,6 +93,7 @@ const ( REQUIRED_APPROVAL_TAGS = "required_approval_tags" REQUIRE_COMMENTS = "require_comments" RESOURCE = "resource" + RESOURCE_KIND = "resource_kind" RESOURCES = "resources" ROLE = "role" ROLE_ATTRIBUTES = "role_attributes" From 7795a7e22b3fa2eefe83a28f9cb119807c0add81 Mon Sep 17 00:00:00 2001 From: Christopher Tarquini Date: Wed, 18 Feb 2026 14:30:42 -0500 Subject: [PATCH 02/12] feat: add validation for approval_settings with non-flag resources - Add validation that errors when service_kind (non-default) or service_config are used with resource_kind segment/aiconfig - Update schema descriptions to document the constraint - Add comprehensive test coverage (7 new test functions) - Update CHANGELOG and migration documentation - Generate updated provider documentation These fields are only supported for flag approval settings. Previously they were silently ignored for segment/aiconfig, which could lead to unexpected behavior. --- APPROVAL_SETTINGS_MIGRATION.md | 264 +++++++++++++++++++++++ CHANGELOG.md | 6 + docs/data-sources/environment.md | 1 + docs/resources/environment.md | 9 +- docs/resources/project.md | 9 +- launchdarkly/approvals_helper.go | 21 +- launchdarkly/approvals_helper_test.go | 182 +++++++++++++++- launchdarkly/environments_helper_test.go | 2 + launchdarkly/keys.go | 2 +- 9 files changed, 482 insertions(+), 14 deletions(-) create mode 100644 APPROVAL_SETTINGS_MIGRATION.md diff --git a/APPROVAL_SETTINGS_MIGRATION.md b/APPROVAL_SETTINGS_MIGRATION.md new file mode 100644 index 00000000..3a996352 --- /dev/null +++ b/APPROVAL_SETTINGS_MIGRATION.md @@ -0,0 +1,264 @@ +# 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/CHANGELOG.md b/CHANGELOG.md index 109be78d..46471540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to the LaunchDarkly Terraform Provider will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [Unreleased] + +### Added + +- Added validation to `approval_settings` that produces an error when `service_kind` (with non-default value) or `service_config` are used with `resource_kind` values other than `"flag"`. These fields are only supported for flag approval settings. Previously, these fields were silently ignored for segment and aiconfig approval settings, which could lead to unexpected behavior. + ## [2.26.2](https://github.com/launchdarkly/terraform-provider-launchdarkly/compare/v2.26.1...v2.26.2) (2026-01-22) 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/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/launchdarkly/approvals_helper.go b/launchdarkly/approvals_helper.go index 6ad87daa..22e5edbd 100644 --- a/launchdarkly/approvals_helper.go +++ b/launchdarkly/approvals_helper.go @@ -67,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`. **Note:** This field is only supported for `resource_kind = \"flag\"`. 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)), }, @@ -75,7 +75,7 @@ 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. **Note:** This field is only supported for `resource_kind = \"flag\"`. 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, @@ -142,9 +142,26 @@ func approvalSettingFromMap(approvalSettingsMap map[string]interface{}) (approva 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 approvalSettingWithKind{}, fmt.Errorf("invalid approval_settings config: auto_apply_approved_changes cannot be set to true for service_kind of launchdarkly") } + return approvalSettingWithKind{ResourceKind: resourceKind, Settings: settings}, nil } diff --git a/launchdarkly/approvals_helper_test.go b/launchdarkly/approvals_helper_test.go index 6f1c3c09..f05a5897 100644 --- a/launchdarkly/approvals_helper_test.go +++ b/launchdarkly/approvals_helper_test.go @@ -40,9 +40,11 @@ func TestApprovalPatchFromSettings_MultipleResourceKinds(t *testing.T) { require.NoError(t, err) // Should have patches for both flag and segment - // Each resource kind has 8 fields (required, canReviewOwnRequest, minNumApprovals, + // Flag has 8 fields (required, canReviewOwnRequest, minNumApprovals, // canApplyDeclinedChanges, requiredApprovalTags, serviceKind, serviceConfig, autoApplyApprovedChanges) - assert.Equal(t, 16, len(patches), "Expected 16 patches (8 for flag + 8 for segment)") + // 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 @@ -57,7 +59,7 @@ func TestApprovalPatchFromSettings_MultipleResourceKinds(t *testing.T) { } assert.Equal(t, 8, flagPatchCount, "Expected 8 patches for flag approval settings") - assert.Equal(t, 8, segmentPatchCount, "Expected 8 patches for segment approval settings") + assert.Equal(t, 5, segmentPatchCount, "Expected 5 patches for segment approval settings") } func TestApprovalPatchFromSettings_RemoveResourceKind(t *testing.T) { @@ -207,3 +209,177 @@ func TestApprovalPatchFromSettings_BackwardsCompatibility(t *testing.T) { 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_test.go b/launchdarkly/environments_helper_test.go index 8117e9e3..7c27a86c 100644 --- a/launchdarkly/environments_helper_test.go +++ b/launchdarkly/environments_helper_test.go @@ -99,6 +99,7 @@ func TestEnvironmentToResourceData(t *testing.T) { TAGS: []string{"test"}, APPROVAL_SETTINGS: []map[string]interface{}{ { + RESOURCE_KIND: "flag", CAN_REVIEW_OWN_REQUEST: true, MIN_NUM_APPROVALS: int32(3), CAN_APPLY_DECLINED_CHANGES: true, @@ -106,6 +107,7 @@ func TestEnvironmentToResourceData(t *testing.T) { 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 ab3262ad..bfc6d1e9 100644 --- a/launchdarkly/keys.go +++ b/launchdarkly/keys.go @@ -93,8 +93,8 @@ const ( REQUIRED_APPROVAL_TAGS = "required_approval_tags" REQUIRE_COMMENTS = "require_comments" RESOURCE = "resource" - RESOURCE_KIND = "resource_kind" RESOURCES = "resources" + RESOURCE_KIND = "resource_kind" ROLE = "role" ROLE_ATTRIBUTES = "role_attributes" ROLLOUT_CONTEXT_KIND = "rollout_context_kind" From 2365e205dd558bcf2b9fc69413087037e2cc0597 Mon Sep 17 00:00:00 2001 From: Christopher Tarquini Date: Wed, 18 Feb 2026 14:36:01 -0500 Subject: [PATCH 03/12] test: add acceptance tests for approval_settings validation - Add positive test for multi-resource approval settings (flag + segment) - Add negative test for invalid service_kind with segment resource - Add negative test for invalid service_config with segment resource - All tests include ImportState verification - Tests validate error messages match expected patterns --- .../resource_launchdarkly_environment_test.go | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) 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] From 814f6fb38247ff80dc6a1af3945c716f5ccec672 Mon Sep 17 00:00:00 2001 From: Henry Barrow Date: Wed, 18 Feb 2026 17:14:06 -0800 Subject: [PATCH 04/12] Run 'make generate' --- .go-version | 2 +- examples/environment_approval_settings_example.tf | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) 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/examples/environment_approval_settings_example.tf b/examples/environment_approval_settings_example.tf index 35f4015c..561f6e9e 100644 --- a/examples/environment_approval_settings_example.tf +++ b/examples/environment_approval_settings_example.tf @@ -1,14 +1,14 @@ # Example of environment with approval settings for both flags and segments resource "launchdarkly_environment" "example" { - name = "Example Environment" - key = "example-env" - color = "FFFFFF" + 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" + resource_kind = "flag" # Optional, defaults to "flag" required = true can_review_own_request = false min_num_approvals = 2 @@ -40,9 +40,9 @@ resource "launchdarkly_environment" "example" { # 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" + name = "Backwards Compatible Environment" + key = "backwards-compat" + color = "000000" project_key = "example-project" approval_settings { From e9a90efcefbf12c332773cf2a7396206b85967ba Mon Sep 17 00:00:00 2001 From: Henry Barrow Date: Wed, 18 Feb 2026 17:20:59 -0800 Subject: [PATCH 05/12] Run 'make fmt' --- .pre-commit-config.yaml | 2 +- launchdarkly/environments_helper_test.go | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) 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/launchdarkly/environments_helper_test.go b/launchdarkly/environments_helper_test.go index 7c27a86c..e755f49e 100644 --- a/launchdarkly/environments_helper_test.go +++ b/launchdarkly/environments_helper_test.go @@ -99,14 +99,14 @@ func TestEnvironmentToResourceData(t *testing.T) { TAGS: []string{"test"}, APPROVAL_SETTINGS: []map[string]interface{}{ { - 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), + 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), }, }, From d50d64460b6a54644777166fb2e2afacc9eec973 Mon Sep 17 00:00:00 2001 From: Henry Barrow Date: Thu, 19 Feb 2026 01:24:48 +0000 Subject: [PATCH 06/12] Apply suggestion from @ldhenry --- CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46471540..109be78d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,6 @@ All notable changes to the LaunchDarkly Terraform Provider will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). -## [Unreleased] - -### Added - -- Added validation to `approval_settings` that produces an error when `service_kind` (with non-default value) or `service_config` are used with `resource_kind` values other than `"flag"`. These fields are only supported for flag approval settings. Previously, these fields were silently ignored for segment and aiconfig approval settings, which could lead to unexpected behavior. - ## [2.26.2](https://github.com/launchdarkly/terraform-provider-launchdarkly/compare/v2.26.1...v2.26.2) (2026-01-22) From dc027bc0de5034ac368500d2a67dc1e760f32f35 Mon Sep 17 00:00:00 2001 From: Christopher Tarquini Date: Wed, 18 Feb 2026 20:42:07 -0500 Subject: [PATCH 07/12] fix: only include segment/aiconfig approval settings if configured The API returns default approval settings for segment and aiconfig resources even when not explicitly configured by the user. This was causing Terraform to detect drift and try to remove these unconfigured settings. This commit filters out segment/aiconfig approval settings that are not actually configured (i.e., where required=false and requiredApprovalTags is empty). The filtering is done inline within environmentApprovalSettingsToResourceData() following the pattern used in targetsToResourceData(). Flag approval settings are always included for backwards compatibility. --- launchdarkly/approvals_helper.go | 15 +++++++++++---- launchdarkly/approvals_helper_test.go | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/launchdarkly/approvals_helper.go b/launchdarkly/approvals_helper.go index 22e5edbd..798143a3 100644 --- a/launchdarkly/approvals_helper.go +++ b/launchdarkly/approvals_helper.go @@ -202,6 +202,7 @@ func environmentApprovalSettingsToResourceData(env ldapi.Environment) []map[stri 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")) } @@ -210,14 +211,20 @@ func environmentApprovalSettingsToResourceData(env ldapi.Environment) []map[stri if env.ResourceApprovalSettings != nil { resourceApprovalSettings := *env.ResourceApprovalSettings - // Handle segment approval settings + // Handle segment approval settings - only include if actually configured if segmentSettings, ok := resourceApprovalSettings["segment"]; ok { - result = append(result, approvalSettingToResourceData(segmentSettings, "segment")) + // Skip if not configured (just defaults from API) + if segmentSettings.Required || (segmentSettings.RequiredApprovalTags != nil && len(segmentSettings.RequiredApprovalTags) > 0) { + result = append(result, approvalSettingToResourceData(segmentSettings, "segment")) + } } - // Handle aiconfig approval settings + // Handle aiconfig approval settings - only include if actually configured if aiconfigSettings, ok := resourceApprovalSettings["aiconfig"]; ok { - result = append(result, approvalSettingToResourceData(aiconfigSettings, "aiconfig")) + // Skip if not configured (just defaults from API) + if aiconfigSettings.Required || (aiconfigSettings.RequiredApprovalTags != nil && len(aiconfigSettings.RequiredApprovalTags) > 0) { + result = append(result, approvalSettingToResourceData(aiconfigSettings, "aiconfig")) + } } } diff --git a/launchdarkly/approvals_helper_test.go b/launchdarkly/approvals_helper_test.go index f05a5897..a4641cd8 100644 --- a/launchdarkly/approvals_helper_test.go +++ b/launchdarkly/approvals_helper_test.go @@ -383,3 +383,4 @@ func TestApprovalSettingFromMap_MultipleFieldErrorsWithSegment(t *testing.T) { // 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'") } + From 21eb9f9e1888de914e39de9402edc7c835f4d93d Mon Sep 17 00:00:00 2001 From: Christopher Tarquini Date: Thu, 19 Feb 2026 13:38:24 -0500 Subject: [PATCH 08/12] fix: handle nil approval_settings in patch generation When approval_settings is not configured by the user, config[APPROVAL_SETTINGS] returns nil. The previous code tried to do a type assertion on nil interface{} to []interface{} which causes a panic. This fix: - Changes approvalPatchFromSettings signature to accept []interface{} directly instead of interface{} - Adds nil checks before type assertions in environments_helper.go - Adds nil checks in resource_launchdarkly_environment.go Fixes the "Unsupported json-patch operation" error in TestAccProject_CSA_Update_And_Revert by preventing panics when approval_settings is not configured. Note: Schema Default values on fields within Optional blocks do NOT cause terraform to instantiate blocks, so keeping all existing defaults. --- launchdarkly/approvals_helper.go | 6 +++--- launchdarkly/environments_helper.go | 7 +++++-- launchdarkly/resource_launchdarkly_environment.go | 9 ++++++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/launchdarkly/approvals_helper.go b/launchdarkly/approvals_helper.go index 798143a3..b2a77a6b 100644 --- a/launchdarkly/approvals_helper.go +++ b/launchdarkly/approvals_helper.go @@ -281,9 +281,9 @@ func approvalPatchForResourceKind(settings approvalSettingWithKind) []ldapi.Patc return patch } -func approvalPatchFromSettings(oldApprovalSettings, newApprovalSettings interface{}) ([]ldapi.PatchOperation, error) { - new := newApprovalSettings.([]interface{}) - old := oldApprovalSettings.([]interface{}) +func approvalPatchFromSettings(oldApprovalSettings, newApprovalSettings []interface{}) ([]ldapi.PatchOperation, error) { + new := newApprovalSettings + old := oldApprovalSettings if len(new) == 0 && len(old) == 0 { return []ldapi.PatchOperation{}, nil diff --git a/launchdarkly/environments_helper.go b/launchdarkly/environments_helper.go index 4d799329..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 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) } From e23c31aafcbcd5450a24a983869a9c9190780f68 Mon Sep 17 00:00:00 2001 From: Christopher Tarquini Date: Thu, 19 Feb 2026 13:59:25 -0500 Subject: [PATCH 09/12] chore: fix formatting --- launchdarkly/approvals_helper_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/launchdarkly/approvals_helper_test.go b/launchdarkly/approvals_helper_test.go index a4641cd8..f05a5897 100644 --- a/launchdarkly/approvals_helper_test.go +++ b/launchdarkly/approvals_helper_test.go @@ -383,4 +383,3 @@ func TestApprovalSettingFromMap_MultipleFieldErrorsWithSegment(t *testing.T) { // 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'") } - From ebe38957cac3a5b12975be4908984492831d797d Mon Sep 17 00:00:00 2001 From: Christopher Tarquini Date: Thu, 19 Feb 2026 14:56:13 -0500 Subject: [PATCH 10/12] chore: remove unused functions and fix linter issues - Remove unused approvalSettingsFromResourceData - Remove unused approvalSettingsToResourceData - Remove unused getApprovalSettingByKind - Fix S1009: omit unnecessary nil checks before len() --- launchdarkly/approvals_helper.go | 39 ++------------------------------ 1 file changed, 2 insertions(+), 37 deletions(-) diff --git a/launchdarkly/approvals_helper.go b/launchdarkly/approvals_helper.go index b2a77a6b..ab3ebcc8 100644 --- a/launchdarkly/approvals_helper.go +++ b/launchdarkly/approvals_helper.go @@ -165,16 +165,6 @@ func approvalSettingFromMap(approvalSettingsMap map[string]interface{}) (approva return approvalSettingWithKind{ResourceKind: resourceKind, Settings: settings}, nil } -func approvalSettingsFromResourceData(val interface{}) (ldapi.ApprovalSettings, error) { - raw := val.([]interface{}) - if len(raw) == 0 { - return ldapi.ApprovalSettings{}, nil - } - approvalSettingsMap := raw[0].(map[string]interface{}) - setting, err := approvalSettingFromMap(approvalSettingsMap) - return setting.Settings, err -} - func approvalSettingToResourceData(settings ldapi.ApprovalSettings, resourceKind string) map[string]interface{} { return map[string]interface{}{ RESOURCE_KIND: resourceKind, @@ -189,13 +179,6 @@ func approvalSettingToResourceData(settings ldapi.ApprovalSettings, resourceKind } } -// approvalSettingsToResourceData converts approval settings from API to Terraform format -// It handles both the root approvalSettings (for flags) and nested resourceApprovalSettings -func approvalSettingsToResourceData(settings ldapi.ApprovalSettings) interface{} { - // For backwards compatibility, this function treats input as flag approval settings - return []map[string]interface{}{approvalSettingToResourceData(settings, "flag")} -} - // 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{} { @@ -214,7 +197,7 @@ func environmentApprovalSettingsToResourceData(env ldapi.Environment) []map[stri // 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 || (segmentSettings.RequiredApprovalTags != nil && len(segmentSettings.RequiredApprovalTags) > 0) { + if segmentSettings.Required || len(segmentSettings.RequiredApprovalTags) > 0 { result = append(result, approvalSettingToResourceData(segmentSettings, "segment")) } } @@ -222,7 +205,7 @@ func environmentApprovalSettingsToResourceData(env ldapi.Environment) []map[stri // 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 || (aiconfigSettings.RequiredApprovalTags != nil && len(aiconfigSettings.RequiredApprovalTags) > 0) { + if aiconfigSettings.Required || len(aiconfigSettings.RequiredApprovalTags) > 0 { result = append(result, approvalSettingToResourceData(aiconfigSettings, "aiconfig")) } } @@ -231,24 +214,6 @@ func environmentApprovalSettingsToResourceData(env ldapi.Environment) []map[stri return result } -// getApprovalSettingByKind finds an approval setting by resource_kind in a list -func getApprovalSettingByKind(settings []interface{}, kind string) (map[string]interface{}, bool) { - for _, rawSetting := range settings { - if rawSetting == nil { - continue - } - setting := rawSetting.(map[string]interface{}) - settingKind, ok := setting[RESOURCE_KIND].(string) - if !ok || settingKind == "" { - settingKind = "flag" - } - if settingKind == kind { - return setting, true - } - } - return nil, false -} - // approvalPatchForResourceKind generates patch operations for a specific resource kind func approvalPatchForResourceKind(settings approvalSettingWithKind) []ldapi.PatchOperation { // Determine the base path based on resource kind From 419ac96d659701d82c991d7e8aba3ad766b95d83 Mon Sep 17 00:00:00 2001 From: Christopher Tarquini Date: Thu, 19 Feb 2026 15:02:17 -0500 Subject: [PATCH 11/12] docs: move approval settings migration guide to docs/guides Per @ldhenry feedback - migration guides belong in docs/guides/ --- .../guides/approval-settings-migration.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename APPROVAL_SETTINGS_MIGRATION.md => docs/guides/approval-settings-migration.md (100%) diff --git a/APPROVAL_SETTINGS_MIGRATION.md b/docs/guides/approval-settings-migration.md similarity index 100% rename from APPROVAL_SETTINGS_MIGRATION.md rename to docs/guides/approval-settings-migration.md From 0db99ebf72e3915f5668b3165490e03ed53d5739 Mon Sep 17 00:00:00 2001 From: Christopher Tarquini Date: Thu, 19 Feb 2026 15:31:35 -0500 Subject: [PATCH 12/12] docs: add front matter to approval settings migration guide --- docs/guides/approval-settings-migration.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/guides/approval-settings-migration.md b/docs/guides/approval-settings-migration.md index 3a996352..be384549 100644 --- a/docs/guides/approval-settings-migration.md +++ b/docs/guides/approval-settings-migration.md @@ -1,3 +1,9 @@ +--- +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