TerraformCLI icon
Docker icon
Log icon
If icon
Pause icon
Fail icon
Loop icon
SlackIncomingWebhook icon
Schedule icon

Import unmanaged resources into Terraform state and reconcile

Detect resources Terraform plans to create that already exist, import them behind an approval then verify the object survived.

Categories
Infrastructure

Safety

terraform import plus terraform state rm write directly to state. State is the only record of what Terraform believes it owns. Import the wrong id and the next apply destroys a live resource to build the one the configuration describes. Remove the wrong entry and Terraform forgets a resource it was managing. Neither command touches infrastructure, so nothing warns you at the time: the damage lands on the next apply, which is usually someone else's pipeline run. That is why this flow refuses by default. Every guard below is load bearing. Each one was proven by its own execution on the final revision.

Guard What it stops
Live object read from the Engine API, matched to the planned create by type plus name importing an address whose create is genuinely new work
allowed_address_prefixes writing state for an address outside the scope you named
protected_addresses touching an address you listed as never importable
importable_types writing state for a type whose import id this flow cannot resolve
max_imports a wrong detector writing state for everything in one run
auto_remediate false plus dry_run true plus a Pause read as onResume.approved ?? false three separate gates before one state write
recheck after the approval, compared by address plus live id importing an id that moved while the approval waited
Per target re-read of the object inside the write task acting on an object that changed after the recheck
Already managed check before the write a second runner importing the same address twice
Post import plan check plus terraform state rm leaving behind an import that the next apply would turn into a destroy
Loop with concurrencyLimit: 1 parallel writes fighting over the backend lock
terraform state pull saved before the writes a bad reconcile with no way back
verify plus Fail on the re-read an import that reported success without landing

How it works

  1. detect (io.kestra.plugin.terraform.cli.TerraformCLI) reads the same system twice in one container. inventory.sh reads containers, networks plus volumes straight off the Docker Engine API. Then terraform plan -refresh=true -detailed-exitcode -out=tfplan produces the plan. terraform state pull saves the state as it was before anything is written.
  2. Still in detect, reconcile.jq joins the plan to that inventory. Every create action is looked up in the live inventory by type plus name then classified: import, state_rm_then_import when a state entry records an object that is gone, create_not_a_collision when the create is real new work, outside_allowed_prefix, protected, type_not_importable or recorded_object_still_live. Only the first two are eligible, sorted by address then capped at max_imports.
  3. report (io.kestra.plugin.core.log.Log) prints the plan exit code, the live object count, the state entry count, the pending changes inside the allowed prefixes, every candidate with its verdict plus whether this run may write.
  4. reconcile_gate (io.kestra.plugin.core.flow.If) needs a real collision together with auto_remediate: true plus dry_run: false. Anything else ends at no_write.
  5. approve_import (io.kestra.plugin.core.flow.Pause) waits for a person. when_approved reads onResume.approved ?? false, so a pause resumed without an answer is a refusal that ends at import_declined.
  6. recheck runs the whole detection again after the approval then keeps only the addresses whose live object still carries the id that was approved. fail_when_plan_stale (io.kestra.plugin.core.execution.Fail) ends the run before the first write when anything moved.
  7. import_targets (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) handles one address at a time with item.value. import_one re-reads that single object from the Engine API, exits without writing when the address is already in state with the live id, runs terraform state rm first when the recorded object is gone, then runs terraform import. It plans again immediately: if the imported object does not match the configuration so the next apply would destroy it, the import is removed from state again then the run fails.
  8. verify reads plan, state plus the Engine API a third time. verify.jq requires that every approved address now records the live id, that the live object still carries the id it had before the run, that the plan no longer wants to create or destroy it plus that any remaining in place difference is limited to tolerated_attributes. fail_when_not_reconciled fails the run otherwise. The errors block posts the failure to Slack.

