Skip to content

Commit 2f6367b

Browse files
authored
feat(ci): onboard GB200 AWS UAT reservation (#1774)
Signed-off-by: Nathan Hensley <nhensley@nvidia.com>
1 parent f0e2273 commit 2f6367b

6 files changed

Lines changed: 390 additions & 18 deletions

File tree

.github/workflows/uat-aws.yaml

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ on:
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:
@@ -48,6 +48,10 @@ on:
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
@@ -90,7 +94,15 @@ jobs:
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

.github/workflows/uat-run.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ jobs:
130130
cloud: ${{ steps.row.outputs.cloud }}
131131
cluster_config_path: ${{ steps.row.outputs['cluster-config-path'] }}
132132
test_config_dir: ${{ steps.row.outputs['test-config-dir'] }}
133+
# Accelerator selects the per-accelerator test-config filename
134+
# (<accelerator>-<intent>-config.yaml) in the AWS pipeline. The AWS
135+
# account (tenancy) is NOT plumbed here: the AWS pipeline derives it from
136+
# the cluster-config's .deployment.tenancy, the single source of truth.
137+
accelerator: ${{ steps.row.outputs.accelerator }}
133138
steps:
134139
- name: Checkout
135140
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -183,6 +188,7 @@ jobs:
183188
lifecycle: ${{ inputs.lifecycle }}
184189
cluster_config_path: ${{ needs.resolve.outputs.cluster_config_path }}
185190
test_config_dir: ${{ needs.resolve.outputs.test_config_dir }}
191+
accelerator: ${{ needs.resolve.outputs.accelerator }}
186192
skip_delete: ${{ inputs.skip_delete }}
187193
skip_tests: ${{ inputs.skip_tests }}
188194

infra/uat/reservations.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,28 @@ reservations:
109109
# rows run both, so add it here rather than removing the key (a bare
110110
# `nightly-intents:` decodes as absent and defaults to [training]).
111111
nightly-intents: [training]
112+
- name: aws-gb200
113+
cloud: aws
114+
# CROSS-ACCOUNT: the GB200 Capacity Block lives in a SEPARATE tenancy
115+
# (dgx-cloud-runai-prod-22, account 897722687756), not the standard UAT
116+
# account. The account is NOT a field here: the AWS pipeline derives the
117+
# tenancy it assumes the OIDC role into from the target cluster-config's
118+
# .deployment.tenancy (cluster-config-gb200.yaml sets it to 897722687756),
119+
# the single source of truth. Prereq: apply infra/uat-aws-account in
120+
# 897722687756 (GitHub OIDC provider + github-actions-role-aicr).
121+
# GB200 reservations are time-bounded Capacity Blocks (not standing
122+
# on-demand CRs like the h100 rows) — a run must fall inside the block's
123+
# active window, else actuator apply fails to acquire capacity.
124+
reservation-id: cr-0e2f3833a602809a6
125+
accelerator: gb200
126+
# 4 Blackwell GPUs per p6e-gb200.36xlarge node; the cluster-config runs 2
127+
# nodes (activates the multi-node NCCL East-West fabric check).
128+
gpu-count: 4
129+
cluster-config-path: tests/uat/aws/cluster-config-gb200.yaml
130+
test-config-dir: tests/uat/aws/tests
131+
# Opted OUT of the nightly batch during bring-up (explicit empty list, NOT
132+
# a bare `nightly-intents:` which would default to [training] and provision
133+
# real GB200 capacity nightly). Stays manually dispatchable via uat-run.yaml.
134+
# Enroll ([training]) after a green manual acceptance run and once the
135+
# Capacity-Block scheduling window is wired into the nightly broker.
136+
nightly-intents: []

pkg/uatbroker/registry_test.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -511,14 +511,15 @@ func TestDaytimeAssignmentsNone(t *testing.T) {
511511
}
512512

513513
// TestCommittedRegistryValid guards the actual checked-in registry: it must
514-
// parse, validate, and carry the two launch reservations. A bad data edit
515-
// fails here before it can break the broker workflows.
514+
// parse, validate, and carry the launch reservations plus the aws-gb200
515+
// bring-up row. A bad data edit fails here before it can break the broker
516+
// workflows.
516517
func TestCommittedRegistryValid(t *testing.T) {
517518
reg, err := LoadRegistryFile(filepath.Join("..", "..", "infra", "uat", "reservations.yaml"))
518519
if err != nil {
519520
t.Fatalf("committed reservations.yaml invalid: %v", err)
520521
}
521-
want := map[string]string{"aws-h100": CloudAWS, "gcp-h100": CloudGCP, "azure-h100": CloudAzure}
522+
want := map[string]string{"aws-h100": CloudAWS, "gcp-h100": CloudGCP, "azure-h100": CloudAzure, "aws-gb200": CloudAWS}
522523
for name, cloud := range want {
523524
res, err := reg.Lookup(name)
524525
if err != nil {
@@ -569,6 +570,11 @@ func TestCommittedRegistryValid(t *testing.T) {
569570
// acceptance run (29125390442); inference joins after a green
570571
// manual intent=inference dispatch.
571572
"azure-h100": {IntentTraining},
573+
// aws-gb200 is OPTED OUT of the nightly batch during bring-up (explicit
574+
// empty list). Locked here so an accidental edit to a bare
575+
// `nightly-intents:` (which defaults to [training]) — provisioning real
576+
// GB200 Capacity-Block capacity nightly — fails this guard instead.
577+
"aws-gb200": {},
572578
}
573579
for name, want := range wantNightly {
574580
res, lookupErr := reg.Lookup(name)

0 commit comments

Comments
 (0)