diff --git a/.github/workflows/jenkins-agent-image-factory.yml b/.github/workflows/jenkins-agent-image-factory.yml new file mode 100644 index 0000000..bd213c5 --- /dev/null +++ b/.github/workflows/jenkins-agent-image-factory.yml @@ -0,0 +1,140 @@ +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 + PACKER_VERSION: 1.15.4 + +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 + with: + persist-credentials: false + - uses: hashicorp/setup-packer@1aa358be5cf73883762b302a3a03abd66e75b232 # v3.1.0 + with: + version: ${{ env.PACKER_VERSION }} + - 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 + # 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: + arch: ${{ fromJSON(github.event.inputs.arches || '["arm64","x86_64"]') }} + permissions: + contents: read + id-token: write + # matrix.arch flows through env, never inline in run blocks, so the + # dispatch-controlled value cannot inject into the shell (zizmor). + env: + ARCH: ${{ matrix.arch }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: hashicorp/setup-packer@1aa358be5cf73883762b302a3a03abd66e75b232 # v3.1.0 + 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 }} + + - name: Bake candidate + id: bake + working-directory: images/jenkins-agent + run: | + set -euo pipefail + packer init . + packer build -color=false -var arch="$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" != "$ARCH" ]; then + echo "FATAL: manifest AMI $ami carries arch=$arch_tag, expected $ARCH" + exit 1 + fi + echo "ami=$ami" >> "$GITHUB_OUTPUT" + echo "- bake ($ARCH): $ami" >> "$GITHUB_STEP_SUMMARY" + + - 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 + packer build -color=false -var candidate_ami="$AMI" -var arch="$ARCH" -var region="$AGENT_FACTORY_REGION" smoke + echo "- smoke ($ARCH): pass" >> "$GITHUB_STEP_SUMMARY" + + - name: Promote (role tag flip) + id: promote + 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 ($ARCH): FAILED ($AMI left as candidate)" >> "$GITHUB_STEP_SUMMARY"; exit 1; } + sleep $((attempt * 5)) + done + echo "- promote ($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 ($ARCH): unpromoted candidate $AMI deregistered" >> "$GITHUB_STEP_SUMMARY" diff --git a/images/jenkins-agent/README.md b/images/jenkins-agent/README.md new file mode 100644 index 0000000..0722d4b --- /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 0000000..6e31c64 --- /dev/null +++ b/images/jenkins-agent/agent.pkr.hcl @@ -0,0 +1,120 @@ +packer { + required_plugins { + amazon = { + source = "github.com/hashicorp/amazon" + version = "= 1.8.2" + } + } +} + +# 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", + ] + } + + # 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 new file mode 100644 index 0000000..a041b41 --- /dev/null +++ b/images/jenkins-agent/justfile @@ -0,0 +1,43 @@ +# 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 (skip_create_ami: no by-product AMI to clean up). +smoke candidate_ami arch="arm64": + packer init smoke + packer build -color=false -var candidate_ami={{candidate_ami}} -var arch={{arch}} -var region={{region}} smoke + +# 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 0000000..f7348c3 --- /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 0000000..659e3db --- /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 0000000..290d9ab --- /dev/null +++ b/images/jenkins-agent/smoke/smoke.pkr.hcl @@ -0,0 +1,82 @@ +packer { + required_plugins { + amazon = { + source = "github.com/hashicorp/amazon" + 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. 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 +} + +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}" + skip_create_ami = true + + 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 + } +} + +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 0000000..f495d14 --- /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 tar unzip 7za; 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 0000000..9aaece2 --- /dev/null +++ b/terraform/iam-gha-agent-image-factory.tf @@ -0,0 +1,265 @@ +# 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] + } + } + + # 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" + 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 a6c35b8..a44afb6 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"] +}