New to Kestra?
Use blueprints to kickstart your first workflows.
Detect an orphaned Terraform state lock in DynamoDB, prove it is not live, then release it with a gated force-unlock and a re-read that fails the run.
A killed terraform apply leaves its lock record behind in the S3 backend's DynamoDB lock table. Every later plan and apply then stops at Error acquiring the state lock, so the stack is frozen until somebody copies the lock ID out of the error message and runs terraform force-unlock by hand. This blueprint does that work with the checks a human would skip at 2am.
force-unlock is the one Terraform command that can make things worse. Releasing a lock that is actually live lets two applies write the same state file, which loses resources from state, leaves real infrastructure orphaned in the cloud account plus forces a manual terraform import for every lost address. So the unlock sits behind four gates that all have to agree: the lock has to be older than min_lock_age_minutes, its ID must not have changed between two samples, auto_remediate has to be true with dry_run false, plus a human has to approve the Pause. The flow re-reads the lock after the approval and refuses when the ID moved, because the lock being held then is not the lock that was approved.
The flow never runs terraform plan as a probe. plan takes the state lock itself, so a probe would create the record it is meant to be hunting.
read_lock_record (io.kestra.plugin.aws.cli.AwsCLI) reads the lock item from DynamoDB with a consistent read, waits liveness_sample_seconds, then reads it again. The S3 backend keys its lock on <bucket>/<key>, so backend_bucket plus state_key identify the record. The Info attribute carries the lock ID, the operation, who held it plus the creation time, which is where the age comes from. A read error exits 2 rather than reporting a clean backend.log_detection (io.kestra.plugin.core.log.Log) prints the findings count, the lock ID, the operation, the holder, the age in minutes plus whether the ID changed between samples.backend_is_clean (io.kestra.plugin.core.flow.If) ends the run with 0 findings when no lock record exists.lock_held branches into three decisions. refuse_young_lock leaves anything inside the grace window alone, because a long apply holds its lock for minutes. refuse_moving_lock leaves the lock alone when the ID changed between the two samples, which means a pipeline is actively taking the lock. stale_lock is the only branch that goes further.unlock_gate checks auto_remediate and not dry_run. The else branch logs the exact force-unlock command it would have run plus stops there, which is the default posture of this flow.approve_unlock (io.kestra.plugin.core.flow.Pause) holds for approval_window. An expired window resumes with no answer, which reads as declined.recheck_lock reads the lock again after the approval. same_lock_check compares the ID against the one that was approved plus refuses if it moved.force_unlock (io.kestra.plugin.terraform.cli.TerraformCLI) writes a backend-only backend.tf, runs terraform init -input=false, then terraform force-unlock -force <id>. It carries allowFailure: true on purpose, because a green CLI task is not evidence that the lock went away.verify_lock_released reads the lock record one more time. unlock_landed fails the run through io.kestra.plugin.core.execution.Fail when the record is still there, so a backend that stayed wedged never reports success.errors block posts to Slack through io.kestra.plugin.slack.notifications.SlackIncomingWebhook with the backend path plus the execution id.Platform plus SRE teams running Terraform through CI against an S3 backend with DynamoDB locking, where a cancelled job or a dead runner wedges the whole stack until somebody with credentials intervenes.
The unlock is cheap. The decision around it is not. Kestra holds the age window, the two-sample liveness test, the approval, the re-check after the approval plus the re-read that fails the run in one declarative file, with every value logged per execution. A shell script in CI can run force-unlock, but it has nowhere to park a human approval plus no record of what the lock looked like before the release.
Run against LocalStack with a real S3 backend plus a real DynamoDB lock table, with endpoint_url pointed at the LocalStack endpoint:
backend_is_clean reports nothing to release.dry_run=true: the exact force-unlock command is logged. The lock record is still in DynamoDB afterwards.auto_remediate=true with dry_run=false plus an approved Pause: terraform force-unlock releases the lock, the re-read reports 0 records left. terraform plan failed with Error acquiring the state lock before the run plus succeeded after it.AWS_ACCESS_KEY_ID: credentials that can read the lock table plus delete the lock item.AWS_SECRET_ACCESS_KEY: the matching secret key.SLACK_WEBHOOK_URL: incoming webhook for the failure alert.dry_run (BOOL, default true): report only.auto_remediate (BOOL, default false): allow the unlock.backend_bucket (STRING, default tfstate): bucket holding the state object.state_key (STRING, default prod/terraform.tfstate): state object key.lock_table (STRING, default terraform-locks): DynamoDB lock table.min_lock_age_minutes (INT, default 30): grace window before a lock counts as stale.liveness_sample_seconds (INT, default 10): gap between the two lock samples.approval_window (DURATION, default PT30M): how long the Pause waits.aws_region (STRING, default us-east-1): region of the bucket plus the table.endpoint_url (STRING, default empty): endpoint override for LocalStack or an S3 compatible gateway.terraform_image (STRING, default hashicorp/terraform:1.9): image used for the unlock.access_key_id / secret_access_key (STRING, default empty): override the AWS secrets for a sandbox run.AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY plus SLACK_WEBHOOK_URL as Kestra secrets.backend_bucket, state_key, lock_table plus aws_region to the backend you want watched.log_detection line. A finding carries the lock ID, the operation, the holder plus the age.auto_remediate=true plus dry_run=false, then approve the Pause from the UI or through POST /api/v1/main/executions/{id}/actions/resume.hourly_lock_sweep once the inputs are right. It stays in report mode.terraform_image at an OpenTofu image to run the same guard against a tofu managed backend.io.kestra.plugin.aws.s3.List call on the state key before the unlock, so a state object written after the lock was taken blocks the release.min_lock_age_minutes above your longest apply, then drop liveness_sample_seconds to zero on a backend no pipeline touches.io.kestra.plugin.core.flow.Subflow plus a list of bucket and key pairs.This blueprint was created by zkasuran.
