Module 5: Apply On Merge The Cd Half

7. Hands-on: running the drift job manually

Description

This lesson runs drift.yml with act workflow_dispatch —the manual button, not the timer— and completes the picture lesson 6 left open: what you'd see if, instead of a project that never finished applying, real infrastructure existed and someone had modified it outside Terraform. The part that runs for real is the complete job, end-to-end. The part that simulates "someone changed something by hand" is representative, with the exact reason explained, in the same spirit as every awslocal in this guide without a valid token.

Connection to the module

This lesson uses lesson 6's same drift.yml, without changing a single line — only the trigger changes (workflow_dispatch instead of schedule) and the narrative context (what would happen if there were real infrastructure to modify). Lesson 8 —this module's project— integrates this workflow into the complete pipeline, alongside ci.yml and apply.yml.


Analogy: the guard's round, with a real forced window

Lesson 6 showed the guard doing their round over a still-empty building —nothing to check, aside from confirming the construction still matches the blueprints—. This lesson gives the guard something real to find: a forced window, a swapped lock, something that doesn't match what the original blueprint describes. The difference between "everything's fine" and "something changed" isn't in how the guard does the round —same route, same check— but in whether there's a real difference to find.


Step 1 — Running the job by hand, with act workflow_dispatch

act workflow_dispatch -j check-drift -W .github/workflows/drift.yml

What to expect (literal output, executed to write this lesson):

[drift-detection/check-drift] ⭐ Run Set up job
[drift-detection/check-drift] 🚀  Start image=catthehacker/ubuntu:act-latest
[drift-detection/check-drift]   ✅  Success - Set up job
[drift-detection/check-drift] ⭐ Run Main Check out andes-cargo-infra
[drift-detection/check-drift]   ✅  Success - Main Check out andes-cargo-infra [42.181834ms]
[drift-detection/check-drift] ⭐ Run Main Set up Terraform
[drift-detection/check-drift]   ✅  Success - Main Set up Terraform [3.112824208s]
[drift-detection/check-drift] ⭐ Run Main Terraform init
[drift-detection/check-drift]   | Initializing the backend...
[drift-detection/check-drift]   | Initializing modules...
[drift-detection/check-drift]   | Initializing provider plugins...
[drift-detection/check-drift]   | Terraform has been successfully initialized!
[drift-detection/check-drift]   ✅  Success - Main Terraform init [33.162088417s]
[drift-detection/check-drift] ⭐ Run Main Install tflocal
[drift-detection/check-drift]   ✅  Success - Main Install tflocal [3.144388042s]
[drift-detection/check-drift] ⭐ Run Main Terraform plan (read-only drift check)
[drift-detection/check-drift]   | Plan: 12 to add, 0 to change, 0 to destroy.
[drift-detection/check-drift]   ✅  Success - Main Terraform plan (read-only drift check) [17.596187875s]
[drift-detection/check-drift]   ⚙  ::set-output:: exitcode=2
[drift-detection/check-drift] ⭐ Run Main Report drift status
[drift-detection/check-drift]   ✅  Success - Main Report drift status [102.771083ms]
[drift-detection/check-drift]   ⚙  Summary - ## Drift check — andes-cargo-infra
Triggered by: workflow_dispatch
Result: DRIFT DETECTED. Terraform found differences between the state and reality.
[drift-detection/check-drift] 🏁  Job succeeded

Notice the one real difference from lesson 6: Triggered by: workflow_dispatch, not schedulegithub.event_name changes depending on how you triggered the job, exactly as Module 2 (lesson 4) confirmed with its own act schedule/act workflow_dispatch pair. Everything else —the plan, the exitcode=2, the result— is identical, because both triggers lead to the same job, with the same internal logic.


Step 2 (REPRESENTATIVE) — Modifying something outside Terraform

With a valid LOCALSTACK_AUTH_TOKEN and Andes Cargo's infrastructure already applied (Module 5, lesson 4, successfully), the scenario drift.yml is designed to catch would look something like this: someone, with direct access to LocalStack —or, on a real account, to the AWS console— changes an attribute on the andes-cargo-shipment-docs bucket without going through any Pull Request, with neither ci.yml nor apply.yml finding out:

