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..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.0.0" + version: "2.2.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 = { "" = { 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. @@ -52,11 +53,23 @@ 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`. 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" 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 (the `key` is already inside the object; if a hand-written map omitted it, 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 +81,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`: `= { "" = { 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 | | | `launchdarkly_segment` | `excluded_contexts` | List | | @@ -109,9 +122,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,13 +225,13 @@ resource "launchdarkly_project" "main" { using_mobile_key = false } - environments = [ - { + environments = { + "production" = { key = "production" name = "Production" color = "EF4444" - }, - { + } + "staging" = { key = "staging" name = "Staging" color = "F59E0B" @@ -226,8 +240,8 @@ resource "launchdarkly_project" "main" { required = true min_num_approvals = 1 }] - }, - ] + } + } } ``` @@ -254,4 +268,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..919927a9 100644 --- a/docs/guides/migrating-to-v3.md +++ b/docs/guides/migrating-to-v3.md @@ -41,6 +41,25 @@ 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, 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" key = "production" + color = "EEEEEE" name = "Production" +} color = "EEEEEE" + } + } +``` + +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] }`. + +~> **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 You need the following things to complete this migration: @@ -74,6 +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 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 @@ -82,7 +102,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..017f2b89 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,11 +41,13 @@ resource "launchdarkly_project" "example" { key = "example-project" name = "Example project" - environments = [{ - key = "production" - name = "Production" - color = "EEEEEE" - }] + environments = { + "production" = { + key = "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..4be770c5 100644 --- a/docs/resources/context_kind.md +++ b/docs/resources/context_kind.md @@ -29,11 +29,13 @@ 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" = { + key = "production" + name = "Production" + color = "000000" + } + } } resource "launchdarkly_context_kind" "organization" { diff --git a/docs/resources/project.md b/docs/resources/project.md index bff2042f..5874081f 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -27,8 +27,13 @@ resource "launchdarkly_project" "example" { require_view_association_for_new_flags = false require_view_association_for_new_segments = false - environments = [ - { + # 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" @@ -39,14 +44,14 @@ resource "launchdarkly_project" "example" { min_num_approvals = 3 required_approval_tags = ["approvals_required"] }] - }, - { + } + "staging" = { key = "staging" name = "Staging" color = "000000" tags = ["terraform"] - }, - ] + } + } } ``` @@ -55,9 +60,13 @@ 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. +- `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. --> **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)) +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. @@ -78,7 +87,6 @@ resource "launchdarkly_project" "example" { 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: @@ -88,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. @@ -134,7 +143,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`. `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" { @@ -142,11 +151,18 @@ 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 = { + "production" = { + key = "production" + name = "Production" + color = "EEEEEE" + } + } + # after the initial create, environment changes are ignored } ``` -**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. 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/docs/resources/view_filter_links.md b/docs/resources/view_filter_links.md index 75ac1447..32bcbcbe 100644 --- a/docs/resources/view_filter_links.md +++ b/docs/resources/view_filter_links.md @@ -38,7 +38,7 @@ resource "launchdarkly_view_filter_links" "platform_resources" { view_key = "platform-team" flag_filter = "tags:platform" segment_filter = "tags anyOf [\"platform\"]" - segment_filter_environment_id = launchdarkly_project.my_project.environments[0].client_side_id + segment_filter_environment_id = launchdarkly_project.my_project.environments["production"].client_side_id } # Link only segments matching a filter @@ -46,7 +46,7 @@ resource "launchdarkly_view_filter_links" "beta_segments" { project_key = "my-project" view_key = "beta-program" segment_filter = "tags anyOf [\"beta\"]" - segment_filter_environment_id = launchdarkly_project.my_project.environments[0].client_side_id + segment_filter_environment_id = launchdarkly_project.my_project.environments["production"].client_side_id } ``` @@ -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..3aceccfd 100644 --- a/examples/provider/provider.tf +++ b/examples/provider/provider.tf @@ -27,11 +27,13 @@ resource "launchdarkly_project" "example" { key = "example-project" name = "Example project" - environments = [{ - key = "production" - name = "Production" - color = "EEEEEE" - }] + environments = { + "production" = { + key = "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..9b58521e 100644 --- a/examples/resources/launchdarkly_context_kind/resource.tf +++ b/examples/resources/launchdarkly_context_kind/resource.tf @@ -1,11 +1,13 @@ resource "launchdarkly_project" "example" { key = "example-project" name = "Example Project" - environments = [{ - key = "production" - name = "Production" - color = "000000" - }] + environments = { + "production" = { + key = "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..ee807a9e 100644 --- a/examples/resources/launchdarkly_project/resource.tf +++ b/examples/resources/launchdarkly_project/resource.tf @@ -10,8 +10,13 @@ resource "launchdarkly_project" "example" { require_view_association_for_new_flags = false require_view_association_for_new_segments = false - environments = [ - { + # 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" @@ -22,12 +27,12 @@ resource "launchdarkly_project" "example" { min_num_approvals = 3 required_approval_tags = ["approvals_required"] }] - }, - { + } + "staging" = { key = "staging" name = "Staging" color = "000000" tags = ["terraform"] - }, - ] + } + } } diff --git a/examples/resources/launchdarkly_view_filter_links/resource.tf b/examples/resources/launchdarkly_view_filter_links/resource.tf index c206cb48..99924275 100644 --- a/examples/resources/launchdarkly_view_filter_links/resource.tf +++ b/examples/resources/launchdarkly_view_filter_links/resource.tf @@ -11,7 +11,7 @@ resource "launchdarkly_view_filter_links" "platform_resources" { view_key = "platform-team" flag_filter = "tags:platform" segment_filter = "tags anyOf [\"platform\"]" - segment_filter_environment_id = launchdarkly_project.my_project.environments[0].client_side_id + segment_filter_environment_id = launchdarkly_project.my_project.environments["production"].client_side_id } # Link only segments matching a filter @@ -19,5 +19,5 @@ resource "launchdarkly_view_filter_links" "beta_segments" { project_key = "my-project" view_key = "beta-program" segment_filter = "tags anyOf [\"beta\"]" - segment_filter_environment_id = launchdarkly_project.my_project.environments[0].client_side_id + segment_filter_environment_id = launchdarkly_project.my_project.environments["production"].client_side_id } 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..745ee8c9 100644 --- a/launchdarkly/environments_framework.go +++ b/launchdarkly/environments_framework.go @@ -4,15 +4,24 @@ 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), 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 // 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/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -27,9 +36,10 @@ 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. 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"` @@ -64,20 +74,26 @@ 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{ +// 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{ 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)}, + 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{ - Required: true, - Description: addForceNewDescription("The project-unique key for the environment.", true), - Validators: []validator.String{keyValidator()}, + 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, @@ -154,27 +170,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 +211,12 @@ func environmentPostsFromPlan(ctx context.Context, list types.List) ([]ldapi.Env return posts, diags } -func environmentPostFromModel(_ context.Context, m environmentModel) (ldapi.EnvironmentPost, diag.Diagnostics) { +// 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(), - Key: m.Key.ValueString(), + Key: key, Color: m.Color.ValueString(), } if !m.DefaultTTL.IsNull() && !m.DefaultTTL.IsUnknown() { @@ -234,40 +263,29 @@ 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} - 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 - } - added[envKey] = true - obj, d := environmentObjectFromAPI(ctx, envAPI, &p) - diags.Append(d...) - ordered = append(ordered, obj) - } +// environmentsMapFromAPI flattens the LD environments slice into a +// 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) { + priorModels, diags := environmentModelsFromMap(ctx, prior) + elements := map[string]attr.Value{} for _, e := range envs { - if added[e.Key] { - continue + var pm *environmentModel + if m, ok := priorModels[e.Key]; ok { + mc := m + pm = &mc } - obj, d := environmentObjectFromAPI(ctx, e, nil) + obj, d := environmentObjectFromAPI(ctx, e, pm) diags.Append(d...) - ordered = append(ordered, obj) + elements[e.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) { diff --git a/launchdarkly/framework_state_upgrade.go b/launchdarkly/framework_state_upgrade.go index f719072b..899040ac 100644 --- a/launchdarkly/framework_state_upgrade.go +++ b/launchdarkly/framework_state_upgrade.go @@ -83,6 +83,64 @@ 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 { + // 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)...) + 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{ + KEY: e.Key, + 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..6727a0c9 100644 --- a/launchdarkly/framework_state_upgrade_unit_test.go +++ b/launchdarkly/framework_state_upgrade_unit_test.go @@ -97,6 +97,93 @@ 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 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") + } + 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") + } + + // 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())) + } +} + 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..6cd33643 100644 --- a/launchdarkly/resource_launchdarkly_project_test.go +++ b/launchdarkly/resource_launchdarkly_project_test.go @@ -11,17 +11,24 @@ import ( // 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 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" { 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,83 @@ 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" + } + } }` + + 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" + } + } +} +` ) func TestAccProject_Create(t *testing.T) { @@ -271,10 +314,12 @@ 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"), + // key is Optional+Computed and defaults to the map key even + // when omitted from config. + resource.TestCheckResourceAttr(resourceName, "environments.test-env.key", "test-env"), ), }, { @@ -285,10 +330,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 +421,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 +445,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 +482,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 +510,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 +548,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 +568,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 +611,59 @@ 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_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.rename" + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckProjectDestroy, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(testAccProjectRenameEnvA, projectKey), + Check: resource.ComposeTestCheckFunc( + testAccCheckProjectExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "environments.%", "1"), + resource.TestCheckResourceAttr(resourceName, "environments.alpha.key", "alpha"), + testAccCheckEnvironmentExistsInProject(projectKey, "alpha"), + ), + }, + { + // 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), + resource.TestCheckResourceAttr(resourceName, "environments.%", "1"), + resource.TestCheckResourceAttr(resourceName, "environments.beta.key", "beta"), + resource.TestCheckNoResourceAttr(resourceName, "environments.alpha.key"), + testAccCheckEnvironmentExistsInProject(projectKey, "beta"), + ), }, }, }) @@ -604,11 +678,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 +694,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 +710,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 +785,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..d320724a 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 { @@ -37,7 +40,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 +176,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 +200,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)...) }, }, @@ -242,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 @@ -271,7 +250,22 @@ 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, ", ")), + ) + } } + envs, diags := markEnvSecretsUnknown(ctx, plan.Environments, state.Environments) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -282,44 +276,89 @@ func (r *ProjectResource) ModifyPlan(ctx context.Context, req resource.ModifyPla 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() || config.Environments.IsUnknown() { + return + } + models, diags := environmentModelsFromMap(ctx, config.Environments) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + 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 / -// 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 +366,20 @@ 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 { + // Pin the Optional+Computed `key` to the map key. The schema otherwise + // synthesizes "" for a new env's omitted key, which Apply replaces with + // the real key — an inconsistency the framework reports as "sensitive" + // because the env object contains sensitive members. + attrs[KEY] = types.StringValue(key) + if secrets, ok := stateByKey[key]; ok { attrs[API_KEY] = secrets.api attrs[MOBILE_KEY] = secrets.mobile attrs[CLIENT_SIDE_ID] = secrets.csid @@ -351,11 +390,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 +576,35 @@ func (r *ProjectResource) applyProjectUpdates(ctx context.Context, projectKey st } } - // Environment reconciliation - planEnvs, d := environmentModelsFromList(ctx, plan.Environments) + // 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. + // + // 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 + } + + 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{} } } @@ -561,12 +614,13 @@ 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 _, 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 +689,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..39af0b45 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" @@ -35,10 +36,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. @@ -124,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) } @@ -179,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.