Command Palette

Search for a command to run...

Hectal

Mission 2.4 · Stage 2 — Network

Prove It Works: A Throwaway Probe Instance

Goal: A tiny instance in a private subnet, reachable through Session Manager with no SSH, that proves NAT egress and the S3 endpoint work — then switched off with one variable.

35 min ~$0.005/hour for a t4g.nano, plus NAT 6 steps 2 break-it drills

By the end of this mission

  • Look up an AMI with a filtered data source instead of hardcoding it
  • Build IAM trust policies with aws_iam_policy_document
  • Create optional resources with count and read them safely with one()
  • Know why -target is for emergencies only

Part 1

Understand it first

Test infrastructure with infrastructure

A clean plan doesn't prove a network works: a route can point to the wrong NAT, or a NACL can drop replies. The quickest proof is to put a machine where the app will run and try the real paths: internet through NAT, S3 through the endpoint. Doing it in Terraform means the probe is reproducible, and a single variable removes it without leaving anything behind.

aws_iam_policy_document: policies as HCL

IAM policies are JSON. You could paste JSON strings, but the aws_iam_policy_document data source lets you write statements in HCL with references, validates the structure, and renders normalised JSON, which avoids diffs caused only by whitespace or key order. You'll use it for every IAM policy from here on.

Optional single resources: count + one()

count = var.enable_probe ? 1 : 0 makes a resource optional. Its address becomes aws_instance.probe[0] when it exists, and there are no instances when it doesn't. Referencing aws_instance.probe[0].id crashes when it's disabled, so use one(aws_instance.probe[*].id), which returns the single element or null for an empty list. The [*] splat turns the instances into a list of IDs.

Probe pathsdiagram
Rendering diagram…

Part 2

Your project after this mission · 3 files change

shoplite/
  • bootstrap/
    • backend.tf
    • main.tf
  • infra/
    • backend.tf
    • locals.tf
    • logs.tf
    • network.tf
    • outputs.tfmodified
    • probe.tfnew
    • providers.tf
    • refactors.tf
    • security.tf
    • storage.tf
    • terraform.tfvars
    • variables.tfmodified
    • versions.tf
  • .gitignore

Part 3

Build it, step by step

  1. 1

    Add the switch

    infra/variables.tfadd to filehcl
    variable "enable_probe" {
      description = "Create a throwaway instance in a private subnet to test networking."
      type        = bool
      default     = false
    }
  2. 2

    Write the probe

    The AMI comes from a filtered, most_recent lookup of Amazon's own Amazon Linux 2023 ARM images, so there's no hardcoded ID that differs per region and goes stale. The role trusts ec2.amazonaws.com and gets the managed SSM policy, which is all Session Manager needs. The probe uses the APP security group, so it tests exactly the paths ShopLite tasks will use.

    infra/probe.tfwhole filehcl
    data "aws_ami" "al2023_arm" {
      most_recent = true
      owners      = ["amazon"]
    
      filter {
        name   = "name"
        values = ["al2023-ami-2023.*-arm64"]
      }
    }
    
    data "aws_iam_policy_document" "ec2_assume" {
      statement {
        actions = ["sts:AssumeRole"]
        principals {
          type        = "Service"
          identifiers = ["ec2.amazonaws.com"]
        }
      }
    }
    
    resource "aws_iam_role" "probe" {
      count              = var.enable_probe ? 1 : 0
      name               = "${local.name_prefix}-probe"
      assume_role_policy = data.aws_iam_policy_document.ec2_assume.json
    }
    
    resource "aws_iam_role_policy_attachment" "probe_ssm" {
      count      = var.enable_probe ? 1 : 0
      role       = aws_iam_role.probe[0].name
      policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
    }
    
    resource "aws_iam_instance_profile" "probe" {
      count = var.enable_probe ? 1 : 0
      name  = "${local.name_prefix}-probe"
      role  = aws_iam_role.probe[0].name
    }
    
    resource "aws_instance" "probe" {
      count = var.enable_probe ? 1 : 0
    
      ami                    = data.aws_ami.al2023_arm.id
      instance_type          = "t4g.nano"
      subnet_id              = aws_subnet.private[local.azs[0]].id
      vpc_security_group_ids = [aws_security_group.app.id]
      iam_instance_profile   = aws_iam_instance_profile.probe[0].name
    
      metadata_options {
        http_tokens = "required" # IMDSv2 only
      }
    
      tags = { Name = "${local.name_prefix}-probe" }
    }
  3. 3

    Output the ID safely

    one() returns null when the probe is disabled instead of erroring.

    infra/outputs.tfadd to filehcl
    output "probe_instance_id" {
      value = one(aws_instance.probe[*].id)
    }
  4. 4

    Turn it on

    Make sure NAT is enabled too. The SSM agent needs outbound access to reach the Systems Manager endpoints.

    terminal
    $ terraform apply -var enable_probe=true
    ── expected output ──
    data.aws_ami.al2023_arm: Read complete after 1s [id=ami-0a1b2c3d4e5f67890]
    ...
    Plan: 4 to add, 0 to change, 0 to destroy.
    ...
    aws_instance.probe[0]: Creation complete after 13s [id=i-0123456789abcdef0]
     
    Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
     
    Outputs:
    ...
    probe_instance_id = "i-0123456789abcdef0"
  5. 5

    Test both outbound paths from inside

    Give the agent a minute to register, then open a shell. checkip returns the NAT gateway's Elastic IP, which proves the private → NAT → IGW path. Listing the assets bucket goes through the S3 gateway endpoint. It will say AccessDenied, because the probe role has no S3 permissions; getting a response from S3 at all proves the network path, and permissions come in Stage 4.

    terminal
    $ aws ssm start-session --target $(terraform output -raw probe_instance_id)
    # inside the session:
    curl -s https://checkip.amazonaws.com
    aws s3 ls s3://shoplite-dev-assets-3f9a --region ap-south-1
    ── expected output ──
    Starting session with SessionId: priya-0a1b2c3d4e5f
    sh-5.2$ curl -s https://checkip.amazonaws.com
    13.233.41.187
    sh-5.2$ aws s3 ls s3://shoplite-dev-assets-3f9a --region ap-south-1
    An error occurred (AccessDenied) when calling the ListObjectsV2 operation: ...
    Compare the IP with `aws ec2 describe-addresses` — it's the NAT's EIP.
  6. 6

    Turn it off

    Apply with the default (enable_probe = false). All four probe resources go, and the output becomes null. The whole network is now proven and ready for ShopLite's containers.

    terminal
    $ terraform apply
    ── expected output ──
    Plan: 0 to add, 0 to change, 4 to destroy.
     
    Changes to Outputs:
    - probe_instance_id = "i-0123456789abcdef0" -> null
    ...
    Apply complete! Resources: 0 added, 0 changed, 4 destroyed.

