Importing Existing Infrastructure into CDKTF

Almost no CDKTF project starts on an empty cloud account. There is a VPC someone built by hand in 2021, a database that predates the platform team, and a pile of buckets created by a CI job nobody owns. Adoption — bringing those objects under CDKTF management without recreating them — is the part of a migration that stalls. This topic sits inside CDKTF workflows and Terraform synthesis and leans hard on two things covered elsewhere: how CDKTF architecture and synthesis turns Python constructs into HCL JSON, and how managing IaC state treats the state file as the authoritative record of what your code owns. Import is where those two meet, and where the details bite.

Problem Framing

Writing infrastructure code for a green field is a one-way function. You describe what you want, Terraform creates it, and any mismatch between intent and reality is resolved in favour of the code. Adoption inverts that. The cloud objects already exist, they are serving production traffic, and now the code has to converge on them. You are solving for a fixed point: a Python program whose synthesized configuration describes the live object accurately enough that the planner proposes nothing at all.

That inversion is why import is the hardest phase of a migration, and it fails in a specific and expensive way. A missing state entry means the planner sees a resource in the configuration with no counterpart in state, so it proposes to create one — and a create against an object that already exists either errors out or, worse, quietly makes a second one. A state entry with mismatched configuration is more dangerous still: the planner sees a managed object whose attributes differ from the code and proposes to fix it. When the differing attribute happens to be a force-new attribute, "fix" means destroy and recreate. A single careless apply against a wrongly-adopted database is the failure mode every migration plan is written to avoid.

The import gap The import gap: A pre-existing estate with 4 facets. A pre-existing estate Cloud API the resources exist and serve traffic State file holds no record of them yet Python code no construct declares them Plan offers to create duplicates
Import closes three gaps at once: the object, the state entry, and the construct that describes it.

Three separate things have to line up before a resource is genuinely adopted. The object must exist (it already does). The state must contain an entry mapping a resource address to that object's provider-assigned ID. And the configuration must describe the object closely enough that refreshing state from the live API produces no diff against the code. Import mechanisms only solve the middle one. The third is your job, and it is where most of the work lives.

Prerequisites

Before importing anything, the boring preconditions matter more than usual, because an import writes to state and a wrong state write is harder to reason about than a wrong resource.

  • CDKTF 0.20 or newer. The import_from() and move_to() construct methods that make adoption reproducible landed in 0.20. Older versions can still be adopted with the CLI, but you lose the ability to express the import in Python.
  • Terraform 1.5 or newer in the CDKTF runtime, for import blocks and plan -generate-config-out.
  • A remote backend already configured for the stack you are importing into, with locking. Follow state backend configuration for CDKTF first — importing into a local state file you later have to migrate doubles the risk surface.
  • A state backup you can actually restore. For S3 backends, that means versioning enabled on the bucket and a known-good version ID written down before you start.
  • Read credentials wide enough to describe every resource, and — for the duration of the import wave — apply credentials that are deliberately narrower than production's, so a mistaken apply cannot delete a database.
# CLI: confirm the toolchain supports the import features this topic relies on
cdktf --version                 # 0.20+ for import_from() / move_to()
terraform version               # 1.5+ for import blocks and -generate-config-out
aws sts get-caller-identity     # confirm you are pointed at the account you think you are

State Import and Code Generation Are Two Different Problems

Practitioners conflate these constantly, and the conflation is the source of most botched migrations. They are separate operations with separate failure modes.

Importing into state attaches an existing object to a resource address. Terraform calls the provider's importer for that resource type, which reads the object from the cloud API and writes a full instance record into state. Nothing in the cloud changes. Nothing in your Python changes. All that happens is that the state file grows one entry, and the planner starts comparing your configuration against a real object instead of against nothing.

Generating code that matches is the inverse: producing Python whose synthesized attributes equal what the provider just read. This is genuinely hard because live objects carry attributes nobody set deliberately — default security group rules, server-side encryption blocks the console added, tags injected by an organisation policy, an aws_s3_bucket with a lifecycle configuration that now lives in a separate aws_s3_bucket_lifecycle_configuration resource. Every one of those is a diff until your code accounts for it.