awslocal s3api put-bucket-tagging \
  --bucket andes-cargo-shipment-docs \
  --tagging 'TagSet=[{Key=Project,Value=andes-cargo},{Key=Environment,Value=dev},{Key=ManagedBy,Value=terraform}]'

Why this command is labeled representative, with the exact technical reason: this awslocal needs a running, actively licensed LocalStack to have something real to modify —and, as you already confirmed in Module 1 (lesson 8) and in every lesson in this guide since then, this machine doesn't have a valid LOCALSTACK_AUTH_TOKEN exported—. Notice this command's specific detail: it deliberately drops the Compliance tag Module 3 (lesson 8) added to the HCL —manifest-retention-required—, leaving only the three original tags. It's exactly the kind of change a guard should notice: someone "fixed" something by hand, without realizing they were undoing a decision that lived in the code.

What to expect (representative — the exact confirmation format for put-bucket-tagging, already seen in aws-core-services-guide for similar S3 operations):

{
    "ResponseMetadata": {
        "HTTPStatusCode": 200
    }
}

Step 3 (REPRESENTATIVE) — The next drift.yml would detect the difference

With that change made outside Terraform, the next read-only terraform plan —whether the 6 AM cron:'s or one triggered by hand with workflow_dispatch— would compare the HCL (which still says Compliance = "manifest-retention-required") against reality (where that tag no longer exists), and would find a real difference of type update, not creation:

What to expect (representative — the exact format for a Terraform ~ update in-place, the way an attribute change on an already-existing resource looks, different from a + create):

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # module.shipment_docs_bucket.aws_s3_bucket.this will be updated in-place
  ~ resource "aws_s3_bucket" "this" {
        id     = "andes-cargo-shipment-docs"
      ~ tags   = {
          - "Compliance"  = "manifest-retention-required" -> null
            "Environment" = "dev"
            "ManagedBy"   = "terraform"
            "Project"     = "andes-cargo"
        }
        # (5 unchanged attributes hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

~ instead of +, and 1 to change instead of 12 to add — the exact visual difference between "this is a complete creation" (what you saw in lessons 6 and 7 up to now, on a project that was never applied) and "this is a real deviation, on infrastructure that does exist" (what this step represents). drift.yml's Report drift status step, running on this plan, would capture exitcode=2 —the same signal you already saw, correct this time for the exact reason drift.yml was designed to catch— and would publish "DRIFT DETECTED" in the job summary, exactly like in Step 1, but now with a genuine cause behind it.


The honest difference between what ran and what was represented

It's worth closing this lesson with the exact distinction, without leaving it implicit:

PartStatus
The complete check-drift job, triggered with act workflow_dispatchExecuted — literal output, Step 1
The terraform plan inside that job, calculating 12 resources to createExecuted — the same mechanism from Module 3 (skip_requesting_account_id), with no need for LocalStack running
terraform_wrapper: false correctly propagating exitcode=2Executed — the finding verified in lesson 6
The awslocal put-bucket-tagging simulating a manual changeRepresentative — needs LocalStack with a valid token and infrastructure already applied
The ~ update in-place plan that change would produceRepresentative — same format already confirmed in aws-core-services-guide/terraform-and-iac-guide for attribute updates, without a live run at this moment

No part of this lesson is "simulated in prose" without a label — every code block explicitly says whether it really ran or not, right at the point where it appears.


Common mistakes

Confusing "Plan: 12 to add" (lessons 6 and 7, Step 1) with "Plan: 0 to add, 1 to change" (Step 3) as if they were the same kind of result (conceptual). What happens: someone reads both code blocks in this lesson and doesn't distinguish why one says + create and the other ~ update in-place. How to spot it: if you can't explain, without re-reading, why the symbol changes. How to fix it: + means the resource doesn't exist yet in the state (this project's case, on this machine, with no real apply ever completed); ~ means the resource does exist, but one or more of its attributes don't match the HCL — the specific signature of real drift, only possible after a successful apply.

