Packer Backup

Automate AMI Creation

Packer Backup

First on my list of things to do after getting this website up and running is to build up my portfolio of projects. I will be using the Hashicorp suite of products as they are widely used and a good base level of knowledge for anyone who wishes to branch out into other platforms.

This first step in the process is to use Packer to create an Amazon Machine Image for use on AWS. This is a very small part of the overall CI/CD workflow, but I am going to do it in steps so that I can explain each file in depth.

The files for this project can be found in the Github repo for this website: github.com/gabrielc1925/gabrielc1925.github.io.

The files use in this basic Packer deployment are:

"/build.pkr.hcl",
"/ansible/playbook.yml",
"/.github/workflows/build-deploy-packer-aws.yml",
and
"/.github/scripts/create_channel_version.sh"

This workflow is triggered by the “build-deploy-packer-aws.yml” file.

The first block establishes the timing for when to run this github action. I set it to only run when a change was pushed to the gh-pages branch, as that is the final step after all other checks when a pull request is completed. I don’t want to update my AMI unnecessarily, so I put this github action to run after all other actions are done.

name: Deploy to Packer and AWS

on:
push:
  tags: ["v[0-9].[0-9]+.[0-9]+"]
  branches:
    - "gh-pages"

The environment variables and where to find them are then defined, and then the jobs block begins. The first job copies the repo with checkout and links it in an environment variable so that it can be accessed by later parts of the gh-actions scripts. It then links the AWS credentials stored in the gh secrets manager to local env variables for this set of functions.

jobs:
  build-artifact:
    name: Build
    runs-on: ubuntu-latest
    outputs:
      version_fingerprint: $
    steps:
      - name: Checkout Repository
        uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@8c3f20df09ac63af7b3ae3d7c91f105f857d8497 # v4.0.0
        with:
          aws-access-key-id: $
          aws-secret-access-key: $
          aws-region: us-east-1

The gh-actions script then begins to run Packer. It initializes the plugins required, then either runs a normal packer build command or includes a tag when running packer build to keep track of major deployments. Finally, it records the Packer version fingerprint for use by later scripts and applications.


    - name: Packer Init
        run: packer init .

    - name: Packer Build - Branches
        if: startsWith(github.ref, 'refs/heads/')
        run: packer build .

    - name: Packer Build - Tags
        if: startsWith(github.ref, 'refs/tags/v')
        run: HCP_PACKER_BUILD_FINGERPRINT=$(date +'%m%d%YT%H%M%S') packer build .

    - name: Get HCP Packer version fingerprint from Packer Manifest
        id: hcp
        run: |
            last_run_uuid=$(jq -r '.last_run_uuid' "./packer_manifest.json")
            build=$(jq -r '.builds[] | select(.packer_run_uuid == "'"$last_run_uuid"'")' "./packer_manifest.json")
            version_fingerprint=$(echo "$build" | jq -r '.custom_data.version_fingerprint')
            echo "::set-output name=version_fingerprint::$version_fingerprint"


Packer build begins by reading the build.pkr.hcl file and loading the plugins listed. It then lists the source for the AMI we will be building and provisioning. I went with a lightweight and low cost Ubuntu LTS 22.04 image as I do not need more than that for this project.


    packer {
    required_plugins {
        amazon = {
        source  = "github.com/hashicorp/amazon"
        version = "~> 1.3.2"
        }
    }
    }

    source "amazon-ebs" "github-pages" {
    region = "us-east-1"
    source_ami    = "ami-04a81a99f5ec58529"
    instance_type  = "t2.micro"
    ssh_username   = "ubuntu"
    ami_name    = "Gabrielc1925-github-io_"
    ami_regions = ["us-east-1"]
    }

The next step is to begin our build block. The first step is to connect to the HCP packer registry and create a bucket to track our changes and store any completed AMIs in later steps.


    build {
    # HCP Packer settings
    hcp_packer_registry {
        bucket_name = "Gabrielc1925-github-io"
        description = <<EOT
    This is an image for a backup of the github pages site for gabrielc1925
        EOT

        bucket_labels = {
        "hashicorp-learn" = "learn-packer-github-actions",
        }
    }
    }

After that the build block specifies the source it is acting on (we only have one above, so this is it), and begins to provision the base image for us. It then runs a provisioner block when the EC2 instance is running, and inputs the commands from the source file. In this case it is the ansible playbook named playbook.yml