You can do either one first, and the order changes the experience. Import-then-write means the planner tells you exactly what is missing: run a plan, read the proposed changes, and each one is a line of Python you have not written yet. Write-then-import means you guess at the configuration up front and find out at import time how close you were. The first loop is shorter and is what generating CDKTF Python code from Terraform state is built around: let the state entry and terraform state show drive the code, rather than writing code from memory of what the console displays.

The Address Is Produced by Synthesis, Not Chosen by You

This is the single most CDKTF-specific fact about importing, and it catches every practitioner arriving from plain Terraform. In HCL you write resource "aws_s3_bucket" "reports" { … } and the address is aws_s3_bucket.reports, because you typed it. In CDKTF you never type an address. You give a construct an ID, the construct sits somewhere in a tree, and synthesis computes a logical ID from that construct's full path within its stack. The import target is whatever synthesis decided.

How an import address is derived How an import address is derived: Python construct then construct path then logical ID then cdk.tf.json then import target Python construct App / Stack / id construct path scope hierarchy logical ID name plus hash cdk.tf.json resource block key import target aws_s3_bucket.assets
The address you import against is produced by synthesis, not chosen by you.

The derivation rule is worth internalising. When a resource is instantiated directly on a TerraformStack, its path inside the stack has exactly one component, and CDKTF uses that construct ID verbatim as the logical ID. Nest the same resource inside an intermediate Construct and the path gains components; CDKTF then joins the components and appends a short hash of the full path to guarantee uniqueness. aws_s3_bucket.reports becomes something like aws_s3_bucket.storage_reports_A1B2C3D4. The hash is stable for a given path and changes the moment you rename any construct along that path.

# main.py — construct IDs and the stack ID decide the addresses you will import against
# CLI: cdktf synth
from constructs import Construct
from cdktf import App, TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket


class LegacyDataStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str, region: str) -> None:
        super().__init__(scope, ns)
        AwsProvider(self, "aws", region=region)

        # Declared directly on the stack: one path component, so the logical ID is
        # the construct ID verbatim and the address is aws_s3_bucket.reports
        self.reports: S3Bucket = S3Bucket(self, "reports", bucket="acme-prod-reports")
        # State implication: with nothing in state, a plan here proposes CREATE, and the
        # apply fails with "BucketAlreadyOwnedByYou" instead of adopting the bucket.


def main() -> None:
    app = App()
    # Stack ID becomes the output directory: cdktf.out/stacks/prod-data
    LegacyDataStack(app, "prod-data", region="eu-west-1")
    app.synth()


if __name__ == "__main__":
    main()

The stack ID matters just as much. It names the directory under cdktf.out/stacks/, which is the working directory every terraform command must be pointed at, and in most backend configurations it also feeds the state key. Renaming a stack after you have imported into it strands the state at the old key and presents you with an apparently empty stack full of resources that already exist.

Reading the real addresses out of cdktf.out

Never guess an address. Synthesize and read it. cdk.tf.json is a plain JSON document whose resource object is keyed by resource type, then by logical ID, so the full set of valid import targets is two levels of keys.

# CLI: list every address the current synthesis will accept as an import target
cdktf synth
jq -r '.resource | to_entries[] | .key as $type | .value | keys[] | "\($type).\(.)"' \
  cdktf.out/stacks/prod-data/cdk.tf.json | sort

Keep that list. It is the contract between your Python and your state, and it is worth freezing in a test so a refactor cannot silently move an address out from under an imported object.

# tests/test_import_addresses.py — freeze the addresses that adoption depends on
# CLI: pytest tests/test_import_addresses.py -q
import json
from typing import Set

from cdktf import Testing
from main import LegacyDataStack


def synthesized_addresses(stack_json: str) -> Set[str]:
    doc = json.loads(stack_json)
    return {
        f"{res_type}.{logical_id}"
        for res_type, block in doc.get("resource", {}).items()
        for logical_id in block
    }


def test_adopted_addresses_are_stable() -> None:
    app = Testing.app()
    stack = LegacyDataStack(app, "prod-data", region="eu-west-1")
    addresses = synthesized_addresses(Testing.synth(stack))
    # State implication: if this assertion breaks, every imported object under that
    # address needs a moved block before the next apply, or it will be recreated.
    assert "aws_s3_bucket.reports" in addresses

If a nested construct produces an address you find unusable — and hashed logical IDs are awkward to type into an import command a hundred times — you can pin it with override_logical_id(). Do this deliberately, not reflexively: the hash exists to stop two constructs at different paths colliding on one address.