Worth reading after a run: {{ outputs.detect.vars.selected }} is what the flow proposes to import, {{ outputs.detect.outputFiles['targets.json'] }} holds every candidate with its verdict, {{ outputs.recheck.outputFiles['state_backup.json'] }} is the state as it was immediately before the writes plus {{ outputs.verify.outputFiles['verified.json'] }} is the per address verdict after them.

What you get

  • An answer to the plan nobody trusts: which of these creates are real new resources, which are things that already exist outside state.
  • The import itself, with the live id read from the provider API rather than typed by hand during an incident.
  • A stale state entry removed before its address is imported again, instead of an import that fails on an address already in state.
  • An import that would make the next apply destroy a live resource undone in the same run, with the run failing loudly.
  • A state backup saved immediately before the writes, so a bad reconcile can be restored with terraform state push.

Who it's for

Platform teams plus infrastructure teams whose pipelines are blocked by Error: ... already exists or by a plan full of creates for resources that are plainly running, usually after an emergency fix was made by hand.

Why orchestrate this with Kestra

The manual fix is a terminal, a plan read in a hurry, an id copied out of a cloud console plus terraform import typed once per resource, with no record of which ids went in or whether the result was safe to apply. As a flow the read, the decision, the approval, the write plus the proof are one audited execution: the plan together with the live inventory are captured as files, the write is held at a Pause, eligibility is read again after the approval, the loop serialises state writes then the verification is what decides whether the run passed. Running it on a disabled schedule in report only mode also turns the question "is anything running outside Terraform" into a routine check rather than a discovery during an incident.

Tested end to end

Kestra 2.0.5, Terraform plugin 1.2.10, Terraform 1.10.5, kreuzwerker/docker provider 3.6.2, state in a Postgres 17.5 pg backend, provider target an isolated Docker 27.5.1 daemon so nothing could touch the host. Flow validate on stock Kestra 2.0.5 was clean: no constraints, no warnings, no deprecations. The fixture declares five containers plus one network. It keeps a healthy control, a live container that no address declares (edge-worker-canary) plus a live container outside the allowed prefixes (legacy-sidecar). Every state, every live id plus every backend serial below was read with the terraform CLI together with the Engine API from outside Kestra, never from the flow's own output. The pg backend bumps the state serial on every write, so an unchanged serial is direct proof that no write happened. All eighteen executions ran on the final revision 7.

