100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Infrastructure as Code — Terraform & Ansible
55 minintermediate

Lab — Run Ansible Playbook Against Live EC2 Inventory via SSM

What You'll Build

This lab connects all four Ansible fundamentals lessons into a complete, real-world workflow. You will use Terraform to provision two EC2 instances with IAM instance profiles granting SSM access, run Ansible with the dynamic EC2 inventory plugin to discover those instances automatically by tag, and configure them using the baseline playbook via AWS SSM Session Manager — entirely without SSH keys, open port 22, or a bastion host. This SSM-based Ansible workflow is the modern production pattern: no credential distribution, no firewall exceptions, and every session logged to CloudWatch. The lab demonstrates the provision-then-configure handoff between Terraform and Ansible that forms the foundation of the complete IaC pipeline covered in Module 5 and the Capstone.

Analogy🏏Cricket
🏏 Think of it like cricket: You are designing the BCCI's standard cricket ground specification module — a parameterisable blueprint that can be instantiated for any venue in the country without redrawing it from scratch. The blueprint fixes the non-negotiables every certified ground shares: a boundary perimeter (the VPC and its CIDR), spectator zones with public access (public subnets with an Internet Gateway route), and restricted player-and-officials zones behind accreditation checks (private subnets routing through NAT). What varies per venue arrives as parameters: how many stands to build (var.azs and subnet counts), whether this is a full international stadium or a modest district ground (var.single_nat_gateway as the cost lever — one shared service corridor instead of one per stand), and the venue's name and signage (tags). Wankhede and a Ranchi district ground are instantiated from the same drawing with different inputs — and when the board later improves the blueprint (adds flow logs, tightens NACLs), every venue inherits the improvement on its next renovation (module version bump) instead of each ground hand-patching its own architecture. That is the entire economics of module authorship: design once, rigorously, then stamp out consistent grounds forever.

Prerequisites

  • Terraform 1.6+ and Ansible 2.15+ installed on the control machine
  • AWS CLI configured with permissions for EC2, IAM and SSM
  • Python packages: boto3, botocore (for dynamic inventory), community.aws collection
  • Session Manager Plugin for AWS CLI: 'curl -s https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb -o /tmp/smp.deb && sudo dpkg -i /tmp/smp.deb'

Step 1 — Provision EC2 Instances with Terraform

bash
# main.tf — Provision EC2 instances for Ansible SSM lab
mkdir -p ~/ansible_ssm_lab && cd ~/ansible_ssm_lab

cat > main.tf << 'TF'
terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "aws" { region = "ap-south-1" }

# Data sources
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter { name = "name";         values = ["al2023-ami-*-x86_64"] }
  filter { name = "state";        values = ["available"] }
  filter { name = "architecture"; values = ["x86_64"] }
}

data "aws_vpc" "default" { default = true }

# IAM role for SSM access
resource "aws_iam_role" "ssm_instance" {
  name = "cricket-lab-ssm-instance"
  assume_role_policy = jsonencode({
    Statement = [{ Effect = "Allow"; Principal = { Service = "ec2.amazonaws.com" }; Action = "sts:AssumeRole" }]
  })
}

resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.ssm_instance.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "ssm" {
  name = "cricket-lab-ssm-profile"
  role = aws_iam_role.ssm_instance.name
}

# Security group: outbound HTTPS only (for SSM), no inbound SSH
resource "aws_security_group" "lab" {
  name   = "cricket-lab-ssm-sg"
  vpc_id = data.aws_vpc.default.id
  egress {
    description = "HTTPS for SSM and package downloads"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    description = "HTTP for package downloads"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags = { Name = "cricket-lab-ssm-sg" }
}

# Two EC2 instances tagged for dynamic inventory discovery
resource "aws_instance" "lab" {
  count                  = 2
  ami                    = data.aws_ami.al2023.id
  instance_type          = "t3.micro"
  iam_instance_profile   = aws_iam_instance_profile.ssm.name
  vpc_security_group_ids = [aws_security_group.lab.id]

  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"  # IMDSv2
    http_put_response_hop_limit = 1
  }

  # CRITICAL: Tags are how the dynamic inventory discovers these instances
  tags = {
    Name        = "cricket-lab-server-${count.index + 1}"
    Project     = "cricket-analytics"   # Matched by aws_ec2.yml plugin filter
    Environment = "development"
    Role        = "api-server"           # Used for group membership in inventory
    AnsibleLab  = "true"                 # Easy filter for lab instances
  }
}