# Set up Nginx with HTML files from Github Pages using Ansible
provisioner "ansible" {
  playbook_file = "./ansible/playbook.yml"
}

  # # Set up Nginx with HTML files from github pages
  # provisioner "shell" {
  #   scripts = [
  #     "setup-deps-gh-pages.sh"
  #   ]
  # }

I previously used a shell script to provision the resources on the AMI, but have since switched it to use Ansible as a provisioner. Ansible is more relevant to real world applications, but I will include the shell scripting walkthrough on a separate page titled ‘shell script’ since I already wrote about it previously and as an example of past work.

When the provisioner script triggers, Ansible takes the playbook file listed and starts to run commands. Ansible has to be installed on the controller computer for this to work, as the ansible plugin references the host file.

First, I will install all of the required programs to the remote host. This is following the installation documentation from each program.

---
- name: Install Docker, Nginx, and Git
  hosts: all
  become: true
  tasks:
    - name: Update apt cache
      apt:
        update_cache: yes

    - name: Install Docker dependencies
      apt:
        name:
          - ca-certificates
          - curl
        state: present

    - name: Create directory for Docker keyring
      file:
        path: /etc/apt/keyrings
        state: directory
        mode: "0755"

    - name: Download Docker GPG key
      get_url:
        url: https://download.docker.com/linux/ubuntu/gpg
        dest: /etc/apt/keyrings/docker.asc

    - name: Set permissions for Docker GPG key
      file:
        path: /etc/apt/keyrings/docker.asc
        mode: "0644"

    - name: Add Docker repository
      block:
        - name: Get architecture
          command: dpkg --print-architecture
          register: architecture

        - name: Get OS codename
          shell: . /etc/os-release && echo "$VERSION_CODENAME"
          register: os_codename

        - name: Add Docker repository to apt sources
          copy:
            content: |
              deb [arch= signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu  stable
            dest: /etc/apt/sources.list.d/docker.list

    - name: Update apt cache after adding Docker repo
      apt:
        update_cache: true

    - name: Install Docker, Nginx, and Git
      apt:
        name:
          - git-all
          - nginx
          - docker-ce
          - docker-ce-cli
          - containerd.io
          - docker-buildx-plugin
          - docker-compose-plugin
        state: present

Next, I will start to configure the files to be served. This begins with creating a directory to store temporary files

- name: configure files to be served
  hosts: all
  become: true
  tasks:
    - name: Create directory for temporary files
      file:
        path: /etc/tmp/github
        state: directory
        mode: "0775"

Then, I clone the github repo and copy the nginx configuration files that are needed:

- name: get github pages files
  git:
    repo: "https://github.com/Gabrielc1925/Gabrielc1925.github.io.git"
    dest: /etc/tmp/github/Gabrielc1925.github.io
    version: main

- name: Copy nginx configuration files
  copy:
    src: /etc/tmp/github/Gabrielc1925.github.io/nginx_setup/nginx/
    dest: /etc/nginx
    remote_src: true

Next, I copy over the HTML files that have already been created by Jekyll. These are stored in the gh-pages branch.

- name: Clone html files from github repo to temporary folder
  git:
    repo: "https://github.com/Gabrielc1925/Gabrielc1925.github.io.git"
    dest: /etc/tmp/github/gh-pages
    version: gh-pages
    update: yes

- name: synchronize html files from gh-pages to var so it can be served by nginx
  copy:
    src: /etc/tmp/github/gh-pages
    dest: /var/www
    remote_src: true

Finally, there is a set of commands to test nginx configuration and enable nginx when the system boots.

- name: Check nginx configuration syntax
  command: nginx -t
  register: nginx_test
  ignore_errors: true

- name: Display nginx syntax check output if it failed
  debug:
    var: nginx_test.stderr_lines
  when: nginx_test.rc != 0

- name: Fail the playbook if nginx config is invalid
  fail:
    msg: "Nginx configuration is invalid. Please fix the errors and try again."
  when: nginx_test.rc != 0

- name: Restart nginx and enable on boot
  service:
    name: nginx
    enabled: true
    state: restarted

At this point Packer is complete and has created a working AMI on my AWS account. It then saves this AMI to the AWS registry, and saves the information needed to locate it later to the local file for upload to the Haschicorp cloud via HCP Packer.

post-processor "manifest" {
output     = "packer_manifest.json"
strip_path = true
custom_data = {
version_fingerprint = packer.versionFingerprint
}
}

The gh-actions workflow has one more job to run still, so it triggers the function to update the HCP Packer registry with the work we did, including the fingerprint that was saved in the last step.


update-hcp-packer-channel:
name: Update HCP Packer channel
needs: ["build-artifact"]
runs-on: ubuntu-latest
steps: - name: Checkout Repository
uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0

        - name: Create and set channel
            working-directory: .github/scripts
            run: |
            channel_name=$( echo $ | sed 's/\./-/g')
            ./create_channel_version.sh $HCP_BUCKET_NAME $channel_name "$"

This triggers the helper script based in “create_channel_version.sh,” which records the changes to the Packer registry in the bucket we specified. This section is not something that I wrote, I just copied it from Hashicorp’s example repo and ensured no modifications were needed due to my region or bucket name. The script uses a bunch of metadata to ensure that the files generated have unique names so they will work with HCP registry


    #! /usr/bin/env bash

    set -eEuo pipefail

    usage() {
    cat <<EOF
    This script is a helper for setting a channel version in HCP Packer
    Usage:
    $(basename "$0") <bucket_slug> <channel_name> <version_fingerprint>
    ---
    Requires the following environment variables to be set:
    - HCP_CLIENT_ID
    - HCP_CLIENT_SECRET
    - HCP_ORGANIZATION_ID
    - HCP_PROJECT_ID
    EOF
    exit 1
    }

    # Entry point
    test "$#" -eq 3 || usage

    bucket_slug="$1"
    channel_name="$2"
    version_fingerprint="$3"
    auth_url="${HCP_AUTH_URL:-https://auth.hashicorp.com}"
    api_host="${HCP_API_HOST:-https://api.cloud.hashicorp.com}"
    base_url="$api_host/packer/2023-01-01/organizations/$HCP_ORGANIZATION_ID/projects/$HCP_PROJECT_ID"

    # If on main branch, set channel to release
    if [ "$channel_name" == "main" ]; then
    channel_name="release"
    fi

    echo "Attempting to assign version ${version_fingerprint} in bucket ${bucket_slug} to channel ${channel_name}"

    # Authenticate
    response=$(curl --request POST --silent \
    --url "$auth_url/oauth/token" \
    --data grant_type=client_credentials \
    --data client_id="$HCP_CLIENT_ID" \
    --data client_secret="$HCP_CLIENT_SECRET" \
    --data audience="https://api.hashicorp.cloud")
    api_error=$(echo "$response" | jq -r '.error')
    if [ "$api_error" != null ]; then
    echo "Failed to get access token: $api_error"
    exit 1
    fi
    bearer=$(echo "$response" | jq -r '.access_token')

    # Get or create channel
    echo "Getting channel ${channel_name}"
    response=$(curl --request GET --silent \
    --url "$base_url/buckets/$bucket_slug/channels/$channel_name" \
    --header "authorization: Bearer $bearer")
    api_error=$(echo "$response" | jq -r '.message')
    if [ "$api_error" != null ]; then
    echo "Channel ${channel_name} like doesn't exist, creating new channel"
    # Channel likely doesn't exist, create it
    api_error=$(curl --request POST --silent \
        --url "$base_url/buckets/$bucket_slug/channels" \
        --data-raw '{"name":"'"$channel_name"'"}' \
        --header "authorization: Bearer $bearer" | jq -r '.error')
    if [ "$api_error" != null ]; then
        echo "Error creating channel: $api_error"
        exit 1
    fi
    fi

    # Update channel to point to version
    echo "Updating channel ${channel_name} to version fingerprint ${version_fingerprint}"
    api_error=$(curl --request PATCH --silent \
    --url "$base_url/buckets/$bucket_slug/channels/$channel_name" \
    --data-raw '{"version_fingerprint": "'$version_fingerprint'", "update_mask": "versionFingerprint"}' \
    --header "authorization: Bearer $bearer" | jq -r '.message')
    if [ "$api_error" != null ]; then
        echo "Error updating channel: $api_error"
        exit 1
    fi

After that, the gh-actions workflow is completed and the AMI generated by Packer is terminated.

The next steps are to set up terraform for this site, and to configure a system to fail over to AWS in case of an outage.