New to Kestra?
Use blueprints to kickstart your first workflows.
Detect resources Terraform plans to create that already exist, import them behind an approval then verify the object survived.
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 |
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.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.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.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.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.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.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.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.
terraform state push.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.
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.
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.
pg backend.| 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 |
| 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 |
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.docker_host, state_pg_endpoint plus task_network so the task containers reach your provider endpoint together with your backend.allowed_address_prefixes to the addresses you are willing to have imported. List anything delicate in protected_addresses.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.hourly_drift_scan trigger once you trust the report. It forces report only mode.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.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.targets.json into a ticket so the resource created by hand gets an owner.{{ 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.
This blueprint was created by zkasuran.