output "instance_ids"   { value = aws_instance.lab[*].id }
output "instance_ips"   { value = aws_instance.lab[*].private_ip }
output "ssm_commands" {
  value = [for i in aws_instance.lab : "aws ssm start-session --target ${i.id} --region ap-south-1"]
}
TF

terraform init && terraform apply -auto-approve
echo 'Instances launched — waiting 90s for SSM agent to register'
sleep 90

Step 2 — Configure Dynamic Inventory

yaml
# inventory/aws_ec2.yml — Dynamic EC2 inventory for the lab
plugin: amazon.aws.ec2

regions:
  - ap-south-1

# Only discover running instances tagged for this lab
filters:
  instance-state-name: running
  'tag:AnsibleLab': 'true'
  'tag:Project': cricket-analytics

hostnames:
  - instance-id  # Use instance ID as hostname for SSM connectivity

keyed_groups:
  - prefix: tag
    key: tags

groups:
  api_servers: "tags.Role == 'api-server'"

compose:
  # CRITICAL: SSM connection uses instance ID, not IP address
  ansible_host: instance_id
  # Set the SSM connection plugin
  ansible_connection: 'community.aws.aws_ssm'
  ansible_aws_ssm_region: placement.region
  # SSM connection does not need SSH user or key
  ansible_user: ec2-user

Step 3 — Verify Inventory and Connectivity

bash
#!/bin/bash
cd ~/ansible_ssm_lab

# Install required collections
ansible-galaxy collection install amazon.aws community.aws community.general

echo '=== Step 3a: Verify dynamic inventory discovers both instances ==='
ansible-inventory -i inventory/aws_ec2.yml --graph
# Expected:
# @all:
#   |--@api_servers:
#   |  |--i-0abc123...
#   |  |--i-0def456...
#   |--@tag_Role_api_server:
#   |  ...

ansible-inventory -i inventory/aws_ec2.yml --list 2>/dev/null | \
  python3 -c "import sys,json; inv=json.load(sys.stdin); print(f'Hosts: {list(inv[\"_meta\"][\"hostvars\"].keys())}')" 

echo
echo '=== Step 3b: Test SSM connectivity ==='
# SSM ping: no SSH key or port 22 required
ansible api_servers \
  -i inventory/aws_ec2.yml \
  -m ansible.builtin.ping \
  -e ansible_connection=community.aws.aws_ssm
# Expected: instance-id | SUCCESS => { 'ping': 'pong' }

echo
echo '=== Step 3c: Collect facts via SSM ==='
ansible api_servers \
  -i inventory/aws_ec2.yml \
  -m ansible.builtin.setup \
  -a 'filter=ansible_distribution*' \
  -e ansible_connection=community.aws.aws_ssm 2>/dev/null | \
  grep -A5 'ansible_distribution'