Checkpoint — you should now have

  • ✓From a private instance, checkip returned the NAT gateway's Elastic IP.
  • ✓You reached S3 through the gateway endpoint (AccessDenied is expected at this stage).
  • ✓You never opened port 22 or created an SSH key.
  • ✓The probe is off again (probe_instance_id = null), and probe.tf is committed for next time.

Part 4

Break it on purpose

Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.

Break #1

Change a security group's description while it's in use

Enable the probe. Temporarily remove lifecycle { create_before_destroy = true } from the app group, change its description to "ShopLite API" (descriptions can't be edited, so this forces replacement), and apply.

terminal
$ terraform apply -var enable_probe=true
── what you'll see ──
# aws_security_group.app must be replaced
-/+ resource "aws_security_group" "app" {
~ description = "ShopLite API tasks" -> "ShopLite API" # forces replacement
...
aws_security_group.app: Destroying... [id=sg-0b2c3d4e5f6071829]
aws_security_group.app: Still destroying... [id=sg-0b2c3d4e5f6071829, 1m0s elapsed]
aws_security_group.app: Still destroying... [id=sg-0b2c3d4e5f6071829, 5m0s elapsed]
...
╷
│ Error: deleting Security Group (sg-0b2c3d4e5f6071829): DependencyViolation:
│ resource sg-0b2c3d4e5f6071829 has a dependent object
╵

Break #2

Reach for -target

Try to create only the instance: terraform apply -var enable_probe=true -target=aws_instance.probe.

terminal
$ terraform apply -var enable_probe=true -target=aws_instance.probe
── what you'll see ──
Plan: 4 to add, 0 to change, 0 to destroy.
╷
│ Warning: Resource targeting is in effect
│
│ You are creating a plan with the -target option, which means that the
│ result of this plan may not represent all of the changes requested by the
│ current configuration.
│
│ The -target option is not for routine use, and is provided only for
│ exceptional situations such as recovering from errors or mistakes, or when
│ Terraform specifically suggests to use it as part of an error message.
╵

Part 5

Interview questions from this mission

01

How do you reference a resource that may or may not exist because of count?

02

Why use aws_iam_policy_document instead of writing JSON policies inline?

03

When is terraform apply -target appropriate?

Before you stop

Clean up

terminal
$ terraform apply -var enable_nat_gateway=false # probe already off; stop NAT charges too
── expected output ──
Apply complete! Resources: 0 added, 0 changed, 4 destroyed.
0/4 · 0%