From a42ed41caf0032eff8ce27e5f4a64c7eee836759 Mon Sep 17 00:00:00 2001 From: Anderson Nogueira Date: Thu, 16 Jul 2026 05:46:37 +0000 Subject: [PATCH 1/2] feat(images): jenkins-agent AMI factory, arm64 profile bakes qemu binfmt - Parameterized Packer factory (images/jenkins-agent) for the Tier-A worker agent AMIs: fleet-common payload baked per (os, arch), extension by provisioner not by template copy - arm64 capability profile bakes the digest-pinned qemu static binary with persistent systemd binfmt registration (F flag), BuildID-verified, bounded-retry pull, file(1) preinstalled - Bake -> fresh-boot smoke -> role-tag-flip promote; failed smokes deregister the candidate; emulation asserted end-to-end on arm64 smokes - GHA workflow: no-AWS validate on PRs, dispatch-gated OIDC bake matrix - OIDC role + SSM builder profile + no-ingress SG as a parameterized sibling of the ppg factory substrate, exact-subject trust on this repository --- .../workflows/jenkins-agent-image-factory.yml | 112 ++++++++ images/jenkins-agent/README.md | 51 ++++ images/jenkins-agent/agent.pkr.hcl | 115 ++++++++ images/jenkins-agent/justfile | 50 ++++ .../jenkins-agent/provisioners/00-common.sh | 32 +++ .../provisioners/10-qemu-binfmt.sh | 67 +++++ images/jenkins-agent/smoke/smoke.pkr.hcl | 86 ++++++ images/jenkins-agent/smoke/verify.sh | 28 ++ terraform/iam-gha-agent-image-factory.tf | 248 ++++++++++++++++++ terraform/variables.tf | 12 + 10 files changed, 801 insertions(+) create mode 100644 .github/workflows/jenkins-agent-image-factory.yml create mode 100644 images/jenkins-agent/README.md create mode 100644 images/jenkins-agent/agent.pkr.hcl create mode 100644 images/jenkins-agent/justfile create mode 100644 images/jenkins-agent/provisioners/00-common.sh create mode 100644 images/jenkins-agent/provisioners/10-qemu-binfmt.sh create mode 100644 images/jenkins-agent/smoke/smoke.pkr.hcl create mode 100644 images/jenkins-agent/smoke/verify.sh create mode 100644 terraform/iam-gha-agent-image-factory.tf diff --git a/.github/workflows/jenkins-agent-image-factory.yml b/.github/workflows/jenkins-agent-image-factory.yml new file mode 100644 index 00000000..a6b6b65f --- /dev/null +++ b/.github/workflows/jenkins-agent-image-factory.yml @@ -0,0 +1,112 @@ +name: jenkins-agent image factory + +# Bake -> smoke -> promote the Tier-A agent AMIs (strategy layer 1). PR runs +# validate templates with no AWS access; the bake job is manual dispatch first +# (weekly cron follows once the pxb canary proves the images). Promotion is a +# role-tag flip, so consumers never resolve an unsmoked image. + +on: + pull_request: + paths: + - 'images/jenkins-agent/**' + - '.github/workflows/jenkins-agent-image-factory.yml' + workflow_dispatch: + inputs: + arches: + description: JSON list of arches to bake + default: '["arm64","x86_64"]' + +env: + AGENT_FACTORY_REGION: eu-central-1 + +jobs: + # No-AWS gate: malformed templates or scripts fail here before any EC2 spins + # up. No id-token, so this job can never assume the factory role. + validate: + name: Validate templates (no AWS) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: hashicorp/setup-packer@1aa358be5cf73883762b302a3a03abd66e75b232 # v3.1.0 + - name: fmt + validate (both arches) + working-directory: images/jenkins-agent + run: | + set -euo pipefail + packer fmt -check -recursive . + for arch in arm64 x86_64; do + packer init . + packer validate -var arch="$arch" . + packer init smoke + packer validate -var candidate_ami=ami-000000000000000ff -var arch="$arch" smoke + done + - name: shellcheck provisioners + smoke + run: shellcheck images/jenkins-agent/provisioners/*.sh images/jenkins-agent/smoke/*.sh + + bake: + name: Bake + smoke + promote (${{ matrix.arch }}) + if: github.event_name == 'workflow_dispatch' + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + arch: ${{ fromJSON(github.event.inputs.arches || '["arm64","x86_64"]') }} + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: hashicorp/setup-packer@1aa358be5cf73883762b302a3a03abd66e75b232 # v3.1.0 + - uses: aws-actions/configure-aws-credentials@a159d7bb5354cf786f855f2f5d1d8d768d9a08d1 # v4.3.1 + with: + role-to-assume: ${{ secrets.AGENT_FACTORY_ROLE_ARN }} + aws-region: ${{ env.AGENT_FACTORY_REGION }} + + - name: Bake candidate + id: bake + working-directory: images/jenkins-agent + run: | + set -euo pipefail + packer init . + packer build -color=false -var arch="${{ matrix.arch }}" -var region="$AGENT_FACTORY_REGION" . | tee build.log + ami=$(grep -oE 'ami-[0-9a-f]{17}' build.log | tail -1) + [ -n "$ami" ] || { echo "FATAL: no AMI id in packer output"; exit 1; } + echo "ami=$ami" >> "$GITHUB_OUTPUT" + echo "- bake (${{ matrix.arch }}): $ami" >> "$GITHUB_STEP_SUMMARY" + + - name: Smoke (fresh boot + assertions) + working-directory: images/jenkins-agent + env: + AMI: ${{ steps.bake.outputs.ami }} + run: | + set -euo pipefail + packer init smoke + if ! packer build -color=false -var candidate_ami="$AMI" -var arch="${{ matrix.arch }}" -var region="$AGENT_FACTORY_REGION" smoke; then + echo "smoke FAILED; deregistering candidate $AMI" + aws ec2 deregister-image --region "$AGENT_FACTORY_REGION" --image-id "$AMI" --delete-associated-snapshots + echo "- smoke (${{ matrix.arch }}): FAIL (candidate deregistered)" >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + discard=$(aws ec2 describe-images --region "$AGENT_FACTORY_REGION" --owners self \ + --filters Name=tag:role,Values=jenkins-agent-smoke-discard --query 'Images[].ImageId' --output text) + for ami in $discard; do + aws ec2 deregister-image --region "$AGENT_FACTORY_REGION" --image-id "$ami" --delete-associated-snapshots + done + echo "- smoke (${{ matrix.arch }}): pass" >> "$GITHUB_STEP_SUMMARY" + + - name: Promote (role tag flip) + env: + AMI: ${{ steps.bake.outputs.ami }} + run: | + set -euo pipefail + for attempt in 1 2 3 4 5; do + aws ec2 create-tags --region "$AGENT_FACTORY_REGION" --resources "$AMI" \ + --tags Key=role,Value=jenkins-agent Key=smoke,Value=passed && { echo "promoted $AMI"; break; } + [ "$attempt" = 5 ] && { echo "- promote (${{ matrix.arch }}): FAILED ($AMI left as candidate)" >> "$GITHUB_STEP_SUMMARY"; exit 1; } + sleep $((attempt * 5)) + done + echo "- promote (${{ matrix.arch }}): $AMI is role=jenkins-agent" >> "$GITHUB_STEP_SUMMARY" diff --git a/images/jenkins-agent/README.md b/images/jenkins-agent/README.md new file mode 100644 index 00000000..0722d4bf --- /dev/null +++ b/images/jenkins-agent/README.md @@ -0,0 +1,51 @@ +# Jenkins agent-image factory + +Packer factory for the worker **agent AMIs** (strategy layer 1): one baked +image per (os, arch, capability profile) carrying the fleet-common payload +every worker label family installs at boot today. Baking removes the measured +multi-minute per-launch setup and the boot-time network dependencies. + +First delivered images: `jenkins-agent-al2023-{x86_64,arm64}`. The arm64 +profile bakes qemu user-mode emulation (persistent binfmt via systemd) so +x86_64-only containers run on Graviton workers. + +## Layout (extension recipe) + +| Path | Role | +|------|------| +| `agent.pkr.hcl` | The single parameterized template. New (os, arch) combos extend variables, never copy the file | +| `provisioners/00-common.sh` | Fleet-common payload (java, git, docker, awscli, archive tools) | +| `provisioners/10-qemu-binfmt.sh` | arm64 capability profile: digest-pinned qemu + persistent binfmt. Self-guards on arch | +| `smoke/` | Fresh-boot assertion build. A candidate promotes only after this passes | +| `justfile` | Local recipes mirroring the workflow: `fmt-check`, `validate`, `bake`, `smoke`, `promote`, `list` | + +To add an image family: add a provisioner script (numbered, self-guarded), +extend the smoke assertions, and if a new OS is involved add its base-AMI SSM +parameter mapping in `agent.pkr.hcl`. The workflow matrix picks it up via the +`arches`/vars inputs. + +## Lifecycle + +Bake (candidate, `role=jenkins-agent-candidate`) -> fresh-boot smoke -> +promote (`role=jenkins-agent` + `smoke=passed` tag flip). Failed smokes +deregister the candidate; consumers resolving by role never see an unsmoked +image. Superseded images age out via native EC2 image deprecation; pruning +follows the fail-safe rails of the ppg factory prune (keep >= 2, protected +roles, loud no-op). + +## Consumption + +Worker fleets consume committed per-region literal AMI ids (ADR 0029), bumped +by PR after promotion. The Graviton fleets (`modules/jenkins-arm-fleet`) +currently resolve the latest Amazon Linux 2 arm64 dynamically; adopting a +baked image means passing `ami_id` explicitly per fleet, starting with a pxb +canary, and slimming the boot user data to the instance-shape-specific steps +(ephemeral /mnt mount, docker data-root) that stay out of the bake. + +## Roadmap + +- Per-region `copy-image` + per-region smoke before multi-region adoption + (the factory bakes in one region; consumer fleets span five). +- Weekly refresh cron once the pxb canary proves the images. +- Hetzner snapshots via the `hcloud` builder in this same directory. +- Rocky/native-OS `min-*` profiles ride existing demand. diff --git a/images/jenkins-agent/agent.pkr.hcl b/images/jenkins-agent/agent.pkr.hcl new file mode 100644 index 00000000..46097cbf --- /dev/null +++ b/images/jenkins-agent/agent.pkr.hcl @@ -0,0 +1,115 @@ +packer { + required_plugins { + amazon = { + source = "github.com/hashicorp/amazon" + version = ">= 1.3.0" + } + } +} + +# Parameterized Jenkins agent-image factory (strategy layer 1). One template +# serves every (os, arch, profile) combo; the first delivered images are +# jenkins-agent-al2023-{x86_64,arm64}, the arm64 profile carrying baked qemu +# user-mode emulation. Extend by adding an os/arch value plus a provisioner, +# not by copying the template. + +variable "region" { + type = string + default = "eu-central-1" +} + +variable "os_name" { + type = string + default = "al2023" +} + +variable "arch" { + type = string + validation { + condition = contains(["x86_64", "arm64"], var.arch) + error_message = "The arch value must be x86_64 or arm64." + } +} + +variable "builder_instance_profile" { + type = string + default = "jenkins-agent-builder-ssm" +} + +variable "builder_sg_name" { + type = string + default = "jenkins-agent-factory-builder" +} + +variable "billing_tag" { + type = string + default = "jenkins-agent-factory" +} + +locals { + timestamp = regex_replace(timestamp(), "[- TZ:]", "") + image_name = "jenkins-agent-${var.os_name}-${var.arch}-${local.timestamp}" + instance_type = var.arch == "arm64" ? "c7g.large" : "c6i.large" + + # Standard (not minimal) AL2023: the SSM agent ships preinstalled, which the + # session_manager communicator and the fleet's shell access both rely on. + base_ami_param = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-${var.arch}" + + common_tags = { + Name = local.image_name + role = "jenkins-agent-candidate" + os = var.os_name + arch = var.arch + source = "factory" + "iit-billing-tag" = var.billing_tag + } +} + +data "amazon-parameterstore" "base_ami" { + region = var.region + name = local.base_ami_param +} + +source "amazon-ebs" "agent" { + region = var.region + source_ami = data.amazon-parameterstore.base_ami.value + instance_type = local.instance_type + ami_name = local.image_name + + # SSM-only builder: no inbound SSH, the pre-created egress-only SG disables + # Packer's temporary SG and the OIDC role carries no SG mutations. + communicator = "ssh" + ssh_username = "ec2-user" + ssh_interface = "session_manager" + iam_instance_profile = var.builder_instance_profile + + security_group_filter { + filters = { + "group-name" = var.builder_sg_name + } + } + + launch_block_device_mappings { + device_name = "/dev/xvda" + volume_size = 20 + volume_type = "gp3" + delete_on_termination = true + } + + run_tags = merge(local.common_tags, { Name = "${local.image_name}-builder" }) + tags = local.common_tags + snapshot_tags = local.common_tags +} + +build { + sources = ["source.amazon-ebs.agent"] + + # 10-qemu-binfmt self-guards on uname and no-ops on x86_64 builders, so the + # provisioner list stays arch-agnostic. + provisioner "shell" { + scripts = [ + "provisioners/00-common.sh", + "provisioners/10-qemu-binfmt.sh", + ] + } +} diff --git a/images/jenkins-agent/justfile b/images/jenkins-agent/justfile new file mode 100644 index 00000000..bd2b5db3 --- /dev/null +++ b/images/jenkins-agent/justfile @@ -0,0 +1,50 @@ +# Jenkins agent-image factory. Bake/smoke/promote one (os, arch) combo at a +# time; the GHA workflow drives the same recipes. Promotion is a role-tag flip +# (candidate -> jenkins-agent) so consumers resolving by tag never see an +# unsmoked image. Worker clouds consume committed per-region literal AMI ids +# (ADR 0029), bumped by PR after promotion. + +region := env_var_or_default("AGENT_FACTORY_REGION", "eu-central-1") +profile := env_var_or_default("AWS_PROFILE", "") + +_aws_profile_args := if profile == "" { "" } else { "--profile " + profile } + +default: + @just --list + +fmt-check: + packer fmt -check -recursive . + +validate arch="arm64": + packer init . + packer validate -var arch={{arch}} -var region={{region}} . + packer init smoke + packer validate -var candidate_ami=ami-000000000000000ff -var arch={{arch}} -var region={{region}} smoke + +# Bake one candidate AMI. Output ends with the ami id (packer manifest). +bake arch="arm64": + packer init . + packer build -color=false -var arch={{arch}} -var region={{region}} . + +# Boot + assert a candidate, then discard the smoke by-product AMI. +smoke candidate_ami arch="arm64": + #!/usr/bin/env bash + set -euo pipefail + packer init smoke + packer build -color=false -var candidate_ami={{candidate_ami}} -var arch={{arch}} -var region={{region}} smoke + discard=$(aws ec2 describe-images {{_aws_profile_args}} --region {{region}} --owners self \ + --filters Name=tag:role,Values=jenkins-agent-smoke-discard --query 'Images[].ImageId' --output text) + for ami in ${discard}; do + aws ec2 deregister-image {{_aws_profile_args}} --region {{region}} --image-id "${ami}" --delete-associated-snapshots + done + +# Promote a smoked candidate: role tag flip + smoke marker. +promote candidate_ami: + aws ec2 create-tags {{_aws_profile_args}} --region {{region}} --resources {{candidate_ami}} \ + --tags Key=role,Value=jenkins-agent Key=smoke,Value=passed + +# Current agent images, newest first. +list: + aws ec2 describe-images {{_aws_profile_args}} --region {{region}} --owners self --include-deprecated \ + --filters Name=tag:role,Values=jenkins-agent \ + --query 'reverse(sort_by(Images,&CreationDate))[].[ImageId,Name,Tags[?Key==`arch`]|[0].Value]' --output table diff --git a/images/jenkins-agent/provisioners/00-common.sh b/images/jenkins-agent/provisioners/00-common.sh new file mode 100644 index 00000000..f7348c36 --- /dev/null +++ b/images/jenkins-agent/provisioners/00-common.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Fleet-common agent payload: everything every worker label family installs at +# boot today (the 2.5-8 min per-launch setup this factory removes). Baked once +# per (os, arch); instance-shape-specific steps (ephemeral-disk mkfs/mount) and +# env-specific ones (/etc/hosts entries, Jenkins remoting) stay in the +# now-minimal boot initScript. +set -euo pipefail + +sudo dnf -y -q update + +# file(1) is load-bearing for 10-qemu-binfmt.sh's BuildID verification. +sudo dnf -y -q install \ + java-17-amazon-corretto-headless \ + git \ + docker \ + awscli-2 \ + file \ + tar \ + unzip \ + p7zip + +sudo systemctl enable docker +sudo systemctl start docker + +# Workers run builds as ec2-user via the Jenkins agent: docker group membership +# replaces the per-boot usermod the initScript does today. +sudo usermod -aG docker ec2-user + +java -version 2>&1 | head -1 +git --version +docker --version +aws --version diff --git a/images/jenkins-agent/provisioners/10-qemu-binfmt.sh b/images/jenkins-agent/provisioners/10-qemu-binfmt.sh new file mode 100644 index 00000000..659e3dbf --- /dev/null +++ b/images/jenkins-agent/provisioners/10-qemu-binfmt.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# arm64 capability profile: bake qemu user-mode emulation so x86_64-only +# containers (the PXB Minio/Vault/KMIP test helpers) run on Graviton workers. +# AL2023 does not package qemu-user-static, so the static binary comes from +# the Percona ECR Public mirror of tonistiigi/binfmt, pinned by index digest +# and by the extracted binary's BuildID. +# +# Registration is PERSISTENT: /etc/binfmt.d + systemd-binfmt, not a boot-time +# /proc write. The F (fix-binary) flag loads the interpreter at registration +# so containers can exec it without the binary existing in their filesystem. +set -euo pipefail + +readonly QEMU_IMAGE="public.ecr.aws/e7j3v3n0/qemu-binfmt@sha256:d3b963f787999e6c0219a48dba02978769286ff61a5f4d26245cb6a6e5567ea3" +readonly QEMU_BUILD_ID="3ce82273ab59ab77b04b0bee9060c5b194769f4c" +readonly QEMU_BIN="/usr/local/bin/qemu-x86_64" + +if [[ "$(uname -m)" != "aarch64" ]]; then + echo "10-qemu-binfmt: x86_64 host, nothing to do" + exit 0 +fi + +# Bounded retry: a bake must fail loudly, never hang on a throttled pull. +for attempt in 1 2 3 4 5; do + if sudo docker pull --platform linux/arm64 "${QEMU_IMAGE}" >/dev/null; then + break + fi + + if [[ "${attempt}" -eq 5 ]]; then + echo "FATAL: qemu image pull failed after ${attempt} attempts" >&2 + exit 1 + fi + + sleep $((attempt * 10)) +done + +container_id="$(sudo docker create --platform linux/arm64 "${QEMU_IMAGE}")" +sudo docker cp "${container_id}:/usr/bin/qemu-x86_64" "${QEMU_BIN}" +sudo docker rm -f "${container_id}" >/dev/null +sudo chmod 0755 "${QEMU_BIN}" + +# file(1) is installed by 00-common.sh: without it this check can never match +# and a healthy binary would be rejected. +if ! file "${QEMU_BIN}" | grep -Fq "BuildID[sha1]=${QEMU_BUILD_ID}"; then + echo "FATAL: ${QEMU_BIN} BuildID mismatch (expected ${QEMU_BUILD_ID})" >&2 + exit 1 +fi + +# printf, never echo: the magic contains NUL escapes that echo can truncate. +printf '%s\n' ':qemu-x86_64:M::\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00:\xff\xff\xff\xff\xff\xfe\xfe\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff:/usr/local/bin/qemu-x86_64:POCF' \ + | sudo tee /etc/binfmt.d/qemu-x86_64.conf >/dev/null + +sudo systemctl enable systemd-binfmt.service +sudo systemctl restart systemd-binfmt.service + +if [[ ! -e /proc/sys/fs/binfmt_misc/qemu-x86_64 ]]; then + echo "FATAL: binfmt registration missing after systemd-binfmt restart" >&2 + exit 1 +fi + +emulated_arch="$(sudo docker run --rm --platform linux/amd64 public.ecr.aws/amazonlinux/amazonlinux:2023 uname -m)" + +if [[ "${emulated_arch}" != "x86_64" ]]; then + echo "FATAL: emulation check returned ${emulated_arch}, expected x86_64" >&2 + exit 1 +fi + +echo "10-qemu-binfmt: qemu ${QEMU_BUILD_ID} registered, x86_64 emulation verified" diff --git a/images/jenkins-agent/smoke/smoke.pkr.hcl b/images/jenkins-agent/smoke/smoke.pkr.hcl new file mode 100644 index 00000000..62f39b5e --- /dev/null +++ b/images/jenkins-agent/smoke/smoke.pkr.hcl @@ -0,0 +1,86 @@ +packer { + required_plugins { + amazon = { + source = "github.com/hashicorp/amazon" + version = ">= 1.3.0" + } + } +} + +# Fresh-boot smoke for an agent AMI candidate: boots the candidate, asserts the +# baked payload, and for arm64 proves x86_64 emulation end-to-end. A candidate +# is promoted (role tag flip) only after this build succeeds. The build +# produces a throwaway AMI name that the workflow deregisters immediately; +# only the boot + provisioner assertions matter. + +variable "candidate_ami" { + type = string +} + +variable "arch" { + type = string +} + +variable "region" { + type = string + default = "eu-central-1" +} + +variable "builder_instance_profile" { + type = string + default = "jenkins-agent-builder-ssm" +} + +variable "builder_sg_name" { + type = string + default = "jenkins-agent-factory-builder" +} + +variable "billing_tag" { + type = string + default = "jenkins-agent-factory" +} + +locals { + timestamp = regex_replace(timestamp(), "[- TZ:]", "") + instance_type = var.arch == "arm64" ? "c7g.large" : "c6i.large" +} + +source "amazon-ebs" "smoke" { + region = var.region + source_ami = var.candidate_ami + instance_type = local.instance_type + ami_name = "jenkins-agent-smoke-discard-${local.timestamp}" + + communicator = "ssh" + ssh_username = "ec2-user" + ssh_interface = "session_manager" + iam_instance_profile = var.builder_instance_profile + + security_group_filter { + filters = { + "group-name" = var.builder_sg_name + } + } + + run_tags = { + Name = "jenkins-agent-smoke-${local.timestamp}" + "iit-billing-tag" = var.billing_tag + } + + tags = { + role = "jenkins-agent-smoke-discard" + "iit-billing-tag" = var.billing_tag + } +} + +build { + sources = ["source.amazon-ebs.smoke"] + + provisioner "shell" { + env = { + SMOKE_ARCH = var.arch + } + script = "smoke/verify.sh" + } +} diff --git a/images/jenkins-agent/smoke/verify.sh b/images/jenkins-agent/smoke/verify.sh new file mode 100644 index 00000000..5109e81c --- /dev/null +++ b/images/jenkins-agent/smoke/verify.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Assertion-only smoke payload, run on a fresh boot of the candidate AMI. +# Every check mirrors something a worker label family does on its first +# build: toolchain presence, a working docker daemon, and (arm64) x86_64 +# emulation surviving a reboot cycle via systemd-binfmt. +set -euo pipefail + +for tool in java git docker aws file; do + command -v "${tool}" >/dev/null || { echo "FATAL: ${tool} missing" >&2; exit 1; } +done + +sudo systemctl is-enabled docker >/dev/null +sudo systemctl is-active docker >/dev/null + +sudo docker run --rm public.ecr.aws/amazonlinux/amazonlinux:2023 true + +if [[ "${SMOKE_ARCH:?}" == "arm64" ]]; then + [[ -e /proc/sys/fs/binfmt_misc/qemu-x86_64 ]] || { echo "FATAL: binfmt registration absent on fresh boot" >&2; exit 1; } + + emulated_arch="$(sudo docker run --rm --platform linux/amd64 public.ecr.aws/amazonlinux/amazonlinux:2023 uname -m)" + + if [[ "${emulated_arch}" != "x86_64" ]]; then + echo "FATAL: emulation returned ${emulated_arch}, expected x86_64" >&2 + exit 1 + fi +fi + +echo "smoke: all assertions passed (${SMOKE_ARCH})" diff --git a/terraform/iam-gha-agent-image-factory.tf b/terraform/iam-gha-agent-image-factory.tf new file mode 100644 index 00000000..5b1fe2a5 --- /dev/null +++ b/terraform/iam-gha-agent-image-factory.tf @@ -0,0 +1,248 @@ +# Owner: platform +# +# AWS resources for the Jenkins agent-image factory: the GitHub Actions OIDC +# role, the SSM builder instance profile, and the egress-only builder security +# group. Consumed by .github/workflows/jenkins-agent-image-factory.yml in THIS +# repository, which bakes the Tier-A worker agent AMIs +# (jenkins-agent--) with Packer over SSM Session Manager. +# +# A parameterized sibling of iam-gha-ppg-ami-factory.tf: same trust shape +# (federated OIDC, StringEquals aud + sub via ./modules/github-oidc-role, no +# wildcards), same no-static-keys, no-inbound-SSH builder posture, its own +# subject (this repo), billing tag, and region. + +# --------------------------------------------------------------------------- +# Builder SSM instance profile (Packer builder + smoke instance run with this) +# --------------------------------------------------------------------------- +data "aws_iam_policy_document" "agent_image_builder_trust" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "agent_image_builder_ssm" { + name = "jenkins-agent-builder-ssm" + description = "Builder + smoke instance profile so Packer/smoke connect over SSM Session Manager (no inbound SSH)." + assume_role_policy = data.aws_iam_policy_document.agent_image_builder_trust.json + tags = local.tags +} + +resource "aws_iam_role_policy_attachment" "agent_image_builder_ssm_core" { + role = aws_iam_role.agent_image_builder_ssm.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +resource "aws_iam_instance_profile" "agent_image_builder_ssm" { + name = "jenkins-agent-builder-ssm" + role = aws_iam_role.agent_image_builder_ssm.name + tags = local.tags +} + +# --------------------------------------------------------------------------- +# No-ingress security group for the Packer builder + smoke instance. Session +# Manager needs no inbound rule; supplying this SG to Packer disables its +# temporary SG, so the OIDC role carries no SG mutations at all. +# --------------------------------------------------------------------------- +data "aws_vpc" "agent_image_factory_default" { + provider = aws.eu-central-1 + default = true +} + +resource "aws_security_group" "agent_image_builder" { + provider = aws.eu-central-1 + name = "jenkins-agent-factory-builder" + description = "Egress-only SG for the Packer agent-AMI builder + smoke instance (SSM Session Manager, no inbound)." + vpc_id = data.aws_vpc.agent_image_factory_default.id + + egress { + description = "All egress (SSM endpoints, AL repos, ECR Public over 443)." + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + # Intentionally NO ingress block: no inbound, SSM-only. + tags = merge(local.tags, { + Name = "jenkins-agent-factory-builder" + "iit-billing-tag" = "jenkins-agent-factory" + }) +} + +# --------------------------------------------------------------------------- +# Least-privilege permissions for the GHA OIDC factory role. Statement shapes +# and their rationale mirror iam-gha-ppg-ami-factory.tf (unconditioned +# CreateTags, split SSM statements, single PassRole); see the comments there. +# --------------------------------------------------------------------------- +locals { + agent_image_factory_acct = data.aws_caller_identity.current.account_id + agent_image_factory_reg = var.agent_image_factory_region +} + +data "aws_iam_policy_document" "gha_agent_image_factory_perms" { + statement { + sid = "RunInstancesInstanceResource" + effect = "Allow" + actions = ["ec2:RunInstances"] + resources = ["arn:aws:ec2:${local.agent_image_factory_reg}:${local.agent_image_factory_acct}:instance/*"] + condition { + test = "StringEquals" + variable = "aws:RequestedRegion" + values = [local.agent_image_factory_reg] + } + condition { + test = "StringEquals" + variable = "aws:RequestTag/iit-billing-tag" + values = ["jenkins-agent-factory"] + } + } + + statement { + sid = "RunInstancesSupportingResources" + effect = "Allow" + actions = ["ec2:RunInstances"] + resources = [ + "arn:aws:ec2:${local.agent_image_factory_reg}::image/*", + "arn:aws:ec2:${local.agent_image_factory_reg}::snapshot/*", + "arn:aws:ec2:${local.agent_image_factory_reg}:${local.agent_image_factory_acct}:network-interface/*", + "arn:aws:ec2:${local.agent_image_factory_reg}:${local.agent_image_factory_acct}:subnet/*", + "arn:aws:ec2:${local.agent_image_factory_reg}:${local.agent_image_factory_acct}:security-group/*", + "arn:aws:ec2:${local.agent_image_factory_reg}:${local.agent_image_factory_acct}:volume/*", + "arn:aws:ec2:${local.agent_image_factory_reg}:${local.agent_image_factory_acct}:key-pair/*", + ] + condition { + test = "StringEquals" + variable = "aws:RequestedRegion" + values = [local.agent_image_factory_reg] + } + } + + statement { + sid = "TagAndKeypair" + effect = "Allow" + actions = [ + "ec2:CreateTags", "ec2:DeleteTags", + "ec2:CreateKeyPair", "ec2:DeleteKeyPair", + ] + resources = ["*"] + condition { + test = "StringEquals" + variable = "aws:RequestedRegion" + values = [local.agent_image_factory_reg] + } + } + + statement { + sid = "TerminateTaggedInstances" + effect = "Allow" + actions = ["ec2:StopInstances", "ec2:TerminateInstances"] + resources = ["arn:aws:ec2:${local.agent_image_factory_reg}:${local.agent_image_factory_acct}:instance/*"] + condition { + test = "StringEquals" + variable = "aws:ResourceTag/iit-billing-tag" + values = ["jenkins-agent-factory"] + } + } + + statement { + sid = "AmiBakeCreate" + effect = "Allow" + actions = ["ec2:CreateImage", "ec2:CreateSnapshot", "ec2:EnableImageDeprecation"] + resources = ["*"] + condition { + test = "StringEquals" + variable = "aws:RequestedRegion" + values = [local.agent_image_factory_reg] + } + } + + statement { + sid = "AmiSnapshotCleanup" + effect = "Allow" + actions = ["ec2:DeregisterImage", "ec2:DeleteSnapshot"] + resources = ["*"] + condition { + test = "StringEquals" + variable = "aws:ResourceTag/iit-billing-tag" + values = ["jenkins-agent-factory"] + } + condition { + test = "StringEquals" + variable = "aws:RequestedRegion" + values = [local.agent_image_factory_reg] + } + } + + statement { + sid = "DescribeReadOnly" + effect = "Allow" + actions = [ + "ec2:DescribeInstances", "ec2:DescribeInstanceStatus", "ec2:DescribeImages", + "ec2:DescribeSnapshots", "ec2:DescribeVolumes", "ec2:DescribeTags", + "ec2:DescribeSubnets", "ec2:DescribeVpcs", "ec2:DescribeSecurityGroups", + "ec2:DescribeRegions", "ec2:DescribeInstanceTypes", "ec2:DescribeKeyPairs", + ] + resources = ["*"] + } + + statement { + sid = "SsmStartSessionOnTaggedInstance" + effect = "Allow" + actions = ["ssm:StartSession"] + resources = ["arn:aws:ec2:${local.agent_image_factory_reg}:${local.agent_image_factory_acct}:instance/*"] + condition { + test = "StringEquals" + variable = "ssm:resourceTag/iit-billing-tag" + values = ["jenkins-agent-factory"] + } + } + statement { + sid = "SsmStartSessionDocument" + effect = "Allow" + actions = ["ssm:StartSession"] + resources = [ + "arn:aws:ssm:${local.agent_image_factory_reg}::document/AWS-StartPortForwardingSession", + "arn:aws:ssm:${local.agent_image_factory_reg}::document/AWS-StartSSHSession", + ] + } + statement { + sid = "SsmSessionHousekeeping" + effect = "Allow" + actions = ["ssm:TerminateSession", "ssm:ResumeSession", "ssm:DescribeInstanceInformation"] + resources = ["*"] + } + + statement { + sid = "PassBuilderRoleToEc2" + effect = "Allow" + actions = ["iam:PassRole"] + resources = [aws_iam_role.agent_image_builder_ssm.arn] + condition { + test = "StringEquals" + variable = "iam:PassedToService" + values = ["ec2.amazonaws.com"] + } + } +} + +module "agent_image_factory_oidc" { + source = "./modules/github-oidc-role" + + name = "gha-agent-image-factory" + role_name_prefix = "${local.cluster_name}-" + description = "Assumed by the jenkins-agent image-factory GHA workflow in Percona/percona-cd-platform (main) via OIDC to bake worker agent AMIs over SSM. No static keys." + + subject_claims = var.agent_image_factory_subject_claims + permissions_policy_json = data.aws_iam_policy_document.gha_agent_image_factory_perms.json + tags = local.tags +} + +output "agent_image_factory_oidc_role_arn" { + description = "role-to-assume for the agent image-factory GHA workflow (store as repo secret AGENT_FACTORY_ROLE_ARN)." + value = module.agent_image_factory_oidc.role_arn +} diff --git a/terraform/variables.tf b/terraform/variables.tf index a6c35b80..a44afb61 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -245,3 +245,15 @@ variable "ppg_ami_factory_subject_claims" { type = list(string) default = ["repo:Percona-Lab/jenkins-pipelines:ref:refs/heads/master"] } + +variable "agent_image_factory_region" { + description = "AWS region the jenkins-agent image factory bakes in. Consumer fleets span five regions; copies are per-region follow-ups gated on per-region smokes." + type = string + default = "eu-central-1" +} + +variable "agent_image_factory_subject_claims" { + description = "GitHub Actions sub claims allowed to assume the agent image-factory role. Production is main-only on Percona/percona-cd-platform." + type = list(string) + default = ["repo:Percona/percona-cd-platform:ref:refs/heads/main"] +} From cfa1d22d699b5dee25b7eda0572d40de38e0f73f Mon Sep 17 00:00:00 2001 From: Anderson Nogueira Date: Thu, 16 Jul 2026 07:34:09 +0000 Subject: [PATCH 2/2] fix(images): address agent-factory review, bake-blocking grants + supply-chain pins - Grant ssm:GetParameter on the AL2023 public parameter path and iam:GetInstanceProfile on the builder profile, the first bake dies without both - skip_create_ami on the smoke build removes the by-product AMI and the concurrent-matrix cleanup race entirely - Read the AMI id from a packer manifest and cross-check its arch tag, never scrape human output; always() cleanup deregisters an unpromoted candidate on any failure or cancellation - Correct the aws-actions/configure-aws-credentials pin to the real v4.3.1 commit; pin packer 1.15.4 and amazon plugin 1.8.2 exactly - Cap the job at 55 minutes so it fails before the one-hour OIDC session expires mid-cleanup --- .../workflows/jenkins-agent-image-factory.yml | 54 +++++++++++++------ images/jenkins-agent/agent.pkr.hcl | 7 ++- images/jenkins-agent/justfile | 9 +--- images/jenkins-agent/smoke/smoke.pkr.hcl | 22 ++++---- images/jenkins-agent/smoke/verify.sh | 2 +- terraform/iam-gha-agent-image-factory.tf | 17 ++++++ 6 files changed, 71 insertions(+), 40 deletions(-) diff --git a/.github/workflows/jenkins-agent-image-factory.yml b/.github/workflows/jenkins-agent-image-factory.yml index a6b6b65f..ed0336b7 100644 --- a/.github/workflows/jenkins-agent-image-factory.yml +++ b/.github/workflows/jenkins-agent-image-factory.yml @@ -18,6 +18,7 @@ on: env: AGENT_FACTORY_REGION: eu-central-1 + PACKER_VERSION: 1.15.4 jobs: # No-AWS gate: malformed templates or scripts fail here before any EC2 spins @@ -31,6 +32,8 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: hashicorp/setup-packer@1aa358be5cf73883762b302a3a03abd66e75b232 # v3.1.0 + with: + version: ${{ env.PACKER_VERSION }} - name: fmt + validate (both arches) working-directory: images/jenkins-agent run: | @@ -50,7 +53,10 @@ jobs: if: github.event_name == 'workflow_dispatch' needs: validate runs-on: ubuntu-latest - timeout-minutes: 90 + # Stays under the OIDC role's one-hour session: credentials do not refresh + # mid-job, so a run that would exceed the session must fail early instead + # of losing AWS access halfway through cleanup or promotion. + timeout-minutes: 55 strategy: fail-fast: false matrix: @@ -61,7 +67,9 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: hashicorp/setup-packer@1aa358be5cf73883762b302a3a03abd66e75b232 # v3.1.0 - - uses: aws-actions/configure-aws-credentials@a159d7bb5354cf786f855f2f5d1d8d768d9a08d1 # v4.3.1 + with: + version: ${{ env.PACKER_VERSION }} + - uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 with: role-to-assume: ${{ secrets.AGENT_FACTORY_ROLE_ARN }} aws-region: ${{ env.AGENT_FACTORY_REGION }} @@ -72,33 +80,33 @@ jobs: run: | set -euo pipefail packer init . - packer build -color=false -var arch="${{ matrix.arch }}" -var region="$AGENT_FACTORY_REGION" . | tee build.log - ami=$(grep -oE 'ami-[0-9a-f]{17}' build.log | tail -1) - [ -n "$ami" ] || { echo "FATAL: no AMI id in packer output"; exit 1; } + packer build -color=false -var arch="${{ matrix.arch }}" -var region="$AGENT_FACTORY_REGION" . + ami=$(jq -r '.builds[-1].artifact_id | split(":")[1]' manifest.json) + if [ -z "$ami" ] || [ "$ami" = "null" ]; then + echo "FATAL: no AMI id in packer manifest" + exit 1 + fi + arch_tag=$(aws ec2 describe-images --region "$AGENT_FACTORY_REGION" --image-ids "$ami" \ + --query "Images[0].Tags[?Key=='arch']|[0].Value" --output text) + if [ "$arch_tag" != "${{ matrix.arch }}" ]; then + echo "FATAL: manifest AMI $ami carries arch=$arch_tag, expected ${{ matrix.arch }}" + exit 1 + fi echo "ami=$ami" >> "$GITHUB_OUTPUT" echo "- bake (${{ matrix.arch }}): $ami" >> "$GITHUB_STEP_SUMMARY" - - name: Smoke (fresh boot + assertions) + - name: Smoke (fresh boot + assertions, no by-product AMI) working-directory: images/jenkins-agent env: AMI: ${{ steps.bake.outputs.ami }} run: | set -euo pipefail packer init smoke - if ! packer build -color=false -var candidate_ami="$AMI" -var arch="${{ matrix.arch }}" -var region="$AGENT_FACTORY_REGION" smoke; then - echo "smoke FAILED; deregistering candidate $AMI" - aws ec2 deregister-image --region "$AGENT_FACTORY_REGION" --image-id "$AMI" --delete-associated-snapshots - echo "- smoke (${{ matrix.arch }}): FAIL (candidate deregistered)" >> "$GITHUB_STEP_SUMMARY" - exit 1 - fi - discard=$(aws ec2 describe-images --region "$AGENT_FACTORY_REGION" --owners self \ - --filters Name=tag:role,Values=jenkins-agent-smoke-discard --query 'Images[].ImageId' --output text) - for ami in $discard; do - aws ec2 deregister-image --region "$AGENT_FACTORY_REGION" --image-id "$ami" --delete-associated-snapshots - done + packer build -color=false -var candidate_ami="$AMI" -var arch="${{ matrix.arch }}" -var region="$AGENT_FACTORY_REGION" smoke echo "- smoke (${{ matrix.arch }}): pass" >> "$GITHUB_STEP_SUMMARY" - name: Promote (role tag flip) + id: promote env: AMI: ${{ steps.bake.outputs.ami }} run: | @@ -110,3 +118,15 @@ jobs: sleep $((attempt * 5)) done echo "- promote (${{ matrix.arch }}): $AMI is role=jenkins-agent" >> "$GITHUB_STEP_SUMMARY" + + # A candidate that exists but never promoted (smoke failure, cancellation, + # parse failure) must not linger: deregister it with its snapshots. + - name: Cleanup unpromoted candidate + if: always() && steps.bake.outputs.ami != '' && steps.promote.outcome != 'success' + env: + AMI: ${{ steps.bake.outputs.ami }} + run: | + set -euo pipefail + echo "cleaning up unpromoted candidate $AMI" + aws ec2 deregister-image --region "$AGENT_FACTORY_REGION" --image-id "$AMI" --delete-associated-snapshots + echo "- cleanup (${{ matrix.arch }}): unpromoted candidate $AMI deregistered" >> "$GITHUB_STEP_SUMMARY" diff --git a/images/jenkins-agent/agent.pkr.hcl b/images/jenkins-agent/agent.pkr.hcl index 46097cbf..6e31c646 100644 --- a/images/jenkins-agent/agent.pkr.hcl +++ b/images/jenkins-agent/agent.pkr.hcl @@ -2,7 +2,7 @@ packer { required_plugins { amazon = { source = "github.com/hashicorp/amazon" - version = ">= 1.3.0" + version = "= 1.8.2" } } } @@ -112,4 +112,9 @@ build { "provisioners/10-qemu-binfmt.sh", ] } + + # The workflow reads the AMI id from here, never from human-readable output. + post-processor "manifest" { + output = "manifest.json" + } } diff --git a/images/jenkins-agent/justfile b/images/jenkins-agent/justfile index bd2b5db3..a041b412 100644 --- a/images/jenkins-agent/justfile +++ b/images/jenkins-agent/justfile @@ -26,17 +26,10 @@ bake arch="arm64": packer init . packer build -color=false -var arch={{arch}} -var region={{region}} . -# Boot + assert a candidate, then discard the smoke by-product AMI. +# Boot + assert a candidate (skip_create_ami: no by-product AMI to clean up). smoke candidate_ami arch="arm64": - #!/usr/bin/env bash - set -euo pipefail packer init smoke packer build -color=false -var candidate_ami={{candidate_ami}} -var arch={{arch}} -var region={{region}} smoke - discard=$(aws ec2 describe-images {{_aws_profile_args}} --region {{region}} --owners self \ - --filters Name=tag:role,Values=jenkins-agent-smoke-discard --query 'Images[].ImageId' --output text) - for ami in ${discard}; do - aws ec2 deregister-image {{_aws_profile_args}} --region {{region}} --image-id "${ami}" --delete-associated-snapshots - done # Promote a smoked candidate: role tag flip + smoke marker. promote candidate_ami: diff --git a/images/jenkins-agent/smoke/smoke.pkr.hcl b/images/jenkins-agent/smoke/smoke.pkr.hcl index 62f39b5e..290d9ab1 100644 --- a/images/jenkins-agent/smoke/smoke.pkr.hcl +++ b/images/jenkins-agent/smoke/smoke.pkr.hcl @@ -2,16 +2,16 @@ packer { required_plugins { amazon = { source = "github.com/hashicorp/amazon" - version = ">= 1.3.0" + version = "= 1.8.2" } } } # Fresh-boot smoke for an agent AMI candidate: boots the candidate, asserts the # baked payload, and for arm64 proves x86_64 emulation end-to-end. A candidate -# is promoted (role tag flip) only after this build succeeds. The build -# produces a throwaway AMI name that the workflow deregisters immediately; -# only the boot + provisioner assertions matter. +# is promoted (role tag flip) only after this build succeeds. skip_create_ami +# means only the boot + assertions matter, no by-product AMI exists, and +# concurrent matrix jobs cannot race on cleanup. variable "candidate_ami" { type = string @@ -47,10 +47,11 @@ locals { } source "amazon-ebs" "smoke" { - region = var.region - source_ami = var.candidate_ami - instance_type = local.instance_type - ami_name = "jenkins-agent-smoke-discard-${local.timestamp}" + region = var.region + source_ami = var.candidate_ami + instance_type = local.instance_type + ami_name = "jenkins-agent-smoke-discard-${local.timestamp}" + skip_create_ami = true communicator = "ssh" ssh_username = "ec2-user" @@ -67,11 +68,6 @@ source "amazon-ebs" "smoke" { Name = "jenkins-agent-smoke-${local.timestamp}" "iit-billing-tag" = var.billing_tag } - - tags = { - role = "jenkins-agent-smoke-discard" - "iit-billing-tag" = var.billing_tag - } } build { diff --git a/images/jenkins-agent/smoke/verify.sh b/images/jenkins-agent/smoke/verify.sh index 5109e81c..f495d14a 100644 --- a/images/jenkins-agent/smoke/verify.sh +++ b/images/jenkins-agent/smoke/verify.sh @@ -5,7 +5,7 @@ # emulation surviving a reboot cycle via systemd-binfmt. set -euo pipefail -for tool in java git docker aws file; do +for tool in java git docker aws file tar unzip 7za; do command -v "${tool}" >/dev/null || { echo "FATAL: ${tool} missing" >&2; exit 1; } done diff --git a/terraform/iam-gha-agent-image-factory.tf b/terraform/iam-gha-agent-image-factory.tf index 5b1fe2a5..9aaece2b 100644 --- a/terraform/iam-gha-agent-image-factory.tf +++ b/terraform/iam-gha-agent-image-factory.tf @@ -178,6 +178,23 @@ data "aws_iam_policy_document" "gha_agent_image_factory_perms" { } } + # Packer's amazon-parameterstore data source resolves the AL2023 base AMI + # (public parameter, account-less ARN), and the amazon-ebs builder validates + # the instance profile before launch. + statement { + sid = "ResolveBaseAmiParameter" + effect = "Allow" + actions = ["ssm:GetParameter"] + resources = ["arn:aws:ssm:${local.agent_image_factory_reg}::parameter/aws/service/ami-amazon-linux-latest/*"] + } + + statement { + sid = "ValidateBuilderInstanceProfile" + effect = "Allow" + actions = ["iam:GetInstanceProfile"] + resources = [aws_iam_instance_profile.agent_image_builder_ssm.arn] + } + statement { sid = "DescribeReadOnly" effect = "Allow"