From d8c84b0b50640864192395345eac2c4a1ae503c4 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Tue, 14 Jul 2026 15:24:06 -0300 Subject: [PATCH 1/3] fix(scope_definition): narrow ignore_changes to the fields that actually drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit service_specification.attributes: the API silently persists a `values = {}` key that the rendered template never sets, causing perpetual drift on every plan. Mirror it in config via merge() instead of hiding the diff. service_specification.name/type, scope_type.status, action_specification. name/annotations/parameters/results/retryable/icon: verified via repeated plan+apply+plan cycles to never actually drift; removed from ignore_changes so real changes are no longer masked. scope_type.provider_type and action_specification.type: confirmed root cause via forced-drift testing — both are populated from a data.external keyed on service_specification.id, so ANY pending change on service_specification (even unrelated ones, like the attributes fix above) replans them as (known after apply). Since both are ForceNew, this forces a phantom destroy+recreate — of scope_type, and of every action_specification instance at once. These two must stay ignored. --- nullplatform/scope_definition/main.tf | 44 +++++++++++++-------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/nullplatform/scope_definition/main.tf b/nullplatform/scope_definition/main.tf index bdebf2f1..9086d08c 100644 --- a/nullplatform/scope_definition/main.tf +++ b/nullplatform/scope_definition/main.tf @@ -6,11 +6,14 @@ resource "nullplatform_service_specification" "from_template" { depends_on = [ data.external.service_spec ] - name = var.service_spec_name - visible_to = concat(local.service_spec_parsed.visible_to, var.extra_visible_to_nrns) - assignable_to = local.service_spec_parsed.assignable_to - type = local.service_spec_parsed.type - attributes = jsonencode(local.service_spec_parsed.attributes) + name = var.service_spec_name + visible_to = concat(local.service_spec_parsed.visible_to, var.extra_visible_to_nrns) + assignable_to = local.service_spec_parsed.assignable_to + type = local.service_spec_parsed.type + # The API persists a `values = {}` key inside `attributes` that the + # template never sets, causing perpetual drift on every plan. Mirroring + # it here keeps config and state in sync instead of masking the diff. + attributes = jsonencode(merge({ values = {} }, local.service_spec_parsed.attributes)) use_default_actions = local.service_spec_parsed.use_default_actions selectors { @@ -19,13 +22,6 @@ resource "nullplatform_service_specification" "from_template" { provider = local.service_spec_parsed.selectors.provider sub_category = local.service_spec_parsed.selectors.sub_category } - - # `visible_to` deliberately NOT in ignore_changes so updates from - # `var.extra_visible_to_nrns` flow via `tofu apply`. The other ignored - # attributes (name, attributes, type) are server-enriched after create. - lifecycle { - ignore_changes = [name, attributes, type] - } } ################################################################################ @@ -43,16 +39,14 @@ resource "nullplatform_scope_type" "from_template" { provider_id = local.service_specification_id provider_type = local.scope_type_def.provider_type - # `provider_type` is read from a `data.external` (gomplate-rendered template) - # which Terraform plans as `(known after apply)`. Combined with the provider - # marking `provider_type` as ForceNew, every plan triggers a phantom replace - # even when the upstream template value is unchanged. `status` is server- - # managed (not user-set). Ignoring both is safe — neither field can be - # mutated after create in any meaningful way — and prevents the false - # `must be replaced` diff that would otherwise destroy and re-create the - # scope_type on every apply. + # `provider_type` comes from a `data.external` keyed on + # `service_specification.id`. Any pending change on `service_specification` + # (even in an unrelated field) replans that data source as + # `(known after apply)`, and since provider_type is ForceNew, the phantom + # unknown forces a destroy+recreate of scope_type. Confirmed by forcing a + # drift on service_specification and observing the cascade. lifecycle { - ignore_changes = [provider_type, status] + ignore_changes = [provider_type] } } @@ -75,8 +69,14 @@ resource "nullplatform_action_specification" "from_templates" { icon = try(jsondecode(base64decode(data.external.action_specs[each.key].result.json_b64)).icon, "") annotations = jsonencode(try(jsondecode(base64decode(data.external.action_specs[each.key].result.json_b64)).annotations, {})) + # `type` comes from the same per-service_specification `data.external` + # pattern as scope_type.provider_type above. Any pending change on + # `service_specification` replans it as `(known after apply)`, and since + # `type` is ForceNew, it forces a destroy+recreate of EVERY + # action_specification instance at once. Confirmed by forcing a drift on + # service_specification and observing the cascade. lifecycle { - ignore_changes = [name, annotations, parameters, results, type, retryable, icon] + ignore_changes = [type] } } From b19e6deef3aab1f68869251ed68fc52cf6faa696 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Tue, 14 Jul 2026 15:53:53 -0300 Subject: [PATCH 2/3] fix(scope_definition): remove redundant depends_on causing phantom replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data.external.scope_type and data.external.action_specs each carried an explicit `depends_on = [nullplatform_service_specification.from_template]` that was already redundant — both implicitly depend on that resource via SERVICE_SPECIFICATION_ID (derived from its .id). The explicit whole-resource depends_on made OpenTofu defer the entire data read to apply-time whenever service_specification had ANY pending change, marking their results `(known after apply)`. Since provider_type/type are ForceNew, that phantom unknown forced a destroy+recreate of scope_type and of every action_specification instance. Removing the redundant depends_on eliminates the cascade at the source: confirmed via forced-drift testing that scope_type and action_specification show zero diff even while service_specification has a real pending change. This makes the previous ignore_changes=[provider_type]/[type] workaround unnecessary — removed along with the comments explaining it. --- nullplatform/scope_definition/data.tf | 13 +++++++++++-- nullplatform/scope_definition/main.tf | 20 -------------------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/nullplatform/scope_definition/data.tf b/nullplatform/scope_definition/data.tf index 56c95c92..d2586e5b 100644 --- a/nullplatform/scope_definition/data.tf +++ b/nullplatform/scope_definition/data.tf @@ -34,9 +34,15 @@ data "external" "service_spec" { } # Process scope type template with service specification context +# `depends_on` deliberately does NOT list `nullplatform_service_specification`: +# the implicit reference to its `.id` via SERVICE_SPECIFICATION_ID already +# creates that edge. An explicit whole-resource depends_on here defers this +# entire data source to apply-time whenever service_specification has ANY +# pending change, marking provider_type unknown and forcing a phantom +# destroy+recreate of scope_type (provider_type is ForceNew). Confirmed by +# forcing a drift on service_specification with/without this depends_on. data "external" "scope_type" { depends_on = [ - nullplatform_service_specification.from_template, data.http.scope_type_template ] @@ -52,10 +58,13 @@ data "external" "scope_type" { } # Process all action specification templates with full service context +# `depends_on` deliberately does NOT list `nullplatform_service_specification` +# — see the comment on `data.external.scope_type` above. Same cascade, but +# it forces a phantom destroy+recreate of EVERY action_specification +# instance at once (their `type` is ForceNew). data "external" "action_specs" { for_each = toset(var.action_spec_names) depends_on = [ - nullplatform_service_specification.from_template, data.http.action_templates ] diff --git a/nullplatform/scope_definition/main.tf b/nullplatform/scope_definition/main.tf index 9086d08c..2c41801d 100644 --- a/nullplatform/scope_definition/main.tf +++ b/nullplatform/scope_definition/main.tf @@ -38,16 +38,6 @@ resource "nullplatform_scope_type" "from_template" { description = var.service_spec_description provider_id = local.service_specification_id provider_type = local.scope_type_def.provider_type - - # `provider_type` comes from a `data.external` keyed on - # `service_specification.id`. Any pending change on `service_specification` - # (even in an unrelated field) replans that data source as - # `(known after apply)`, and since provider_type is ForceNew, the phantom - # unknown forces a destroy+recreate of scope_type. Confirmed by forcing a - # drift on service_specification and observing the cascade. - lifecycle { - ignore_changes = [provider_type] - } } ################################################################################ @@ -68,16 +58,6 @@ resource "nullplatform_action_specification" "from_templates" { retryable = try(jsondecode(base64decode(data.external.action_specs[each.key].result.json_b64)).retryable, false) icon = try(jsondecode(base64decode(data.external.action_specs[each.key].result.json_b64)).icon, "") annotations = jsonencode(try(jsondecode(base64decode(data.external.action_specs[each.key].result.json_b64)).annotations, {})) - - # `type` comes from the same per-service_specification `data.external` - # pattern as scope_type.provider_type above. Any pending change on - # `service_specification` replans it as `(known after apply)`, and since - # `type` is ForceNew, it forces a destroy+recreate of EVERY - # action_specification instance at once. Confirmed by forcing a drift on - # service_specification and observing the cascade. - lifecycle { - ignore_changes = [type] - } } ################################################################################ From cb024fc9e9c186c783e00114bd6110cc0622c5d7 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Tue, 14 Jul 2026 16:00:56 -0300 Subject: [PATCH 3/3] fix(scope_definition_agent_association): remove unneeded ignore_changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit source/type on nullplatform_notification_channel had no explicit resource-level depends_on and no cascade risk (their inputs come from already-known module output variables from scope_definition, which no longer produces phantom unknowns after the earlier fix). Verified via repeated plan+apply+plan cycles, including forcing a real drift upstream in service_specification, that neither field ever drifts or cascades. replace_triggered_by on terraform_data.api_key_trigger is untouched — it's a deliberate mechanism to rotate the notification channel when api_key changes, unrelated to this drift investigation. --- nullplatform/scope_definition_agent_association/main.tf | 4 ---- 1 file changed, 4 deletions(-) diff --git a/nullplatform/scope_definition_agent_association/main.tf b/nullplatform/scope_definition_agent_association/main.tf index ace6a45b..ebdf6a01 100644 --- a/nullplatform/scope_definition_agent_association/main.tf +++ b/nullplatform/scope_definition_agent_association/main.tf @@ -41,10 +41,6 @@ resource "nullplatform_notification_channel" "from_template" { } filters = local.merged_filters_json lifecycle { - ignore_changes = [ - source, - type, - ] replace_triggered_by = [terraform_data.api_key_trigger] } }