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 bdebf2f1..2c41801d 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] - } } ################################################################################ @@ -42,18 +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` 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. - lifecycle { - ignore_changes = [provider_type, status] - } } ################################################################################ @@ -74,10 +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, {})) - - lifecycle { - ignore_changes = [name, annotations, parameters, results, type, retryable, icon] - } } ################################################################################ diff --git a/nullplatform/scope_definition_agent_association/main.tf b/nullplatform/scope_definition_agent_association/main.tf index 66697422..68fe7c1f 100644 --- a/nullplatform/scope_definition_agent_association/main.tf +++ b/nullplatform/scope_definition_agent_association/main.tf @@ -42,10 +42,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] } }