Cloud Infrastructure · Infrastructure as Code

Importing Existing Resources into a Declarative Configuration

What adoption actually writes into state, why a written resource block must match the object before the plan is clean, and where import cannot reach.

Provisioning tools are good at creating objects and indifferent to objects that already exist. The model has no way to discover that an unmanaged resource should belong to a configuration; the configuration names addresses, state maps those addresses to identifiers, and an object with no mapping is simply not part of the world the tool reasons about. Import is the operation that creates the mapping for an object that was made some other way.

It is a narrower operation than it sounds. Import writes state; it does not write configuration. After an import the object is managed, but the configuration that is supposed to describe it may not, and the next plan will show every attribute that differs as a change to be applied. Adoption is therefore a two-part problem: binding the identity, then reconciling the description.

What the command actually does

An import takes an address and a provider-specific identifier. The address is the configuration address the object should occupy (including module path and instance key) and the identifier is whatever the provider uses to locate the object, such as a resource id or a name. The provider reads the object, maps it into its schema, and writes a state entry.

Nothing else happens. The configuration file is untouched. If the address does not exist in the configuration at all, some versions refuse the import and others accept it and leave an orphaned state entry that no configuration refers to. That entry is worse than nothing: the next plan sees a state address with no configuration counterpart and proposes destroying the object.

The identifier format is provider-specific and not standardized. Some providers use a single opaque string, some use a composite of several fields joined by a separator, and the separator itself is a provider convention. Getting it wrong produces a not-found error that looks like a permissions problem. This is the main reason imports are error-prone when done by hand.

The plan after import is the real work

An imported object is described by the API, not by the configuration. The provider records what it read, which includes server-assigned defaults, generated names, and fields the configuration author never thought about. The configuration, meanwhile, is usually written from memory or from a template. The diff between them is the difference between the two descriptions.

Three outcomes are possible for each attribute:

  • The configuration omits a field that the object has. Depending on the schema, this is either no change (the field is optional and the recorded value stands) or a change to the default.
  • The configuration sets a field to a value the object does not have. This is a proposed update, which is correct and should be reviewed.
  • The configuration sets a field that requires replacement to change. This is the dangerous case: the plan proposes destroying and recreating an object that was imported precisely because it was not to be disturbed.

The third case is why the standard advice is to import with the smallest possible configuration (the resource block with only required arguments) then run a plan, then fill in the optional attributes from the recorded state. Each pass narrows the diff. Filling in from the state rather than from intent is the part that feels backwards: it means the configuration is being written to match the object rather than the object being changed to match the configuration. That is the correct order for an object that is already serving traffic, and the wrong order for an object that was created by accident and should be corrected.

Generated configuration is a starting point, not a result

Some tools can emit a configuration block from an imported object. Terraform’s -generate-config-out option writes blocks for resources that are imported but not declared, producing HCL from the schema and the recorded attributes.

The output is mechanical. It contains every attribute the schema exposes, including computed ones that cannot be set, which the generator usually omits, and it contains no structure: no variables, no loops, no module boundaries, no naming conventions. It reproduces the object, not the intent. A configuration generated this way will produce a clean plan and will be unmaintainable, because every environment gets a hard-coded copy.

The useful pattern is to generate, read the result to learn the object’s real shape, then rewrite it as a parameterized block with the environment-specific values extracted. The generated file is a specification of the object, and the rewrite is the design work.

Import blocks and the move to declarative adoption

Older import workflows are imperative: a command run once per object, against a specific state file, by someone who has the credentials. The command is not in version control, so the record of what was imported lives in state and in whoever’s memory performed it.

Newer tools support import as a configuration construct, a block that names an address and an identifier, evaluated during plan. The plan shows the import as a change, the apply performs it, and the record travels with the code. This has three consequences worth noting. Imports become reviewable in the same diff as everything else. A fresh environment can be built by importing its existing objects and then applying, without a separate manual step. And once an object is in state, the block that imported it no longer produces a change, so it stays in the configuration as dead weight until someone removes it, a small but real maintenance obligation that the imperative form did not have.

The block form also makes bulk adoption tractable. A list of identifiers can be turned into a generated set of import blocks, applied once, and then removed. The failure mode is partial application: if the apply stops after importing half the list, the remaining blocks still name objects, and the state now contains some of them. Re-running the plan is safe because the import is idempotent for objects already bound, but the operator must be able to tell which half succeeded.

Where import does not reach

Import binds an identity. It cannot bind an identity that does not exist in the provider’s model. Objects that the provider does not expose (a virtual machine’s guest-level users, files inside a bucket, records in a managed database) have no address and cannot be imported. Managing them requires a different tool, usually one that runs inside the machine rather than against the control-plane API.

Import also cannot recover objects whose identifiers are not unique enough to address. Some APIs identify an object by a path that includes a parent, and if the parent is also unmanaged, the import must start from the top. Composite identifiers are common in nested resources, and the provider documentation is the only reliable source for their format.

Finally, import does not remove the need for state surgery in the cases where the object’s address must change. Importing at address A and then deciding it belongs at address B requires a move, which is the same operation described in state as a mapping. The mapping is a label, and relabeling after adoption is no cheaper than relabeling before it.

Adopting a whole environment

A migration from hand-built infrastructure to a managed configuration is the same operation repeated at scale, plus a coverage question. The coverage question is which objects should be managed, and it is answered by inventory rather than by the tool: list what exists, decide what belongs in the configuration, and treat the rest as explicitly out of scope. Objects left unmanaged are not invisible to the account’s cost or security posture, only to the configuration.

The order that works is bottom-up along the dependency graph. Network primitives first, then the objects built on them, because importing a dependent object before its parent means the identifier cannot be resolved and the reference in the configuration points at an unmapped address. The graph that orders creation orders adoption for the same reason: data flows from parent to child, and the child’s identifier is derived from the parent’s.

The end state is a configuration that produces a clean plan against a world it did not create. That is the strongest available evidence that the description is accurate, and it is the precondition for the next apply to be a no-op rather than an unplanned rebuild.

References

  1. Terraform CLI Documentation: terraform import — HashiCorp
  2. Terraform Language: Import — HashiCorp
  3. Kubernetes Documentation: Server-Side Apply — Kubernetes project