# network.py — nesting changes the address; pin it only when you have a reason
# CLI: cdktf synth && jq '.resource.aws_vpc | keys' cdktf.out/stacks/prod-data/cdk.tf.json
from constructs import Construct
from cdktf_cdktf_provider_aws.vpc import Vpc


class NetworkFabric(Construct):
    def __init__(self, scope: Construct, ns: str, cidr: str) -> None:
        super().__init__(scope, ns)
        self.vpc: Vpc = Vpc(self, "main", cidr_block=cidr)
        # Provider note: unpinned, the address here is aws_vpc.network_main_<hash8>,
        # because the construct path now has two components inside the stack.
        self.vpc.override_logical_id("primary_vpc")
        # State implication: the address becomes aws_vpc.primary_vpc permanently —
        # changing or removing this call later orphans the state entry.

Choosing an Import Mechanism

There are four ways to get an existing object attached to one of those addresses, and they differ mainly in where the record of the import lives afterwards.

Three ways to attach an object Three ways to attach an object: comparison across Lives in, Undo, CDKTF fit. Mechanism Lives in Undo CDKTF fit terraform import CLI history state rm works, not replayable import block cdk.tf.json delete block needs an override import_from() Python source delete call native, reviewable generate config plan artifact delete file seeds the Python
The mechanism decides whether the adoption survives a fresh clone of the repository.

The terraform import command is the original mechanism. It is imperative, it runs against one address at a time, and it writes state immediately with no plan step. Its weakness in a CDKTF project is that it leaves no artefact: a colleague who clones the repository and runs against a fresh state has no idea which objects were adopted or with what IDs. It is still the right tool for exploratory work and for one-off repairs.

# CLI: attach the live bucket to the synthesized address in this stack's state
cdktf synth
terraform -chdir=cdktf.out/stacks/prod-data init
terraform -chdir=cdktf.out/stacks/prod-data import aws_s3_bucket.reports acme-prod-reports
# State implication: one instance record is written to the backend; the cloud is untouched.

The import block is declarative and plannable — Terraform shows you what it intends to import before it does it, so a mistyped ID surfaces in a plan rather than in state. In CDKTF you rarely hand-write one, because the block lives in HCL JSON that synthesis owns. If you do need to inject one directly, note the JSON syntax quirk: the to argument expects a resource reference, not a string, so in JSON it is written as an interpolation of exactly that reference.

{
  "import": [
    {
      "id": "acme-prod-reports",
      "to": "${aws_s3_bucket.reports}"
    }
  ]
}

import_from() on the construct is the CDKTF-native option and the one to reach for by default. It expresses the import in Python, next to the resource it adopts, so it is reviewable in a pull request, reproducible on a fresh state, and impossible to forget to document.

# adopt.py — the object and its adoption are declared in one place
# CLI: cdktf synth && terraform -chdir=cdktf.out/stacks/prod-data plan
self.reports: S3Bucket = S3Bucket(
    self,
    "reports",
    bucket="acme-prod-reports",
    tags={"owner": "platform", "adopted": "2026-Q1"},
)
self.reports.import_from(id="acme-prod-reports")
# Provider note: synthesis emits an import block whose "to" points at this construct's
# logical ID, so renaming the construct also moves the import target.
# State implication: the object enters state on the next apply, not at synth time.

plan -generate-config-out is not an import mechanism at all — it is a code-generation aid that pairs with import blocks. Terraform reads each import target and writes HCL describing the object as it actually is, which you then translate. It is the fastest route from "a hundred untracked resources" to "a hundred draft resource definitions", and it is the backbone of the workflow in importing existing AWS resources into CDKTF Python.

# CLI: let Terraform describe the live objects, then translate the HCL to Python
terraform -chdir=cdktf.out/stacks/prod-data plan -generate-config-out=generated.tf
cdktf convert --language python < cdktf.out/stacks/prod-data/generated.tf > draft_stack.py
# Provider note: generated HCL includes every non-default attribute the provider read,
# including ones you would never have written by hand.

Treat the generated Python as a draft, never as the final artifact. It has no constructs, no shared configuration, no types worth the name, and it will happily hard-code an account ID in forty places. Its value is that it tells you the truth about the live object.

Step-by-Step: Adopting a Single Resource End to End