Case Execution Result
Healthy 5zULcj4ojebDhTTfj1zkPy SUCCESS. 0 collisions out of 1 candidate, 6 state entries against 10 live objects, serial 99 unchanged
New resource inside the allowed scope 2BtvgdrYraI8DRrgY16VfV SUCCESS. With the prefixes widened to every container plus network, the create whose object was deleted is spared as create_not_a_collision, serial 99 unchanged
Degraded dry run 4tufKsHyZ7FK0nmFDSxAo1 SUCCESS. 3 collisions named with their live ids, 1 spared, state still 3 entries, serial 102 unchanged. The live edge-worker-canary that no address declares was never a candidate
Protected addresses 6BId0iAS7gt1Ia8a3Ruvf5 SUCCESS. Two of the three eligible addresses listed in protected_addresses drop to spared, 1 collision left, serial 102 unchanged
Type filter 7HiZjRkxgZN0H7JUeRTF2X SUCCESS. With importable_types cut to docker_network, both container collisions become type_not_importable, 1 collision left
Per run cap 5ouXZ05WvySlEisAoWO2wf SUCCESS. 3 collisions, 1 selected, 2 held back, serial 102 unchanged
Declined approval 59WDqFt5QeL4WhmaaUteMq SUCCESS. Pause answered false, 0 state entries added, serial 102 unchanged
Change during the Pause 37DX0CBmQJfH769jBTEna9 FAILED as intended. A target container was replaced while the approval waited. recheck reported 3 approved, 2 still eligible, 1 stale, then fail_when_plan_stale stopped the run before the first write. Serial 102 unchanged, so the two targets that had not moved were left alone as well
Remediation 6UjqWqsxdORjhRAacNuXok SUCCESS. 3 addresses imported in 3 serial loop iterations. An independent terraform apply on exactly those targets went from exit 1 with "Plan: 3 to add" plus 3 name collisions to exit 0 with "0 added, 2 changed, 0 destroyed" plus 0 name collisions. All 6 live container ids were identical before the run plus after the verifying apply, so nothing was rebuilt
Repeat LrtERW86VX0tvk9zgjRQr SUCCESS. The identical second run found 0 collisions, took the read only exit, serial 106 unchanged
Second full reconcile 276eGFFED9Pfryf3cK3C72 SUCCESS. From a freshly degraded fixture, 3 imports again, serial 111 to 114, one write per import
Change mid loop 6qlwOtVoXdMjr9XZx7qd1M FAILED as intended. The first target was replaced after recheck but before its write. Loop iteration 0 (5OtMkL82rwcZIFRdIo5tov) compared the id it had been given with the id the daemon returned then refused. Serial 109 unchanged, 0 state entries added, no later iteration ran
Second runner mid loop 5biorsXIobZesWqrnU8Gh SUCCESS. An operator imported the address by hand after recheck. Loop iteration 0 (4bBCDZM2pYDOOFVBWiTDlF) found it already recording the live id then wrote nothing. Serial moved 109 to 110, which is the operator's own import, leaving exactly 1 state entry for that address
Interrupted pass 5GJmOYUGNJz7OkLYT7WyhG FAILED as intended. Iteration 0 committed its import, iteration 1 was refused because its target had been replaced, iteration 2 never ran. Serial 117 to 118, exactly one committed write, two addresses left unmanaged
Recovery after that interrupted pass 4fZZOj68sL3rXsuvZW3DT4 SUCCESS. The next run found only the 2 remaining collisions, imported each with the id it has now rather than the id the interrupted pass was approved for, left the committed entry untouched, created 0 duplicates. Serial 118 to 120
Refused write 1UUuUXyFelACKe2rax9sQI FAILED as intended. A trigger on the state table made every backend write fail. terraform import reported Error writing state file, the run failed, serial stayed at 123 plus 0 state entries were added
Import that would destroy the object 4n9lohxGepaDE7sRDkrwmL FAILED as intended. A live container whose command did not match the configuration was imported, the post import plan showed a destroy, the import was removed from state again then the run failed. The address is unmanaged again, the container still carries the id it was created with by hand. The committed first iteration was kept
Stale state entry 4QByOKl0FAftTB29Comqg SUCCESS. The entry recording a container that no longer existed was removed first then the live container was imported in its place: the recorded id moved from the dead object to the live one, the entry count stayed at 6, serial 129 to 131

A second fixture on fresh containers (a separate Docker daemon with zero containers at the start plus its own empty Postgres) repeated the three that matter: healthy 6LS8lfuVRun4jhgkmIVLDo with 0 collisions plus the serial unchanged at 1, remediation 2qmQmHv5NI5Q4YLRcNwlod with the same apply exit 1 to exit 0 plus serial 11 to 14 for three imports plus every live id conserved, then the pause race 5cXlseb8dP9G2WGPi1V6Az refused again with serial 18 unchanged. Slack was a local HTTP sink that recorded the failure alerts.

Earlier revisions failed two cases for real reasons that are now fixed in the flow: an imported container reports the env its image sets, which forces replacement unless the configuration ignores it. remove_volumes plus container_read_refresh_timeout_milliseconds exist only in the configuration, so they always show as an in place update after an import. The recorded_object_still_live plus no_identity_in_plan verdicts are defensive: the fixtures could not produce a plan that reaches them, so they are not counted as tested.

