From f40de4079a360577d9a4aba1e7cb79bc53362e82 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 30 Jun 2026 11:23:07 +0100 Subject: [PATCH 1/7] feat!: key-address launchdarkly_project environments (REL-14236) Re-model `launchdarkly_project.environments` from a positional list to a map keyed by the environment `key`, so reordering, adding, or removing one environment no longer shifts array indices and forces destructive plans on its siblings. The inner `key` argument moves out of the object and becomes the map key. The attribute is now Optional+Computed and the non-empty constraint is dropped: declare the environments you want to manage and the rest are left untouched (manage a subset, leave the others to the LaunchDarkly UI). Set `environments = {}` to manage none; omitting it entirely emits a plan-time warning. Read refreshes only the keys already in state and the reconcile loop only deletes formerly-managed environments, so an environment created outside Terraform is never deleted. Supporting changes: - migrate-tf-syntax: new `map_key` transform converts the v2 block form to the v3 map (forward hoists `key` to the map key; reverse expands back to blocks) and rewrites positional `environments[N]` references to `environments[""]`. - v0 -> v1 state upgrader converts the SDKv2 ordered list to the map, preserving the approval-settings API-defaults nulling. The v0 prior schema is pinned to the original list shape so genuine v2 state still decodes. - docs, examples, migration guide, and the block-to-nested-attrs skill updated for the map syntax. BREAKING CHANGE: `launchdarkly_project.environments` is now a map keyed by environment key (`environments = { "production" = { ... } }`) instead of an ordered list. The inner `key` attribute is removed; the map key carries it. Rewrite configurations with `migrate-tf-syntax` (it also rewrites `environments[N]` references to `environments[""]`). The v2 -> v3 state upgrade is automatic. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SKILL.md | 41 +- docs/guides/migrating-to-v3.md | 18 +- docs/index.md | 11 +- docs/resources/context_kind.md | 11 +- docs/resources/project.md | 50 +- docs/resources/view_filter_links.md | 2 +- examples/provider/provider.tf | 11 +- .../launchdarkly_context_kind/resource.tf | 11 +- .../launchdarkly_project/resource.tf | 16 +- launchdarkly/ai_test_helpers_test.go | 11 +- ...a_source_launchdarkly_feature_flag_test.go | 11 +- .../data_source_launchdarkly_segment_test.go | 13 +- .../data_source_launchdarkly_view_test.go | 22 +- launchdarkly/environments_framework.go | 138 ++-- launchdarkly/framework_state_upgrade.go | 50 ++ .../framework_state_upgrade_unit_test.go | 82 +++ ...resource_launchdarkly_context_kind_test.go | 11 +- ...resource_launchdarkly_feature_flag_test.go | 66 +- ...source_launchdarkly_flag_templates_test.go | 22 +- .../resource_launchdarkly_project_test.go | 595 +++++++++++------- .../resource_launchdarkly_segment_test.go | 33 +- ...rce_launchdarkly_segment_view_keys_test.go | 66 +- ...rce_launchdarkly_view_filter_links_test.go | 140 +++-- .../resource_launchdarkly_view_links_test.go | 104 +-- .../resource_launchdarkly_view_test.go | 44 +- launchdarkly/resource_project_framework.go | 149 ++--- launchdarkly/resource_project_upgrade.go | 54 ++ launchdarkly/resource_view_links_framework.go | 2 +- scripts/migrate-tf-syntax/README.md | 4 +- scripts/migrate-tf-syntax/main.go | 172 +++++ scripts/migrate-tf-syntax/main_test.go | 115 ++++ scripts/migrate-tf-syntax/mappings.json | 2 +- templates/guides/migrating-to-v3.md.tmpl | 18 +- templates/resources/project.md.tmpl | 8 +- 34 files changed, 1399 insertions(+), 704 deletions(-) diff --git a/.claude/skills/terraform-provider-block-to-nested-attrs/SKILL.md b/.claude/skills/terraform-provider-block-to-nested-attrs/SKILL.md index ad13c6cc..2f63287f 100644 --- a/.claude/skills/terraform-provider-block-to-nested-attrs/SKILL.md +++ b/.claude/skills/terraform-provider-block-to-nested-attrs/SKILL.md @@ -4,7 +4,7 @@ description: Migrate LaunchDarkly Terraform provider HCL configs between block s compatibility: Works on any directory containing `.tf` files that use `launchdarkly_*` resources. No external tools required beyond a working `terraform` CLI for validation. metadata: author: ffeldberg - version: "2.0.0" + version: "2.1.0" --- # LaunchDarkly Terraform Provider: Block ↔ Nested Attribute Migration @@ -13,6 +13,7 @@ The LaunchDarkly Terraform provider v3.0.0 finished the migration from `terrafor - **List/Set nested attributes** (genuinely plural, e.g. `variations`, `rules`, `statements`) → `name = [{ ... }]`. - **Single nested attributes** (genuinely one object — `client_side_availability`, `defaults`, `default_client_side_availability`, `fallthrough`) → `name = { ... }`. These four were modeled as max-1 lists through `3.0.0-beta.3` and switched to single objects for GA (REL-14237) so the bracketless object form is the correct v3.0.0 syntax. **If you are on a `3.0.0-beta.N` pre-release, use the list form `= [{ ... }]` for these four instead** — the object form is GA-only. +- **Map nested attribute** (`launchdarkly_project.environments`, keyed by env `key`) → `name = { "" = { ... } }`. The environment `key` moves out of the object and becomes the map key (REL-14236). Reordering/adding/removing one environment no longer churns the others. This skill enumerates every affected attribute, gives the exact rewrite, and lists the gotchas that bite during migration. @@ -52,11 +53,22 @@ client_side_availability { client_side_availability = { } } ``` -To go v3 → v2, do the inverse: for list attributes strip `= [` / `]` and split `},` separators into a new `foo {` per element; for the four single-object attributes just drop `= ` and the braces become a block (`foo = { ... }` → `foo { ... }`). +**Exception — one map attribute** (`launchdarkly_project.environments`) is a `MapNestedAttribute` in v3 (REL-14236), keyed by the environment `key`. The block's `key` argument becomes the map key and is dropped from the object: + +```hcl +# v2 (block) # v3 (map nested attribute) +environments { environments = { + key = "production" "production" = { + name = "Production" name = "Production" +} } + } +``` + +To go v3 → v2, do the inverse: for list attributes strip `= [` / `]` and split `},` separators into a new `foo {` per element; for the four single-object attributes just drop `= ` and the braces become a block (`foo = { ... }` → `foo { ... }`); for the `environments` map, emit one `environments { ... }` block per map entry and re-inject `key = ""`. ## Mapping table -Every attribute that changed from block → nested attribute in v3. The **Type** column drives the syntax: `List` / `Set` render as `= [{...}]`; `Object` (the four single-nested attributes) renders as `= {...}` with no brackets. +Every attribute that changed from block → nested attribute in v3. The **Type** column drives the syntax: `List` / `Set` render as `= [{...}]`; `Object` (the four single-nested attributes) renders as `= {...}` with no brackets; `Map` (`environments`) renders as `= { "" = {...} }`. | Resource | Attribute | Underlying type | Notes | |---|---|---|---| @@ -68,8 +80,8 @@ Every attribute that changed from block → nested attribute in v3. The **Type** | `launchdarkly_custom_role` | `policy_statements` | List | | | `launchdarkly_relay_proxy_configuration` | `policy` | List | Required. | | `launchdarkly_project` | `default_client_side_availability` | **Object** | v3.0.0 GA: `= { ... }`. Was List (max 1) through `3.0.0-beta.3`. | -| `launchdarkly_project` | `environments` | List | Required; min 1. | -| `launchdarkly_project.environments[*]` | `approval_settings` | List (max 1) | Nested inside each environment block. | +| `launchdarkly_project` | `environments` | **Map** | Keyed by env `key`: `= { "" = { ... } }`. The inner `key` is now the map key. Optional — manage a subset and leave the rest to the UI, or `= {}` to manage none (REL-14236). Was an ordered List through the early v3 preview. | +| `launchdarkly_project.environments[""]` | `approval_settings` | List (max 1) | Nested inside each environment map value. | | `launchdarkly_environment` | `approval_settings` | List (max 1) | Same shape as inline-in-project. | | `launchdarkly_segment` | `included_contexts` | List | | | `launchdarkly_segment` | `excluded_contexts` | List | | @@ -109,9 +121,10 @@ If an attribute on a `launchdarkly_*` resource is not listed here, it was either 3. **`launchdarkly_view_links.segments` uses set semantics.** If `environment_id` is sourced from a data source field marked `Sensitive` (e.g. `data.launchdarkly_environment.x.client_side_id`), the set hash will be unstable across plans. Wrap the value in `nonsensitive(...)` to stabilize the hash. Without this you get perpetual "segments updated" drift. -4. **Single-object vs single-element-list.** Two different shapes both hold one object — don't confuse them: +4. **Single-object vs single-element-list vs map.** Three shapes use brace-ish syntax — don't confuse them: - **Single objects** (no brackets, `= { ... }`): `client_side_availability`, `defaults` (feature_flag), `default_client_side_availability` (project), `fallthrough` (flag_environment). These are `SingleNestedAttribute` in v3.0.0 GA. A bracketed list here fails with a type error. (Through `3.0.0-beta.3` they were max-1 lists — if you target a beta pre-release, use brackets.) - **Max-1 lists** (still bracketed, `= [{ ... }]`): `boolean_defaults` (flag_templates), `approval_settings`, `instructions` (flag_trigger). A bare object map here fails with a type error. + - **Map** (keyed object, `= { "" = { ... } }`): `launchdarkly_project.environments`. Looks like a single object but the top-level keys are env keys, each mapping to an environment object. A list `= [{ ... }]` here fails with `map of object required`. Reference elements as `environments[""]`, never `environments[0]`. 5. **`config` blocks on `launchdarkly_audit_log_subscription` and `launchdarkly_destination` were never blocks** — they have always been maps (`config = { ... }`). Do not wrap them in `[ ]`. @@ -211,14 +224,12 @@ resource "launchdarkly_project" "main" { using_mobile_key = false } - environments = [ - { - key = "production" + environments = { + "production" = { name = "Production" color = "EF4444" - }, - { - key = "staging" + } + "staging" = { name = "Staging" color = "F59E0B" @@ -226,8 +237,8 @@ resource "launchdarkly_project" "main" { required = true min_num_approvals = 1 }] - }, - ] + } + } } ``` @@ -254,4 +265,4 @@ This skill's mapping table is a snapshot. If a future LD provider release adds, grep -nE 'tfsdk:"()"' launchdarkly/*.go ``` -inside the `terraform-provider-launchdarkly` repo. A `types.List` / `types.Set` field paired with a `ListNestedAttribute` / `SetNestedAttribute` schema entry means list-of-objects → use `= [{...}]`. A `types.Object` field paired with a `SingleNestedAttribute` means single object → use `= {...}`. As of v3.0.0 GA the `types.Object` attributes are `client_side_availability`, `defaults`, `default_client_side_availability`, and `fallthrough`; watch for more in later releases. +inside the `terraform-provider-launchdarkly` repo. A `types.List` / `types.Set` field paired with a `ListNestedAttribute` / `SetNestedAttribute` schema entry means list-of-objects → use `= [{...}]`. A `types.Object` field paired with a `SingleNestedAttribute` means single object → use `= {...}`. A `types.Map` field paired with a `MapNestedAttribute` means a key-addressed map → use `= { "" = {...} }`. As of v3.0.0 GA the `types.Object` attributes are `client_side_availability`, `defaults`, `default_client_side_availability`, and `fallthrough`, and the only `types.Map` nested attribute is `launchdarkly_project.environments`; watch for more in later releases. diff --git a/docs/guides/migrating-to-v3.md b/docs/guides/migrating-to-v3.md index 8e34c969..2dedd068 100644 --- a/docs/guides/migrating-to-v3.md +++ b/docs/guides/migrating-to-v3.md @@ -41,6 +41,22 @@ client_side_availability { client_side_availability = { When you read one of these from a data source, use object access without a list index: `data.launchdarkly_feature_flag.x.client_side_availability.using_environment_id`. +`launchdarkly_project.environments` becomes a **map keyed by the environment `key`** rather than an ordered list. Each environment's `key` moves out of the object and becomes the map key, so reordering, adding, or removing one environment no longer shifts the others or forces a destructive plan. The `migrate-tf-syntax` tool performs this rewrite for you: + +```hcl +# v2 block syntax # v3 map syntax (keyed by env key) +environments { environments = { + key = "production" "production" = { + name = "Production" name = "Production" + color = "EEEEEE" color = "EEEEEE" +} } + } +``` + +Reference an environment by its key instead of by index. A v2 interpolation such as `launchdarkly_project.example.environments[0].client_side_id` becomes `launchdarkly_project.example.environments["production"].client_side_id`. The tool rewrites positional references whose key it can resolve statically and warns on any it cannot. + +Because environments are now keyed, you can manage a subset and leave the rest to the LaunchDarkly UI: only the environments present in your `environments` map are managed, and environments you never declared are not deleted. Set `environments = {}` (or omit the attribute) to create a project without managing any of its environments. + ## Prerequisites You need the following things to complete this migration: @@ -82,7 +98,7 @@ The provider includes a state upgrader for every resource that lost an attribute - `launchdarkly_access_token`: moves `policy_statements` into `inline_roles`, and discards `expire`. - `launchdarkly_custom_role`: converts `policy` into `policy_statements`. - `launchdarkly_feature_flag`: converts `include_in_snippet` into `client_side_availability`. -- `launchdarkly_project`: converts `include_in_snippet` into `default_client_side_availability`. +- `launchdarkly_project`: converts `include_in_snippet` into `default_client_side_availability`, and re-keys the ordered `environments` list into a map keyed by environment key. - `launchdarkly_metric`: discards `is_active`. ## Your first plan after upgrading diff --git a/docs/index.md b/docs/index.md index dfbffa8c..c3e2e2ed 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,11 +41,12 @@ resource "launchdarkly_project" "example" { key = "example-project" name = "Example project" - environments = [{ - key = "production" - name = "Production" - color = "EEEEEE" - }] + environments = { + "production" = { + name = "Production" + color = "EEEEEE" + } + } } # Create a boolean feature flag in that project diff --git a/docs/resources/context_kind.md b/docs/resources/context_kind.md index 9d69ce25..238f54db 100644 --- a/docs/resources/context_kind.md +++ b/docs/resources/context_kind.md @@ -29,11 +29,12 @@ If you currently manage context kinds via the Mastercard `restapi_object` resour resource "launchdarkly_project" "example" { key = "example-project" name = "Example Project" - environments = [{ - key = "production" - name = "Production" - color = "000000" - }] + environments = { + "production" = { + name = "Production" + color = "000000" + } + } } resource "launchdarkly_context_kind" "organization" { diff --git a/docs/resources/project.md b/docs/resources/project.md index bff2042f..86433f2d 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -27,9 +27,10 @@ resource "launchdarkly_project" "example" { require_view_association_for_new_flags = false require_view_association_for_new_segments = false - environments = [ - { - key = "production" + # environments is a map keyed by the environment key. Reordering, adding, or + # removing one environment does not affect the others. + environments = { + "production" = { name = "Production" color = "EEEEEE" tags = ["terraform"] @@ -39,14 +40,13 @@ resource "launchdarkly_project" "example" { min_num_approvals = 3 required_approval_tags = ["approvals_required"] }] - }, - { - key = "staging" + } + "staging" = { name = "Staging" color = "000000" tags = ["terraform"] - }, - ] + } + } } ``` @@ -55,15 +55,15 @@ resource "launchdarkly_project" "example" { ### Required -- `environments` (Attributes List) List of nested `environments` attributes describing LaunchDarkly environments that belong to the project. When managing LaunchDarkly projects in Terraform, you should always manage your environments as nested project resources. - --> **Note:** Mixing the use of nested `environments` and [`launchdarkly_environment`](/docs/providers/launchdarkly/r/environment.html) resources is not recommended. `launchdarkly_environment` resources should only be used when the encapsulating project is not managed in Terraform. (see [below for nested schema](#nestedatt--environments)) - `key` (String) The project's unique key. A change in this field will force the destruction of the existing resource and the creation of a new one. - `name` (String) The project's name. ### Optional - `default_client_side_availability` (Attributes) Which client-side SDKs can use new flags by default. (see [below for nested schema](#nestedatt--default_client_side_availability)) +- `environments` (Attributes Map) Map of environments that belong to the project, keyed by environment `key`. When managing LaunchDarkly projects in Terraform, you should always manage your environments as nested project resources. Environments not present in the map are left unmanaged (terraform will not modify or delete them), so you can manage a subset and leave the rest to the LaunchDarkly UI. Set this to `{}` to create a project while managing none of its environments. Omitting the attribute entirely is discouraged: the provider records the environments LaunchDarkly auto-provisions into state but does not manage them, which is easy to do by accident — prefer `{}` or an explicit map. + +-> **Note:** Mixing the use of nested `environments` and [`launchdarkly_environment`](/docs/providers/launchdarkly/r/environment.html) resources is not recommended. `launchdarkly_environment` resources should only be used when the encapsulating project is not managed in Terraform. (see [below for nested schema](#nestedatt--environments)) - `require_view_association_for_new_flags` (Boolean) Whether new flags created in this project must be associated with at least one view. - `require_view_association_for_new_segments` (Boolean) Whether new segments created in this project must be associated with at least one view. - `tags` (Set of String) Tags associated with your resource. @@ -72,13 +72,21 @@ resource "launchdarkly_project" "example" { - `id` (String) The ID of this resource. + +### Nested Schema for `default_client_side_availability` + +Required: + +- `using_environment_id` (Boolean) +- `using_mobile_key` (Boolean) + + ### Nested Schema for `environments` Required: - `color` (String) The color swatch as an RGB hex value with no leading `#`. For example: `000000` -- `key` (String) The project-unique key for the environment. A change in this field will force the destruction of the existing resource and the creation of a new one. - `name` (String) The name of the environment. Optional: @@ -115,16 +123,6 @@ Optional: - `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. - - - -### Nested Schema for `default_client_side_availability` - -Required: - -- `using_environment_id` (Boolean) -- `using_mobile_key` (Boolean) - ## Import Import is supported using the following syntax: @@ -134,7 +132,7 @@ Import is supported using the following syntax: terraform import launchdarkly_project.example example-project ``` -**IMPORTANT:** Please note that, regardless of how many `environments` blocks you include on your import, _all_ of the project's environments will be saved to the Terraform state and will update with subsequent applies. This means that any environments not included in your import configuration will be torn down with any subsequent apply. If you wish to manage project properties with Terraform but not nested environments consider using Terraform's [ignore changes](https://www.terraform.io/docs/language/meta-arguments/lifecycle.html#ignore_changes) lifecycle meta-argument; see below for example. +**IMPORTANT:** On import, _all_ of the project's environments are saved to the Terraform state, keyed by their environment `key`. The `environments` you declare in your configuration are managed; on a subsequent apply, any environment that is in state but absent from your configuration is deleted. To manage only some environments and leave the rest to the LaunchDarkly UI, declare just those environments in the map (or set `environments = {}` to manage none), or use Terraform's [ignore changes](https://www.terraform.io/docs/language/meta-arguments/lifecycle.html#ignore_changes) lifecycle meta-argument; see below for example. ```terraform resource "launchdarkly_project" "example" { @@ -142,11 +140,11 @@ resource "launchdarkly_project" "example" { ignore_changes = [environments] } name = "testProject" - key = "%s" - # environments not included on this configuration will not be affected by subsequent applies + key = "example-project" + # environments not included in this configuration will not be affected by subsequent applies } ``` -**Note:** Following an import, the first apply may show a diff in the order of your environments as Terraform realigns its state with the order of configurations in your project configuration. This will not change your environments or their SDK keys. +Because `environments` is a map keyed by environment `key`, reordering, adding, or removing one environment never forces changes to the others, and import is order-independent. **Managing environment resources with Terraform should always be done on the project unless the project is not also managed with Terraform.** diff --git a/docs/resources/view_filter_links.md b/docs/resources/view_filter_links.md index 75ac1447..fb3cbd6c 100644 --- a/docs/resources/view_filter_links.md +++ b/docs/resources/view_filter_links.md @@ -63,7 +63,7 @@ resource "launchdarkly_view_filter_links" "beta_segments" { - `flag_filter` (String) A filter expression to match feature flags for linking to the view. Uses the same filter syntax as the flag list API endpoint (e.g. `tags:frontend`, `status:active`). - `reconcile_on_apply` (Boolean) Whether to re-resolve configured filters on every `terraform apply` even when no resource arguments changed. When true, Terraform will show an in-place update on each apply and `resolved_at` will change every run. - `segment_filter` (String) A filter expression to match segments for linking to the view. Uses the segment query filter syntax (e.g. `tags anyOf ["backend"]`, `query = "my-segment"`, `unbounded = true`). Requires `segment_filter_environment_id` to be set. -- `segment_filter_environment_id` (String) The environment ID to use when resolving segment filters. Required when `segment_filter` is set. This is the environment's opaque ID (e.g. from `launchdarkly_project.environments[*].client_side_id`). +- `segment_filter_environment_id` (String) The environment ID to use when resolving segment filters. Required when `segment_filter` is set. This is the environment's opaque ID (e.g. from `launchdarkly_project.environments[""].client_side_id`). ### Read-Only diff --git a/examples/provider/provider.tf b/examples/provider/provider.tf index 02a076ac..2b6c8082 100644 --- a/examples/provider/provider.tf +++ b/examples/provider/provider.tf @@ -27,11 +27,12 @@ resource "launchdarkly_project" "example" { key = "example-project" name = "Example project" - environments = [{ - key = "production" - name = "Production" - color = "EEEEEE" - }] + environments = { + "production" = { + name = "Production" + color = "EEEEEE" + } + } } # Create a boolean feature flag in that project diff --git a/examples/resources/launchdarkly_context_kind/resource.tf b/examples/resources/launchdarkly_context_kind/resource.tf index 02830a6c..9b92726b 100644 --- a/examples/resources/launchdarkly_context_kind/resource.tf +++ b/examples/resources/launchdarkly_context_kind/resource.tf @@ -1,11 +1,12 @@ resource "launchdarkly_project" "example" { key = "example-project" name = "Example Project" - environments = [{ - key = "production" - name = "Production" - color = "000000" - }] + environments = { + "production" = { + name = "Production" + color = "000000" + } + } } resource "launchdarkly_context_kind" "organization" { diff --git a/examples/resources/launchdarkly_project/resource.tf b/examples/resources/launchdarkly_project/resource.tf index c20173a5..fe7b4131 100644 --- a/examples/resources/launchdarkly_project/resource.tf +++ b/examples/resources/launchdarkly_project/resource.tf @@ -10,9 +10,10 @@ resource "launchdarkly_project" "example" { require_view_association_for_new_flags = false require_view_association_for_new_segments = false - environments = [ - { - key = "production" + # environments is a map keyed by the environment key. Reordering, adding, or + # removing one environment does not affect the others. + environments = { + "production" = { name = "Production" color = "EEEEEE" tags = ["terraform"] @@ -22,12 +23,11 @@ resource "launchdarkly_project" "example" { min_num_approvals = 3 required_approval_tags = ["approvals_required"] }] - }, - { - key = "staging" + } + "staging" = { name = "Staging" color = "000000" tags = ["terraform"] - }, - ] + } + } } diff --git a/launchdarkly/ai_test_helpers_test.go b/launchdarkly/ai_test_helpers_test.go index 8d4c7158..603991a6 100644 --- a/launchdarkly/ai_test_helpers_test.go +++ b/launchdarkly/ai_test_helpers_test.go @@ -22,11 +22,12 @@ func withAITestProject(projectKey, resource string) string { resource "launchdarkly_project" "test" { key = "%s" name = "AI Config Test Project" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } %s`, projectKey, resource) diff --git a/launchdarkly/data_source_launchdarkly_feature_flag_test.go b/launchdarkly/data_source_launchdarkly_feature_flag_test.go index 04b774d2..3bb9289a 100644 --- a/launchdarkly/data_source_launchdarkly_feature_flag_test.go +++ b/launchdarkly/data_source_launchdarkly_feature_flag_test.go @@ -135,11 +135,12 @@ func TestAccDataSourceFeatureFlag_withViews(t *testing.T) { resource "launchdarkly_project" "test" { key = "%s" name = "Terraform Flag Views Test Project" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_feature_flag" "test" { diff --git a/launchdarkly/data_source_launchdarkly_segment_test.go b/launchdarkly/data_source_launchdarkly_segment_test.go index 142b9ca9..63e8b65b 100644 --- a/launchdarkly/data_source_launchdarkly_segment_test.go +++ b/launchdarkly/data_source_launchdarkly_segment_test.go @@ -277,11 +277,12 @@ func TestAccDataSourceSegment_WithLinkedViews(t *testing.T) { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test" - color = "000000" - }] + environments = { + "test" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -305,7 +306,7 @@ resource "launchdarkly_view_links" "test" { project_key = launchdarkly_project.test.key view_key = launchdarkly_view.test.key segments = [{ - environment_id = launchdarkly_project.test.environments[0].client_side_id + environment_id = launchdarkly_project.test.environments["test"].client_side_id segment_key = launchdarkly_segment.test.key }] } diff --git a/launchdarkly/data_source_launchdarkly_view_test.go b/launchdarkly/data_source_launchdarkly_view_test.go index df3a2fdc..b43f9292 100644 --- a/launchdarkly/data_source_launchdarkly_view_test.go +++ b/launchdarkly/data_source_launchdarkly_view_test.go @@ -63,11 +63,12 @@ func TestAccDataSourceView_exists(t *testing.T) { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -124,11 +125,12 @@ func TestAccDataSourceView_withLinkedFlags(t *testing.T) { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { diff --git a/launchdarkly/environments_framework.go b/launchdarkly/environments_framework.go index 1edbe4b9..3e4256ae 100644 --- a/launchdarkly/environments_framework.go +++ b/launchdarkly/environments_framework.go @@ -4,15 +4,21 @@ package launchdarkly // by launchdarkly_project and the conversion helpers between framework // state values and the LD-API environment shapes. // +// As of REL-14236 environments is a Map keyed by the environment key +// (was a positional List). The map key carries the environment identity, +// so the nested object no longer has its own `key` attribute. Keying by +// env key makes reorder/add/remove of one environment a no-op for its +// siblings. +// // The standalone launchdarkly_environment resource lives in // resource_environment_framework.go and uses the same approval_settings // shape as the nested-environments attribute here. import ( "context" + "sort" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" - "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -27,11 +33,11 @@ import ( ldapi "github.com/launchdarkly/api-client-go/v22" ) -// environmentModel matches the nested-environments element shape used -// in launchdarkly_project. KEY is intentionally NOT ForceNew here — -// in the nested form, terraform tracks env identity by KEY across plans. +// environmentModel matches the nested-environments element shape used in +// launchdarkly_project. The environment key lives in the enclosing map's +// key, NOT in this struct — terraform tracks env identity by map key +// across plans. type environmentModel struct { - Key types.String `tfsdk:"key"` Name types.String `tfsdk:"name"` Color types.String `tfsdk:"color"` Critical types.Bool `tfsdk:"critical"` @@ -48,7 +54,6 @@ type environmentModel struct { } var environmentAttrTypes = map[string]attr.Type{ - KEY: types.StringType, NAME: types.StringType, COLOR: types.StringType, CRITICAL: types.BoolType, @@ -64,21 +69,21 @@ var environmentAttrTypes = map[string]attr.Type{ APPROVAL_SETTINGS: types.ListType{ElemType: types.ObjectType{AttrTypes: frameworkApprovalSettingsObjectAttrTypes}}, } +// environmentObjectType is the element type of the environments map. +var environmentObjectType = types.ObjectType{AttrTypes: environmentAttrTypes} + // projectEnvironmentsAttribute returns the nested-environments attribute -// for the project resource schema. It is Required (Min:1 enforced by -// the list-size validator). -func projectEnvironmentsAttribute() schema.ListNestedAttribute { - return schema.ListNestedAttribute{ - Required: true, - Description: "List of nested `environments` attributes describing LaunchDarkly environments that belong to the project. When managing LaunchDarkly projects in Terraform, you should always manage your environments as nested project resources.\n\n-> **Note:** Mixing the use of nested `environments` and [`launchdarkly_environment`](/docs/providers/launchdarkly/r/environment.html) resources is not recommended. `launchdarkly_environment` resources should only be used when the encapsulating project is not managed in Terraform.", - Validators: []validator.List{listvalidator.SizeAtLeast(1)}, +// for the project resource schema. It is a Map keyed by environment key. +// Optional+Computed: omitting it (or setting `{}`) lets the project be +// created with the environments LaunchDarkly auto-provisions without +// terraform churn, and a declared map manages exactly its keys. +func projectEnvironmentsAttribute() schema.MapNestedAttribute { + return schema.MapNestedAttribute{ + Optional: true, + Computed: true, + Description: "Map of environments that belong to the project, keyed by environment `key`. When managing LaunchDarkly projects in Terraform, you should always manage your environments as nested project resources. Environments not present in the map are left unmanaged (terraform will not modify or delete them), so you can manage a subset and leave the rest to the LaunchDarkly UI. Set this to `{}` to create a project while managing none of its environments. Omitting the attribute entirely is discouraged: the provider records the environments LaunchDarkly auto-provisions into state but does not manage them, which is easy to do by accident — prefer `{}` or an explicit map.\n\n-> **Note:** Mixing the use of nested `environments` and [`launchdarkly_environment`](/docs/providers/launchdarkly/r/environment.html) resources is not recommended. `launchdarkly_environment` resources should only be used when the encapsulating project is not managed in Terraform.", NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ - KEY: schema.StringAttribute{ - Required: true, - Description: addForceNewDescription("The project-unique key for the environment.", true), - Validators: []validator.String{keyValidator()}, - }, NAME: schema.StringAttribute{ Required: true, Description: "The name of the environment.", @@ -154,27 +159,38 @@ func projectEnvironmentsAttribute() schema.ListNestedAttribute { } } -// environmentModelsFromList unpacks a framework ListValue of nested -// environment blocks into a slice of typed models. -func environmentModelsFromList(ctx context.Context, list types.List) ([]environmentModel, diag.Diagnostics) { - if list.IsNull() || list.IsUnknown() { +// environmentModelsFromMap unpacks a framework MapValue of nested +// environment objects into a map of typed models keyed by environment key. +func environmentModelsFromMap(ctx context.Context, m types.Map) (map[string]environmentModel, diag.Diagnostics) { + if m.IsNull() || m.IsUnknown() { return nil, nil } - var models []environmentModel - diags := list.ElementsAs(ctx, &models, false) + models := make(map[string]environmentModel, len(m.Elements())) + diags := m.ElementsAs(ctx, &models, false) return models, diags } -// environmentPostsFromPlan converts the plan's environments block into a +// sortedEnvKeys returns the keys of an environment model map in a stable +// order so POST bodies and patch sequences are deterministic. +func sortedEnvKeys(models map[string]environmentModel) []string { + keys := make([]string, 0, len(models)) + for k := range models { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// environmentPostsFromPlan converts the plan's environments map into a // slice of ldapi.EnvironmentPost for the initial PostProject call. -func environmentPostsFromPlan(ctx context.Context, list types.List) ([]ldapi.EnvironmentPost, diag.Diagnostics) { - models, diags := environmentModelsFromList(ctx, list) +func environmentPostsFromPlan(ctx context.Context, m types.Map) ([]ldapi.EnvironmentPost, diag.Diagnostics) { + models, diags := environmentModelsFromMap(ctx, m) if diags.HasError() || models == nil { return nil, diags } posts := make([]ldapi.EnvironmentPost, 0, len(models)) - for _, m := range models { - p, d := environmentPostFromModel(ctx, m) + for _, key := range sortedEnvKeys(models) { + p, d := environmentPostFromModel(ctx, key, models[key]) diags.Append(d...) if diags.HasError() { return nil, diags @@ -184,10 +200,10 @@ func environmentPostsFromPlan(ctx context.Context, list types.List) ([]ldapi.Env return posts, diags } -func environmentPostFromModel(_ context.Context, m environmentModel) (ldapi.EnvironmentPost, diag.Diagnostics) { +func environmentPostFromModel(_ context.Context, key string, m environmentModel) (ldapi.EnvironmentPost, diag.Diagnostics) { post := ldapi.EnvironmentPost{ Name: m.Name.ValueString(), - Key: m.Key.ValueString(), + Key: key, Color: m.Color.ValueString(), } if !m.DefaultTTL.IsNull() && !m.DefaultTTL.IsUnknown() { @@ -234,40 +250,55 @@ func planOrNullList(hadOld bool, l types.List) types.List { return l } -// environmentsListFromAPI flattens the LD environments slice back into a -// framework ListValue, preserving the order of envs already in `prior` -// (the most recent state) then appending any unmanaged environments. -func environmentsListFromAPI(ctx context.Context, envs []ldapi.Environment, prior types.List) (basetypes.ListValue, diag.Diagnostics) { - objType := types.ObjectType{AttrTypes: environmentAttrTypes} +// environmentsMapFromAPI flattens the LD environments slice into a +// framework MapValue keyed by environment key. +// +// Append rule: when `prior` is null/unknown (import or the first read +// after a create that omitted environments) every API environment is +// surfaced so import captures the whole project. When `prior` is a +// populated map the result tracks ONLY the keys present in `prior` +// (managed mode) — environments created outside terraform are left +// untracked, which is what lets a user manage a subset and leave the +// rest to the UI. +func environmentsMapFromAPI(ctx context.Context, envs []ldapi.Environment, prior types.Map) (basetypes.MapValue, diag.Diagnostics) { + var diags diag.Diagnostics envByKey := make(map[string]ldapi.Environment, len(envs)) for _, e := range envs { envByKey[e.Key] = e } - priorModels, diags := environmentModelsFromList(ctx, prior) - added := map[string]bool{} - ordered := make([]attr.Value, 0, len(envs)) - for _, p := range priorModels { - envKey := p.Key.ValueString() - envAPI, ok := envByKey[envKey] - if !ok { - continue + + elements := map[string]attr.Value{} + if prior.IsNull() || prior.IsUnknown() { + for _, e := range envs { + obj, d := environmentObjectFromAPI(ctx, e, nil) + diags.Append(d...) + elements[e.Key] = obj } - added[envKey] = true - obj, d := environmentObjectFromAPI(ctx, envAPI, &p) + m, d := types.MapValue(environmentObjectType, elements) diags.Append(d...) - ordered = append(ordered, obj) + return m, diags } - for _, e := range envs { - if added[e.Key] { + + priorModels, d := environmentModelsFromMap(ctx, prior) + diags.Append(d...) + if diags.HasError() { + return types.MapNull(environmentObjectType), diags + } + for key, pm := range priorModels { + envAPI, ok := envByKey[key] + if !ok { + // Managed env deleted out-of-band: drop it so the next plan + // shows it being recreated rather than carrying stale state. continue } - obj, d := environmentObjectFromAPI(ctx, e, nil) + pmCopy := pm + obj, d := environmentObjectFromAPI(ctx, envAPI, &pmCopy) diags.Append(d...) - ordered = append(ordered, obj) + elements[key] = obj } - list, d := types.ListValue(objType, ordered) + m, d := types.MapValue(environmentObjectType, elements) diags.Append(d...) - return list, diags + return m, diags } func environmentObjectFromAPI(ctx context.Context, e ldapi.Environment, prior *environmentModel) (basetypes.ObjectValue, diag.Diagnostics) { @@ -300,7 +331,6 @@ func environmentObjectFromAPI(ctx context.Context, e ldapi.Environment, prior *e approvals = list } obj, d := types.ObjectValue(environmentAttrTypes, map[string]attr.Value{ - KEY: types.StringValue(e.Key), NAME: types.StringValue(e.Name), COLOR: types.StringValue(e.Color), CRITICAL: types.BoolValue(e.Critical), diff --git a/launchdarkly/framework_state_upgrade.go b/launchdarkly/framework_state_upgrade.go index f719072b..ca297703 100644 --- a/launchdarkly/framework_state_upgrade.go +++ b/launchdarkly/framework_state_upgrade.go @@ -83,6 +83,56 @@ func csaObjectFromV0List(ctx context.Context, l types.List, attrTypes map[string return obj, diags } +// environmentsMapFromV0List re-keys a v0 (SDKv2 / pre-REL-14236) +// environments list — whose elements carried the env key inline — into the +// v3 map keyed by env key. Each per-env approval_settings that matches the +// API defaults the v2.29 SDKv2 provider persisted verbatim is collapsed to +// null so the v3 plan doesn't churn. Returns a null map for null/empty +// input. +func environmentsMapFromV0List(ctx context.Context, l types.List) (types.Map, diag.Diagnostics) { + var diags diag.Diagnostics + if l.IsNull() || l.IsUnknown() || len(l.Elements()) == 0 { + return types.MapNull(environmentObjectType), diags + } + var v0envs []environmentModelV0 + diags.Append(l.ElementsAs(ctx, &v0envs, false)...) + if diags.HasError() { + return types.MapNull(environmentObjectType), diags + } + approvalElemType := types.ObjectType{AttrTypes: frameworkApprovalSettingsObjectAttrTypes} + elements := make(map[string]attr.Value, len(v0envs)) + for _, e := range v0envs { + approvals := e.ApprovalSettings + if !approvals.IsNull() && !approvals.IsUnknown() && len(approvals.Elements()) == 1 { + var items []approvalSettingsModel + d := approvals.ElementsAs(ctx, &items, false) + if !d.HasError() && len(items) == 1 && approvalSettingsMatchesAPIDefaults(items[0]) { + approvals = types.ListNull(approvalElemType) + } + } + obj, d := types.ObjectValue(environmentAttrTypes, map[string]attr.Value{ + NAME: e.Name, + COLOR: e.Color, + CRITICAL: e.Critical, + API_KEY: e.APIKey, + MOBILE_KEY: e.MobileKey, + CLIENT_SIDE_ID: e.ClientSideID, + DEFAULT_TTL: e.DefaultTTL, + SECURE_MODE: e.SecureMode, + DEFAULT_TRACK_EVENTS: e.DefaultTrackEvents, + REQUIRE_COMMENTS: e.RequireComments, + CONFIRM_CHANGES: e.ConfirmChanges, + TAGS: e.Tags, + APPROVAL_SETTINGS: approvals, + }) + diags.Append(d...) + elements[e.Key.ValueString()] = obj + } + m, d := types.MapValue(environmentObjectType, elements) + diags.Append(d...) + return m, diags +} + // defaultsObjectFromV0List projects a v0 (SDKv2) single-element // feature_flag defaults list into the v3 single-object shape. Returns a // null object for null/empty input. diff --git a/launchdarkly/framework_state_upgrade_unit_test.go b/launchdarkly/framework_state_upgrade_unit_test.go index 40520894..4b7acece 100644 --- a/launchdarkly/framework_state_upgrade_unit_test.go +++ b/launchdarkly/framework_state_upgrade_unit_test.go @@ -97,6 +97,88 @@ func TestDefaultsObjectFromV0List(t *testing.T) { }) } +func TestEnvironmentsMapFromV0List(t *testing.T) { + ctx := context.Background() + + // v0 env element = current environmentAttrTypes plus the inline KEY. + v0Attr := map[string]attr.Type{KEY: types.StringType} + for k, v := range environmentAttrTypes { + v0Attr[k] = v + } + v0ObjType := types.ObjectType{AttrTypes: v0Attr} + approvalObjType := types.ObjectType{AttrTypes: frameworkApprovalSettingsObjectAttrTypes} + + approval := func(required bool, min int64) basetypes.ListValue { + return types.ListValueMust(approvalObjType, []attr.Value{ + types.ObjectValueMust(frameworkApprovalSettingsObjectAttrTypes, map[string]attr.Value{ + REQUIRED: types.BoolValue(required), + CAN_REVIEW_OWN_REQUEST: types.BoolValue(false), + MIN_NUM_APPROVALS: types.Int64Value(min), + CAN_APPLY_DECLINED_CHANGES: types.BoolValue(true), + REQUIRED_APPROVAL_TAGS: types.ListValueMust(types.StringType, []attr.Value{}), + SERVICE_KIND: types.StringValue("launchdarkly"), + SERVICE_CONFIG: types.MapValueMust(types.StringType, map[string]attr.Value{}), + AUTO_APPLY_APPROVED_CHANGES: types.BoolValue(false), + }), + }) + } + + mkEnv := func(name string, approvals attr.Value) func(key string) attr.Value { + return func(key string) attr.Value { + return types.ObjectValueMust(v0Attr, map[string]attr.Value{ + KEY: types.StringValue(key), + NAME: types.StringValue(name), + COLOR: types.StringValue("000000"), + CRITICAL: types.BoolValue(false), + API_KEY: types.StringValue(""), + MOBILE_KEY: types.StringValue(""), + CLIENT_SIDE_ID: types.StringValue(""), + DEFAULT_TTL: types.Int64Value(0), + SECURE_MODE: types.BoolValue(false), + DEFAULT_TRACK_EVENTS: types.BoolValue(false), + REQUIRE_COMMENTS: types.BoolValue(false), + CONFIRM_CHANGES: types.BoolValue(false), + TAGS: types.SetValueMust(types.StringType, []attr.Value{}), + APPROVAL_SETTINGS: approvals, + }) + } + } + + list := types.ListValueMust(v0ObjType, []attr.Value{ + mkEnv("Production", approval(true, 2))("production"), // real approval → preserved + mkEnv("Test", approval(false, 1))("test"), // matches API defaults → nulled + mkEnv("Staging", types.ListNull(approvalObjType))("stage"), // null → stays null + }) + + m, diags := environmentsMapFromV0List(ctx, list) + if diags.HasError() { + t.Fatalf("unexpected diags: %v", diags) + } + models := map[string]environmentModel{} + if d := m.ElementsAs(ctx, &models, false); d.HasError() { + t.Fatalf("decode map: %v", d) + } + if len(models) != 3 { + t.Fatalf("expected 3 envs keyed by env key, got %d: %v", len(models), models) + } + if got := models["production"].Name.ValueString(); got != "Production" { + t.Errorf("production name not preserved: %q", got) + } + if models["production"].ApprovalSettings.IsNull() || len(models["production"].ApprovalSettings.Elements()) != 1 { + t.Error("real approval_settings must be preserved on production") + } + if !models["test"].ApprovalSettings.IsNull() { + t.Error("API-default approval_settings must be nulled on test") + } + if !models["stage"].ApprovalSettings.IsNull() { + t.Error("null approval_settings must stay null on stage") + } + + if nm, _ := environmentsMapFromV0List(ctx, types.ListNull(v0ObjType)); !nm.IsNull() { + t.Error("null list must project to null map") + } +} + func TestFFEFallthroughObjectFromV0List(t *testing.T) { ctx := context.Background() objType := types.ObjectType{AttrTypes: ffeFallthroughAttrTypes} diff --git a/launchdarkly/resource_launchdarkly_context_kind_test.go b/launchdarkly/resource_launchdarkly_context_kind_test.go index d3dfa7d7..87243744 100644 --- a/launchdarkly/resource_launchdarkly_context_kind_test.go +++ b/launchdarkly/resource_launchdarkly_context_kind_test.go @@ -16,11 +16,12 @@ resource "launchdarkly_project" "test" { key = "%s" name = "Context kind acceptance test" tags = ["terraform", "context-kind-test"] - environments = [{ - name = "Test Environment" - key = "test-env" - color = "010101" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "010101" + } + } } `, projectKey) } diff --git a/launchdarkly/resource_launchdarkly_feature_flag_test.go b/launchdarkly/resource_launchdarkly_feature_flag_test.go index fa996967..247017ef 100644 --- a/launchdarkly/resource_launchdarkly_feature_flag_test.go +++ b/launchdarkly/resource_launchdarkly_feature_flag_test.go @@ -539,11 +539,12 @@ func withRandomProject(randomProject, resource string) string { } name = "testProject" key = "%s" - environments = [{ - name = "testEnvironment" - key = "test" - color = "000000" - }] + environments = { + "test" = { + name = "testEnvironment" + color = "000000" + } + } } %s`, randomProject, resource) @@ -561,11 +562,12 @@ func withProjectWithSpecifiedCSADefaults(randomProject string, resource string, using_environment_id = %v using_mobile_key = %v } - environments = [{ - name = "testEnvironment" - key = "test" - color = "000000" - }] + environments = { + "test" = { + name = "testEnvironment" + color = "000000" + } + } } %s`, randomProject, usingEnvironmentId, usingMobileKey, resource) @@ -579,11 +581,12 @@ func withRandomProjectAndEnv(randomProject, randomEnvironment, resource string) } name = "testProject" key = "%s" - environments = [{ - name = "testEnvironment" - key = "%s" - color = "000000" - }] + environments = { + "%s" = { + name = "testEnvironment" + color = "000000" + } + } } %s`, randomProject, randomEnvironment, resource) @@ -1452,11 +1455,12 @@ resource "launchdarkly_project" "test" { key = "%s" name = "View Requirement Test" require_view_association_for_new_flags = true - environments = [{ - key = "test-env" - name = "Test Environment" - color = "010101" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "010101" + } + } } resource "launchdarkly_feature_flag" "test" { @@ -1477,11 +1481,12 @@ resource "launchdarkly_project" "test" { key = "%s" name = "View Requirement Test" require_view_association_for_new_flags = true - environments = [{ - key = "test-env" - name = "Test Environment" - color = "010101" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "010101" + } + } } resource "launchdarkly_view" "test" { @@ -1825,11 +1830,12 @@ func TestAccFeatureFlag_ArchiveOnDestroy(t *testing.T) { } name = "testProject" key = "%s" - environments = [{ - name = "testEnvironment" - key = "test" - color = "000000" - }] + environments = { + "test" = { + name = "testEnvironment" + color = "000000" + } + } } `, projectKey) diff --git a/launchdarkly/resource_launchdarkly_flag_templates_test.go b/launchdarkly/resource_launchdarkly_flag_templates_test.go index eff2790b..b4752931 100644 --- a/launchdarkly/resource_launchdarkly_flag_templates_test.go +++ b/launchdarkly/resource_launchdarkly_flag_templates_test.go @@ -17,11 +17,12 @@ resource "launchdarkly_project" "test" { } name = "Flag Templates Test Project" key = "%s" - environments = [{ - name = "testEnvironment" - key = "test" - color = "000000" - }] + environments = { + "test" = { + name = "testEnvironment" + color = "000000" + } + } } resource "launchdarkly_flag_templates" "test" { @@ -50,11 +51,12 @@ resource "launchdarkly_project" "test" { } name = "Flag Templates Test Project" key = "%s" - environments = [{ - name = "testEnvironment" - key = "test" - color = "000000" - }] + environments = { + "test" = { + name = "testEnvironment" + color = "000000" + } + } } resource "launchdarkly_flag_templates" "test" { diff --git a/launchdarkly/resource_launchdarkly_project_test.go b/launchdarkly/resource_launchdarkly_project_test.go index 30004d92..34d6ffa0 100644 --- a/launchdarkly/resource_launchdarkly_project_test.go +++ b/launchdarkly/resource_launchdarkly_project_test.go @@ -7,21 +7,28 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" + ldapi "github.com/launchdarkly/api-client-go/v22" ) // Project resources should be formatted with a random project key because acceptance tests // are run in parallel on a single account. +// +// As of REL-14236 environments is a map keyed by env key +// (`environments = { "key" = { ... } }`). The count key is `environments.%` +// and elements are addressed `environments..`; there is no inner +// `key` attribute (the map key carries it). const ( testAccProjectCreate = ` resource "launchdarkly_project" "test" { key = "%s" name = "test project" tags = [ "terraform", "test" ] - environments = [{ - name = "Test Environment" - key = "test-env" - color = "010101" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "010101" + } + } } ` testAccProjectUpdate = ` @@ -29,11 +36,12 @@ resource "launchdarkly_project" "test" { key = "%s" name = "awesome test project" tags = [ "terraform" ] - environments = [{ - name = "Test Environment 2.0" - key = "test-env" - color = "020202" - }] + environments = { + "test-env" = { + name = "Test Environment 2.0" + color = "020202" + } + } } ` @@ -41,11 +49,12 @@ resource "launchdarkly_project" "test" { resource "launchdarkly_project" "test" { key = "%s" name = "awesome test project" - environments = [{ - name = "Test Environment 2.0" - key = "test-env" - color = "020202" - }] + environments = { + "test-env" = { + name = "Test Environment 2.0" + color = "020202" + } + } } ` @@ -53,82 +62,86 @@ resource "launchdarkly_project" "test" { resource "launchdarkly_project" "env_test" { key = "%s" name = "test project" - environments = [{ - key = "test-env" - name = "test environment" - color = "000000" - tags = ["terraform", "test"] - }] -} + environments = { + "test-env" = { + name = "test environment" + color = "000000" + tags = ["terraform", "test"] + } + } +} ` testAccProjectWithEnvironmentUpdate = ` resource "launchdarkly_project" "env_test" { key = "%s" name = "test project" - environments = [{ - key = "test-env" - name = "test environment updated" - color = "AAAAAA" - tags = ["terraform", "test", "updated"] - default_ttl = 30 - secure_mode = true - default_track_events = true - require_comments = true - confirm_changes = true - }, { - key = "new-approvals-env" - name = "New approvals environment" - color = "EEEEEE" - tags = ["new"] - approval_settings = [{ - required = true - can_review_own_request = true - min_num_approvals = 2 - }] - }] -} + environments = { + "test-env" = { + name = "test environment updated" + color = "AAAAAA" + tags = ["terraform", "test", "updated"] + default_ttl = 30 + secure_mode = true + default_track_events = true + require_comments = true + confirm_changes = true + } + "new-approvals-env" = { + name = "New approvals environment" + color = "EEEEEE" + tags = ["new"] + approval_settings = [{ + required = true + can_review_own_request = true + min_num_approvals = 2 + }] + } + } +} ` testAccProjectWithEnvironmentUpdateApprovalSettings = ` resource "launchdarkly_project" "env_test" { key = "%s" name = "test project" - environments = [{ - key = "test-env" - name = "test environment updated" - color = "AAAAAA" - tags = ["terraform", "test", "updated"] - default_ttl = 30 - secure_mode = true - default_track_events = true - require_comments = true - confirm_changes = true - }, { - key = "new-approvals-env" - name = "New approvals environment" - color = "EEEEEE" - tags = ["new"] - approval_settings = [{ - required_approval_tags = ["approvals_required"] - can_review_own_request = false - min_num_approvals = 1 - can_apply_declined_changes = false - }] - }] -} + environments = { + "test-env" = { + name = "test environment updated" + color = "AAAAAA" + tags = ["terraform", "test", "updated"] + default_ttl = 30 + secure_mode = true + default_track_events = true + require_comments = true + confirm_changes = true + } + "new-approvals-env" = { + name = "New approvals environment" + color = "EEEEEE" + tags = ["new"] + approval_settings = [{ + required_approval_tags = ["approvals_required"] + can_review_own_request = false + min_num_approvals = 1 + can_apply_declined_changes = false + }] + } + } +} ` testAccProjectWithEnvironmentUpdateRemove = ` resource "launchdarkly_project" "env_test" { key = "%s" name = "test project" - environments = [{ - key = "test-env" - name = "test environment updated" - color = "AAAAAA" - }] -} + environments = { + "test-env" = { + name = "test environment updated" + color = "AAAAAA" + } + } +} ` testAccProjectClientSideAvailabilityTrue = ` @@ -140,11 +153,12 @@ resource "launchdarkly_project" "test" { using_mobile_key = true } tags = [ "terraform", "test" ] - environments = [{ - name = "Test Environment" - key = "test-env" - color = "010101" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "010101" + } + } } ` @@ -158,13 +172,12 @@ resource "launchdarkly_project" "many_envs" { key = "%s" name = "Project with many environments" - environments = [ - for n in local.envs : { - key = format("env-%s", n) + environments = { + for n in local.envs : format("env-%s", n) => { name = format("Env %s", n) color = "000000" } - ] + } tags = [ "terraform", "test" ] } @@ -173,53 +186,63 @@ resource "launchdarkly_project" "many_envs" { resource "launchdarkly_project" "approval_env_test" { key = "%s" name = "test project" - environments = [{ - key = "approval-env" - name = "env with approval settings" - color = "AAAAAA" - approval_settings = [{ - can_review_own_request = false - can_apply_declined_changes = false - min_num_approvals = 2 - required = true - }] - }, { - key = "default-env" - name = "env with default approval settings" - color = "AAAAAA" - }] + environments = { + "approval-env" = { + name = "env with approval settings" + color = "AAAAAA" + approval_settings = [{ + can_review_own_request = false + can_apply_declined_changes = false + min_num_approvals = 2 + required = true + }] + } + "default-env" = { + name = "env with default approval settings" + color = "AAAAAA" + } + } }` testAccProjectWithEnvApprovalSettingsUpdate = ` resource "launchdarkly_project" "approval_env_test" { key = "%s" name = "test project" - environments = [{ - key = "new-env" - name = "New env with approval settings" - color = "AAAAAA" - approval_settings = [{ - can_review_own_request = false - can_apply_declined_changes = false - min_num_approvals = 1 - required = false - }] - }, { - key = "approval-env" - name = "env with approval settings" - color = "AAAAAA" - approval_settings = [{ - can_review_own_request = false - can_apply_declined_changes = false - min_num_approvals = 2 - required = true - }] - }, { - key = "default-env" - name = "env with default approval settings" - color = "AAAAAA" - }] + environments = { + "new-env" = { + name = "New env with approval settings" + color = "AAAAAA" + approval_settings = [{ + can_review_own_request = false + can_apply_declined_changes = false + min_num_approvals = 1 + required = false + }] + } + "approval-env" = { + name = "env with approval settings" + color = "AAAAAA" + approval_settings = [{ + can_review_own_request = false + can_apply_declined_changes = false + min_num_approvals = 2 + required = true + }] + } + "default-env" = { + name = "env with default approval settings" + color = "AAAAAA" + } + } }` + + testAccProjectZeroEnvironments = ` +resource "launchdarkly_project" "zero_env" { + key = "%s" + name = "zero env project" + environments = {} +} +` ) func TestAccProject_Create(t *testing.T) { @@ -271,10 +294,9 @@ func TestAccProject_Update(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "tags.#", "2"), resource.TestCheckResourceAttr(resourceName, "tags.0", "terraform"), resource.TestCheckResourceAttr(resourceName, "tags.1", "test"), - resource.TestCheckResourceAttr(resourceName, "environments.#", "1"), - resource.TestCheckResourceAttr(resourceName, "environments.0.name", "Test Environment"), - resource.TestCheckResourceAttr(resourceName, "environments.0.key", "test-env"), - resource.TestCheckResourceAttr(resourceName, "environments.0.color", "010101"), + resource.TestCheckResourceAttr(resourceName, "environments.%", "1"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.name", "Test Environment"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.color", "010101"), ), }, { @@ -285,10 +307,9 @@ func TestAccProject_Update(t *testing.T) { resource.TestCheckResourceAttr(resourceName, NAME, "awesome test project"), resource.TestCheckResourceAttr(resourceName, "tags.#", "1"), resource.TestCheckResourceAttr(resourceName, "tags.0", "terraform"), - resource.TestCheckResourceAttr(resourceName, "environments.#", "1"), - resource.TestCheckResourceAttr(resourceName, "environments.0.name", "Test Environment 2.0"), - resource.TestCheckResourceAttr(resourceName, "environments.0.key", "test-env"), - resource.TestCheckResourceAttr(resourceName, "environments.0.color", "020202"), + resource.TestCheckResourceAttr(resourceName, "environments.%", "1"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.name", "Test Environment 2.0"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.color", "020202"), ), }, { // make sure that removal of optional attributes reverts them to their null value @@ -377,17 +398,17 @@ func TestAccProject_WithEnvironments(t *testing.T) { testAccCheckProjectExists(resourceName), resource.TestCheckResourceAttr(resourceName, KEY, projectKey), resource.TestCheckResourceAttr(resourceName, NAME, "test project"), - resource.TestCheckResourceAttr(resourceName, "environments.#", "1"), - resource.TestCheckResourceAttr(resourceName, "environments.0.name", "test environment"), - resource.TestCheckResourceAttr(resourceName, "environments.0.tags.#", "2"), - resource.TestCheckResourceAttr(resourceName, "environments.0.color", "000000"), + resource.TestCheckResourceAttr(resourceName, "environments.%", "1"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.name", "test environment"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.tags.#", "2"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.color", "000000"), // default environment values - resource.TestCheckResourceAttr(resourceName, "environments.0.default_ttl", "0"), - resource.TestCheckResourceAttr(resourceName, "environments.0.secure_mode", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.0.default_track_events", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.0.require_comments", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.0.confirm_changes", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.default_ttl", "0"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.secure_mode", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.default_track_events", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.require_comments", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.confirm_changes", "false"), ), }, { @@ -401,32 +422,31 @@ func TestAccProject_WithEnvironments(t *testing.T) { testAccCheckProjectExists(resourceName), resource.TestCheckResourceAttr(resourceName, KEY, projectKey), resource.TestCheckResourceAttr(resourceName, NAME, "test project"), - resource.TestCheckResourceAttr(resourceName, "environments.#", "2"), - - // Check environment 0 was updated - resource.TestCheckResourceAttr(resourceName, "environments.0.name", "test environment updated"), - resource.TestCheckResourceAttr(resourceName, "environments.0.tags.#", "3"), - resource.TestCheckResourceAttr(resourceName, "environments.0.color", "AAAAAA"), - resource.TestCheckResourceAttr(resourceName, "environments.0.default_ttl", "30"), - resource.TestCheckResourceAttr(resourceName, "environments.0.secure_mode", "true"), - resource.TestCheckResourceAttr(resourceName, "environments.0.default_track_events", "true"), - resource.TestCheckResourceAttr(resourceName, "environments.0.require_comments", "true"), - resource.TestCheckResourceAttr(resourceName, "environments.0.confirm_changes", "true"), - - // Check environment 1 is created - resource.TestCheckResourceAttr(resourceName, "environments.1.key", "new-approvals-env"), - resource.TestCheckResourceAttr(resourceName, "environments.1.name", "New approvals environment"), - resource.TestCheckResourceAttr(resourceName, "environments.1.tags.#", "1"), - resource.TestCheckResourceAttr(resourceName, "environments.1.color", "EEEEEE"), - resource.TestCheckResourceAttr(resourceName, "environments.1.default_ttl", "0"), - resource.TestCheckResourceAttr(resourceName, "environments.1.secure_mode", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.1.default_track_events", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.1.require_comments", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.1.confirm_changes", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.1.approval_settings.0.required", "true"), - resource.TestCheckResourceAttr(resourceName, "environments.1.approval_settings.0.can_review_own_request", "true"), - resource.TestCheckResourceAttr(resourceName, "environments.1.approval_settings.0.min_num_approvals", "2"), - resource.TestCheckResourceAttr(resourceName, "environments.1.approval_settings.0.can_apply_declined_changes", "true"), // defaults to true + resource.TestCheckResourceAttr(resourceName, "environments.%", "2"), + + // Check test-env was updated + resource.TestCheckResourceAttr(resourceName, "environments.test-env.name", "test environment updated"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.tags.#", "3"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.color", "AAAAAA"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.default_ttl", "30"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.secure_mode", "true"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.default_track_events", "true"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.require_comments", "true"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.confirm_changes", "true"), + + // Check new-approvals-env is created + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.name", "New approvals environment"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.tags.#", "1"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.color", "EEEEEE"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.default_ttl", "0"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.secure_mode", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.default_track_events", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.require_comments", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.confirm_changes", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.approval_settings.0.required", "true"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.approval_settings.0.can_review_own_request", "true"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.approval_settings.0.min_num_approvals", "2"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.approval_settings.0.can_apply_declined_changes", "true"), // defaults to true ), }, { @@ -439,23 +459,22 @@ func TestAccProject_WithEnvironments(t *testing.T) { testAccCheckProjectExists(resourceName), resource.TestCheckResourceAttr(resourceName, KEY, projectKey), resource.TestCheckResourceAttr(resourceName, NAME, "test project"), - resource.TestCheckResourceAttr(resourceName, "environments.#", "2"), + resource.TestCheckResourceAttr(resourceName, "environments.%", "2"), // Check approval_settings have updated as expected - resource.TestCheckResourceAttr(resourceName, "environments.1.key", "new-approvals-env"), - resource.TestCheckResourceAttr(resourceName, "environments.1.name", "New approvals environment"), - resource.TestCheckResourceAttr(resourceName, "environments.1.tags.#", "1"), - resource.TestCheckResourceAttr(resourceName, "environments.1.color", "EEEEEE"), - resource.TestCheckResourceAttr(resourceName, "environments.1.default_ttl", "0"), - resource.TestCheckResourceAttr(resourceName, "environments.1.secure_mode", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.1.default_track_events", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.1.require_comments", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.1.confirm_changes", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.1.approval_settings.0.required", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.1.approval_settings.0.required_approval_tags.0", "approvals_required"), - resource.TestCheckResourceAttr(resourceName, "environments.1.approval_settings.0.can_review_own_request", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.1.approval_settings.0.min_num_approvals", "1"), - resource.TestCheckResourceAttr(resourceName, "environments.1.approval_settings.0.can_apply_declined_changes", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.name", "New approvals environment"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.tags.#", "1"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.color", "EEEEEE"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.default_ttl", "0"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.secure_mode", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.default_track_events", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.require_comments", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.confirm_changes", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.approval_settings.0.required", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.approval_settings.0.required_approval_tags.0", "approvals_required"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.approval_settings.0.can_review_own_request", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.approval_settings.0.min_num_approvals", "1"), + resource.TestCheckResourceAttr(resourceName, "environments.new-approvals-env.approval_settings.0.can_apply_declined_changes", "false"), ), }, { @@ -468,17 +487,17 @@ func TestAccProject_WithEnvironments(t *testing.T) { testAccCheckProjectExists(resourceName), resource.TestCheckResourceAttr(resourceName, KEY, projectKey), resource.TestCheckResourceAttr(resourceName, NAME, "test project"), - resource.TestCheckResourceAttr(resourceName, "environments.#", "1"), + resource.TestCheckResourceAttr(resourceName, "environments.%", "1"), // Check that optional attributes defaulted back to false - resource.TestCheckResourceAttr(resourceName, "environments.0.name", "test environment updated"), - resource.TestCheckResourceAttr(resourceName, "environments.0.tags.#", "0"), - resource.TestCheckResourceAttr(resourceName, "environments.0.color", "AAAAAA"), - resource.TestCheckResourceAttr(resourceName, "environments.0.default_ttl", "0"), - resource.TestCheckResourceAttr(resourceName, "environments.0.secure_mode", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.0.default_track_events", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.0.require_comments", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.0.confirm_changes", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.name", "test environment updated"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.tags.#", "0"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.color", "AAAAAA"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.default_ttl", "0"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.secure_mode", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.default_track_events", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.require_comments", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.test-env.confirm_changes", "false"), ), }, { @@ -506,15 +525,13 @@ func TestAccProject_EnvApprovalUpdate(t *testing.T) { testAccCheckProjectExists(resourceName), resource.TestCheckResourceAttr(resourceName, KEY, projectKey), resource.TestCheckResourceAttr(resourceName, NAME, "test project"), - resource.TestCheckResourceAttr(resourceName, "environments.#", "2"), - resource.TestCheckResourceAttr(resourceName, "environments.0.key", "approval-env"), - resource.TestCheckResourceAttr(resourceName, "environments.0.name", "env with approval settings"), - resource.TestCheckResourceAttr(resourceName, "environments.0.approval_settings.0.required", "true"), - resource.TestCheckResourceAttr(resourceName, "environments.0.approval_settings.0.min_num_approvals", "2"), - resource.TestCheckResourceAttr(resourceName, "environments.1.key", "default-env"), - resource.TestCheckResourceAttr(resourceName, "environments.1.name", "env with default approval settings"), - // env[1] omits approval_settings so state stays null. - resource.TestCheckNoResourceAttr(resourceName, "environments.1.approval_settings.#"), + resource.TestCheckResourceAttr(resourceName, "environments.%", "2"), + resource.TestCheckResourceAttr(resourceName, "environments.approval-env.name", "env with approval settings"), + resource.TestCheckResourceAttr(resourceName, "environments.approval-env.approval_settings.0.required", "true"), + resource.TestCheckResourceAttr(resourceName, "environments.approval-env.approval_settings.0.min_num_approvals", "2"), + resource.TestCheckResourceAttr(resourceName, "environments.default-env.name", "env with default approval settings"), + // default-env omits approval_settings so state stays null. + resource.TestCheckNoResourceAttr(resourceName, "environments.default-env.approval_settings.#"), ), }, { @@ -528,31 +545,28 @@ func TestAccProject_EnvApprovalUpdate(t *testing.T) { testAccCheckProjectExists(resourceName), resource.TestCheckResourceAttr(resourceName, KEY, projectKey), resource.TestCheckResourceAttr(resourceName, NAME, "test project"), - resource.TestCheckResourceAttr(resourceName, "environments.#", "3"), - resource.TestCheckResourceAttr(resourceName, "environments.0.key", "new-env"), - resource.TestCheckResourceAttr(resourceName, "environments.0.name", "New env with approval settings"), - resource.TestCheckResourceAttr(resourceName, "environments.0.approval_settings.0.required", "false"), - resource.TestCheckResourceAttr(resourceName, "environments.0.approval_settings.0.min_num_approvals", "1"), - resource.TestCheckResourceAttr(resourceName, "environments.1.key", "approval-env"), - resource.TestCheckResourceAttr(resourceName, "environments.1.name", "env with approval settings"), - resource.TestCheckResourceAttr(resourceName, "environments.1.approval_settings.0.required", "true"), - resource.TestCheckResourceAttr(resourceName, "environments.1.approval_settings.0.min_num_approvals", "2"), - resource.TestCheckResourceAttr(resourceName, "environments.2.key", "default-env"), - resource.TestCheckResourceAttr(resourceName, "environments.2.name", "env with default approval settings"), - // env[2] omits approval_settings so state stays null. - resource.TestCheckNoResourceAttr(resourceName, "environments.2.approval_settings.#"), + resource.TestCheckResourceAttr(resourceName, "environments.%", "3"), + resource.TestCheckResourceAttr(resourceName, "environments.new-env.name", "New env with approval settings"), + resource.TestCheckResourceAttr(resourceName, "environments.new-env.approval_settings.0.required", "false"), + resource.TestCheckResourceAttr(resourceName, "environments.new-env.approval_settings.0.min_num_approvals", "1"), + resource.TestCheckResourceAttr(resourceName, "environments.approval-env.name", "env with approval settings"), + resource.TestCheckResourceAttr(resourceName, "environments.approval-env.approval_settings.0.required", "true"), + resource.TestCheckResourceAttr(resourceName, "environments.approval-env.approval_settings.0.min_num_approvals", "2"), + resource.TestCheckResourceAttr(resourceName, "environments.default-env.name", "env with default approval settings"), + // default-env omits approval_settings so state stays null. + resource.TestCheckNoResourceAttr(resourceName, "environments.default-env.approval_settings.#"), ), }, { ResourceName: resourceName, ImportState: true, ImportStateVerify: true, - // env[0] in Step 3 declares an approval_settings whose values - // are all LD defaults. On Import we have no prior state to - // tell "user declared" from "user omitted", so we fall back - // to an isZero heuristic that collapses the all-defaults - // case to null. Ignore that drift here. - ImportStateVerifyIgnore: []string{"environments.0.approval_settings.#", "environments.0.approval_settings.0.%", "environments.0.approval_settings.0.required", "environments.0.approval_settings.0.min_num_approvals", "environments.0.approval_settings.0.can_review_own_request", "environments.0.approval_settings.0.can_apply_declined_changes", "environments.0.approval_settings.0.auto_apply_approved_changes", "environments.0.approval_settings.0.service_kind", "environments.0.approval_settings.0.service_config.%", "environments.0.approval_settings.0.required_approval_tags.#"}, + // new-env in Step 3 declares an approval_settings whose values + // collapse to the LD defaults. On Import we have no prior state + // to tell "user declared" from "user omitted", so we fall back + // to an isZero heuristic that collapses the all-defaults case to + // null. Ignore that drift here. + ImportStateVerifyIgnore: []string{"environments.new-env.approval_settings.#", "environments.new-env.approval_settings.0.%", "environments.new-env.approval_settings.0.required", "environments.new-env.approval_settings.0.min_num_approvals", "environments.new-env.approval_settings.0.can_review_own_request", "environments.new-env.approval_settings.0.can_apply_declined_changes", "environments.new-env.approval_settings.0.auto_apply_approved_changes", "environments.new-env.approval_settings.0.service_kind", "environments.new-env.approval_settings.0.service_config.%", "environments.new-env.approval_settings.0.required_approval_tags.#"}, }, }, }) @@ -574,22 +588,108 @@ func TestAccProject_ManyEnvironments(t *testing.T) { testAccCheckProjectExists(resourceName), resource.TestCheckResourceAttr(resourceName, KEY, projectKey), resource.TestCheckResourceAttr(resourceName, NAME, "Project with many environments"), - resource.TestCheckResourceAttr(resourceName, "environments.#", "25"), + resource.TestCheckResourceAttr(resourceName, "environments.%", "25"), resource.TestCheckResourceAttr(resourceName, "tags.#", "2"), resource.TestCheckResourceAttr(resourceName, "tags.0", "terraform"), resource.TestCheckResourceAttr(resourceName, "tags.1", "test"), ), }, { + // environments is now a map keyed by env key, so import is + // order-independent and ImportStateVerify is no longer flaky on + // the 25-environment project. ResourceName: resourceName, ImportState: true, ImportStateVerify: true, - // framework migration: LD API returns environments in - // non-deterministic order, so ImportStateVerify of - // 25-environment ordered TypeList is flaky. The framework - // preserves config order on Refresh but has no config on - // Import, so post-import env ordering follows the API. - ImportStateVerifyIgnore: []string{"environments"}, + }, + }, + }) +} + +// TestAccProject_ZeroEnvironments exercises the REL-14236 relaxation that lets +// a project be created without managing any environments (environments = {}). +// LaunchDarkly auto-provisions its default environments, but with an empty map +// the provider manages none of them, so state reports zero managed environments +// and the plan is stable. +func TestAccProject_ZeroEnvironments(t *testing.T) { + projectKey := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + resourceName := "launchdarkly_project.zero_env" + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckProjectDestroy, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(testAccProjectZeroEnvironments, projectKey), + Check: resource.ComposeTestCheckFunc( + testAccCheckProjectExists(resourceName), + resource.TestCheckResourceAttr(resourceName, KEY, projectKey), + resource.TestCheckResourceAttr(resourceName, NAME, "zero env project"), + resource.TestCheckResourceAttr(resourceName, "environments.%", "0"), + ), + }, + }, + }) +} + +// TestAccProject_ManageSubset proves the REL-14236 manage-a-subset behavior: +// an environment created outside Terraform (as the LaunchDarkly UI would) is +// neither pulled into state nor deleted by a subsequent apply of the unchanged +// config. This guards against silently destroying unmanaged environments. +func TestAccProject_ManageSubset(t *testing.T) { + projectKey := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + resourceName := "launchdarkly_project.subset" + config := fmt.Sprintf(` +resource "launchdarkly_project" "subset" { + key = "%s" + name = "subset project" + environments = { + "alpha" = { + name = "Alpha" + color = "010101" + } + } +} +`, projectKey) + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckProjectDestroy, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + testAccCheckProjectExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "environments.%", "1"), + resource.TestCheckResourceAttr(resourceName, "environments.alpha.name", "Alpha"), + ), + }, + { + // Create "beta" out-of-band, then re-apply the unchanged config. + // The provider must ignore the unmanaged env: it stays out of + // state, the plan is empty, and it is NOT deleted. + PreConfig: func() { + client := mustTestAccClient() + _, _, err := client.ld.EnvironmentsApi.PostEnvironment(client.ctx, projectKey). + EnvironmentPost(ldapi.EnvironmentPost{Key: "beta", Name: "Beta", Color: "123456"}).Execute() + if err != nil { + t.Fatalf("failed to create out-of-band environment: %s", handleLdapiErr(err)) + } + }, + Config: config, + Check: resource.ComposeTestCheckFunc( + testAccCheckProjectExists(resourceName), + // managed env still tracked; unmanaged env not pulled into state + resource.TestCheckResourceAttr(resourceName, "environments.%", "1"), + resource.TestCheckResourceAttr(resourceName, "environments.alpha.name", "Alpha"), + resource.TestCheckNoResourceAttr(resourceName, "environments.beta.name"), + // unmanaged env survived the apply (was not deleted) + testAccCheckEnvironmentExistsInProject(projectKey, "beta"), + ), }, }, }) @@ -604,11 +704,12 @@ func TestAccProject_ViewAssociationRequirement(t *testing.T) { resource "launchdarkly_project" "view_req_test" { key = "%s" name = "View Requirement Test" - environments = [{ - key = "test-env" - name = "Test Environment" - color = "010101" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "010101" + } + } } `, projectKey) @@ -619,11 +720,12 @@ resource "launchdarkly_project" "view_req_test" { name = "View Requirement Test" require_view_association_for_new_flags = true require_view_association_for_new_segments = true - environments = [{ - key = "test-env" - name = "Test Environment" - color = "010101" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "010101" + } + } } `, projectKey) @@ -634,11 +736,12 @@ resource "launchdarkly_project" "view_req_test" { name = "View Requirement Test" require_view_association_for_new_flags = true require_view_association_for_new_segments = false - environments = [{ - key = "test-env" - name = "Test Environment" - color = "010101" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "010101" + } + } } `, projectKey) @@ -708,6 +811,20 @@ func testAccCheckProjectExists(resourceName string) resource.TestCheckFunc { } } +// testAccCheckEnvironmentExistsInProject asserts an environment is still +// present on the project — used to prove an unmanaged environment was not +// deleted by an apply. +func testAccCheckEnvironmentExistsInProject(projectKey, envKey string) resource.TestCheckFunc { + return func(_ *terraform.State) error { + client := mustTestAccClient() + _, _, err := client.ld.EnvironmentsApi.GetEnvironment(client.ctx, projectKey, envKey).Execute() + if err != nil { + return fmt.Errorf("expected out-of-band environment %q to still exist in project %q: %s", envKey, projectKey, err) + } + return nil + } +} + // testAccCheckProjectDestroy verifies the project has been destroyed func testAccCheckProjectDestroy(s *terraform.State) error { client := mustTestAccClient() diff --git a/launchdarkly/resource_launchdarkly_segment_test.go b/launchdarkly/resource_launchdarkly_segment_test.go index 5e7cb204..6e0aa565 100644 --- a/launchdarkly/resource_launchdarkly_segment_test.go +++ b/launchdarkly/resource_launchdarkly_segment_test.go @@ -661,11 +661,12 @@ resource "launchdarkly_project" "test" { key = "%s" name = "View Requirement Test" require_view_association_for_new_segments = true - environments = [{ - key = "test-env" - name = "Test Environment" - color = "010101" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "010101" + } + } } resource "launchdarkly_segment" "test" { @@ -682,11 +683,12 @@ resource "launchdarkly_project" "test" { key = "%s" name = "View Requirement Test" require_view_association_for_new_segments = true - environments = [{ - key = "test-env" - name = "Test Environment" - color = "010101" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "010101" + } + } } resource "launchdarkly_view" "test" { @@ -752,11 +754,12 @@ func TestAccSegment_ApprovalRequired(t *testing.T) { resource "launchdarkly_project" "test" { key = "%s" name = "Segment Approvals Test" - environments = [{ - key = "%s" - name = "Test Environment" - color = "010101" - }] + environments = { + "%s" = { + name = "Test Environment" + color = "010101" + } + } } `, projectKey, envKey) diff --git a/launchdarkly/resource_launchdarkly_segment_view_keys_test.go b/launchdarkly/resource_launchdarkly_segment_view_keys_test.go index 66980e96..e8e5d311 100644 --- a/launchdarkly/resource_launchdarkly_segment_view_keys_test.go +++ b/launchdarkly/resource_launchdarkly_segment_view_keys_test.go @@ -20,11 +20,12 @@ const ( resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "view1" { @@ -68,11 +69,12 @@ resource "launchdarkly_segment" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "view1" { @@ -116,11 +118,12 @@ resource "launchdarkly_segment" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "view1" { @@ -161,11 +164,12 @@ resource "launchdarkly_segment" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "view1" { @@ -209,11 +213,12 @@ resource "launchdarkly_segment" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "view1" { @@ -229,11 +234,12 @@ resource "launchdarkly_view" "view1" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "view1" { diff --git a/launchdarkly/resource_launchdarkly_view_filter_links_test.go b/launchdarkly/resource_launchdarkly_view_filter_links_test.go index 4b6fcb5e..95a5611a 100644 --- a/launchdarkly/resource_launchdarkly_view_filter_links_test.go +++ b/launchdarkly/resource_launchdarkly_view_filter_links_test.go @@ -15,11 +15,12 @@ const ( resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -70,11 +71,12 @@ resource "launchdarkly_view_filter_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -138,11 +140,12 @@ resource "launchdarkly_view_filter_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -206,11 +209,12 @@ resource "launchdarkly_view_filter_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -233,7 +237,7 @@ resource "launchdarkly_view_filter_links" "test" { project_key = launchdarkly_project.test.key view_key = launchdarkly_view.test.key segment_filter = "tags anyOf [\"segment-filter-test\"]" - segment_filter_environment_id = launchdarkly_project.test.environments[0].client_side_id + segment_filter_environment_id = launchdarkly_project.test.environments["test-env"].client_side_id depends_on = [ launchdarkly_segment.test1 @@ -245,11 +249,12 @@ resource "launchdarkly_view_filter_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -285,7 +290,7 @@ resource "launchdarkly_view_filter_links" "test" { view_key = launchdarkly_view.test.key flag_filter = "tags:both-filter-test" segment_filter = "tags anyOf [\"both-filter-test\"]" - segment_filter_environment_id = launchdarkly_project.test.environments[0].client_side_id + segment_filter_environment_id = launchdarkly_project.test.environments["test-env"].client_side_id depends_on = [ launchdarkly_feature_flag.test1, @@ -298,11 +303,12 @@ resource "launchdarkly_view_filter_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -354,11 +360,12 @@ resource "launchdarkly_view_filter_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -410,11 +417,12 @@ resource "launchdarkly_view_filter_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -466,11 +474,12 @@ resource "launchdarkly_view_filter_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -522,11 +531,12 @@ resource "launchdarkly_view_filter_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -557,7 +567,7 @@ resource "launchdarkly_view_filter_links" "test" { project_key = launchdarkly_project.test.key view_key = launchdarkly_view.test.key segment_filter = "tags anyOf [\"segment-trigger-test\"]" - segment_filter_environment_id = launchdarkly_project.test.environments[0].client_side_id + segment_filter_environment_id = launchdarkly_project.test.environments["test-env"].client_side_id reconcile_on_apply = true depends_on = [ @@ -571,11 +581,12 @@ resource "launchdarkly_view_filter_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -606,7 +617,7 @@ resource "launchdarkly_view_filter_links" "test" { project_key = launchdarkly_project.test.key view_key = launchdarkly_view.test.key segment_filter = "tags anyOf [\"segment-trigger-test\"]" - segment_filter_environment_id = launchdarkly_project.test.environments[0].client_side_id + segment_filter_environment_id = launchdarkly_project.test.environments["test-env"].client_side_id reconcile_on_apply = true depends_on = [ @@ -620,11 +631,12 @@ resource "launchdarkly_view_filter_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { diff --git a/launchdarkly/resource_launchdarkly_view_links_test.go b/launchdarkly/resource_launchdarkly_view_links_test.go index 9f6cdcca..63737248 100644 --- a/launchdarkly/resource_launchdarkly_view_links_test.go +++ b/launchdarkly/resource_launchdarkly_view_links_test.go @@ -15,11 +15,12 @@ const ( resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -67,11 +68,12 @@ resource "launchdarkly_view_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -130,11 +132,12 @@ resource "launchdarkly_view_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -185,15 +188,16 @@ resource "launchdarkly_feature_flag" "test3" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }, { - name = "Production" - key = "production" - color = "AAAAAA" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + "production" = { + name = "Production" + color = "AAAAAA" + } + } } resource "launchdarkly_view" "test" { @@ -271,10 +275,10 @@ resource "launchdarkly_view_links" "test" { ] segments = [{ - environment_id = launchdarkly_project.test.environments[0].client_side_id + environment_id = launchdarkly_project.test.environments["test-env"].client_side_id segment_key = launchdarkly_segment.segment1.key }, { - environment_id = launchdarkly_project.test.environments[0].client_side_id + environment_id = launchdarkly_project.test.environments["test-env"].client_side_id segment_key = launchdarkly_segment.segment2.key }] } @@ -284,15 +288,16 @@ resource "launchdarkly_view_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }, { - name = "Production" - key = "production" - color = "AAAAAA" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + "production" = { + name = "Production" + color = "AAAAAA" + } + } } resource "launchdarkly_view" "test" { @@ -370,10 +375,10 @@ resource "launchdarkly_view_links" "test" { ] segments = [{ - environment_id = launchdarkly_project.test.environments[0].client_side_id + environment_id = launchdarkly_project.test.environments["test-env"].client_side_id segment_key = launchdarkly_segment.segment1.key }, { - environment_id = launchdarkly_project.test.environments[1].client_side_id + environment_id = launchdarkly_project.test.environments["production"].client_side_id segment_key = launchdarkly_segment.segment3.key }] } @@ -383,15 +388,16 @@ resource "launchdarkly_view_links" "test" { resource "launchdarkly_project" "test" { name = "%s" key = "%s" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }, { - name = "Production" - key = "production" - color = "AAAAAA" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + "production" = { + name = "Production" + color = "AAAAAA" + } + } } resource "launchdarkly_view" "test" { @@ -469,13 +475,13 @@ resource "launchdarkly_view_links" "test" { ] segments = [{ - environment_id = launchdarkly_project.test.environments[0].client_side_id + environment_id = launchdarkly_project.test.environments["test-env"].client_side_id segment_key = launchdarkly_segment.segment2.key }, { - environment_id = launchdarkly_project.test.environments[1].client_side_id + environment_id = launchdarkly_project.test.environments["production"].client_side_id segment_key = launchdarkly_segment.segment3.key }, { - environment_id = launchdarkly_project.test.environments[0].client_side_id + environment_id = launchdarkly_project.test.environments["test-env"].client_side_id segment_key = launchdarkly_segment.segment1.key }] } diff --git a/launchdarkly/resource_launchdarkly_view_test.go b/launchdarkly/resource_launchdarkly_view_test.go index 3093f915..1671b56f 100644 --- a/launchdarkly/resource_launchdarkly_view_test.go +++ b/launchdarkly/resource_launchdarkly_view_test.go @@ -16,11 +16,12 @@ const ( resource "launchdarkly_project" "test" { key = "%s" name = "Test project" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -37,11 +38,12 @@ resource "launchdarkly_view" "test" { resource "launchdarkly_project" "test" { key = "%s" name = "Test project" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_view" "test" { @@ -58,11 +60,12 @@ resource "launchdarkly_view" "test" { resource "launchdarkly_project" "test" { key = "%s" name = "Test project" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_team" "test_team" { @@ -86,11 +89,12 @@ resource "launchdarkly_view" "test" { resource "launchdarkly_project" "test" { key = "%s" name = "Test project" - environments = [{ - name = "Test Environment" - key = "test-env" - color = "000000" - }] + environments = { + "test-env" = { + name = "Test Environment" + color = "000000" + } + } } resource "launchdarkly_team" "test_team" { diff --git a/launchdarkly/resource_project_framework.go b/launchdarkly/resource_project_framework.go index 9c320699..4b41f71f 100644 --- a/launchdarkly/resource_project_framework.go +++ b/launchdarkly/resource_project_framework.go @@ -37,7 +37,7 @@ type ProjectResourceModel struct { Name types.String `tfsdk:"name"` DefaultClientSideAvailability types.Object `tfsdk:"default_client_side_availability"` Tags types.Set `tfsdk:"tags"` - Environments types.List `tfsdk:"environments"` + Environments types.Map `tfsdk:"environments"` RequireViewAssociationForNewFlags types.Bool `tfsdk:"require_view_association_for_new_flags"` RequireViewAssociationForNewSegments types.Bool `tfsdk:"require_view_association_for_new_segments"` } @@ -173,7 +173,6 @@ func (r *ProjectResource) UpgradeState(_ context.Context) map[int64]resource.Sta Name: prior.Name, DefaultClientSideAvailability: priorDCSA, Tags: prior.Tags, - Environments: prior.Environments, RequireViewAssociationForNewFlags: prior.RequireViewAssociationForNewFlags, RequireViewAssociationForNewSegments: prior.RequireViewAssociationForNewSegments, } @@ -198,40 +197,16 @@ func (r *ProjectResource) UpgradeState(_ context.Context) map[int64]resource.Sta // inner shape is identical. data.DefaultClientSideAvailability = types.ObjectNull(projectCSAAttrTypes) } - // each environments[].approval_settings whose 1-element matches - // API defaults → null. Decode envs, modify each, re-encode. - if !data.Environments.IsNull() && !data.Environments.IsUnknown() && len(data.Environments.Elements()) > 0 { - var envs []environmentModel - resp.Diagnostics.Append(data.Environments.ElementsAs(ctx, &envs, false)...) - if resp.Diagnostics.HasError() { - return - } - approvalListType := types.ListType{ElemType: types.ObjectType{AttrTypes: frameworkApprovalSettingsObjectAttrTypes}} - mutated := false - for i := range envs { - as := envs[i].ApprovalSettings - if as.IsNull() || as.IsUnknown() || len(as.Elements()) != 1 { - continue - } - var items []approvalSettingsModel - d := as.ElementsAs(ctx, &items, false) - if d.HasError() || len(items) != 1 { - continue - } - if approvalSettingsMatchesAPIDefaults(items[0]) { - envs[i].ApprovalSettings = types.ListNull(approvalListType.ElemType) - mutated = true - } - } - if mutated { - envObjectType := types.ObjectType{AttrTypes: environmentAttrTypes} - newList, d := types.ListValueFrom(ctx, envObjectType, envs) - resp.Diagnostics.Append(d...) - if !resp.Diagnostics.HasError() { - data.Environments = newList - } - } + // v0 stored environments as a positional list with the env key + // nested in each element. v3 keys a map by env key. Convert + // here, dropping each per-env approval_settings that matches the + // API defaults the v2.29 SDKv2 provider persisted verbatim. + envMap, d := environmentsMapFromV0List(ctx, prior.Environments) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return } + data.Environments = envMap resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) }, }, @@ -272,6 +247,25 @@ func (r *ProjectResource) ModifyPlan(ctx context.Context, req resource.ModifyPla return } } + + // Warn when environments is omitted entirely. Because the attribute is + // Optional+Computed, an omitted value records whatever environments exist + // at create time into state without managing them from configuration — + // which is easy to do by accident. `environments = {}` is the explicit way + // to manage no environments. + var config ProjectResourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + if config.Environments.IsNull() { + resp.Diagnostics.AddAttributeWarning( + path.Root(ENVIRONMENTS), + "environments not configured", + "`environments` is not set, so Terraform does not manage this project's environments. LaunchDarkly auto-provisions default environments, which are recorded in state but not managed from configuration. Set `environments = {}` to explicitly manage no environments, or declare the environments you want to manage as a map keyed by environment key.", + ) + } + envs, diags := markEnvSecretsUnknown(ctx, plan.Environments, state.Environments) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -283,43 +277,35 @@ func (r *ProjectResource) ModifyPlan(ctx context.Context, req resource.ModifyPla } // markEnvSecretsUnknown rewrites plan.Environments so api_key / -// mobile_key / client_side_id reflect the right env for each list -// position. The framework's UseStateForUnknown plan modifier on those -// inner attributes is index-based: when the user reorders envs, the -// modifier paints state[i]'s sensitive values onto plan[i] regardless -// of whether plan[i].key actually matches state[i].key. The result is -// post-Apply state pulling fresh API values that don't match the -// index-aligned plan and tripping the framework's plan-vs-apply -// consistency check. +// mobile_key / client_side_id reflect the right env for each key. The +// framework synthesizes "" for the inner Computed sensitive fields once +// the user supplies the required attributes; Apply then replaces "" with +// the real secret, tripping the plan-vs-apply consistency check for new +// envs (see [[feedback-nested-attr-computed-sensitive]]). // -// Fix: match by env key. For each plan env, if state has an env with -// the same key, use that state env's sensitive values; otherwise -// mark them Unknown so Apply can fill them in. -func markEnvSecretsUnknown(ctx context.Context, planList, stateList types.List) (types.List, diag.Diagnostics) { +// Because environments is now a map keyed by env key, matching is by map +// key directly: for each plan env, if state has the same key reuse its +// sensitive values; otherwise mark them Unknown so Apply can fill them in. +func markEnvSecretsUnknown(_ context.Context, planMap, stateMap types.Map) (types.Map, diag.Diagnostics) { var diags diag.Diagnostics - if planList.IsNull() || planList.IsUnknown() { - return planList, diags + if planMap.IsNull() || planMap.IsUnknown() { + return planMap, diags } - objType := types.ObjectType{AttrTypes: environmentAttrTypes} - planEls := planList.Elements() + planEls := planMap.Elements() if len(planEls) == 0 { - return planList, diags + return planMap, diags } type envSecrets struct{ api, mobile, csid attr.Value } stateByKey := make(map[string]envSecrets) - if !stateList.IsNull() && !stateList.IsUnknown() { - for _, el := range stateList.Elements() { + if !stateMap.IsNull() && !stateMap.IsUnknown() { + for key, el := range stateMap.Elements() { obj, ok := el.(basetypes.ObjectValue) if !ok { continue } a := obj.Attributes() - keyVal, _ := a[KEY].(basetypes.StringValue) - if keyVal.IsNull() || keyVal.IsUnknown() { - continue - } - stateByKey[keyVal.ValueString()] = envSecrets{ + stateByKey[key] = envSecrets{ api: a[API_KEY], mobile: a[MOBILE_KEY], csid: a[CLIENT_SIDE_ID], @@ -327,20 +313,15 @@ func markEnvSecretsUnknown(ctx context.Context, planList, stateList types.List) } } - out := make([]attr.Value, 0, len(planEls)) - for _, el := range planEls { + out := make(map[string]attr.Value, len(planEls)) + for key, el := range planEls { obj, ok := el.(basetypes.ObjectValue) if !ok { - out = append(out, el) + out[key] = el continue } attrs := obj.Attributes() - keyVal, _ := attrs[KEY].(basetypes.StringValue) - envKey := "" - if !keyVal.IsNull() && !keyVal.IsUnknown() { - envKey = keyVal.ValueString() - } - if secrets, ok := stateByKey[envKey]; ok { + if secrets, ok := stateByKey[key]; ok { attrs[API_KEY] = secrets.api attrs[MOBILE_KEY] = secrets.mobile attrs[CLIENT_SIDE_ID] = secrets.csid @@ -351,11 +332,11 @@ func markEnvSecretsUnknown(ctx context.Context, planList, stateList types.List) } newObj, d := types.ObjectValue(environmentAttrTypes, attrs) diags.Append(d...) - out = append(out, newObj) + out[key] = newObj } - newList, d := types.ListValue(objType, out) + newMap, d := types.MapValue(environmentObjectType, out) diags.Append(d...) - return newList, diags + return newMap, diags } // projectCSAValueFromAPI emits the CSA attribute matching the prior @@ -537,21 +518,24 @@ func (r *ProjectResource) applyProjectUpdates(ctx context.Context, projectKey st } } - // Environment reconciliation - planEnvs, d := environmentModelsFromList(ctx, plan.Environments) + // Environment reconciliation. environments is a map keyed by env key, + // so identity is the map key and only the keys present in config are + // managed; keys absent from config (e.g. UI-created envs) are left + // untouched. + planEnvs, d := environmentModelsFromMap(ctx, plan.Environments) diags.Append(d...) if diags.HasError() { return diags } stateEnvs := map[string]environmentModel{} if !isCreate { - stateEnvList, d := environmentModelsFromList(ctx, state.Environments) + stateEnvs, d = environmentModelsFromMap(ctx, state.Environments) diags.Append(d...) if diags.HasError() { return diags } - for _, e := range stateEnvList { - stateEnvs[e.Key.ValueString()] = e + if stateEnvs == nil { + stateEnvs = map[string]environmentModel{} } } @@ -562,11 +546,11 @@ func (r *ProjectResource) applyProjectUpdates(ctx context.Context, projectKey st } desired := map[string]bool{} - for _, env := range planEnvs { - envKey := env.Key.ValueString() + for _, envKey := range sortedEnvKeys(planEnvs) { + env := planEnvs[envKey] desired[envKey] = true if !environmentExistsInProject(*project, envKey) { - envPost, d := environmentPostFromModel(ctx, env) + envPost, d := environmentPostFromModel(ctx, envKey, env) diags.Append(d...) if diags.HasError() { return diags @@ -635,14 +619,15 @@ func (r *ProjectResource) readIntoModel(ctx context.Context, projectKey string, diags.Append(d...) data.DefaultClientSideAvailability = csaObj - // Environments — preserve config order, then append unmanaged ones. + // Environments — refresh the managed keys; on import/first read + // (null prior) surface all environments so import captures the project. envItems := []ldapi.Environment{} if project.Environments != nil { envItems = project.Environments.Items } - envList, d := environmentsListFromAPI(ctx, envItems, data.Environments) + envMap, d := environmentsMapFromAPI(ctx, envItems, data.Environments) diags.Append(d...) - data.Environments = envList + data.Environments = envMap // View association settings settings, err := getProjectViewSettings(ctx, r.client, projectKey) diff --git a/launchdarkly/resource_project_upgrade.go b/launchdarkly/resource_project_upgrade.go index 91de43b7..08b66947 100644 --- a/launchdarkly/resource_project_upgrade.go +++ b/launchdarkly/resource_project_upgrade.go @@ -25,6 +25,27 @@ type ProjectResourceModelV0 struct { RequireViewAssociationForNewSegments types.Bool `tfsdk:"require_view_association_for_new_segments"` } +// environmentModelV0 is the v0 (SDKv2 / pre-REL-14236) environment element +// shape: a positional list element that carried the env key inline. The +// v0->v1 upgrader decodes this and re-keys a map by `Key` (see +// environmentsMapFromV0List). +type environmentModelV0 struct { + Key types.String `tfsdk:"key"` + Name types.String `tfsdk:"name"` + Color types.String `tfsdk:"color"` + Critical types.Bool `tfsdk:"critical"` + APIKey types.String `tfsdk:"api_key"` + MobileKey types.String `tfsdk:"mobile_key"` + ClientSideID types.String `tfsdk:"client_side_id"` + DefaultTTL types.Int64 `tfsdk:"default_ttl"` + SecureMode types.Bool `tfsdk:"secure_mode"` + DefaultTrackEvents types.Bool `tfsdk:"default_track_events"` + RequireComments types.Bool `tfsdk:"require_comments"` + ConfirmChanges types.Bool `tfsdk:"confirm_changes"` + Tags types.Set `tfsdk:"tags"` + ApprovalSettings types.List `tfsdk:"approval_settings"` +} + func projectSchemaAttributesV0() map[string]schema.Attribute { attrs := projectSchemaAttributes() attrs[INCLUDE_IN_SNIPPET] = schema.BoolAttribute{ @@ -47,5 +68,38 @@ func projectSchemaAttributesV0() map[string]schema.Attribute { }, }, } + // v0 (SDKv2) stored environments as a positional list whose elements + // carried the env key inline. The current (v3) schema models it as a + // map keyed by env key. Pin the prior schema to the original list shape + // so genuine v2.x state still decodes; the upgrader body re-keys via + // environmentsMapFromV0List. + attrs[ENVIRONMENTS] = projectEnvironmentsAttributeV0() return attrs } + +// projectEnvironmentsAttributeV0 reproduces the pre-REL-14236 (v0) +// environments list shape — a list of objects each holding its own `key` +// — used only as PriorSchema for the v0->v1 state upgrader. +func projectEnvironmentsAttributeV0() schema.ListNestedAttribute { + return schema.ListNestedAttribute{ + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + KEY: schema.StringAttribute{Required: true}, + NAME: schema.StringAttribute{Required: true}, + COLOR: schema.StringAttribute{Required: true}, + CRITICAL: schema.BoolAttribute{Optional: true, Computed: true}, + API_KEY: schema.StringAttribute{Computed: true, Sensitive: true}, + MOBILE_KEY: schema.StringAttribute{Computed: true, Sensitive: true}, + CLIENT_SIDE_ID: schema.StringAttribute{Computed: true, Sensitive: true}, + DEFAULT_TTL: schema.Int64Attribute{Optional: true, Computed: true}, + SECURE_MODE: schema.BoolAttribute{Optional: true, Computed: true}, + DEFAULT_TRACK_EVENTS: schema.BoolAttribute{Optional: true, Computed: true}, + REQUIRE_COMMENTS: schema.BoolAttribute{Optional: true, Computed: true}, + CONFIRM_CHANGES: schema.BoolAttribute{Optional: true, Computed: true}, + TAGS: schema.SetAttribute{Optional: true, ElementType: types.StringType}, + APPROVAL_SETTINGS: frameworkApprovalSettingsResourceAttribute(), + }, + }, + } +} diff --git a/launchdarkly/resource_view_links_framework.go b/launchdarkly/resource_view_links_framework.go index 4021f065..a85a86c2 100644 --- a/launchdarkly/resource_view_links_framework.go +++ b/launchdarkly/resource_view_links_framework.go @@ -474,7 +474,7 @@ func (r *ViewFilterLinksResource) Schema(_ context.Context, _ resource.SchemaReq }, SEGMENT_FILTER_ENVIRONMENT_ID: schema.StringAttribute{ Optional: true, - Description: "The environment ID to use when resolving segment filters. Required when `segment_filter` is set. This is the environment's opaque ID (e.g. from `launchdarkly_project.environments[*].client_side_id`).", + Description: "The environment ID to use when resolving segment filters. Required when `segment_filter` is set. This is the environment's opaque ID (e.g. from `launchdarkly_project.environments[\"\"].client_side_id`).", Validators: []validator.String{idValidator()}, }, RECONCILE_ON_APPLY: schema.BoolAttribute{ diff --git a/scripts/migrate-tf-syntax/README.md b/scripts/migrate-tf-syntax/README.md index 1398e13f..eac186b9 100644 --- a/scripts/migrate-tf-syntax/README.md +++ b/scripts/migrate-tf-syntax/README.md @@ -31,7 +31,7 @@ go build -o ../../bin/migrate-tf-syntax . `mappings.json` (embedded as default) maps resource type → object containing three optional sections: -- `blocks` — attributes that switched from block to nested attribute. Nested entries describe attributes inside an element that themselves migrated (e.g. `rules` contains `clauses`). Set `"object": true` for a genuine single-object attribute (`SingleNestedAttribute`): forward emits `name = { ... }` (no brackets) and reverse parses that object back to a block. Omit it for the default list-of-objects shape. LD's four single-object attributes are `client_side_availability`, `defaults`, `default_client_side_availability`, and `fallthrough` (REL-14237). +- `blocks` — attributes that switched from block to nested attribute. Nested entries describe attributes inside an element that themselves migrated (e.g. `rules` contains `clauses`). Set `"object": true` for a genuine single-object attribute (`SingleNestedAttribute`): forward emits `name = { ... }` (no brackets) and reverse parses that object back to a block. Omit it for the default list-of-objects shape. LD's four single-object attributes are `client_side_availability`, `defaults`, `default_client_side_availability`, and `fallthrough` (REL-14237). Set `"map_key": ""` for a `MapNestedAttribute` keyed by one inner field (`launchdarkly_project.environments`, keyed by `key`, REL-14236): forward emits `name = { = { ...rest } }`, hoisting that field to the map key and dropping it from the object; reverse expands the map back to repeated blocks, re-injecting ` = `. Only literal-string keys convert automatically — a non-literal key (a `var`/`local`/function call) warns and is left for the author. `object` and `map_key` are mutually exclusive. - `deprecations` — attributes removed from the v3 schema. Each entry has `name`, `action`, and (for some actions) `to`. Supported actions: - `drop` — remove the attribute outright (no replacement). - `rename` — move the value verbatim onto `to` (e.g. `policy_statements` → `inline_roles`). If `to` already exists in the config, the existing value wins and the deprecated attribute is dropped. @@ -79,7 +79,7 @@ grep -nE 'tfsdk:"[^"]+"' launchdarkly/resource_*_framework.go \ | grep -E 'types\.(List|Set|Object)\b' ``` -A `types.List` / `types.Set` paired with a `ListNestedAttribute` / `SetNestedAttribute` means list-of-objects; add it to `mappings.json` without `object`. A `types.Object` paired with a `SingleNestedAttribute` means single object; add it with `"object": true`. +A `types.List` / `types.Set` paired with a `ListNestedAttribute` / `SetNestedAttribute` means list-of-objects; add it to `mappings.json` without `object`. A `types.Object` paired with a `SingleNestedAttribute` means single object; add it with `"object": true`. A `types.Map` paired with a `MapNestedAttribute` means a key-addressed map; add it with `"map_key": ""`. ## Round-trip test diff --git a/scripts/migrate-tf-syntax/main.go b/scripts/migrate-tf-syntax/main.go index 397a26b2..9fddbf3a 100644 --- a/scripts/migrate-tf-syntax/main.go +++ b/scripts/migrate-tf-syntax/main.go @@ -35,10 +35,16 @@ var defaultMappings []byte // Object marks a genuine single-object attribute (provider v3.x SingleNestedAttribute): forward emits // `name = { ... }` and reverse parses that object back to a block, instead of the list-of-objects // `name = [{ ... }]` shape used by List/SetNestedAttribute. See REL-14237. +// MapKey names an inner attribute hoisted to the map key for a MapNestedAttribute (provider v3.x): +// forward emits `name = { = { ...rest } }` keyed by each block's MapKey attribute (which is +// dropped from the inner object), and reverse expands the map back to repeated blocks, re-injecting +// `MapKey = `. Only literal-string keys are hoisted automatically; non-literal keys warn+skip. +// Mutually exclusive with Object. See REL-14236 (launchdarkly_project environments). type AttrSpec struct { Name string `json:"name"` Nested []*AttrSpec `json:"nested,omitempty"` Object bool `json:"object,omitempty"` + MapKey string `json:"map_key,omitempty"` } // DeprecationSpec describes an attribute that was removed from the provider schema between v2 and v3. @@ -339,6 +345,46 @@ func forward(body *hclwrite.Body, specs []*AttrSpec, where string) bool { } tokens = append(tokens, trimLeadingNewlines(matched[0].Body().BuildTokens(nil))...) tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenCBrace, Bytes: []byte("}")}) + } else if s.MapKey != "" { + // Map attribute keyed by an inner field (v3 MapNestedAttribute): + // emit `name = { = { ...rest } }`, hoisting each block's + // MapKey attribute to the map key and dropping it from the object. + mapTokens := hclwrite.Tokens{ + {Type: hclsyntax.TokenOBrace, Bytes: []byte("{")}, + {Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}, + } + skip := false + for _, b := range matched { + keyAttr := b.Body().GetAttribute(s.MapKey) + if keyAttr == nil { + warnf("%s: %q block is missing the %q attribute needed to key the v3 map; convert by hand", where, s.Name, s.MapKey) + skip = true + break + } + keyExpr, ok := stringLiteralValue(keyAttr.Expr().BuildTokens(nil)) + if !ok { + warnf("%s: %q block's %q is not a literal string; cannot hoist it to a map key automatically — convert by hand", where, s.Name, s.MapKey) + skip = true + break + } + b.Body().RemoveAttribute(s.MapKey) + mapTokens = append(mapTokens, keyExpr...) + mapTokens = append(mapTokens, + &hclwrite.Token{Type: hclsyntax.TokenEqual, Bytes: []byte(" = ")}, + &hclwrite.Token{Type: hclsyntax.TokenOBrace, Bytes: []byte("{")}, + &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}, + ) + mapTokens = append(mapTokens, trimLeadingNewlines(b.Body().BuildTokens(nil))...) + mapTokens = append(mapTokens, + &hclwrite.Token{Type: hclsyntax.TokenCBrace, Bytes: []byte("}")}, + &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}, + ) + } + if skip { + continue + } + mapTokens = append(mapTokens, &hclwrite.Token{Type: hclsyntax.TokenCBrace, Bytes: []byte("}")}) + tokens = mapTokens } else { tokens = hclwrite.Tokens{ {Type: hclsyntax.TokenOBrack, Bytes: []byte("[")}, @@ -386,6 +432,37 @@ func reverse(body *hclwrite.Body, specs []*AttrSpec) bool { changed = true continue } + if s.MapKey != "" { + // v3 map `name = { = { ... } }` → repeated v2 blocks + // `name { MapKey = ... }`, re-injecting the hoisted key. + entries := extractMapEntries(attr.Expr().BuildTokens(nil)) + if len(entries) == 0 { + continue + } + body.RemoveAttribute(s.Name) + for _, e := range entries { + bodyTokens := e.body + if len(s.Nested) > 0 { + wrapped := []byte(fmt.Sprintf("dummy {\n%s\n}\n", tokensString(ensureTrailingNewline(trimLeadingNewlines(e.body))))) + tmp, diag := hclwrite.ParseConfig(wrapped, "", hcl.Pos{Line: 1, Column: 1}) + if !diag.HasErrors() && len(tmp.Body().Blocks()) > 0 { + reverse(tmp.Body().Blocks()[0].Body(), s.Nested) + bodyTokens = tmp.Body().Blocks()[0].Body().BuildTokens(nil) + } + } + keyLine := hclwrite.Tokens{ + {Type: hclsyntax.TokenIdent, Bytes: []byte(s.MapKey)}, + {Type: hclsyntax.TokenEqual, Bytes: []byte(" = ")}, + } + keyLine = append(keyLine, e.key...) + keyLine = append(keyLine, &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}) + combined := append(keyLine, trimLeadingNewlines(bodyTokens)...) + newBlock := body.AppendNewBlock(s.Name, nil) + newBlock.Body().AppendUnstructuredTokens(ensureTrailingNewline(combined)) + changed = true + } + continue + } elems := extractTupleElements(attr.Expr().BuildTokens(nil)) if len(elems) == 0 { continue @@ -677,6 +754,101 @@ func extractTupleElements(tokens hclwrite.Tokens) []hclwrite.Tokens { return elems } +// stringLiteralValue reports whether tokens form a single quoted-string literal +// (`"..."`) and, if so, returns the clean quote/lit/quote token slice (with any +// leading indentation stripped) suitable for use as a v3 map key. Non-literal +// expressions (idents, function calls, interpolations) return false. +func stringLiteralValue(tokens hclwrite.Tokens) (hclwrite.Tokens, bool) { + var nonWs hclwrite.Tokens + for _, t := range tokens { + if t.Type == hclsyntax.TokenNewline { + continue + } + nonWs = append(nonWs, t) + } + if len(nonWs) == 3 && + nonWs[0].Type == hclsyntax.TokenOQuote && + nonWs[1].Type == hclsyntax.TokenQuotedLit && + nonWs[2].Type == hclsyntax.TokenCQuote { + clean := hclwrite.Tokens{ + {Type: hclsyntax.TokenOQuote, Bytes: nonWs[0].Bytes}, + {Type: hclsyntax.TokenQuotedLit, Bytes: nonWs[1].Bytes}, + {Type: hclsyntax.TokenCQuote, Bytes: nonWs[2].Bytes}, + } + return clean, true + } + return nil, false +} + +// mapEntry is one ` = { ... }` pair of a v3 map attribute. +type mapEntry struct { + key hclwrite.Tokens + body hclwrite.Tokens +} + +// extractMapEntries walks token stream `{ = {...}, = {...}, ... }` +// and returns each entry's key tokens and the inner body tokens of its `{...}` +// value (excluding the value's surrounding braces). Used by the reverse +// direction for map-nested attributes (MapKey specs). +func extractMapEntries(tokens hclwrite.Tokens) []mapEntry { + i := 0 + for ; i < len(tokens); i++ { + if tokens[i].Type == hclsyntax.TokenOBrace { + i++ + break + } + } + var entries []mapEntry + for i < len(tokens) { + for i < len(tokens) && (tokens[i].Type == hclsyntax.TokenNewline || tokens[i].Type == hclsyntax.TokenComma) { + i++ + } + if i >= len(tokens) || tokens[i].Type == hclsyntax.TokenCBrace { + break + } + var key hclwrite.Tokens + for i < len(tokens) && tokens[i].Type != hclsyntax.TokenEqual { + if tokens[i].Type == hclsyntax.TokenOBrace || tokens[i].Type == hclsyntax.TokenCBrace { + break + } + if tokens[i].Type != hclsyntax.TokenNewline { + key = append(key, &hclwrite.Token{Type: tokens[i].Type, Bytes: tokens[i].Bytes}) + } + i++ + } + if i >= len(tokens) || tokens[i].Type != hclsyntax.TokenEqual { + break + } + i++ // consume '=' + for i < len(tokens) && tokens[i].Type == hclsyntax.TokenNewline { + i++ + } + if i >= len(tokens) || tokens[i].Type != hclsyntax.TokenOBrace { + break + } + brace := 1 + i++ + var bodyToks hclwrite.Tokens + for i < len(tokens) && brace > 0 { + switch tokens[i].Type { + case hclsyntax.TokenOBrace: + brace++ + case hclsyntax.TokenCBrace: + brace-- + if brace == 0 { + i++ + goto done + } + } + bodyToks = append(bodyToks, tokens[i]) + i++ + } + done: + entries = append(entries, mapEntry{key: key, body: bodyToks}) + } + return entries +} + // extractObjectBody returns the inner tokens of a single object expression `{ ... }` (excluding the // outer braces). Used by the reverse direction for single-object (SingleNestedAttribute) attributes, // which serialize as `name = { ... }` rather than the `name = [{ ... }]` tuple shape. diff --git a/scripts/migrate-tf-syntax/main_test.go b/scripts/migrate-tf-syntax/main_test.go index 66bbaf22..bcf92edf 100644 --- a/scripts/migrate-tf-syntax/main_test.go +++ b/scripts/migrate-tf-syntax/main_test.go @@ -201,6 +201,121 @@ func TestReverseObjectBlock(t *testing.T) { } } +func TestForwardConvertsMapBlock(t *testing.T) { + src := `resource "launchdarkly_project" "p" { + environments { + key = "production" + name = "Production" + color = "417505" + approval_settings { + required = true + min_num_approvals = 2 + } + } + environments { + key = "test" + name = "Test" + color = "f5a623" + } +} +` + f, body := parseBody(t, src) + spec := []*AttrSpec{{Name: "environments", MapKey: "key", Nested: []*AttrSpec{{Name: "approval_settings"}}}} + if !forward(body, spec, "test.tf") { + t.Fatal("expected conversion") + } + out := string(hclwrite.Format(f.Bytes())) + for _, want := range []string{ + "environments = {", + `"production" = {`, + `"test" = {`, + "approval_settings = [{", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in:\n%s", want, out) + } + } + // the hoisted key attribute must not survive inside the object. + if regexp.MustCompile(`(?m)^\s*key\s*=`).MatchString(out) { + t.Errorf("inner key attribute must be hoisted to the map key, got:\n%s", out) + } + if strings.Contains(out, "environments = [{") { + t.Errorf("map attribute must not be a list:\n%s", out) + } + if _, diag := hclwrite.ParseConfig([]byte(out), "out.tf", hcl.Pos{Line: 1, Column: 1}); diag.HasErrors() { + t.Errorf("converted output does not parse: %s", diag) + } +} + +func TestReverseMapBlock(t *testing.T) { + src := `resource "launchdarkly_project" "p" { + environments = { + "production" = { + name = "Production" + color = "417505" + approval_settings = [{ + required = true + min_num_approvals = 2 + }] + } + "test" = { + name = "Test" + color = "f5a623" + } + } +} +` + f, body := parseBody(t, src) + spec := []*AttrSpec{{Name: "environments", MapKey: "key", Nested: []*AttrSpec{{Name: "approval_settings"}}}} + if !reverse(body, spec) { + t.Fatal("expected reverse conversion") + } + out := string(hclwrite.Format(f.Bytes())) + // two environments blocks, each with its key re-injected. + if n := strings.Count(out, "environments {"); n != 2 { + t.Errorf("expected 2 environments blocks, got %d:\n%s", n, out) + } + for _, want := range []string{ + `key = "production"`, + `key = "test"`, + "approval_settings {", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, "environments = {") || strings.Contains(out, "environments = [") { + t.Errorf("reverse must drop the map-assignment form:\n%s", out) + } + if _, diag := hclwrite.ParseConfig([]byte(out), "out.tf", hcl.Pos{Line: 1, Column: 1}); diag.HasErrors() { + t.Errorf("reversed output does not parse: %s", diag) + } +} + +func TestForwardMapSkipsNonLiteralKey(t *testing.T) { + src := `resource "launchdarkly_project" "p" { + environments { + key = local.env_key + name = "X" + color = "000000" + } +} +` + warningsBefore := warningCount + f, body := parseBody(t, src) + spec := []*AttrSpec{{Name: "environments", MapKey: "key"}} + if forward(body, spec, "test.tf: resource launchdarkly_project.p") { + t.Error("forward must skip a map whose key is a non-literal expression") + } + if warningCount != warningsBefore+1 { + t.Errorf("warningCount delta = %d, want 1", warningCount-warningsBefore) + } + out := string(f.Bytes()) + if !strings.Contains(out, "environments {") || !strings.Contains(out, "key = local.env_key") { + t.Errorf("body must be left untouched, got:\n%s", out) + } +} + func TestEnsureBooleanVariations(t *testing.T) { rule := []*DeprecationSpec{{Name: "variations", Action: "ensure_boolean_variations"}} diff --git a/scripts/migrate-tf-syntax/mappings.json b/scripts/migrate-tf-syntax/mappings.json index 8ff3e649..45d38f1b 100644 --- a/scripts/migrate-tf-syntax/mappings.json +++ b/scripts/migrate-tf-syntax/mappings.json @@ -30,7 +30,7 @@ "launchdarkly_project": { "blocks": [ {"name": "default_client_side_availability", "object": true}, - {"name": "environments", "nested": [{"name": "approval_settings"}]} + {"name": "environments", "map_key": "key", "nested": [{"name": "approval_settings"}]} ], "deprecations": [ {"name": "include_in_snippet", "action": "iis_to_csa", "to": "default_client_side_availability", "using_mobile_key": "true"} diff --git a/templates/guides/migrating-to-v3.md.tmpl b/templates/guides/migrating-to-v3.md.tmpl index 8e34c969..2dedd068 100644 --- a/templates/guides/migrating-to-v3.md.tmpl +++ b/templates/guides/migrating-to-v3.md.tmpl @@ -41,6 +41,22 @@ client_side_availability { client_side_availability = { When you read one of these from a data source, use object access without a list index: `data.launchdarkly_feature_flag.x.client_side_availability.using_environment_id`. +`launchdarkly_project.environments` becomes a **map keyed by the environment `key`** rather than an ordered list. Each environment's `key` moves out of the object and becomes the map key, so reordering, adding, or removing one environment no longer shifts the others or forces a destructive plan. The `migrate-tf-syntax` tool performs this rewrite for you: + +```hcl +# v2 block syntax # v3 map syntax (keyed by env key) +environments { environments = { + key = "production" "production" = { + name = "Production" name = "Production" + color = "EEEEEE" color = "EEEEEE" +} } + } +``` + +Reference an environment by its key instead of by index. A v2 interpolation such as `launchdarkly_project.example.environments[0].client_side_id` becomes `launchdarkly_project.example.environments["production"].client_side_id`. The tool rewrites positional references whose key it can resolve statically and warns on any it cannot. + +Because environments are now keyed, you can manage a subset and leave the rest to the LaunchDarkly UI: only the environments present in your `environments` map are managed, and environments you never declared are not deleted. Set `environments = {}` (or omit the attribute) to create a project without managing any of its environments. + ## Prerequisites You need the following things to complete this migration: @@ -82,7 +98,7 @@ The provider includes a state upgrader for every resource that lost an attribute - `launchdarkly_access_token`: moves `policy_statements` into `inline_roles`, and discards `expire`. - `launchdarkly_custom_role`: converts `policy` into `policy_statements`. - `launchdarkly_feature_flag`: converts `include_in_snippet` into `client_side_availability`. -- `launchdarkly_project`: converts `include_in_snippet` into `default_client_side_availability`. +- `launchdarkly_project`: converts `include_in_snippet` into `default_client_side_availability`, and re-keys the ordered `environments` list into a map keyed by environment key. - `launchdarkly_metric`: discards `is_active`. ## Your first plan after upgrading diff --git a/templates/resources/project.md.tmpl b/templates/resources/project.md.tmpl index 072f6e52..ebb17208 100644 --- a/templates/resources/project.md.tmpl +++ b/templates/resources/project.md.tmpl @@ -21,7 +21,7 @@ Import is supported using the following syntax: {{ codefile "sh" .ImportFile | trimspace }} -**IMPORTANT:** Please note that, regardless of how many `environments` blocks you include on your import, _all_ of the project's environments will be saved to the Terraform state and will update with subsequent applies. This means that any environments not included in your import configuration will be torn down with any subsequent apply. If you wish to manage project properties with Terraform but not nested environments consider using Terraform's [ignore changes](https://www.terraform.io/docs/language/meta-arguments/lifecycle.html#ignore_changes) lifecycle meta-argument; see below for example. +**IMPORTANT:** On import, _all_ of the project's environments are saved to the Terraform state, keyed by their environment `key`. The `environments` you declare in your configuration are managed; on a subsequent apply, any environment that is in state but absent from your configuration is deleted. To manage only some environments and leave the rest to the LaunchDarkly UI, declare just those environments in the map (or set `environments = {}` to manage none), or use Terraform's [ignore changes](https://www.terraform.io/docs/language/meta-arguments/lifecycle.html#ignore_changes) lifecycle meta-argument; see below for example. ```terraform resource "launchdarkly_project" "example" { @@ -29,11 +29,11 @@ resource "launchdarkly_project" "example" { ignore_changes = [environments] } name = "testProject" - key = "%s" - # environments not included on this configuration will not be affected by subsequent applies + key = "example-project" + # environments not included in this configuration will not be affected by subsequent applies } ``` -**Note:** Following an import, the first apply may show a diff in the order of your environments as Terraform realigns its state with the order of configurations in your project configuration. This will not change your environments or their SDK keys. +Because `environments` is a map keyed by environment `key`, reordering, adding, or removing one environment never forces changes to the others, and import is order-independent. **Managing environment resources with Terraform should always be done on the project unless the project is not also managed with Terraform.** From fd76fb25a4d6f599839f3aec61c4b1a8893d5296 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 30 Jun 2026 11:51:55 +0100 Subject: [PATCH 2/7] fix: address Cursor Bugbot findings on env-map migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resource_project: skip environment reconciliation entirely when plan.Environments is null or unknown (Optional+Computed omitted/ not-yet-computed). Previously a null/unknown plan yielded zero desired keys and the reconcile loop deleted every managed environment — not the same as an explicit `environments = {}`. An explicit empty map is concrete (not null), so it still deletes managed envs as intended. - migrate-tf-syntax: validate every block's map key before mutating any block in the forward map_key path. A missing/non-literal key on a later block previously aborted with earlier blocks already stripped of their key, leaving invalid v2 syntax; now the file is left untouched on skip. Adds a multi-block regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- launchdarkly/resource_project_framework.go | 11 ++++++ scripts/migrate-tf-syntax/main.go | 25 +++++++++----- scripts/migrate-tf-syntax/main_test.go | 39 ++++++++++++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/launchdarkly/resource_project_framework.go b/launchdarkly/resource_project_framework.go index 4b41f71f..dc79703a 100644 --- a/launchdarkly/resource_project_framework.go +++ b/launchdarkly/resource_project_framework.go @@ -522,6 +522,17 @@ func (r *ProjectResource) applyProjectUpdates(ctx context.Context, projectKey st // so identity is the map key and only the keys present in config are // managed; keys absent from config (e.g. UI-created envs) are left // untouched. + // + // A null or unknown plan value means environments is not determined by + // configuration (the attribute is Optional+Computed and was omitted, or + // the value is still being computed) — NOT "remove every environment". + // Skip all reconciliation so we never create, patch, or delete based on a + // non-concrete plan. An explicit empty map `{}` is concrete (not null), so + // it still falls through and deletes managed environments as intended. + if plan.Environments.IsNull() || plan.Environments.IsUnknown() { + return diags + } + planEnvs, d := environmentModelsFromMap(ctx, plan.Environments) diags.Append(d...) if diags.HasError() { diff --git a/scripts/migrate-tf-syntax/main.go b/scripts/migrate-tf-syntax/main.go index 9fddbf3a..5a426f80 100644 --- a/scripts/migrate-tf-syntax/main.go +++ b/scripts/migrate-tf-syntax/main.go @@ -349,10 +349,12 @@ func forward(body *hclwrite.Body, specs []*AttrSpec, where string) bool { // Map attribute keyed by an inner field (v3 MapNestedAttribute): // emit `name = { = { ...rest } }`, hoisting each block's // MapKey attribute to the map key and dropping it from the object. - mapTokens := hclwrite.Tokens{ - {Type: hclsyntax.TokenOBrace, Bytes: []byte("{")}, - {Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}, - } + // + // Validate EVERY block's key before mutating any block. A missing + // or non-literal key on any block aborts the whole attribute with + // the file untouched — otherwise earlier blocks would already be + // stripped of their key, leaving invalid v2 syntax. + keyExprs := make([]hclwrite.Tokens, 0, len(matched)) skip := false for _, b := range matched { keyAttr := b.Body().GetAttribute(s.MapKey) @@ -367,8 +369,18 @@ func forward(body *hclwrite.Body, specs []*AttrSpec, where string) bool { skip = true break } + keyExprs = append(keyExprs, keyExpr) + } + if skip { + continue + } + mapTokens := hclwrite.Tokens{ + {Type: hclsyntax.TokenOBrace, Bytes: []byte("{")}, + {Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}, + } + for i, b := range matched { b.Body().RemoveAttribute(s.MapKey) - mapTokens = append(mapTokens, keyExpr...) + mapTokens = append(mapTokens, keyExprs[i]...) mapTokens = append(mapTokens, &hclwrite.Token{Type: hclsyntax.TokenEqual, Bytes: []byte(" = ")}, &hclwrite.Token{Type: hclsyntax.TokenOBrace, Bytes: []byte("{")}, @@ -380,9 +392,6 @@ func forward(body *hclwrite.Body, specs []*AttrSpec, where string) bool { &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}, ) } - if skip { - continue - } mapTokens = append(mapTokens, &hclwrite.Token{Type: hclsyntax.TokenCBrace, Bytes: []byte("}")}) tokens = mapTokens } else { diff --git a/scripts/migrate-tf-syntax/main_test.go b/scripts/migrate-tf-syntax/main_test.go index bcf92edf..841a5901 100644 --- a/scripts/migrate-tf-syntax/main_test.go +++ b/scripts/migrate-tf-syntax/main_test.go @@ -316,6 +316,45 @@ func TestForwardMapSkipsNonLiteralKey(t *testing.T) { } } +func TestForwardMapPartialSkipLeavesFileUntouched(t *testing.T) { + // A literal-key block followed by a non-literal-key block must abort the + // whole attribute with the file untouched — the first block must NOT lose + // its key (regression for partial-mutation on skip). + src := `resource "launchdarkly_project" "p" { + environments { + key = "production" + name = "Production" + color = "000000" + } + environments { + key = local.staging_key + name = "Staging" + color = "111111" + } +} +` + warningsBefore := warningCount + f, body := parseBody(t, src) + spec := []*AttrSpec{{Name: "environments", MapKey: "key"}} + if forward(body, spec, "test.tf: resource launchdarkly_project.p") { + t.Error("forward must skip when any block has a non-literal map key") + } + if warningCount != warningsBefore+1 { + t.Errorf("warningCount delta = %d, want 1", warningCount-warningsBefore) + } + out := string(f.Bytes()) + if strings.Contains(out, "environments = {") { + t.Errorf("must not emit a partial map:\n%s", out) + } + if strings.Count(out, "environments {") != 2 { + t.Errorf("both environments blocks must be preserved, got:\n%s", out) + } + // the literal block must keep its key (not stripped before the abort). + if !strings.Contains(out, `key = "production"`) { + t.Errorf("first block lost its key on skip:\n%s", out) + } +} + func TestEnsureBooleanVariations(t *testing.T) { rule := []*DeprecationSpec{{Name: "variations", Action: "ensure_boolean_variations"}} From a446e4cb7bee34102b53129f5386b795f9ff5d5a Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 30 Jun 2026 12:01:05 +0100 Subject: [PATCH 3/7] fix: address second Cursor Bugbot pass on env-map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resource_project: distinguish unknown from null prior in environmentsMapFromAPI. A create that omits environments now manages NONE (returns the empty map) instead of materializing the auto-provisioned environments into state. Previously omit -> append-all stored every auto-provisioned env, so adding a partial environments map later deleted the undeclared ones — data loss the omit warning promised would not happen. Null prior (import) still surfaces all environments. Adds TestAccProject_OmitThenSubset. Warning + schema description updated to match (omit == manage none, same as `{}`). - docs/migration guide: correct the claim that migrate-tf-syntax rewrites positional `environments[N]` references. The tool converts the block to a map but does not touch resource index expressions; that is now listed as a manual follow-up step. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guides/migrating-to-v3.md | 3 +- docs/resources/project.md | 2 +- launchdarkly/environments_framework.go | 28 ++++++--- .../resource_launchdarkly_project_test.go | 60 +++++++++++++++++++ launchdarkly/resource_project_framework.go | 10 ++-- templates/guides/migrating-to-v3.md.tmpl | 3 +- 6 files changed, 88 insertions(+), 18 deletions(-) diff --git a/docs/guides/migrating-to-v3.md b/docs/guides/migrating-to-v3.md index 2dedd068..1d6f6284 100644 --- a/docs/guides/migrating-to-v3.md +++ b/docs/guides/migrating-to-v3.md @@ -53,7 +53,7 @@ environments { environments = { } ``` -Reference an environment by its key instead of by index. A v2 interpolation such as `launchdarkly_project.example.environments[0].client_side_id` becomes `launchdarkly_project.example.environments["production"].client_side_id`. The tool rewrites positional references whose key it can resolve statically and warns on any it cannot. +Reference an environment by its key instead of by index. A v2 interpolation such as `launchdarkly_project.example.environments[0].client_side_id` becomes `launchdarkly_project.example.environments["production"].client_side_id`. The `migrate-tf-syntax` tool converts the `environments` block to a map but does **not** rewrite these positional index references elsewhere in your configuration — update them by hand (see "Finish the migration by hand" below). Because environments are now keyed, you can manage a subset and leave the rest to the LaunchDarkly UI: only the environments present in your `environments` map are managed, and environments you never declared are not deleted. Set `environments = {}` (or omit the attribute) to create a project without managing any of its environments. @@ -90,6 +90,7 @@ The tool converts syntax only. Complete these follow-ups yourself: - Add `variations` by hand only for a flag whose `variation_type` is a non-literal expression, such as a variable or local. The tool cannot resolve those statically, so it warns and skips them. Boolean flags with a literal `variation_type` are handled automatically, and the provider preserves any variation `name` or `description` set outside Terraform when your configuration omits them. - Rewrite `dynamic` blocks. A `dynamic "variations"` block needs a for expression, for example `variations = [for v in var.values : { value = v }]`. The tool warns with the file and resource address, and it leaves the attribute unchanged. - Upgrade modules sourced from a registry or a git URL. The tool rewrites only files it reaches on disk, so upgrade those modules at their source. +- Rewrite positional references to `launchdarkly_project` environments. The tool converts the `environments` block to a map but does not touch index expressions elsewhere in your config — change `launchdarkly_project.x.environments[0].client_side_id` to `launchdarkly_project.x.environments[""].client_side_id` by hand. ## How v3 upgrades your state diff --git a/docs/resources/project.md b/docs/resources/project.md index 86433f2d..2199d478 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -61,7 +61,7 @@ resource "launchdarkly_project" "example" { ### Optional - `default_client_side_availability` (Attributes) Which client-side SDKs can use new flags by default. (see [below for nested schema](#nestedatt--default_client_side_availability)) -- `environments` (Attributes Map) Map of environments that belong to the project, keyed by environment `key`. When managing LaunchDarkly projects in Terraform, you should always manage your environments as nested project resources. Environments not present in the map are left unmanaged (terraform will not modify or delete them), so you can manage a subset and leave the rest to the LaunchDarkly UI. Set this to `{}` to create a project while managing none of its environments. Omitting the attribute entirely is discouraged: the provider records the environments LaunchDarkly auto-provisions into state but does not manage them, which is easy to do by accident — prefer `{}` or an explicit map. +- `environments` (Attributes Map) Map of environments that belong to the project, keyed by environment `key`. When managing LaunchDarkly projects in Terraform, you should always manage your environments as nested project resources. Environments not present in the map are left unmanaged (terraform will not modify or delete them), so you can manage a subset and leave the rest to the LaunchDarkly UI. Set this to `{}` to create a project while managing none of its environments. Omitting the attribute entirely also manages no environments (the same effect as `{}`) but emits a plan-time warning — prefer `{}` to be explicit. -> **Note:** Mixing the use of nested `environments` and [`launchdarkly_environment`](/docs/providers/launchdarkly/r/environment.html) resources is not recommended. `launchdarkly_environment` resources should only be used when the encapsulating project is not managed in Terraform. (see [below for nested schema](#nestedatt--environments)) - `require_view_association_for_new_flags` (Boolean) Whether new flags created in this project must be associated with at least one view. diff --git a/launchdarkly/environments_framework.go b/launchdarkly/environments_framework.go index 3e4256ae..b495f4f4 100644 --- a/launchdarkly/environments_framework.go +++ b/launchdarkly/environments_framework.go @@ -81,7 +81,7 @@ func projectEnvironmentsAttribute() schema.MapNestedAttribute { return schema.MapNestedAttribute{ Optional: true, Computed: true, - Description: "Map of environments that belong to the project, keyed by environment `key`. When managing LaunchDarkly projects in Terraform, you should always manage your environments as nested project resources. Environments not present in the map are left unmanaged (terraform will not modify or delete them), so you can manage a subset and leave the rest to the LaunchDarkly UI. Set this to `{}` to create a project while managing none of its environments. Omitting the attribute entirely is discouraged: the provider records the environments LaunchDarkly auto-provisions into state but does not manage them, which is easy to do by accident — prefer `{}` or an explicit map.\n\n-> **Note:** Mixing the use of nested `environments` and [`launchdarkly_environment`](/docs/providers/launchdarkly/r/environment.html) resources is not recommended. `launchdarkly_environment` resources should only be used when the encapsulating project is not managed in Terraform.", + Description: "Map of environments that belong to the project, keyed by environment `key`. When managing LaunchDarkly projects in Terraform, you should always manage your environments as nested project resources. Environments not present in the map are left unmanaged (terraform will not modify or delete them), so you can manage a subset and leave the rest to the LaunchDarkly UI. Set this to `{}` to create a project while managing none of its environments. Omitting the attribute entirely also manages no environments (the same effect as `{}`) but emits a plan-time warning — prefer `{}` to be explicit.\n\n-> **Note:** Mixing the use of nested `environments` and [`launchdarkly_environment`](/docs/providers/launchdarkly/r/environment.html) resources is not recommended. `launchdarkly_environment` resources should only be used when the encapsulating project is not managed in Terraform.", NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ NAME: schema.StringAttribute{ @@ -253,13 +253,18 @@ func planOrNullList(hadOld bool, l types.List) types.List { // environmentsMapFromAPI flattens the LD environments slice into a // framework MapValue keyed by environment key. // -// Append rule: when `prior` is null/unknown (import or the first read -// after a create that omitted environments) every API environment is -// surfaced so import captures the whole project. When `prior` is a -// populated map the result tracks ONLY the keys present in `prior` -// (managed mode) — environments created outside terraform are left -// untracked, which is what lets a user manage a subset and leave the -// rest to the UI. +// The `prior` value's state drives whether unmanaged environments are +// surfaced — and null vs unknown mean different things: +// - unknown: a create that omitted `environments` (Optional+Computed, +// not yet known). Manage NOTHING — return the empty map. Materializing +// the environments LaunchDarkly auto-provisions would make them look +// managed, so a later partial config would delete the ones the user +// never declared. +// - null: import (ImportState set only key+id). Surface EVERY environment +// so import captures the whole project. +// - populated map: managed mode — track ONLY the keys present in `prior`. +// Environments created outside terraform are left untracked, which is +// what lets a user manage a subset and leave the rest to the UI. func environmentsMapFromAPI(ctx context.Context, envs []ldapi.Environment, prior types.Map) (basetypes.MapValue, diag.Diagnostics) { var diags diag.Diagnostics envByKey := make(map[string]ldapi.Environment, len(envs)) @@ -268,7 +273,12 @@ func environmentsMapFromAPI(ctx context.Context, envs []ldapi.Environment, prior } elements := map[string]attr.Value{} - if prior.IsNull() || prior.IsUnknown() { + if prior.IsUnknown() { + m, d := types.MapValue(environmentObjectType, elements) + diags.Append(d...) + return m, diags + } + if prior.IsNull() { for _, e := range envs { obj, d := environmentObjectFromAPI(ctx, e, nil) diags.Append(d...) diff --git a/launchdarkly/resource_launchdarkly_project_test.go b/launchdarkly/resource_launchdarkly_project_test.go index 34d6ffa0..abcee94b 100644 --- a/launchdarkly/resource_launchdarkly_project_test.go +++ b/launchdarkly/resource_launchdarkly_project_test.go @@ -695,6 +695,66 @@ resource "launchdarkly_project" "subset" { }) } +// TestAccProject_OmitThenSubset guards the data-loss path where a project is +// created with environments omitted (manages none) and then a partial map is +// added: the auto-provisioned environments the user never declared must NOT be +// deleted. Omitting environments must store zero managed environments (not the +// auto-provisioned ones), so adopting one later leaves the rest untouched. +func TestAccProject_OmitThenSubset(t *testing.T) { + projectKey := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + resourceName := "launchdarkly_project.omit" + omitted := fmt.Sprintf(` +resource "launchdarkly_project" "omit" { + key = "%s" + name = "omit project" +} +`, projectKey) + subset := fmt.Sprintf(` +resource "launchdarkly_project" "omit" { + key = "%s" + name = "omit project" + environments = { + "test" = { + name = "Adopted Test" + color = "AABBCC" + } + } +} +`, projectKey) + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckProjectDestroy, + Steps: []resource.TestStep{ + { + // Omitted environments => manage none. State records zero + // environments even though LaunchDarkly auto-provisions some. + Config: omitted, + Check: resource.ComposeTestCheckFunc( + testAccCheckProjectExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "environments.%", "0"), + // auto-provisioned envs exist on the project but are unmanaged + testAccCheckEnvironmentExistsInProject(projectKey, "test"), + testAccCheckEnvironmentExistsInProject(projectKey, "production"), + ), + }, + { + // Adopt one auto-provisioned env. The undeclared one + // ("production") must survive — it was never managed. + Config: subset, + Check: resource.ComposeTestCheckFunc( + testAccCheckProjectExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "environments.%", "1"), + resource.TestCheckResourceAttr(resourceName, "environments.test.name", "Adopted Test"), + testAccCheckEnvironmentExistsInProject(projectKey, "production"), + ), + }, + }, + }) +} + func TestAccProject_ViewAssociationRequirement(t *testing.T) { projectKey := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) resourceName := "launchdarkly_project.view_req_test" diff --git a/launchdarkly/resource_project_framework.go b/launchdarkly/resource_project_framework.go index dc79703a..a2a95775 100644 --- a/launchdarkly/resource_project_framework.go +++ b/launchdarkly/resource_project_framework.go @@ -248,11 +248,9 @@ func (r *ProjectResource) ModifyPlan(ctx context.Context, req resource.ModifyPla } } - // Warn when environments is omitted entirely. Because the attribute is - // Optional+Computed, an omitted value records whatever environments exist - // at create time into state without managing them from configuration — - // which is easy to do by accident. `environments = {}` is the explicit way - // to manage no environments. + // Warn when environments is omitted entirely. An omitted value manages no + // environments (the same effect as `{}`), which is easy to do by accident + // — nudge toward the explicit form or a declared map. var config ProjectResourceModel resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) if resp.Diagnostics.HasError() { @@ -262,7 +260,7 @@ func (r *ProjectResource) ModifyPlan(ctx context.Context, req resource.ModifyPla resp.Diagnostics.AddAttributeWarning( path.Root(ENVIRONMENTS), "environments not configured", - "`environments` is not set, so Terraform does not manage this project's environments. LaunchDarkly auto-provisions default environments, which are recorded in state but not managed from configuration. Set `environments = {}` to explicitly manage no environments, or declare the environments you want to manage as a map keyed by environment key.", + "`environments` is not set, so Terraform manages none of this project's environments (LaunchDarkly still auto-provisions its default environments, which Terraform leaves untouched). Set `environments = {}` to manage no environments explicitly, or declare the environments you want to manage as a map keyed by environment key.", ) } diff --git a/templates/guides/migrating-to-v3.md.tmpl b/templates/guides/migrating-to-v3.md.tmpl index 2dedd068..1d6f6284 100644 --- a/templates/guides/migrating-to-v3.md.tmpl +++ b/templates/guides/migrating-to-v3.md.tmpl @@ -53,7 +53,7 @@ environments { environments = { } ``` -Reference an environment by its key instead of by index. A v2 interpolation such as `launchdarkly_project.example.environments[0].client_side_id` becomes `launchdarkly_project.example.environments["production"].client_side_id`. The tool rewrites positional references whose key it can resolve statically and warns on any it cannot. +Reference an environment by its key instead of by index. A v2 interpolation such as `launchdarkly_project.example.environments[0].client_side_id` becomes `launchdarkly_project.example.environments["production"].client_side_id`. The `migrate-tf-syntax` tool converts the `environments` block to a map but does **not** rewrite these positional index references elsewhere in your configuration — update them by hand (see "Finish the migration by hand" below). Because environments are now keyed, you can manage a subset and leave the rest to the LaunchDarkly UI: only the environments present in your `environments` map are managed, and environments you never declared are not deleted. Set `environments = {}` (or omit the attribute) to create a project without managing any of its environments. @@ -90,6 +90,7 @@ The tool converts syntax only. Complete these follow-ups yourself: - Add `variations` by hand only for a flag whose `variation_type` is a non-literal expression, such as a variable or local. The tool cannot resolve those statically, so it warns and skips them. Boolean flags with a literal `variation_type` are handled automatically, and the provider preserves any variation `name` or `description` set outside Terraform when your configuration omits them. - Rewrite `dynamic` blocks. A `dynamic "variations"` block needs a for expression, for example `variations = [for v in var.values : { value = v }]`. The tool warns with the file and resource address, and it leaves the attribute unchanged. - Upgrade modules sourced from a registry or a git URL. The tool rewrites only files it reaches on disk, so upgrade those modules at their source. +- Rewrite positional references to `launchdarkly_project` environments. The tool converts the `environments` block to a map but does not touch index expressions elsewhere in your config — change `launchdarkly_project.x.environments[0].client_side_id` to `launchdarkly_project.x.environments[""].client_side_id` by hand. ## How v3 upgrades your state From 43e2bcc383d01d88f384ea945ca616222a563996 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 30 Jun 2026 12:07:08 +0100 Subject: [PATCH 4/7] fix: v0->v1 upgrade with no environments projects to empty map, not null environmentsMapFromV0List returned a null map for a null/empty v0 environments list. A null prior makes the next Read import every LaunchDarkly environment (the null branch of environmentsMapFromAPI), undoing manage-none/subset semantics and risking later unintended deletes. Return an empty map instead so the upgraded state manages no environments. (v2 required at least one environment, so this is defensive.) Updates the unit test accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- launchdarkly/framework_state_upgrade.go | 9 ++++++++- launchdarkly/framework_state_upgrade_unit_test.go | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/launchdarkly/framework_state_upgrade.go b/launchdarkly/framework_state_upgrade.go index ca297703..820f2eff 100644 --- a/launchdarkly/framework_state_upgrade.go +++ b/launchdarkly/framework_state_upgrade.go @@ -92,7 +92,14 @@ func csaObjectFromV0List(ctx context.Context, l types.List, attrTypes map[string func environmentsMapFromV0List(ctx context.Context, l types.List) (types.Map, diag.Diagnostics) { var diags diag.Diagnostics if l.IsNull() || l.IsUnknown() || len(l.Elements()) == 0 { - return types.MapNull(environmentObjectType), diags + // No environments in the v0 state: manage none. Return an EMPTY map, + // not null — a null prior makes the next Read import every environment + // (the null branch of environmentsMapFromAPI), which would undo + // manage-none and risk later unintended deletes. (v2 required at least + // one environment, so this branch is defensive.) + m, d := types.MapValue(environmentObjectType, map[string]attr.Value{}) + diags.Append(d...) + return m, diags } var v0envs []environmentModelV0 diags.Append(l.ElementsAs(ctx, &v0envs, false)...) diff --git a/launchdarkly/framework_state_upgrade_unit_test.go b/launchdarkly/framework_state_upgrade_unit_test.go index 4b7acece..809edb22 100644 --- a/launchdarkly/framework_state_upgrade_unit_test.go +++ b/launchdarkly/framework_state_upgrade_unit_test.go @@ -174,8 +174,10 @@ func TestEnvironmentsMapFromV0List(t *testing.T) { t.Error("null approval_settings must stay null on stage") } - if nm, _ := environmentsMapFromV0List(ctx, types.ListNull(v0ObjType)); !nm.IsNull() { - t.Error("null list must project to null map") + // A null/empty v0 list must project to an EMPTY (not null) map so the next + // Read manages none rather than importing every environment. + if nm, _ := environmentsMapFromV0List(ctx, types.ListNull(v0ObjType)); nm.IsNull() || len(nm.Elements()) != 0 { + t.Errorf("null list must project to an empty (non-null) map, got null=%v len=%d", nm.IsNull(), len(nm.Elements())) } } From 48095d2496db959479cb97c0d4ce4fb07c851051 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 30 Jun 2026 14:04:30 +0100 Subject: [PATCH 5/7] refactor!: authoritative environments map + restore inner key (REL-14236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify the environments re-model per design review: - Re-add the environment `key` attribute (Optional+Computed, validated to equal the map key) so references like environments["prod"].key keep working. UseStateForUnknown avoids plan churn. - environments is Required with at least one entry (mapvalidator), matching the API (a project always has at least one environment). - Manage environments authoritatively: the map is the project's complete env set and Read surfaces all of them. To manage environments outside Terraform, use lifecycle { ignore_changes = [environments] } (composes with the standalone launchdarkly_environment resource). - Warn at plan time when any managed environment is removed (rename or delete) since it is irreversible. Reconcile creates new envs before deleting removed ones (LD rejects deleting a project's last environment). - Remove the manage-subset machinery (managed-keys-only Read, {}/omit manage-none, null-vs-unknown prior split) — the source of the earlier Bugbot findings; subset management is not a supported use case. - migrate-tf-syntax: keep `key` inside the converted map object; detect and warn on positional environments[N] references with the exact map-key replacement (it does not edit expressions). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SKILL.md | 17 +- docs/guides/migrating-to-v3.md | 17 +- docs/index.md | 1 + docs/resources/context_kind.md | 1 + docs/resources/project.md | 52 +++-- examples/provider/provider.tf | 1 + .../launchdarkly_context_kind/resource.tf | 1 + .../launchdarkly_project/resource.tf | 9 +- launchdarkly/environments_framework.go | 106 ++++------ launchdarkly/framework_state_upgrade.go | 1 + .../framework_state_upgrade_unit_test.go | 3 + .../resource_launchdarkly_project_test.go | 182 +++++------------- launchdarkly/resource_project_framework.go | 130 +++++++++---- scripts/migrate-tf-syntax/main.go | 129 ++++++++++--- scripts/migrate-tf-syntax/main_test.go | 56 +++++- templates/guides/migrating-to-v3.md.tmpl | 17 +- templates/resources/project.md.tmpl | 13 +- 17 files changed, 429 insertions(+), 307 deletions(-) diff --git a/.claude/skills/terraform-provider-block-to-nested-attrs/SKILL.md b/.claude/skills/terraform-provider-block-to-nested-attrs/SKILL.md index 2f63287f..359e86ab 100644 --- a/.claude/skills/terraform-provider-block-to-nested-attrs/SKILL.md +++ b/.claude/skills/terraform-provider-block-to-nested-attrs/SKILL.md @@ -4,7 +4,7 @@ description: Migrate LaunchDarkly Terraform provider HCL configs between block s compatibility: Works on any directory containing `.tf` files that use `launchdarkly_*` resources. No external tools required beyond a working `terraform` CLI for validation. metadata: author: ffeldberg - version: "2.1.0" + version: "2.2.0" --- # LaunchDarkly Terraform Provider: Block ↔ Nested Attribute Migration @@ -13,7 +13,7 @@ The LaunchDarkly Terraform provider v3.0.0 finished the migration from `terrafor - **List/Set nested attributes** (genuinely plural, e.g. `variations`, `rules`, `statements`) → `name = [{ ... }]`. - **Single nested attributes** (genuinely one object — `client_side_availability`, `defaults`, `default_client_side_availability`, `fallthrough`) → `name = { ... }`. These four were modeled as max-1 lists through `3.0.0-beta.3` and switched to single objects for GA (REL-14237) so the bracketless object form is the correct v3.0.0 syntax. **If you are on a `3.0.0-beta.N` pre-release, use the list form `= [{ ... }]` for these four instead** — the object form is GA-only. -- **Map nested attribute** (`launchdarkly_project.environments`, keyed by env `key`) → `name = { "" = { ... } }`. The environment `key` moves out of the object and becomes the map key (REL-14236). Reordering/adding/removing one environment no longer churns the others. +- **Map nested attribute** (`launchdarkly_project.environments`, keyed by env `key`) → `name = { "" = { key = "", ... } }`. Each block's `key` value becomes the map key; the `key` attribute is also **kept inside** the object (Optional+Computed in v3, equals the map key) so `.environments["x"].key` references keep working (REL-14236). Reordering/adding/removing one environment no longer churns the others. This skill enumerates every affected attribute, gives the exact rewrite, and lists the gotchas that bite during migration. @@ -53,18 +53,19 @@ client_side_availability { client_side_availability = { } } ``` -**Exception — one map attribute** (`launchdarkly_project.environments`) is a `MapNestedAttribute` in v3 (REL-14236), keyed by the environment `key`. The block's `key` argument becomes the map key and is dropped from the object: +**Exception — one map attribute** (`launchdarkly_project.environments`) is a `MapNestedAttribute` in v3 (REL-14236), keyed by the environment `key`. Each block's `key` value becomes the map key; the `key` attribute stays inside the object (it equals the map key): ```hcl # v2 (block) # v3 (map nested attribute) environments { environments = { key = "production" "production" = { - name = "Production" name = "Production" -} } + name = "Production" key = "production" +} name = "Production" + } } ``` -To go v3 → v2, do the inverse: for list attributes strip `= [` / `]` and split `},` separators into a new `foo {` per element; for the four single-object attributes just drop `= ` and the braces become a block (`foo = { ... }` → `foo { ... }`); for the `environments` map, emit one `environments { ... }` block per map entry and re-inject `key = ""`. +To go v3 → v2, do the inverse: for list attributes strip `= [` / `]` and split `},` separators into a new `foo {` per element; for the four single-object attributes just drop `= ` and the braces become a block (`foo = { ... }` → `foo { ... }`); for the `environments` map, emit one `environments { ... }` block per map entry (the `key` is already inside the object; if a hand-written map omitted it, re-inject `key = ""`). ## Mapping table @@ -80,7 +81,7 @@ Every attribute that changed from block → nested attribute in v3. The **Type** | `launchdarkly_custom_role` | `policy_statements` | List | | | `launchdarkly_relay_proxy_configuration` | `policy` | List | Required. | | `launchdarkly_project` | `default_client_side_availability` | **Object** | v3.0.0 GA: `= { ... }`. Was List (max 1) through `3.0.0-beta.3`. | -| `launchdarkly_project` | `environments` | **Map** | Keyed by env `key`: `= { "" = { ... } }`. The inner `key` is now the map key. Optional — manage a subset and leave the rest to the UI, or `= {}` to manage none (REL-14236). Was an ordered List through the early v3 preview. | +| `launchdarkly_project` | `environments` | **Map** | Keyed by env `key`: `= { "" = { key = "", ... } }`. The `key` stays inside the object (Optional+Computed, equals the map key). Required, at least one entry; authoritative (an env removed from the map is deleted). Use `lifecycle { ignore_changes = [environments] }` to manage environments outside Terraform. Was an ordered List through the early v3 preview (REL-14236). | | `launchdarkly_project.environments[""]` | `approval_settings` | List (max 1) | Nested inside each environment map value. | | `launchdarkly_environment` | `approval_settings` | List (max 1) | Same shape as inline-in-project. | | `launchdarkly_segment` | `included_contexts` | List | | @@ -226,10 +227,12 @@ resource "launchdarkly_project" "main" { environments = { "production" = { + key = "production" name = "Production" color = "EF4444" } "staging" = { + key = "staging" name = "Staging" color = "F59E0B" diff --git a/docs/guides/migrating-to-v3.md b/docs/guides/migrating-to-v3.md index 1d6f6284..919927a9 100644 --- a/docs/guides/migrating-to-v3.md +++ b/docs/guides/migrating-to-v3.md @@ -41,21 +41,24 @@ client_side_availability { client_side_availability = { When you read one of these from a data source, use object access without a list index: `data.launchdarkly_feature_flag.x.client_side_availability.using_environment_id`. -`launchdarkly_project.environments` becomes a **map keyed by the environment `key`** rather than an ordered list. Each environment's `key` moves out of the object and becomes the map key, so reordering, adding, or removing one environment no longer shifts the others or forces a destructive plan. The `migrate-tf-syntax` tool performs this rewrite for you: +`launchdarkly_project.environments` becomes a **map keyed by the environment `key`** rather than an ordered list, so reordering, adding, or removing one environment no longer shifts the others or forces a destructive plan. The environment's `key` is also kept inside the object (it equals the map key), so references like `launchdarkly_project.example.environments["production"].key` keep working. The `migrate-tf-syntax` tool performs this rewrite for you: ```hcl # v2 block syntax # v3 map syntax (keyed by env key) environments { environments = { key = "production" "production" = { - name = "Production" name = "Production" - color = "EEEEEE" color = "EEEEEE" -} } + name = "Production" key = "production" + color = "EEEEEE" name = "Production" +} color = "EEEEEE" + } } ``` -Reference an environment by its key instead of by index. A v2 interpolation such as `launchdarkly_project.example.environments[0].client_side_id` becomes `launchdarkly_project.example.environments["production"].client_side_id`. The `migrate-tf-syntax` tool converts the `environments` block to a map but does **not** rewrite these positional index references elsewhere in your configuration — update them by hand (see "Finish the migration by hand" below). +The map is **authoritative**: an environment removed from the map is deleted, and a project must have at least one environment. To manage the project in Terraform but its environments in the LaunchDarkly UI (or via [`launchdarkly_environment`](/docs/providers/launchdarkly/r/environment.html) resources), declare your environments and add `lifecycle { ignore_changes = [environments] }`. -Because environments are now keyed, you can manage a subset and leave the rest to the LaunchDarkly UI: only the environments present in your `environments` map are managed, and environments you never declared are not deleted. Set `environments = {}` (or omit the attribute) to create a project without managing any of its environments. +~> **Warning:** Changing an environment's key (the map key) deletes that environment — including its SDK keys and all flag targeting — and creates a new one. + +Reference an environment by its key instead of by index: a v2 interpolation such as `launchdarkly_project.example.environments[0].client_side_id` becomes `launchdarkly_project.example.environments["production"].client_side_id`. The `migrate-tf-syntax` tool does **not** rewrite these positional references (auto-editing arbitrary expressions risks corrupting your config), but it **detects them and prints the exact replacement** to make — including the resolved key — so the fix is mechanical. See "Finish the migration by hand" below. ## Prerequisites @@ -90,7 +93,7 @@ The tool converts syntax only. Complete these follow-ups yourself: - Add `variations` by hand only for a flag whose `variation_type` is a non-literal expression, such as a variable or local. The tool cannot resolve those statically, so it warns and skips them. Boolean flags with a literal `variation_type` are handled automatically, and the provider preserves any variation `name` or `description` set outside Terraform when your configuration omits them. - Rewrite `dynamic` blocks. A `dynamic "variations"` block needs a for expression, for example `variations = [for v in var.values : { value = v }]`. The tool warns with the file and resource address, and it leaves the attribute unchanged. - Upgrade modules sourced from a registry or a git URL. The tool rewrites only files it reaches on disk, so upgrade those modules at their source. -- Rewrite positional references to `launchdarkly_project` environments. The tool converts the `environments` block to a map but does not touch index expressions elsewhere in your config — change `launchdarkly_project.x.environments[0].client_side_id` to `launchdarkly_project.x.environments[""].client_side_id` by hand. +- Rewrite positional references to `launchdarkly_project` environments. The tool converts the `environments` block to a map but does not edit index expressions elsewhere in your config; it warns on each one with the exact replacement (e.g. `environments[0]` → `environments["production"]`, and `environments[*]` → `values(...)`). Apply those edits by hand. ## How v3 upgrades your state diff --git a/docs/index.md b/docs/index.md index c3e2e2ed..017f2b89 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,6 +43,7 @@ resource "launchdarkly_project" "example" { environments = { "production" = { + key = "production" name = "Production" color = "EEEEEE" } diff --git a/docs/resources/context_kind.md b/docs/resources/context_kind.md index 238f54db..4be770c5 100644 --- a/docs/resources/context_kind.md +++ b/docs/resources/context_kind.md @@ -31,6 +31,7 @@ resource "launchdarkly_project" "example" { name = "Example Project" environments = { "production" = { + key = "production" name = "Production" color = "000000" } diff --git a/docs/resources/project.md b/docs/resources/project.md index 2199d478..5874081f 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -27,10 +27,14 @@ resource "launchdarkly_project" "example" { require_view_association_for_new_flags = false require_view_association_for_new_segments = false - # environments is a map keyed by the environment key. Reordering, adding, or - # removing one environment does not affect the others. + # environments is a map keyed by the environment key (the map key and the + # nested `key` are the same value). Reordering, adding, or removing one + # environment does not affect the others. The map is authoritative: an + # environment removed from it is deleted. To manage environments outside + # Terraform instead, add `lifecycle { ignore_changes = [environments] }`. environments = { "production" = { + key = "production" name = "Production" color = "EEEEEE" tags = ["terraform"] @@ -42,6 +46,7 @@ resource "launchdarkly_project" "example" { }] } "staging" = { + key = "staging" name = "Staging" color = "000000" tags = ["terraform"] @@ -55,15 +60,19 @@ resource "launchdarkly_project" "example" { ### Required +- `environments` (Attributes Map) Map of environments that belong to the project, keyed by environment `key`. This is the complete, authoritative set of the project's environments: any environment not present in the map is deleted on apply. Reordering, adding, or removing one environment does not affect the others. A project must have at least one environment. + +~> **Warning:** Changing an environment's key (the map key) deletes that environment — including its SDK keys and all of its flag targeting — and creates a new one. This is irreversible. + +To manage the project in Terraform but manage its environments elsewhere (the LaunchDarkly UI or [`launchdarkly_environment`](/docs/providers/launchdarkly/r/environment.html) resources), declare your initial environments and add `lifecycle { ignore_changes = [environments] }`. + +-> **Note:** Mixing the use of nested `environments` and `launchdarkly_environment` resources for the same project is not recommended. `launchdarkly_environment` resources should be used together with `ignore_changes` on the project's `environments`, or when the encapsulating project is not managed in Terraform. (see [below for nested schema](#nestedatt--environments)) - `key` (String) The project's unique key. A change in this field will force the destruction of the existing resource and the creation of a new one. - `name` (String) The project's name. ### Optional - `default_client_side_availability` (Attributes) Which client-side SDKs can use new flags by default. (see [below for nested schema](#nestedatt--default_client_side_availability)) -- `environments` (Attributes Map) Map of environments that belong to the project, keyed by environment `key`. When managing LaunchDarkly projects in Terraform, you should always manage your environments as nested project resources. Environments not present in the map are left unmanaged (terraform will not modify or delete them), so you can manage a subset and leave the rest to the LaunchDarkly UI. Set this to `{}` to create a project while managing none of its environments. Omitting the attribute entirely also manages no environments (the same effect as `{}`) but emits a plan-time warning — prefer `{}` to be explicit. - --> **Note:** Mixing the use of nested `environments` and [`launchdarkly_environment`](/docs/providers/launchdarkly/r/environment.html) resources is not recommended. `launchdarkly_environment` resources should only be used when the encapsulating project is not managed in Terraform. (see [below for nested schema](#nestedatt--environments)) - `require_view_association_for_new_flags` (Boolean) Whether new flags created in this project must be associated with at least one view. - `require_view_association_for_new_segments` (Boolean) Whether new segments created in this project must be associated with at least one view. - `tags` (Set of String) Tags associated with your resource. @@ -72,15 +81,6 @@ resource "launchdarkly_project" "example" { - `id` (String) The ID of this resource. - -### Nested Schema for `default_client_side_availability` - -Required: - -- `using_environment_id` (Boolean) -- `using_mobile_key` (Boolean) - - ### Nested Schema for `environments` @@ -96,6 +96,7 @@ Optional: - `critical` (Boolean) Denotes whether the environment is critical. - `default_track_events` (Boolean) Set to `true` to enable data export for every flag created in this environment after you configure this argument. This field will default to `false` when not set. To learn more, read [Data Export](https://docs.launchdarkly.com/home/data-export). - `default_ttl` (Number) The TTL for the environment. This must be between 0 and 60 minutes. The TTL setting only applies to environments using the PHP SDK. This field will default to `0` when not set. To learn more, read [TTL settings](https://docs.launchdarkly.com/home/organize/environments#ttl-settings). +- `key` (String) The project-unique key for the environment. Must equal the map key; it defaults to the map key when omitted. Changing it (or the map key) replaces the environment. - `require_comments` (Boolean) Set to `true` if this environment requires comments for flag and segment changes. This field will default to `false` when not set. - `secure_mode` (Boolean) Set to `true` to ensure a user of the client-side SDK cannot impersonate another user. This field will default to `false` when not set. - `tags` (Set of String) Tags associated with your resource. @@ -123,6 +124,16 @@ Optional: - `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. + + + +### Nested Schema for `default_client_side_availability` + +Required: + +- `using_environment_id` (Boolean) +- `using_mobile_key` (Boolean) + ## Import Import is supported using the following syntax: @@ -132,7 +143,7 @@ Import is supported using the following syntax: terraform import launchdarkly_project.example example-project ``` -**IMPORTANT:** On import, _all_ of the project's environments are saved to the Terraform state, keyed by their environment `key`. The `environments` you declare in your configuration are managed; on a subsequent apply, any environment that is in state but absent from your configuration is deleted. To manage only some environments and leave the rest to the LaunchDarkly UI, declare just those environments in the map (or set `environments = {}` to manage none), or use Terraform's [ignore changes](https://www.terraform.io/docs/language/meta-arguments/lifecycle.html#ignore_changes) lifecycle meta-argument; see below for example. +**IMPORTANT:** On import, _all_ of the project's environments are saved to the Terraform state, keyed by their environment `key`. `environments` is authoritative: on a subsequent apply, any environment that is in state but absent from your configuration is **deleted** (along with its SDK keys and all flag targeting). To manage the project in Terraform but leave its environments to the LaunchDarkly UI (or to [`launchdarkly_environment`](/docs/providers/launchdarkly/r/environment.html) resources), declare your environments and use Terraform's [ignore changes](https://www.terraform.io/docs/language/meta-arguments/lifecycle.html#ignore_changes) lifecycle meta-argument: ```terraform resource "launchdarkly_project" "example" { @@ -141,10 +152,17 @@ resource "launchdarkly_project" "example" { } name = "testProject" key = "example-project" - # environments not included in this configuration will not be affected by subsequent applies + environments = { + "production" = { + key = "production" + name = "Production" + color = "EEEEEE" + } + } + # after the initial create, environment changes are ignored } ``` -Because `environments` is a map keyed by environment `key`, reordering, adding, or removing one environment never forces changes to the others, and import is order-independent. +Because `environments` is a map keyed by environment `key`, reordering, adding, or removing one environment never forces changes to the others, and import is order-independent. Changing an environment's key (the map key) deletes the old environment and creates a new one. **Managing environment resources with Terraform should always be done on the project unless the project is not also managed with Terraform.** diff --git a/examples/provider/provider.tf b/examples/provider/provider.tf index 2b6c8082..3aceccfd 100644 --- a/examples/provider/provider.tf +++ b/examples/provider/provider.tf @@ -29,6 +29,7 @@ resource "launchdarkly_project" "example" { environments = { "production" = { + key = "production" name = "Production" color = "EEEEEE" } diff --git a/examples/resources/launchdarkly_context_kind/resource.tf b/examples/resources/launchdarkly_context_kind/resource.tf index 9b92726b..9b58521e 100644 --- a/examples/resources/launchdarkly_context_kind/resource.tf +++ b/examples/resources/launchdarkly_context_kind/resource.tf @@ -3,6 +3,7 @@ resource "launchdarkly_project" "example" { name = "Example Project" environments = { "production" = { + key = "production" name = "Production" color = "000000" } diff --git a/examples/resources/launchdarkly_project/resource.tf b/examples/resources/launchdarkly_project/resource.tf index fe7b4131..ee807a9e 100644 --- a/examples/resources/launchdarkly_project/resource.tf +++ b/examples/resources/launchdarkly_project/resource.tf @@ -10,10 +10,14 @@ resource "launchdarkly_project" "example" { require_view_association_for_new_flags = false require_view_association_for_new_segments = false - # environments is a map keyed by the environment key. Reordering, adding, or - # removing one environment does not affect the others. + # environments is a map keyed by the environment key (the map key and the + # nested `key` are the same value). Reordering, adding, or removing one + # environment does not affect the others. The map is authoritative: an + # environment removed from it is deleted. To manage environments outside + # Terraform instead, add `lifecycle { ignore_changes = [environments] }`. environments = { "production" = { + key = "production" name = "Production" color = "EEEEEE" tags = ["terraform"] @@ -25,6 +29,7 @@ resource "launchdarkly_project" "example" { }] } "staging" = { + key = "staging" name = "Staging" color = "000000" tags = ["terraform"] diff --git a/launchdarkly/environments_framework.go b/launchdarkly/environments_framework.go index b495f4f4..745ee8c9 100644 --- a/launchdarkly/environments_framework.go +++ b/launchdarkly/environments_framework.go @@ -5,10 +5,12 @@ package launchdarkly // state values and the LD-API environment shapes. // // As of REL-14236 environments is a Map keyed by the environment key -// (was a positional List). The map key carries the environment identity, -// so the nested object no longer has its own `key` attribute. Keying by -// env key makes reorder/add/remove of one environment a no-op for its -// siblings. +// (was a positional List), which makes reorder/add/remove of one +// environment a no-op for its siblings. The environment object also +// carries a `key` attribute (Optional+Computed) that always equals the +// map key, preserved for familiarity and for references such as +// `environments["prod"].key`. The map is the authoritative set of the +// project's environments: any environment not present in it is deleted. // // The standalone launchdarkly_environment resource lives in // resource_environment_framework.go and uses the same approval_settings @@ -19,6 +21,7 @@ import ( "sort" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -34,10 +37,11 @@ import ( ) // environmentModel matches the nested-environments element shape used in -// launchdarkly_project. The environment key lives in the enclosing map's -// key, NOT in this struct — terraform tracks env identity by map key -// across plans. +// launchdarkly_project. Key always equals the enclosing map key (it is +// Optional+Computed and validated to match); terraform tracks env identity +// by the map key. type environmentModel struct { + Key types.String `tfsdk:"key"` Name types.String `tfsdk:"name"` Color types.String `tfsdk:"color"` Critical types.Bool `tfsdk:"critical"` @@ -54,6 +58,7 @@ type environmentModel struct { } var environmentAttrTypes = map[string]attr.Type{ + KEY: types.StringType, NAME: types.StringType, COLOR: types.StringType, CRITICAL: types.BoolType, @@ -73,17 +78,23 @@ var environmentAttrTypes = map[string]attr.Type{ var environmentObjectType = types.ObjectType{AttrTypes: environmentAttrTypes} // projectEnvironmentsAttribute returns the nested-environments attribute -// for the project resource schema. It is a Map keyed by environment key. -// Optional+Computed: omitting it (or setting `{}`) lets the project be -// created with the environments LaunchDarkly auto-provisions without -// terraform churn, and a declared map manages exactly its keys. +// for the project resource schema: a Required map keyed by environment +// key, with at least one entry. The map is authoritative — environments +// absent from it are deleted. func projectEnvironmentsAttribute() schema.MapNestedAttribute { return schema.MapNestedAttribute{ - Optional: true, - Computed: true, - Description: "Map of environments that belong to the project, keyed by environment `key`. When managing LaunchDarkly projects in Terraform, you should always manage your environments as nested project resources. Environments not present in the map are left unmanaged (terraform will not modify or delete them), so you can manage a subset and leave the rest to the LaunchDarkly UI. Set this to `{}` to create a project while managing none of its environments. Omitting the attribute entirely also manages no environments (the same effect as `{}`) but emits a plan-time warning — prefer `{}` to be explicit.\n\n-> **Note:** Mixing the use of nested `environments` and [`launchdarkly_environment`](/docs/providers/launchdarkly/r/environment.html) resources is not recommended. `launchdarkly_environment` resources should only be used when the encapsulating project is not managed in Terraform.", + Required: true, + Validators: []validator.Map{mapvalidator.SizeAtLeast(1)}, + Description: "Map of environments that belong to the project, keyed by environment `key`. This is the complete, authoritative set of the project's environments: any environment not present in the map is deleted on apply. Reordering, adding, or removing one environment does not affect the others. A project must have at least one environment.\n\n~> **Warning:** Changing an environment's key (the map key) deletes that environment — including its SDK keys and all of its flag targeting — and creates a new one. This is irreversible.\n\nTo manage the project in Terraform but manage its environments elsewhere (the LaunchDarkly UI or [`launchdarkly_environment`](/docs/providers/launchdarkly/r/environment.html) resources), declare your initial environments and add `lifecycle { ignore_changes = [environments] }`.\n\n-> **Note:** Mixing the use of nested `environments` and `launchdarkly_environment` resources for the same project is not recommended. `launchdarkly_environment` resources should be used together with `ignore_changes` on the project's `environments`, or when the encapsulating project is not managed in Terraform.", NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ + KEY: schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The project-unique key for the environment. Must equal the map key; it defaults to the map key when omitted. Changing it (or the map key) replaces the environment.", + Validators: []validator.String{keyValidator()}, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, NAME: schema.StringAttribute{ Required: true, Description: "The name of the environment.", @@ -200,6 +211,8 @@ func environmentPostsFromPlan(ctx context.Context, m types.Map) ([]ldapi.Environ return posts, diags } +// environmentPostFromModel builds an EnvironmentPost. The key is the map +// key (authoritative), not m.Key — they are validated to be equal. func environmentPostFromModel(_ context.Context, key string, m environmentModel) (ldapi.EnvironmentPost, diag.Diagnostics) { post := ldapi.EnvironmentPost{ Name: m.Name.ValueString(), @@ -251,60 +264,24 @@ func planOrNullList(hadOld bool, l types.List) types.List { } // environmentsMapFromAPI flattens the LD environments slice into a -// framework MapValue keyed by environment key. -// -// The `prior` value's state drives whether unmanaged environments are -// surfaced — and null vs unknown mean different things: -// - unknown: a create that omitted `environments` (Optional+Computed, -// not yet known). Manage NOTHING — return the empty map. Materializing -// the environments LaunchDarkly auto-provisions would make them look -// managed, so a later partial config would delete the ones the user -// never declared. -// - null: import (ImportState set only key+id). Surface EVERY environment -// so import captures the whole project. -// - populated map: managed mode — track ONLY the keys present in `prior`. -// Environments created outside terraform are left untracked, which is -// what lets a user manage a subset and leave the rest to the UI. +// framework MapValue keyed by environment key. The map is authoritative, +// so EVERY environment the API returns is surfaced (import-complete). For +// each env that exists in `prior` state we pass that prior model through +// so optional-attr shapes (tags, approval_settings) are preserved across +// the plan-apply consistency check; envs with no prior (import or newly +// adopted) use isZero detection. func environmentsMapFromAPI(ctx context.Context, envs []ldapi.Environment, prior types.Map) (basetypes.MapValue, diag.Diagnostics) { - var diags diag.Diagnostics - envByKey := make(map[string]ldapi.Environment, len(envs)) - for _, e := range envs { - envByKey[e.Key] = e - } - + priorModels, diags := environmentModelsFromMap(ctx, prior) elements := map[string]attr.Value{} - if prior.IsUnknown() { - m, d := types.MapValue(environmentObjectType, elements) - diags.Append(d...) - return m, diags - } - if prior.IsNull() { - for _, e := range envs { - obj, d := environmentObjectFromAPI(ctx, e, nil) - diags.Append(d...) - elements[e.Key] = obj - } - m, d := types.MapValue(environmentObjectType, elements) - diags.Append(d...) - return m, diags - } - - priorModels, d := environmentModelsFromMap(ctx, prior) - diags.Append(d...) - if diags.HasError() { - return types.MapNull(environmentObjectType), diags - } - for key, pm := range priorModels { - envAPI, ok := envByKey[key] - if !ok { - // Managed env deleted out-of-band: drop it so the next plan - // shows it being recreated rather than carrying stale state. - continue + for _, e := range envs { + var pm *environmentModel + if m, ok := priorModels[e.Key]; ok { + mc := m + pm = &mc } - pmCopy := pm - obj, d := environmentObjectFromAPI(ctx, envAPI, &pmCopy) + obj, d := environmentObjectFromAPI(ctx, e, pm) diags.Append(d...) - elements[key] = obj + elements[e.Key] = obj } m, d := types.MapValue(environmentObjectType, elements) diags.Append(d...) @@ -341,6 +318,7 @@ func environmentObjectFromAPI(ctx context.Context, e ldapi.Environment, prior *e approvals = list } obj, d := types.ObjectValue(environmentAttrTypes, map[string]attr.Value{ + KEY: types.StringValue(e.Key), NAME: types.StringValue(e.Name), COLOR: types.StringValue(e.Color), CRITICAL: types.BoolValue(e.Critical), diff --git a/launchdarkly/framework_state_upgrade.go b/launchdarkly/framework_state_upgrade.go index 820f2eff..899040ac 100644 --- a/launchdarkly/framework_state_upgrade.go +++ b/launchdarkly/framework_state_upgrade.go @@ -118,6 +118,7 @@ func environmentsMapFromV0List(ctx context.Context, l types.List) (types.Map, di } } obj, d := types.ObjectValue(environmentAttrTypes, map[string]attr.Value{ + KEY: e.Key, NAME: e.Name, COLOR: e.Color, CRITICAL: e.Critical, diff --git a/launchdarkly/framework_state_upgrade_unit_test.go b/launchdarkly/framework_state_upgrade_unit_test.go index 809edb22..6727a0c9 100644 --- a/launchdarkly/framework_state_upgrade_unit_test.go +++ b/launchdarkly/framework_state_upgrade_unit_test.go @@ -164,6 +164,9 @@ func TestEnvironmentsMapFromV0List(t *testing.T) { if got := models["production"].Name.ValueString(); got != "Production" { t.Errorf("production name not preserved: %q", got) } + if got := models["production"].Key.ValueString(); got != "production" { + t.Errorf("production key not preserved: %q", got) + } if models["production"].ApprovalSettings.IsNull() || len(models["production"].ApprovalSettings.Elements()) != 1 { t.Error("real approval_settings must be preserved on production") } diff --git a/launchdarkly/resource_launchdarkly_project_test.go b/launchdarkly/resource_launchdarkly_project_test.go index abcee94b..6cd33643 100644 --- a/launchdarkly/resource_launchdarkly_project_test.go +++ b/launchdarkly/resource_launchdarkly_project_test.go @@ -7,16 +7,16 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" - ldapi "github.com/launchdarkly/api-client-go/v22" ) // Project resources should be formatted with a random project key because acceptance tests // are run in parallel on a single account. // -// As of REL-14236 environments is a map keyed by env key -// (`environments = { "key" = { ... } }`). The count key is `environments.%` -// and elements are addressed `environments..`; there is no inner -// `key` attribute (the map key carries it). +// As of REL-14236 environments is an authoritative map keyed by env key +// (`environments = { "key" = { ... } }`): the count key is `environments.%` +// and elements are addressed `environments..`. The object also has +// an Optional+Computed `key` attribute that equals the map key (configs may +// omit it). A project must declare at least one environment. const ( testAccProjectCreate = ` resource "launchdarkly_project" "test" { @@ -236,11 +236,31 @@ resource "launchdarkly_project" "approval_env_test" { } }` - testAccProjectZeroEnvironments = ` -resource "launchdarkly_project" "zero_env" { - key = "%s" - name = "zero env project" - environments = {} + testAccProjectRenameEnvA = ` +resource "launchdarkly_project" "rename" { + key = "%s" + name = "rename project" + environments = { + "alpha" = { + key = "alpha" + name = "Alpha" + color = "010101" + } + } +} +` + + testAccProjectRenameEnvB = ` +resource "launchdarkly_project" "rename" { + key = "%s" + name = "rename project" + environments = { + "beta" = { + key = "beta" + name = "Beta" + color = "020202" + } + } } ` ) @@ -297,6 +317,9 @@ func TestAccProject_Update(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "environments.%", "1"), resource.TestCheckResourceAttr(resourceName, "environments.test-env.name", "Test Environment"), resource.TestCheckResourceAttr(resourceName, "environments.test-env.color", "010101"), + // key is Optional+Computed and defaults to the map key even + // when omitted from config. + resource.TestCheckResourceAttr(resourceName, "environments.test-env.key", "test-env"), ), }, { @@ -606,14 +629,14 @@ func TestAccProject_ManyEnvironments(t *testing.T) { }) } -// TestAccProject_ZeroEnvironments exercises the REL-14236 relaxation that lets -// a project be created without managing any environments (environments = {}). -// LaunchDarkly auto-provisions its default environments, but with an empty map -// the provider manages none of them, so state reports zero managed environments -// and the plan is stable. -func TestAccProject_ZeroEnvironments(t *testing.T) { +// TestAccProject_RenameEnvironment renames an environment by changing its map +// key. Because the LaunchDarkly API rejects deleting a project's last +// environment, the provider must create the new env before deleting the old +// one (1 -> 2 -> 1). The old env is deleted (authoritative management) and the +// new env exists afterwards. +func TestAccProject_RenameEnvironment(t *testing.T) { projectKey := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) - resourceName := "launchdarkly_project.zero_env" + resourceName := "launchdarkly_project.rename" resource.ParallelTest(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) @@ -622,72 +645,23 @@ func TestAccProject_ZeroEnvironments(t *testing.T) { CheckDestroy: testAccCheckProjectDestroy, Steps: []resource.TestStep{ { - Config: fmt.Sprintf(testAccProjectZeroEnvironments, projectKey), - Check: resource.ComposeTestCheckFunc( - testAccCheckProjectExists(resourceName), - resource.TestCheckResourceAttr(resourceName, KEY, projectKey), - resource.TestCheckResourceAttr(resourceName, NAME, "zero env project"), - resource.TestCheckResourceAttr(resourceName, "environments.%", "0"), - ), - }, - }, - }) -} - -// TestAccProject_ManageSubset proves the REL-14236 manage-a-subset behavior: -// an environment created outside Terraform (as the LaunchDarkly UI would) is -// neither pulled into state nor deleted by a subsequent apply of the unchanged -// config. This guards against silently destroying unmanaged environments. -func TestAccProject_ManageSubset(t *testing.T) { - projectKey := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) - resourceName := "launchdarkly_project.subset" - config := fmt.Sprintf(` -resource "launchdarkly_project" "subset" { - key = "%s" - name = "subset project" - environments = { - "alpha" = { - name = "Alpha" - color = "010101" - } - } -} -`, projectKey) - resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { - testAccPreCheck(t) - }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - CheckDestroy: testAccCheckProjectDestroy, - Steps: []resource.TestStep{ - { - Config: config, + Config: fmt.Sprintf(testAccProjectRenameEnvA, projectKey), Check: resource.ComposeTestCheckFunc( testAccCheckProjectExists(resourceName), resource.TestCheckResourceAttr(resourceName, "environments.%", "1"), - resource.TestCheckResourceAttr(resourceName, "environments.alpha.name", "Alpha"), + resource.TestCheckResourceAttr(resourceName, "environments.alpha.key", "alpha"), + testAccCheckEnvironmentExistsInProject(projectKey, "alpha"), ), }, { - // Create "beta" out-of-band, then re-apply the unchanged config. - // The provider must ignore the unmanaged env: it stays out of - // state, the plan is empty, and it is NOT deleted. - PreConfig: func() { - client := mustTestAccClient() - _, _, err := client.ld.EnvironmentsApi.PostEnvironment(client.ctx, projectKey). - EnvironmentPost(ldapi.EnvironmentPost{Key: "beta", Name: "Beta", Color: "123456"}).Execute() - if err != nil { - t.Fatalf("failed to create out-of-band environment: %s", handleLdapiErr(err)) - } - }, - Config: config, + // Rename alpha -> beta. New env is created before the old one is + // deleted, so the project is never left with zero environments. + Config: fmt.Sprintf(testAccProjectRenameEnvB, projectKey), Check: resource.ComposeTestCheckFunc( testAccCheckProjectExists(resourceName), - // managed env still tracked; unmanaged env not pulled into state resource.TestCheckResourceAttr(resourceName, "environments.%", "1"), - resource.TestCheckResourceAttr(resourceName, "environments.alpha.name", "Alpha"), - resource.TestCheckNoResourceAttr(resourceName, "environments.beta.name"), - // unmanaged env survived the apply (was not deleted) + resource.TestCheckResourceAttr(resourceName, "environments.beta.key", "beta"), + resource.TestCheckNoResourceAttr(resourceName, "environments.alpha.key"), testAccCheckEnvironmentExistsInProject(projectKey, "beta"), ), }, @@ -695,66 +669,6 @@ resource "launchdarkly_project" "subset" { }) } -// TestAccProject_OmitThenSubset guards the data-loss path where a project is -// created with environments omitted (manages none) and then a partial map is -// added: the auto-provisioned environments the user never declared must NOT be -// deleted. Omitting environments must store zero managed environments (not the -// auto-provisioned ones), so adopting one later leaves the rest untouched. -func TestAccProject_OmitThenSubset(t *testing.T) { - projectKey := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) - resourceName := "launchdarkly_project.omit" - omitted := fmt.Sprintf(` -resource "launchdarkly_project" "omit" { - key = "%s" - name = "omit project" -} -`, projectKey) - subset := fmt.Sprintf(` -resource "launchdarkly_project" "omit" { - key = "%s" - name = "omit project" - environments = { - "test" = { - name = "Adopted Test" - color = "AABBCC" - } - } -} -`, projectKey) - resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { - testAccPreCheck(t) - }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - CheckDestroy: testAccCheckProjectDestroy, - Steps: []resource.TestStep{ - { - // Omitted environments => manage none. State records zero - // environments even though LaunchDarkly auto-provisions some. - Config: omitted, - Check: resource.ComposeTestCheckFunc( - testAccCheckProjectExists(resourceName), - resource.TestCheckResourceAttr(resourceName, "environments.%", "0"), - // auto-provisioned envs exist on the project but are unmanaged - testAccCheckEnvironmentExistsInProject(projectKey, "test"), - testAccCheckEnvironmentExistsInProject(projectKey, "production"), - ), - }, - { - // Adopt one auto-provisioned env. The undeclared one - // ("production") must survive — it was never managed. - Config: subset, - Check: resource.ComposeTestCheckFunc( - testAccCheckProjectExists(resourceName), - resource.TestCheckResourceAttr(resourceName, "environments.%", "1"), - resource.TestCheckResourceAttr(resourceName, "environments.test.name", "Adopted Test"), - testAccCheckEnvironmentExistsInProject(projectKey, "production"), - ), - }, - }, - }) -} - func TestAccProject_ViewAssociationRequirement(t *testing.T) { projectKey := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) resourceName := "launchdarkly_project.view_req_test" diff --git a/launchdarkly/resource_project_framework.go b/launchdarkly/resource_project_framework.go index a2a95775..01d01bc9 100644 --- a/launchdarkly/resource_project_framework.go +++ b/launchdarkly/resource_project_framework.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log" + "sort" + "strings" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" @@ -21,10 +23,11 @@ import ( ) var ( - _ resource.Resource = &ProjectResource{} - _ resource.ResourceWithImportState = &ProjectResource{} - _ resource.ResourceWithModifyPlan = &ProjectResource{} - _ resource.ResourceWithUpgradeState = &ProjectResource{} + _ resource.Resource = &ProjectResource{} + _ resource.ResourceWithImportState = &ProjectResource{} + _ resource.ResourceWithModifyPlan = &ProjectResource{} + _ resource.ResourceWithUpgradeState = &ProjectResource{} + _ resource.ResourceWithValidateConfig = &ProjectResource{} ) type ProjectResource struct { @@ -217,15 +220,16 @@ func (r *ProjectResource) Configure(_ context.Context, req resource.ConfigureReq r.client = configureResourceClient(req, resp) } -// ModifyPlan addresses environments-level sensitive Unknowns: nested- -// attribute schemas synthesize zero values for inner Computed fields -// once the user supplies the required ones (key/name/color). For -// api_key, mobile_key, client_side_id — secrets only LD can mint — -// this produces a "" plan value that Apply replaces with the real -// secret, tripping the framework's plan-vs-apply consistency check -// (see [[feedback-nested-attr-computed-sensitive]]). Mark these -// fields Unknown whenever there's no prior state entry for the same -// env key. +// ModifyPlan does two things: +// +// 1. Warns when a managed environment is being removed (or renamed, which is +// a remove + add), since deleting an environment is irreversible. +// 2. Fixes environments-level sensitive Unknowns: nested-attribute schemas +// synthesize "" for inner Computed fields (api_key/mobile_key/ +// client_side_id) once the user supplies the required ones, and Apply +// then replaces "" with the real secret, tripping the plan-vs-apply +// consistency check (see [[feedback-nested-attr-computed-sensitive]]). +// Mark those fields Unknown when there's no prior state entry for the key. func (r *ProjectResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { if req.Plan.Raw.IsNull() { return @@ -246,32 +250,83 @@ func (r *ProjectResource) ModifyPlan(ctx context.Context, req resource.ModifyPla if resp.Diagnostics.HasError() { return } + // Warn loudly when a managed environment is being removed. Because + // environments is authoritative, any key in state but absent from the + // plan is deleted on apply — which destroys the environment along with + // its SDK/mobile/client-side keys and all of its flag targeting. + // Renaming an environment's key shows up here too (old key removed, + // new key added). This is a warning, not an error: apply is the gate + // (see [[feedback-prereq-destroy-plantime-warning]]). + if removed := removedEnvKeys(plan.Environments, state.Environments); len(removed) > 0 { + resp.Diagnostics.AddAttributeWarning( + path.Root(ENVIRONMENTS), + "environment(s) will be deleted", + fmt.Sprintf("The following environment(s) will be deleted from project %q, including their SDK/mobile/client-side keys and all flag targeting — this is irreversible: %s.\n\nIf you are renaming an environment, note that changing the map key deletes the old environment and creates a new one. To manage these environments outside Terraform instead, add `lifecycle { ignore_changes = [environments] }`.", plan.Key.ValueString(), strings.Join(removed, ", ")), + ) + } } - // Warn when environments is omitted entirely. An omitted value manages no - // environments (the same effect as `{}`), which is easy to do by accident - // — nudge toward the explicit form or a declared map. + envs, diags := markEnvSecretsUnknown(ctx, plan.Environments, state.Environments) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + plan.Environments = envs + + resp.Diagnostics.Append(resp.Plan.Set(ctx, &plan)...) +} + +// removedEnvKeys returns the env keys present in stateMap but absent from +// planMap — the environments an apply would delete — in sorted order. +func removedEnvKeys(planMap, stateMap types.Map) []string { + if stateMap.IsNull() || stateMap.IsUnknown() { + return nil + } + planKeys := map[string]bool{} + if !planMap.IsNull() && !planMap.IsUnknown() { + for k := range planMap.Elements() { + planKeys[k] = true + } + } + var removed []string + for k := range stateMap.Elements() { + if !planKeys[k] { + removed = append(removed, k) + } + } + sort.Strings(removed) + return removed +} + +// ValidateConfig enforces that each environment's `key` (when set) equals its +// map key. The map key is the authoritative identity; a per-attribute +// validator can't see its own map key, so the cross-check lives here. +func (r *ProjectResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { var config ProjectResourceModel resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) if resp.Diagnostics.HasError() { return } - if config.Environments.IsNull() { - resp.Diagnostics.AddAttributeWarning( - path.Root(ENVIRONMENTS), - "environments not configured", - "`environments` is not set, so Terraform manages none of this project's environments (LaunchDarkly still auto-provisions its default environments, which Terraform leaves untouched). Set `environments = {}` to manage no environments explicitly, or declare the environments you want to manage as a map keyed by environment key.", - ) + if config.Environments.IsNull() || config.Environments.IsUnknown() { + return } - - envs, diags := markEnvSecretsUnknown(ctx, plan.Environments, state.Environments) + models, diags := environmentModelsFromMap(ctx, config.Environments) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - plan.Environments = envs - - resp.Diagnostics.Append(resp.Plan.Set(ctx, &plan)...) + for mapKey, env := range models { + if env.Key.IsNull() || env.Key.IsUnknown() { + continue + } + if env.Key.ValueString() != mapKey { + resp.Diagnostics.AddAttributeError( + path.Root(ENVIRONMENTS).AtMapKey(mapKey).AtName(KEY), + "environment key must match its map key", + fmt.Sprintf("environment %q sets key = %q; the nested `key` must equal the map key (or be omitted). The map key is the environment's identity.", mapKey, env.Key.ValueString()), + ) + } + } } // markEnvSecretsUnknown rewrites plan.Environments so api_key / @@ -516,17 +571,17 @@ func (r *ProjectResource) applyProjectUpdates(ctx context.Context, projectKey st } } - // Environment reconciliation. environments is a map keyed by env key, - // so identity is the map key and only the keys present in config are - // managed; keys absent from config (e.g. UI-created envs) are left - // untouched. + // Environment reconciliation. environments is an authoritative map keyed + // by env key: the plan's keys are the project's complete env set, so any + // state env absent from the plan is deleted. New envs are created BEFORE + // removed envs are deleted — LaunchDarkly rejects deleting a project's + // last environment, so a rename (remove + add) must go 1->2->1, never + // through 0. // - // A null or unknown plan value means environments is not determined by - // configuration (the attribute is Optional+Computed and was omitted, or - // the value is still being computed) — NOT "remove every environment". - // Skip all reconciliation so we never create, patch, or delete based on a - // non-concrete plan. An explicit empty map `{}` is concrete (not null), so - // it still falls through and deletes managed environments as intended. + // Defensive guard: a null/unknown plan value means environments is not + // concrete (e.g. ignore_changes, or still being computed) — NOT "delete + // every environment". Skip reconciliation entirely in that case rather + // than risk deleting all environments off a non-concrete plan. if plan.Environments.IsNull() || plan.Environments.IsUnknown() { return diags } @@ -554,6 +609,7 @@ func (r *ProjectResource) applyProjectUpdates(ctx context.Context, projectKey st return diags } + // Pass 1: create missing + patch existing (create-before-delete ordering). desired := map[string]bool{} for _, envKey := range sortedEnvKeys(planEnvs) { env := planEnvs[envKey] diff --git a/scripts/migrate-tf-syntax/main.go b/scripts/migrate-tf-syntax/main.go index 5a426f80..313cea4b 100644 --- a/scripts/migrate-tf-syntax/main.go +++ b/scripts/migrate-tf-syntax/main.go @@ -20,6 +20,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "github.com/hashicorp/hcl/v2" @@ -130,8 +131,15 @@ func main() { if len(matches) == 0 { die(fmt.Sprintf("no .tf files in %s", *dir)) } + // Collect each project's environment keys (in source order) up front so we + // can resolve positional `environments[N]` references to their map keys in + // migration warnings. Done before conversion, while configs are still v2. + projectEnvKeys := map[string][]string{} + if *direction == "v2-to-v3" { + projectEnvKeys = collectProjectEnvKeys(matches) + } for _, f := range matches { - if err := process(f, *direction, spec, *dryRun); err != nil { + if err := process(f, *direction, spec, *dryRun, projectEnvKeys); err != nil { fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", f, err) os.Exit(1) } @@ -185,16 +193,89 @@ func warnf(format string, args ...interface{}) { fmt.Fprintf(os.Stderr, "warning: "+format+"\n", args...) } +// envIndexRefRe matches positional references to a project's environments, +// e.g. launchdarkly_project.