Do the first one by hand and slowly. The loop you establish here is the one you will run several hundred times.

One import round trip One import round trip: Python source → cdktf synth → terraform → cloud API. Python source cdktf synth terraform cloud API declare cdk.tf.json read object attributes write state diff to close
Every import is a loop: declare, synthesize, read the live object, then narrow the diff.

1. Write the minimum viable construct

Declare the resource with only its identity-defining arguments — the ones the provider requires and the ones that would force replacement if wrong. For a bucket that is the name; for a database instance the identifier, engine, and instance class. Resist the urge to write the full configuration from memory. You are building a target address, not a specification.

2. Synthesize and confirm the address exists

# CLI: the address must appear here before any import can reference it
cdktf synth
jq -r '.resource.aws_s3_bucket | keys[]' cdktf.out/stacks/prod-data/cdk.tf.json

If the address is not in the output, the import will fail with Error: resource address "aws_s3_bucket.reports" does not exist in the configuration. — the configuration must always come first.

3. Find the provider's import ID

The import ID is not always the name or the ARN, and getting it wrong is the most common early error. An S3 bucket imports by bucket name. An IAM role imports by role name. A route table association imports by subnet-id/route-table-id. A security group rule imports by a composite string of the group ID, direction, protocol, port range, and source. Check the resource's import ID format before running anything, and confirm the object exists.

# CLI: confirm the object exists and capture the exact ID the provider will want
aws s3api head-bucket --bucket acme-prod-reports
aws iam get-role --role-name acme-batch-runner --query 'Role.RoleName' --output text

4. Import, then read back what the provider stored

# CLI: import and immediately inspect the attributes the provider recorded
terraform -chdir=cdktf.out/stacks/prod-data import aws_s3_bucket.reports acme-prod-reports
terraform -chdir=cdktf.out/stacks/prod-data state show aws_s3_bucket.reports
# State implication: state now holds every attribute the importer read, which is the
# reference you write the remaining Python against.

state show is the highest-value command in the whole workflow. Everything it prints that your Python does not set is a candidate diff.

5. Close the diff, one plan at a time

Run a plan, read the proposed changes, add the missing arguments to the construct, and run it again. Each iteration should shrink the diff. Stop when the plan is empty — not when it is "small" or "only tags".

Verification: A Zero-Diff Plan Is the Definition of Done

An import is not finished when the state entry appears. It is finished when a plan against the adopted resource proposes nothing. That is the only check that proves the configuration, the state, and the live object all agree, and it is machine-checkable, so it belongs in CI rather than in a person's judgement.

# CLI: the acceptance test for an adopted resource
terraform -chdir=cdktf.out/stacks/prod-data plan -detailed-exitcode -out=adopt.tfplan
# exit 0 = no changes (adoption complete), 2 = changes pending, 1 = error
terraform -chdir=cdktf.out/stacks/prod-data show -json adopt.tfplan \
  | jq -r '.resource_changes[]
           | select(.change.actions != ["no-op"])
           | "\(.address)\t\(.change.actions | join(","))"'

Read the action list carefully, because not all non-empty plans are equally alarming. ["update"] means the configuration and the object differ on an in-place attribute — annoying, usually a tag or a description. ["delete","create"] or ["create","delete"] means replacement, and against an adopted production resource that is a stop-everything signal: something identity-defining in your Python does not match reality. ["read"] on a data source is noise. And an empty resource_changes array with a non-empty output_changes is fine.

Two ways of reaching zero diff are not equivalent. Adding the missing arguments to the construct is a real adoption. Adding ignore_changes to silence the difference is a deferral: the plan goes quiet, but the code no longer describes the object, and the next person to read it will be misled. Reserve ignore_changes for attributes that genuinely have an external owner — an autoscaler writing desired_count, a deployment pipeline rewriting an image tag — and record why in a comment. Anything else you suppress is unfinished work, which is exactly the material resolving drift after importing infrastructure into CDKTF deals with.

One more check is worth running once per wave: a refresh-only plan. It compares state against the live API without considering your configuration, which separates "my code is wrong" from "the object changed underneath me".

# CLI: distinguish configuration drift from real-world drift
terraform -chdir=cdktf.out/stacks/prod-data plan -refresh-only -detailed-exitcode
# Provider note: a non-zero result here means the live object moved, not that your Python is wrong.