Analogy🏏Cricket
🏏 Think of it like cricket: The verification step you just ran — 'ansible-inventory --graph' and an ad-hoc ping before the real playbook — is the umpires' pre-match check of the team sheets against the live player registry, and no professional skips it. The dynamic inventory is a live query, which is powerful precisely because nothing is hardcoded — but that also means nothing is guaranteed: a typo'd tag filter matches zero instances (a team sheet naming a squad that does not exist — the playbook would 'succeed' against no hosts, the most deceptive green tick in Ansible), a too-loose filter matches instances from another project (players from a different franchise wandering onto your team sheet — a playbook applied to someone else's servers), and an instance whose SSM agent has not registered yet is a player named on the sheet who has not physically arrived at the ground (discovered by inventory via the EC2 API, yet unreachable for actual play through the connection plugin). The graph command confirms WHO is on the sheet and in which groups; the ping confirms each named player is actually present and responding through the real match channel (an end-to-end SSM round-trip, not just an API listing). Thirty seconds of checking, and the difference between configuring your fleet and configuring nothing — or worse, the wrong fleet.

Step 4 — Run Baseline Playbook via SSM

yaml
# lab_playbook.yml — simplified baseline for the lab (Amazon Linux 2023)
---
- name: Configure Cricket Analytics lab servers via SSM
  hosts: api_servers
  become: true
  gather_facts: true

  handlers:
    - name: Restart Nginx
      ansible.builtin.service:
        name: nginx
        state: restarted

  tasks:
    - name: Install packages (dnf for Amazon Linux)
      ansible.builtin.dnf:
        name:
          - nginx
          - chrony
          - python3
          - jq
        state: present
      tags: [packages]

    - name: Configure Nginx  simple health endpoint
      ansible.builtin.copy:
        content: |
          server {
              listen 80;
              location /health {
                  add_header Content-Type application/json;
                  return 200 '{"status":"ok","host":"{{ inventory_hostname }}","env":"lab"}';
              }
          }
        dest: /etc/nginx/conf.d/cricket-health.conf
        mode: '0644'
      notify: Restart Nginx
      tags: [nginx]

    - name: Enable and start Nginx
      ansible.builtin.service:
        name: nginx
        enabled: true
        state: started
      tags: [nginx]

    - name: Configure chrony to use AWS Time Sync Service
      ansible.builtin.lineinfile:
        path: /etc/chrony.conf
        regexp: '^pool '
        line: 'server 169.254.169.123 prefer iburst'
        state: present
      notify: Restart chrony
      tags: [chrony]

    - name: Ensure chrony is enabled and running
      ansible.builtin.service:
        name: chronyd
        enabled: true
        state: started
      tags: [chrony]

    - name: Verify Nginx health endpoint
      ansible.builtin.uri:
        url: http://localhost/health
        status_code: 200
      register: health
      tags: [verify]

    - name: Show health check result
      ansible.builtin.debug:
        msg: 'Health: {{ health.json }}'
      tags: [verify]

  handlers:
    - name: Restart chrony
      ansible.builtin.service:
        name: chronyd
        state: restarted
bash
#!/bin/bash
cd ~/ansible_ssm_lab

echo '=== Step 4a: Dry run first ==='
ansible-playbook \
  -i inventory/aws_ec2.yml \
  lab_playbook.yml \
  --check --diff

echo
echo '=== Step 4b: Apply playbook via SSM ==='
ansible-playbook \
  -i inventory/aws_ec2.yml \
  lab_playbook.yml \
  -v  # Verbose output

echo
echo '=== Step 4c: Verify idempotency ==='
ansible-playbook \
  -i inventory/aws_ec2.yml \
  lab_playbook.yml 2>&1 | grep 'PLAY RECAP' -A3
# All changed= should be 0 on second run

echo
echo '=== Step 4d: Check SSM Session Manager logs in CloudWatch ==='
# SSM logs every command executed via Session Manager
aws logs describe-log-groups \
  --log-group-name-prefix '/aws/ssm' \
  --query 'logGroups[*].logGroupName' \
  --output text

echo
echo '=== Step 5: Cleanup — destroy Terraform resources ==='
terraform destroy -auto-approve
echo 'Lab resources destroyed'

Pro Tip

Use 'serial: 1' and 'max_fail_percentage: 0' in the play definition for production playbook runs against multiple hosts. Serial: 1 runs the playbook against one host at a time — if any host fails, the play stops before the remaining hosts are affected. Max_fail_percentage: 0 means a single failure stops the entire play. This canary deployment pattern catches bad configurations early without applying them to all hosts simultaneously. For a rolling update, use 'serial: '25%'' to update one quarter of the fleet at a time, allowing manual verification between batches.

  • SSM-based Ansible connectivity uses 'ansible_connection: community.aws.aws_ssm' and 'ansible_host: instance_id' — no SSH keys, no port 22, no bastion host required; IAM role provides the authentication mechanism.
  • The dynamic EC2 inventory discovers instances by tags — any instance tagged 'Project=cricket-analytics' and 'Role=api-server' automatically appears in the correct Ansible groups without manual inventory updates.
  • Tags on EC2 instances are the handoff point between Terraform (provisioning) and Ansible (configuration) — Terraform tags instances, Ansible discovers them; this decouples the two tools while maintaining correct group membership.
  • Verify idempotency by running the playbook twice — the second run should show changed=0 for all tasks; non-zero changed= on re-run indicates a task that makes changes even when the system is already in the desired state.
  • SSM Session Manager logs all command executions to CloudWatch — this provides the audit trail required for compliance (SOC 2, PCI-DSS) without separate bastion host logging infrastructure.
  • Use '--check --diff' before any production playbook run — check mode shows what would change, --diff shows the exact content changes for file-modifying tasks; this is the Ansible equivalent of 'terraform plan'.
Lesson 21 of 33
0% complete