Prerequisites

  • Terraform 1.6 or newer with a remote backend, so every task in the flow reads plus writes the same state. Tested on 1.10.5 with the pg backend.
  • A provider endpoint the Kestra worker can reach, plus a configuration whose resources this flow can look up by name.
  • Kestra 2.0 with the Terraform plugin plus the Slack plugin, together with a Docker task runner on a network that reaches both the backend plus the provider endpoint.
  • Minimum permissions: read on the provider inventory, read plus write on the state backend. No apply rights are needed, because this flow never applies.

Secrets

Secret What it is
POSTGRES_USERNAME state backend account used in PG_CONN_STR
POSTGRES_PASSWORD its password
SLACK_WEBHOOK_URL Slack incoming webhook for the failure alert

Inputs

Input Type Default What it does
dry_run BOOL true report the import plan without writing state
auto_remediate BOOL false allow state writes, still behind dry_run false plus an approval
docker_host STRING tcp://127.0.0.1:2375 provider endpoint, also read directly over the Engine API
allowed_address_prefixes ARRAY docker_container.edge_, docker_network.edge_ the only addresses an import is ever attempted for
protected_addresses ARRAY [] exact addresses never imported, even when eligible
importable_types ARRAY container, network, volume types whose import id the flow can read from the API
max_imports INT 5 ceiling on state writes per run
tolerated_attributes ARRAY must_run, attach, logs, start, rm, wait, wait_timeout, remove_volumes, container_read_refresh_timeout_milliseconds attributes the provider cannot read back on import, so an in place update for them is accepted
task_network STRING bridge Docker network the Terraform containers join
state_pg_endpoint STRING postgres:5432 host plus port of the state backend
state_pg_database STRING terraform_state database holding the state table
node_image STRING busybox:1.37 variable passed to the shipped example configuration

Quick start

  1. Add the three secrets above to your Kestra instance.
  2. Replace the main.tf in detect with your own configuration. You can also fetch it with io.kestra.plugin.git.Clone then pass the checkout as inputFiles. Keep the backend remote.
  3. Set docker_host, state_pg_endpoint plus task_network so the task containers reach your provider endpoint together with your backend.
  4. Execute with the defaults. Read the candidate lines: each one names the address, the live id plus the verdict.
  5. Narrow allowed_address_prefixes to the addresses you are willing to have imported. List anything delicate in protected_addresses.
  6. Execute with dry_run: false plus auto_remediate: true. The run pauses. Approve it, then read the verification lines: state ids, live ids plus what the plan still wants to do.
  7. Enable the hourly_drift_scan trigger once you trust the report. It forces report only mode.

How to extend

  • Swap the Docker provider for your own: replace inventory.sh with the API or CLI read that returns {type, name, live_id} for your resources. List those types in importable_types. The rest of the flow is provider agnostic.
  • Add import blocks to a pull request instead of writing state directly, by sending targets.json to a Git task. The selection logic stays the same.
  • Feed targets.json into a ticket so the resource created by hand gets an owner.
  • Run one execution per workspace, each with its own prefixes, approval plus schedule.
  • Keep {{ outputs.recheck.outputFiles['state_backup.json'] }} in your incident record. It is the state immediately before the writes, which is what terraform state push needs for a rollback.

Limits: the flow imports resources whose live id can be resolved from their name, so a resource type with a composite or opaque import id needs its own lookup. Resource types whose import needs a module path or an index are out of scope. The example configuration is a sample, not a reconciler for every provider. Verification proves the plan no longer creates or destroys the imported addresses plus that the object kept its id. It does not run terraform apply, so the measured payoff of an apply that stops failing was taken with the CLI in the test rig rather than inside the flow. Attributes a provider cannot read back are accepted rather than corrected, which is why tolerated_attributes is a list you should review for your own resources. A run holds the backend lock only for the length of one write, so two executions can interleave: the post approval recheck together with the already managed check make that safe, they do not serialise it. A refused iteration stops the loop, so a run can end with part of its plan committed. That residue is state only, it is reported in the failure, the next run reconciles what is left plus the saved state backup restores the rest.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.