Sequencing a Large Estate

A few dozen resources can be adopted in an afternoon. A few thousand cannot, and trying turns into a months-long branch that never merges. The workable approach is waves, ordered by blast radius, each one taken to zero diff and merged before the next begins.

Import waves ordered by blast radius Import waves ordered by blast radius: layered from Wave 1 - inert leaves down to Wave 4 - stateful stores. Wave 1 - inert leaves log groups, ECR repos, SNS topics, IAM policies Wave 2 - network fabric VPC, subnets, route tables, security groups Wave 3 - compute and runtime clusters, task definitions, functions, scaling Wave 4 - stateful stores RDS, ElastiCache, EBS volumes, buckets with data
Each wave must reach a zero-diff plan before the next one starts.

Start with inert leaves. Log groups, ECR repositories, SNS topics, standalone IAM policies, parameter store entries. Nothing depends on them, their attribute surface is small, and if you get one wrong the cost of recreating it is close to zero. This wave exists to shake out the mechanics — backend access, address derivation, review process, CI wiring — against resources where mistakes do not page anyone.

Then the network fabric. VPCs, subnets, route tables, security groups, NAT gateways. These are heavily referenced but rarely modified, which is the ideal profile for early adoption: once they are correct they stay correct, and every later wave can reference them as real construct attributes rather than hard-coded IDs. Budget more time than you expect for security groups, because inline ingress and egress blocks versus standalone aws_vpc_security_group_ingress_rule resources is a genuine modelling decision, not a formality.

Then compute and runtime. Container services, the EKS cluster and its node groups, Lambda functions, task definitions, scaling policies. These have high attribute counts and many defaults injected by the platform, so the diffs are long. They also change often, which means a long-lived import branch will go stale — keep these waves small.

Stateful stores last, with guardrails on. Databases, caches, volumes, and buckets containing data are where a mistaken replacement is unrecoverable. Adopt them with prevent_destroy set from the first commit, and leave it on.

# data.py — adopt stateful resources with a hard stop against replacement
# CLI: cdktf synth && terraform -chdir=cdktf.out/stacks/prod-data plan
from cdktf import TerraformResourceLifecycle
from cdktf_cdktf_provider_aws.db_instance import DbInstance

orders_db: DbInstance = DbInstance(
    self,
    "orders",
    identifier="acme-prod-orders",
    engine="postgres",
    instance_class="db.r6g.xlarge",
    allocated_storage=512,
    skip_final_snapshot=False,
    lifecycle=TerraformResourceLifecycle(prevent_destroy=True),
)
orders_db.import_from(id="acme-prod-orders")
# State implication: prevent_destroy makes any plan that would replace this instance
# fail at plan time with "Instance cannot be destroyed" instead of at apply time.

Two structural decisions make waves easier. First, give each wave its own stack, at least initially — separate state files mean a broken import in one wave cannot lock or corrupt another, and stacks can be merged later with moved blocks once everything is stable. Second, decide early how you will handle infrastructure that is already managed by plain Terraform modules, because that is a migration rather than an import: the objects are already in a state file, and moving them is covered in adopting Terraform modules into a CDKTF stack.

Track progress as a number, not a feeling. The count of resources in the account minus the count of addresses in state is the remaining work, and it should fall monotonically. If it rises, someone is still creating infrastructure outside the code, and that has to stop before adoption can finish.

What to Do About Resources That Cannot Be Imported

Some objects will not come across, and recognising the category quickly saves days of fighting the wrong problem.

When an object will not import When an object will not import: choose among 3 options. Import unsupported orimpossible no importer Recreate it underCDKTF and cut over write-only Ignore the field orsource it at applytime composite id Find the real IDformat the providerexpects
Three distinct failure classes, each with a different escape route.

No importer implemented. Some resource types simply have no import support, and you will get Error: resource type aws_iam_policy_attachment doesn't support import. Others are pure abstractions with nothing behind them — null_resource, terraform_data, provisioner-driven resources. There is no way to attach these to anything; the only path is to declare them in CDKTF and let them be created, then remove whatever the old mechanism was. Where the resource type is a wrapper around a relationship that is importable in another form — a policy attachment expressed instead as aws_iam_role_policy_attachment, which imports by role-name/policy-arn — remodel and import the importable version.

