#!/usr/bin/env bash ######################################################################################### #### packer-build-pipeline.sh — Build, test, and promote machine images with Packer #### #### Validate templates, build images, run post-build tests, and tag for promotion #### #### Requires: bash 4+, packer #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### ./packer-build-pipeline.sh --build --template ./image.pkr.hcl #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # --------------------------------------------------------------------------- # Color variables — pre-initialized empty, set by setup_colors() # --------------------------------------------------------------------------- RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET="" setup_colors() { if [[ "${COLOR}" == "never" ]]; then return fi if [[ "${COLOR}" == "always" ]] || [[ -t 1 ]]; then RED="\033[0;31m" GREEN="\033[0;32m" YELLOW="\033[0;33m" BLUE="\033[0;34m" CYAN="\033[0;36m" BOLD="\033[1m" DIM="\033[2m" RESET="\033[0m" fi } # --------------------------------------------------------------------------- # Standard helpers # --------------------------------------------------------------------------- log() { printf "%b[+]%b %s\n" "$GREEN" "$RESET" "$*"; } warn() { printf "%b[!]%b %s\n" "$YELLOW" "$RESET" "$*" >&2; } err() { printf "%b[-]%b %s\n" "$RED" "$RESET" "$*" >&2; } verbose() { [[ "$VERBOSE" == "true" ]] && printf "%b[~]%b %s\n" "$DIM" "$RESET" "$*"; return 0; } die() { err "$*"; exit 1; } section_header() { printf "\n%b%b══ %b%s%b\n" "$CYAN" "$BOLD" "$BLUE" "$*" "$RESET" } field() { printf " %-24s %s\n" "$1" "$2" } field_color() { local label="$1" color="$2" value="$3" printf " %-24s %b%s%b\n" "$label" "$color" "$value" "$RESET" } # --------------------------------------------------------------------------- # Defaults # --------------------------------------------------------------------------- RUN_MODE="" TEMPLATE_PATH="${PACKER_TEMPLATE:-}" VAR_FILE="${PACKER_VAR_FILE:-}" BUILD_DIR="${PACKER_BUILD_DIR:-.}" OUTPUT_DIR="${PACKER_OUTPUT_DIR:-./packer-output}" TEST_SCRIPT="${PACKER_TEST_SCRIPT:-}" PROMOTE_TAG="${PACKER_PROMOTE_TAG:-production}" PACKER_PATH="${PACKER_PATH:-packer}" ON_ERROR="${PACKER_ON_ERROR:-cleanup}" FORCE_BUILD=false VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- readonly SCRIPT_NAME="${0##*/}" START_TIME="" BUILD_ID="" export BUILD_STATUS="" # --------------------------------------------------------------------------- # Dependency checks # --------------------------------------------------------------------------- require_packer() { if ! command -v "$PACKER_PATH" &>/dev/null; then die "Packer not found at '${PACKER_PATH}'. Install from https://packer.io or set PACKER_PATH." fi verbose "Packer found: $(command -v "$PACKER_PATH") ($("$PACKER_PATH" --version 2>/dev/null || echo 'unknown'))" } # --------------------------------------------------------------------------- # Manifest helpers # --------------------------------------------------------------------------- write_manifest() { local manifest_dir="$1" artifact_id="$2" artifact_type="${3:-unknown}" status="${4:-success}" local manifest_file="${manifest_dir}/manifest.json" local timestamp timestamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)" cat > "$manifest_file" </dev/null | sort -r | head -1)" if [[ -z "$latest_dir" ]]; then die "No build directories found in ${OUTPUT_DIR}" fi local manifest="${latest_dir}/manifest.json" if [[ ! -f "$manifest" ]]; then die "No manifest.json in latest build directory: ${latest_dir}" fi printf "%s" "$manifest" } # --------------------------------------------------------------------------- # Elapsed time helper # --------------------------------------------------------------------------- elapsed() { local end_time end_time="$(date +%s)" echo "$(( end_time - START_TIME ))s" } # --------------------------------------------------------------------------- # Mode: validate # --------------------------------------------------------------------------- do_validate() { require_packer section_header "Validate Packer Template" if [[ -z "$TEMPLATE_PATH" ]]; then die "No template specified. Use --template or set PACKER_TEMPLATE." fi if [[ ! -f "$TEMPLATE_PATH" ]]; then die "Template not found: ${TEMPLATE_PATH}" fi field "Template:" "$TEMPLATE_PATH" [[ -n "$VAR_FILE" ]] && field "Var file:" "$VAR_FILE" # Initialize plugins log "Running packer init..." if ! "$PACKER_PATH" init "$TEMPLATE_PATH" 2>&1; then warn "packer init returned non-zero (plugins may already be installed)" fi # Build validate command local -a validate_cmd=("$PACKER_PATH" "validate") if [[ -n "$VAR_FILE" ]]; then validate_cmd+=("-var-file=${VAR_FILE}") fi validate_cmd+=("$TEMPLATE_PATH") log "Running packer validate..." verbose "Command: ${validate_cmd[*]}" local validate_output if validate_output="$("${validate_cmd[@]}" 2>&1)"; then field_color "Result:" "$GREEN" "PASS" verbose "$validate_output" BUILD_STATUS="validated" else err "$validate_output" field_color "Result:" "$RED" "FAIL" BUILD_STATUS="validation-failed" return 1 fi } # --------------------------------------------------------------------------- # Mode: build # --------------------------------------------------------------------------- do_build() { require_packer section_header "Build Packer Image" if [[ -z "$TEMPLATE_PATH" ]]; then die "No template specified. Use --template or set PACKER_TEMPLATE." fi if [[ ! -f "$TEMPLATE_PATH" ]]; then die "Template not found: ${TEMPLATE_PATH}" fi BUILD_ID="$(date +%Y%m%d-%H%M%S)" local build_output_dir="${OUTPUT_DIR}/${BUILD_ID}" local log_file="${build_output_dir}/build.log" mkdir -p "$build_output_dir" field "Template:" "$TEMPLATE_PATH" field "Build ID:" "$BUILD_ID" field "Output dir:" "$build_output_dir" field "On error:" "$ON_ERROR" [[ -n "$VAR_FILE" ]] && field "Var file:" "$VAR_FILE" [[ "$FORCE_BUILD" == "true" ]] && field "Force:" "yes" # Initialize plugins log "Running packer init..." "$PACKER_PATH" init "$TEMPLATE_PATH" >> "$log_file" 2>&1 || true # Build packer build args local -a build_cmd=("$PACKER_PATH" "build") if [[ -n "$VAR_FILE" ]]; then build_cmd+=("-var-file=${VAR_FILE}") fi build_cmd+=("-on-error=${ON_ERROR}") if [[ "$FORCE_BUILD" == "true" ]]; then build_cmd+=("-force") fi build_cmd+=("-color=false") build_cmd+=("$TEMPLATE_PATH") log "Starting packer build..." verbose "Command: ${build_cmd[*]}" local build_start build_end build_rc=0 build_start="$(date +%s)" if "${build_cmd[@]}" 2>&1 | tee -a "$log_file"; then build_rc=0 else build_rc=$? fi build_end="$(date +%s)" local build_duration="$(( build_end - build_start ))s" if [[ "$build_rc" -ne 0 ]]; then field_color "Result:" "$RED" "FAIL (exit code ${build_rc})" BUILD_STATUS="build-failed" write_manifest "$build_output_dir" "" "unknown" "failed" return 1 fi # Parse artifact info from build output local artifact_id="" artifact_type="unknown" # Try to find AMI IDs (aws-style) local ami_match ami_match="$(grep -oP 'ami-[0-9a-f]+' "$log_file" | tail -1 || true)" if [[ -n "$ami_match" ]]; then artifact_id="$ami_match" artifact_type="aws-ami" fi # Try generic artifact lines if no AMI found if [[ -z "$artifact_id" ]]; then local artifact_line artifact_line="$(grep -i 'artifact' "$log_file" | grep -i 'id\|name\|created' | tail -1 || true)" if [[ -n "$artifact_line" ]]; then # Extract the last colon-separated value or the last word artifact_id="$(echo "$artifact_line" | sed 's/.*[: ]//' | tr -d '[:space:]')" artifact_type="generic" fi fi # Fall back to build ID as artifact reference if [[ -z "$artifact_id" ]]; then artifact_id="build-${BUILD_ID}" artifact_type="local" warn "Could not parse artifact ID from build output — using ${artifact_id}" fi write_manifest "$build_output_dir" "$artifact_id" "$artifact_type" "success" section_header "Build Results" field "Build time:" "$build_duration" field_color "Artifact ID:" "$GREEN" "$artifact_id" field "Artifact type:" "$artifact_type" field "Log file:" "$log_file" field "Manifest:" "${build_output_dir}/manifest.json" BUILD_STATUS="built" log "Build completed successfully" } # --------------------------------------------------------------------------- # Mode: test # --------------------------------------------------------------------------- do_test() { section_header "Post-Build Tests" if [[ -z "$TEST_SCRIPT" ]]; then die "No test script specified. Use --test-script or set PACKER_TEST_SCRIPT." fi if [[ ! -f "$TEST_SCRIPT" ]]; then die "Test script not found: ${TEST_SCRIPT}" fi if [[ ! -x "$TEST_SCRIPT" ]]; then die "Test script is not executable: ${TEST_SCRIPT}" fi local manifest_file manifest_file="$(find_latest_manifest)" local artifact_id artifact_id="$(read_manifest "$manifest_file")" field "Test script:" "$TEST_SCRIPT" field "Artifact ID:" "$artifact_id" field "Manifest:" "$manifest_file" log "Running test script..." verbose "Command: ${TEST_SCRIPT} ${artifact_id}" local test_output test_rc=0 if test_output="$("$TEST_SCRIPT" "$artifact_id" 2>&1)"; then test_rc=0 else test_rc=$? fi if [[ -n "$test_output" ]]; then printf "\n%s\n" "$test_output" fi if [[ "$test_rc" -eq 0 ]]; then field_color "Result:" "$GREEN" "PASS" BUILD_STATUS="tested" else field_color "Result:" "$RED" "FAIL (exit code ${test_rc})" BUILD_STATUS="test-failed" return 1 fi } # --------------------------------------------------------------------------- # Mode: promote # --------------------------------------------------------------------------- do_promote() { section_header "Promote Artifact" local manifest_file manifest_file="$(find_latest_manifest)" local artifact_id artifact_id="$(read_manifest "$manifest_file")" field "Artifact ID:" "$artifact_id" field "Promote tag:" "$PROMOTE_TAG" field "Manifest:" "$manifest_file" # Update the manifest with the promotion tag local tmp_manifest tmp_manifest="$(mktemp)" sed "s/\"promote_tag\": \".*\"/\"promote_tag\": \"${PROMOTE_TAG}\"/" "$manifest_file" > "$tmp_manifest" mv "$tmp_manifest" "$manifest_file" log "Updated manifest with promote tag: ${PROMOTE_TAG}" # If the artifact looks like an AWS AMI, show the tagging command if [[ "$artifact_id" =~ ^ami- ]]; then section_header "AWS AMI Tagging" log "To tag this AMI for promotion, run:" printf "\n %baws ec2 create-tags \\\\\n --resources %s \\\\\n --tags Key=Environment,Value=%s%b\n\n" \ "$DIM" "$artifact_id" "$PROMOTE_TAG" "$RESET" warn "Command not executed automatically — review and run manually." fi field_color "Status:" "$GREEN" "Promoted as ${PROMOTE_TAG}" BUILD_STATUS="promoted" log "Promotion complete" } # --------------------------------------------------------------------------- # Mode: full pipeline # --------------------------------------------------------------------------- do_full() { section_header "Full Pipeline: Validate → Build → Test → Promote" log "Stage 1/4: Validate" if ! do_validate; then die "Pipeline aborted: validation failed" fi log "Stage 2/4: Build" if ! do_build; then die "Pipeline aborted: build failed" fi if [[ -n "$TEST_SCRIPT" ]]; then log "Stage 3/4: Test" if ! do_test; then die "Pipeline aborted: tests failed" fi else log "Stage 3/4: Test (skipped — no test script configured)" fi log "Stage 4/4: Promote" if ! do_promote; then die "Pipeline aborted: promotion failed" fi section_header "Pipeline Summary" field "Template:" "$TEMPLATE_PATH" field "Build ID:" "$BUILD_ID" field "Promote tag:" "$PROMOTE_TAG" field_color "Status:" "$GREEN" "All stages completed successfully" field "Duration:" "$(elapsed)" } # --------------------------------------------------------------------------- # Help # --------------------------------------------------------------------------- show_help() { cat < [OPTIONS] MODES --validate Validate the Packer template (init + validate) --build Build the image (init + build + capture artifacts) --test Run post-build tests against the built artifact --promote Tag/promote the latest built artifact --full Run the full pipeline: validate → build → test → promote OPTIONS --template PATH Packer template file (.pkr.hcl or .json) --var-file PATH Variables file to pass to packer --build-dir DIR Working directory for packer (default: .) --output-dir DIR Output directory for logs/manifests (default: ./packer-output) --test-script PATH Post-build test script (receives artifact ID as \$1) --promote-tag TAG Promotion tag name (default: production) --on-error ACTION Packer on-error behavior: cleanup, abort, ask (default: cleanup) --force Force build even if artifacts exist --verbose Enable verbose output --no-color Disable color output --help Show this help message ENVIRONMENT VARIABLES PACKER_TEMPLATE Default template path PACKER_VAR_FILE Default var-file path PACKER_BUILD_DIR Default build directory PACKER_OUTPUT_DIR Default output directory PACKER_TEST_SCRIPT Default test script path PACKER_PROMOTE_TAG Default promotion tag PACKER_PATH Path to packer binary PACKER_ON_ERROR Default on-error behavior VERBOSE Set to 'true' for verbose output COLOR Set to 'never' to disable colors EXAMPLES ${SCRIPT_NAME} --validate --template ./image.pkr.hcl ${SCRIPT_NAME} --build --template ./image.pkr.hcl --var-file vars.pkrvars.hcl ${SCRIPT_NAME} --build --template ./image.pkr.hcl --on-error abort --force ${SCRIPT_NAME} --test --test-script ./tests/verify-image.sh ${SCRIPT_NAME} --promote --promote-tag staging ${SCRIPT_NAME} --full --template ./image.pkr.hcl --test-script ./tests/verify.sh PIPELINE FLOW validate → Syntax/config checks on template build → packer init + packer build; logs + manifest saved test → Run external test script with artifact ID promote → Tag artifact in manifest (show AWS command if AMI) EOF } # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --validate) RUN_MODE="validate" shift ;; --build) RUN_MODE="build" shift ;; --test) RUN_MODE="test" shift ;; --promote) RUN_MODE="promote" shift ;; --full) RUN_MODE="full" shift ;; --template) TEMPLATE_PATH="${2:-}" [[ -z "$TEMPLATE_PATH" ]] && die "--template requires a PATH argument" shift 2 ;; --var-file) VAR_FILE="${2:-}" [[ -z "$VAR_FILE" ]] && die "--var-file requires a PATH argument" shift 2 ;; --build-dir) BUILD_DIR="${2:-}" [[ -z "$BUILD_DIR" ]] && die "--build-dir requires a DIR argument" shift 2 ;; --output-dir) OUTPUT_DIR="${2:-}" [[ -z "$OUTPUT_DIR" ]] && die "--output-dir requires a DIR argument" shift 2 ;; --test-script) TEST_SCRIPT="${2:-}" [[ -z "$TEST_SCRIPT" ]] && die "--test-script requires a PATH argument" shift 2 ;; --promote-tag) PROMOTE_TAG="${2:-}" [[ -z "$PROMOTE_TAG" ]] && die "--promote-tag requires a TAG argument" shift 2 ;; --on-error) ON_ERROR="${2:-}" [[ -z "$ON_ERROR" ]] && die "--on-error requires an ACTION argument" case "$ON_ERROR" in cleanup|abort|ask) ;; *) die "--on-error must be one of: cleanup, abort, ask" ;; esac shift 2 ;; --force) FORCE_BUILD=true shift ;; --verbose) VERBOSE="true" shift ;; --no-color) COLOR="never" shift ;; --help|-h) RUN_MODE="help" shift ;; *) die "Unknown option: $1 (see --help)" ;; esac done } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- main() { parse_args "$@" setup_colors START_TIME="$(date +%s)" case "$RUN_MODE" in validate) do_validate ;; build) do_build ;; test) do_test ;; promote) do_promote ;; full) do_full ;; help) show_help ;; "") show_help; die "No mode specified — use --validate, --build, --test, --promote, or --full" ;; *) die "Unknown mode: ${RUN_MODE}" ;; esac } main "$@"