3333 type : string
3434 default : ' '
3535 intent :
36- description : ' Recipe intent — selects the per-intent test config (h100 -<intent>-config.yaml).'
36+ description : ' Recipe intent — selects the per-intent test config (<accelerator> -<intent>-config.yaml).'
3737 type : string
3838 default : training
3939 lifecycle :
4848 description : ' Directory holding the AICRConfig test files (from infra/uat/reservations.yaml).'
4949 type : string
5050 required : true
51+ accelerator :
52+ description : ' GPU accelerator family (from the reservation); selects the test config <accelerator>-<intent>-config.yaml.'
53+ type : string
54+ default : h100
5155 skip_delete :
5256 description : ' Skip cluster teardown (manual runs only; the nightly batch always deletes).'
5357 type : boolean
9094 # the ingest-evidence job below. Empty when no bundle was produced.
9195 bundle_ref : ${{ steps.evidence_ref.outputs.ref }}
9296 env :
93- AWS_ACCOUNT_ID : " 615299774277"
97+ # AWS_ACCOUNT_ID (the tenancy the OIDC role is assumed into, and where the
98+ # cluster + Terraform state live) is NOT set here and is NOT a caller
99+ # input: it is the single-source-of-truth `.deployment.tenancy` from the
100+ # cluster-config, exported to $GITHUB_ENV by the "Validate inputs" step
101+ # below. Deriving it — rather than passing it in — makes it impossible to
102+ # assume role in one account while provisioning/state land in another
103+ # (which a separate `account` input could silently do). A cross-account
104+ # reservation (e.g. a GB200 Capacity Block in a separate tenancy) is
105+ # selected purely by its cluster-config's tenancy field.
94106 AWS_REGION : " us-east-1"
95107 GITHUB_ACTIONS_ROLE_NAME : " github-actions-role-aicr"
96108 # Cluster name (EKS actuator derives it from .deployment.id). The nightly
@@ -104,9 +116,10 @@ jobs:
104116 && format('aicr-uat-day-{0}', inputs.reservation)
105117 || format('aicr-uat-{0}', github.run_id) }}
106118 CLUSTER_CONFIG : ${{ inputs.cluster_config_path }}
107- # DC2 selects the AICRConfig by intent; both intents drive the same
119+ # The AICRConfig is selected by accelerator + intent
120+ # (<accelerator>-<intent>-config.yaml); both intents drive the same
108121 # cluster-config (GPU pool from the reservation, system/CPU pools dynamic).
109- TEST_CONFIG : ${{ inputs.test_config_dir }}/h100 -${{ inputs.intent }}-config.yaml
122+ TEST_CONFIG : ${{ inputs.test_config_dir }}/${{ inputs.accelerator }} -${{ inputs.intent }}-config.yaml
110123 # v0.4.27 — required for node-group taints in the cluster-config schema
111124 # (GPU pool carries skyhook.nvidia.com=runtime-required:NoSchedule until
112125 # NodeWrite finishes tuning + reboot). Earlier v0.4.x pins predate taint
@@ -115,6 +128,16 @@ jobs:
115128 EKS_IMAGE : " ghcr.io/mchmarny/cluster/eks@sha256:8bc33d14e6e5659d242aa2cdbcd69fca389cfcdb9e94f7a7a1b82fd33176befa"
116129 VALIDATOR_IMAGE_PREFIX : " ghcr.io/nvidia/aicr-validators"
117130 VALIDATOR_TAG : " uat-${{ github.run_id }}"
131+ # Target platform(s) for the main-cell image builds (snapshot-agent + the
132+ # validators). h100 GPU workers (p5.48xlarge) are amd64; GB200 GPU workers
133+ # (p6e-gb200.36xlarge) are Grace/arm64, and the agent + GPU-scheduled
134+ # validators are pinned to that pool — so an amd64-only image cannot start
135+ # there. GB200 therefore builds a multi-arch (amd64+arm64) manifest: amd64
136+ # still covers the m7i system pool, arm64 covers the GPU pool. h100 stays
137+ # single-arch so the common nightly path skips the slow QEMU arm64 cross
138+ # build. Release cells are unaffected (they pull the already-multi-arch
139+ # released images). Mirrors the release build's linux/amd64,linux/arm64.
140+ BUILD_PLATFORMS : ${{ inputs.accelerator == 'gb200' && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
118141 # Pin validator-image resolution for MAIN cells to this run's freshly
119142 # built+pushed validators (the `Build and push validator images` step
120143 # tags them `:uat-<run_id>`), mirroring how AICR_IMAGE pins the agent — so
@@ -152,10 +175,32 @@ jobs:
152175 nightly|daytime-up|daytime-down) ;;
153176 *) echo "::error::unknown lifecycle '${LIFECYCLE}' (want nightly|daytime-up|daytime-down)"; exit 1 ;;
154177 esac
155- if [[ ! -f "${TEST_CONFIG}" ]]; then
178+ # Derive + export the account FIRST, before any lifecycle-specific
179+ # gate below can exit. The account the OIDC role is assumed into
180+ # (AWS_ACCOUNT_ID) is the cluster-config's .deployment.tenancy — the
181+ # single source of truth for which tenancy this run provisions + keeps
182+ # state in. Deriving it here (rather than accepting it as an input)
183+ # means the role can never be assumed into a different account than
184+ # the one being provisioned; a cross-account reservation (e.g. a GB200
185+ # Capacity Block in a separate tenancy) is selected purely by its
186+ # cluster-config. Exporting it up front is load-bearing for teardown:
187+ # daytime-down re-authenticates and builds the role ARN from
188+ # AWS_ACCOUNT_ID, so a later early-exit here must not skip this — else
189+ # teardown cannot assume the role and the cluster + Capacity Block
190+ # leak. Uses the runner-preinstalled yq (Install tools runs later).
191+ TENANCY=$(yq eval '.deployment.tenancy' "${CLUSTER_CONFIG}")
192+ if [[ -z "${TENANCY}" || "${TENANCY}" == "null" ]]; then
193+ echo "::error::${CLUSTER_CONFIG} has no .deployment.tenancy"; exit 1
194+ fi
195+ echo "AWS_ACCOUNT_ID=${TENANCY}" >> "${GITHUB_ENV}"
196+ # The test config only matters for the run phases (nightly / daytime-up
197+ # provision+deploy). daytime-down just tears the held cluster down and
198+ # never reads it, so skip the existence check there — a missing sibling
199+ # config must not block teardown and strand the Capacity Block.
200+ if [[ "${LIFECYCLE}" != "daytime-down" && ! -f "${TEST_CONFIG}" ]]; then
156201 echo "::error::test config not found: ${TEST_CONFIG}"; exit 1
157202 fi
158- echo "intent=${INTENT} lifecycle=${LIFECYCLE} test-config=${TEST_CONFIG} cluster=${DEPLOYMENT_ID}"
203+ echo "intent=${INTENT} lifecycle=${LIFECYCLE} test-config=${TEST_CONFIG} cluster=${DEPLOYMENT_ID} account=${TENANCY} "
159204
160205 # Versions
161206 - name : Load versions
@@ -227,6 +272,18 @@ jobs:
227272 - name : Authenticate to GHCR
228273 uses : ./.github/actions/ghcr-login
229274
275+ # QEMU registers binfmt handlers so the amd64 runner can build the arm64
276+ # layer of a GB200 multi-arch image. Only needed when BUILD_PLATFORMS
277+ # includes arm64 (GB200); h100 builds native amd64 and skips it, so the
278+ # common nightly path pays no emulation cost. buildx (below) is always set
279+ # up because the multi-platform build path uses `buildx build --push`.
280+ - name : Set up QEMU
281+ if : inputs.aicr_version == '' && inputs.skip_tests != true && inputs.lifecycle != 'daytime-down' && contains(env.BUILD_PLATFORMS, 'arm64')
282+ uses : docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
283+ - name : Set up Docker Buildx
284+ if : inputs.aicr_version == '' && inputs.skip_tests != true && inputs.lifecycle != 'daytime-down'
285+ uses : docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
286+
230287 # Main cell only: release cells use the released validator images the
231288 # released binary self-resolves to (skipped when aicr_version is set).
232289 - name : Build and push validator images
@@ -245,20 +302,27 @@ jobs:
245302 # until the job timeout (#1636). It uses a different Dockerfile path
246303 # and takes no GO_VERSION build-arg. Keep the phase set in sync with
247304 # VALIDATOR_PHASES in on-push.yaml / on-tag.yaml.
305+ #
306+ # buildx build --push emits a multi-arch manifest for GB200
307+ # (BUILD_PLATFORMS=linux/amd64,linux/arm64) and a single-arch image
308+ # for h100 (linux/amd64) through one code path. Multi-platform builds
309+ # cannot be loaded into the local daemon, so we push directly (the
310+ # GHCR login above authenticates the push).
248311 for phase in deployment performance conformance aiperf-bench; do
249312 IMAGE="${VALIDATOR_IMAGE_PREFIX}/${phase}:${VALIDATOR_TAG}"
250313 case "${phase}" in
251314 aiperf-bench)
252- docker build -f validators/performance/aiperf-bench.Dockerfile \
253- -t "${IMAGE}" .
315+ docker buildx build --platform="${BUILD_PLATFORMS}" \
316+ -f validators/performance/aiperf-bench.Dockerfile \
317+ -t "${IMAGE}" --push .
254318 ;;
255319 *)
256- docker build -f "validators/${phase}/Dockerfile" \
320+ docker buildx build --platform="${BUILD_PLATFORMS}" \
321+ -f "validators/${phase}/Dockerfile" \
257322 --build-arg "GO_VERSION=${GO_VERSION}" \
258- -t "${IMAGE}" .
323+ -t "${IMAGE}" --push .
259324 ;;
260325 esac
261- docker push "${IMAGE}"
262326 done
263327
264328 # Auth
@@ -392,9 +456,13 @@ jobs:
392456 # build — so any artifact-schema change in the PR under test (e.g. an
393457 # apiVersion bump) produces a snapshot the freshly-built binary rejects.
394458 # Build the agent from the PR source and point AICR_IMAGE at it so the
395- # snapshot is stamped by the code under test, not a stale release. Single
396- # arch: the GPU worker (p5.48xlarge) is amd64. Lives in the aicr-validators
397- # namespace (not the release ghcr.io/nvidia/aicr repo); cleaned up below.
459+ # snapshot is stamped by the code under test, not a stale release. Built
460+ # for BUILD_PLATFORMS: amd64 for h100 (p5.48xlarge), amd64+arm64 for GB200
461+ # (the snapshot agent is pinned to the arm64 p6e-gb200 GPU pool). ko
462+ # cross-compiles Go natively — no QEMU — and emits a manifest list at the
463+ # single tag, so the kubelet pulls the node's arch. Lives in the
464+ # aicr-validators namespace (not the release ghcr.io/nvidia/aicr repo);
465+ # cleaned up below.
398466 - name : Build and push snapshot-agent image
399467 if : inputs.aicr_version == '' && inputs.skip_tests != true && inputs.lifecycle != 'daytime-down'
400468 env :
@@ -404,7 +472,7 @@ jobs:
404472 set -euo pipefail
405473 ko build ./cmd/aicr \
406474 --bare \
407- --platform=linux/amd64 \
475+ --platform="${BUILD_PLATFORMS}" \
408476 --image-label="org.opencontainers.image.revision=${{ github.sha }}" \
409477 --tags="${VALIDATOR_TAG}"
410478
0 commit comments