Write-only or unreadable attributes. Passwords, private keys, and generated secrets are never returned by the API, so the importer cannot populate them. The state entry appears with an empty or placeholder value and the very next plan proposes to set it, which for a database master password means an in-place update you may not want. The realistic options are to accept one controlled rotation at adoption time, to source the value from a secret manager so the code and the object agree, or to ignore_changes the attribute and document the external owner. Certificates and key material generated by tls_private_key-style resources fall in the same bucket: they cannot be adopted, only regenerated.

Composite or undocumented ID formats. These are importable; you just have not found the right string. Security group rules, route table associations, and anything representing a many-to-many relationship use compound IDs with provider-specific separators, and a wrong guess produces a message like Error: Wrong format of resource or a bare Cannot import non-existent remote object. The fix is mechanical: find the resource type's documented import ID format, assemble it from the live object's attributes, and retry.

Objects you should not adopt at all are a fourth category worth naming. Resources created by another controller — Kubernetes-managed load balancers, service-mesh sidecars, anything an operator reconciles — will fight your code forever if you import them. Leave them to their owner and reference them as data sources instead.

Troubleshooting

Error: resource address "aws_s3_bucket.reports" does not exist in the configuration. The address you passed does not exist in the synthesized JSON. Re-run cdktf synth, then list the actual keys with jq. Nine times in ten the construct is nested and the real address carries a hash suffix.

Error: Resource already managed by Terraform — the full message continues "To import to this address you must first remove the existing object from the state." You have already imported this address, possibly in an earlier attempt. Inspect it with terraform state show, and if the entry is genuinely wrong, detach it with terraform state rm <address> before re-importing. state rm does not touch the cloud object.

Error: Cannot import non-existent remote object — the provider looked the ID up and found nothing. Usually the ID format is wrong, occasionally the provider is pointed at the wrong region or account. Verify with the cloud CLI in the same credentials the provider is using.

A plan proposing replacement immediately after import. Read the # forces replacement annotations in the plan output; each one names the attribute that disagrees. This is almost always an identity attribute you typed from memory — a name, an availability zone, an engine version, a subnet ID list in a different order. Fix the Python, never the state.

Import blocks that appear to do nothing. An import block whose target is already in state is a no-op by design, which makes stale blocks harmless but confusing. import_from() calls left in the code behave the same way. Clean them out once a wave is verified, so the source stops advertising work that is already done.

Addresses shifting after a refactor. Moving a resource into a construct, renaming a construct, or renaming the stack changes the logical ID and therefore the address, and Terraform reads that as "destroy the old thing, create a new one". Express the change as a move — add_move_target() on the destination and move_to() on the source — so synthesis emits a moved block and the state entry follows the rename instead of being destroyed.

FAQ

Does importing a resource change anything in my cloud account?

No. terraform import, an import block, and import_from() all only read the object and write to state. The danger is the next apply, which will act on whatever difference exists between your configuration and the object the importer just recorded — so always plan before you apply.

Why does my CDKTF import address have a random-looking suffix?

Because the resource is not declared directly on the stack. CDKTF joins the construct path components and appends a short hash of the full path to keep logical IDs unique. The suffix is deterministic for a given path, and you can replace it with a fixed name using override_logical_id() if you accept responsibility for uniqueness.

Can I import several hundred resources in one command?

Not with the CLI, which handles one address per invocation. Import blocks scale better: generate them in bulk, run one plan with -generate-config-out to draft the configuration, and apply once. Even then, keep each batch scoped to a single wave so that a failure is easy to unwind.

What if the resource is already managed by an existing Terraform workspace?

Then it is a state move, not an import. Removing it from the old state with terraform state rm and importing it into the CDKTF stack works, but leaves a window where nothing manages the object. Plan that transition deliberately, and see the guidance on migrating state between backends under managing IaC state.

How do I stop new unmanaged resources appearing while I migrate?

Freeze console and CLI write access for the accounts you are adopting, or at minimum alert on any resource creation whose principal is not the CI role. Adoption cannot converge while the estate is still growing outside the code.

Is a zero-diff plan really achievable on old infrastructure?

Usually yes, but it takes more iterations than people expect — a dozen plan cycles on a complicated resource is normal. Where it is genuinely impossible, the honest outcome is a narrowly scoped ignore_changes with a comment naming the external owner of that attribute, not a broadly suppressed diff.