Thinking drift.yml can prevent the manual change (expectation-based). What happens: someone expects drift.yml, somehow, to automatically block or revert a change made outside Terraform. How to fix it: drift.yml, as built in this module, only detects and reports — it doesn't automatically revert anything. Fixing the drift (reapplying the HCL to restore the desired state, or updating the HCL to reflect the intentional change) remains a human decision, made after reading the report.

Running this lesson's awslocal put-bucket-tagging expecting it to just work (workflow-based). What happens: someone copies Step 2's command and runs it, expecting to see the confirmation JSON, without having followed Module 1 (lesson 8, Step 5) to start LocalStack with a valid token. How to fix it: review "The version you'd see with a valid token" in this module's lesson 4 — the same requirement applies here. Without LocalStack running, this command would fail with the same kind of connection error you already know, not with this lesson's representative JSON.


Exercises

Exercise 1 — Distinguish a plan's symbols. Without looking at this lesson, explain the difference between +, ~, and - in a terraform plan's output, and what kind of infrastructure change each one represents.

See solution

+ (create) means the resource doesn't exist yet and Terraform would create it from scratch. ~ (update in-place) means the resource already exists, but one or more attributes need to change to match the HCL — this lesson's real drift case. - (destroy) means the resource exists but is no longer declared in the HCL, and Terraform would delete it — the case Module 6's guardrail is specifically going to watch for the Shipments table.

Exercise 2 — Explain why this lesson's Step 2 is representative, without saying "there's no LocalStack." Without looking at this lesson, give the exact technical reason —not just "LocalStack isn't running"— why this lesson's awslocal put-bucket-tagging didn't actually run.

See solution

A complete answer sounds, roughly, like this: "The command needs two things this machine doesn't have right now: a running LocalStack with a valid LOCALSTACK_AUTH_TOKEN, and —even more important— an andes-cargo-shipment-docs bucket that genuinely exists inside that LocalStack, the product of a previous successful terraform apply (Module 5, lesson 4). Neither condition has been met in this guide so far, so there's no real bucket to apply the tag change to."

Exercise 3 — Predict the result if the drift were on the Shipments table. Looking ahead to Module 6: if Step 2's representative change had instead completely deleted the Shipments table with awslocal dynamodb delete-table, what symbol would you expect to see in the next drift.yml, and how does this relate to the guardrail you're going to build later?

See solution

You'd see the + (create) symbol for aws_dynamodb_table.shipments — not because Terraform "detects a deletion," but because, from the state's perspective, the table should exist and no longer does, so the next plan would propose recreating it from scratch. This connects directly to Module 6: the guardrail you're going to build there specifically reviews plans that propose destroying Shipments before a real apply does it — drift.yml, on the other hand, would only detect the fact after someone already deleted it outside the pipeline, too late to prevent it, only in time to notice it.


Summary and next step

In this lesson you ran drift.yml end-to-end with act workflow_dispatch, with literal output structurally identical to lesson 6's, except for the event_name. You completed the picture with two clearly labeled representative steps: a manual change via awslocal put-bucket-tagging that drops the Compliance tag, and the ~ update in-place plan that change would produce in drift.yml's next run —the exact visual difference between an unapplied project (+ create, the only thing you could actually run on this machine) and real drift on existing infrastructure (~ update in-place, representative).

Before moving on you should be able to: run drift.yml by hand with act workflow_dispatch; distinguish +, ~, and - in any terraform plan output; and explain, with the exact technical reason, why this lesson's real drift scenario couldn't run on this machine.

With ci.yml, apply.yml, and drift.yml built and tested separately, lesson 8 —this module's project— runs them in sequence, on the same real Andes Cargo change that has run through this module since its start.

Resources

  1. Terraform Docs — Resource actions and plan output — official reference for the +/~/- symbols in a plan's output.
  2. nektosact.com — User Guide — reference for act workflow_dispatch, used end-to-end in this lesson.
  3. AWS CLI — s3api put-bucket-tagging — official reference for Step 2's representative command.
  4. This guide's Module 3 (06-hands-on-running-terraform-plan-in-ci.md) — the skip_requesting_account_id mechanism that lets this lesson's terraform plan run without LocalStack, reused here unchanged.