Skip to content

Commit 19ee39a

Browse files
committed
feat(ci): onboard GB200 AWS UAT reservation
Add the aws-gb200 reservation row (cross-account GB200 Capacity Block), its EKS cluster-config and Kubeflow training test-config, and generalize the AWS UAT pipeline's test-config selection from h100-<intent> to <accelerator>-<intent> so per-accelerator configs are picked up. Derive AWS_ACCOUNT_ID from the cluster-config's .deployment.tenancy rather than a caller-supplied input: the OIDC role can only be assumed into the account being provisioned, removing the two-source drift risk that a separate account input carried. Signed-off-by: Nathan Hensley <nhensley@nvidia.com>
1 parent 51232cd commit 19ee39a

6 files changed

Lines changed: 336 additions & 8 deletions

File tree

.github/workflows/uat-aws.yaml

Lines changed: 31 additions & 5 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
@@ -155,7 +168,20 @@ jobs:
155168
if [[ ! -f "${TEST_CONFIG}" ]]; then
156169
echo "::error::test config not found: ${TEST_CONFIG}"; exit 1
157170
fi
158-
echo "intent=${INTENT} lifecycle=${LIFECYCLE} test-config=${TEST_CONFIG} cluster=${DEPLOYMENT_ID}"
171+
# The account the OIDC role is assumed into (AWS_ACCOUNT_ID) is the
172+
# cluster-config's .deployment.tenancy — the single source of truth
173+
# for which tenancy this run provisions + keeps state in. Deriving it
174+
# here (rather than accepting it as an input) means the role can never
175+
# be assumed into a different account than the one being provisioned.
176+
# A cross-account reservation (e.g. a GB200 Capacity Block in a
177+
# separate tenancy) is selected purely by its cluster-config. Uses the
178+
# runner-preinstalled yq (Install tools runs later).
179+
TENANCY=$(yq eval '.deployment.tenancy' "${CLUSTER_CONFIG}")
180+
if [[ -z "${TENANCY}" || "${TENANCY}" == "null" ]]; then
181+
echo "::error::${CLUSTER_CONFIG} has no .deployment.tenancy"; exit 1
182+
fi
183+
echo "AWS_ACCOUNT_ID=${TENANCY}" >> "${GITHUB_ENV}"
184+
echo "intent=${INTENT} lifecycle=${LIFECYCLE} test-config=${TEST_CONFIG} cluster=${DEPLOYMENT_ID} account=${TENANCY}"
159185
160186
# Versions
161187
- name: Load versions

.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)
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# EKS GB200 UAT cluster (reservation aws-gb200, infra/uat/reservations.yaml).
16+
#
17+
# Differs from the p5 cluster-config.yaml (aws-h100) in three structural ways,
18+
# all forced by GB200 hardware and its Capacity Block:
19+
# 1. CROSS-ACCOUNT — the Capacity Block is held in tenancy 897722687756
20+
# (dgx-cloud-runai-prod-22), not the standard UAT account. The AWS
21+
# pipeline assumes the OIDC role in .deployment.tenancy below (which must
22+
# match reservations.yaml `account`). Prereq: apply infra/uat-aws-account
23+
# in 897722687756 first (GitHub OIDC provider + github-actions-role-aicr).
24+
# 2. LOCAL ZONE — p6e-gb200 capacity lives in us-east-1-dfw-2a (parent AZ
25+
# us-east-1d). The EKS control plane cannot live in a Local Zone, so the
26+
# GPU worker + pod subnets are in the Local Zone while control-plane /
27+
# system / public subnets stay in region AZs (incl. us-east-1d, the
28+
# parent). Local-Zone worker egress routes through a region NAT.
29+
# 3. arm64 + Capacity Block — GB200 is Grace/arm64 and the reservation is a
30+
# time-bounded Capacity Block (marketType below), not a standing CR.
31+
#
32+
# Two mchmarny/cluster actuator workarounds are load-bearing (see the inline
33+
# notes on the worker nodeGroup and adminRoles).
34+
35+
apiVersion: github.com/mchmarny/cluster/v1alpha1
36+
kind: Cluster
37+
38+
deployment:
39+
id: aicr-uat-gb200 # workflow overrides with a run-scoped id
40+
provider: eks
41+
tenancy: "897722687756" # dgx-cloud-runai-prod-22 (GB200 Capacity Block)
42+
location: us-east-1
43+
state: tenancy # remote TF state in this account's S3
44+
destroy: false
45+
tags:
46+
env: dev
47+
48+
cluster:
49+
eks:
50+
version: "1.35"
51+
# Empty: the CI OIDC role (github-actions-role-aicr) is the cluster
52+
# CREATOR and gets cluster-admin automatically
53+
# (bootstrap_cluster_creator_admin_permissions=true). The actuator ALSO
54+
# writes an explicit access entry per adminRole; listing the creator's own
55+
# role here collides with the bootstrap entry and 409s. Human admins can
56+
# add this account's SSO admin role once its name is known (it differs from
57+
# the standard account's AWSReservedSSO_CS-Admin_55d42b5a9a2a8c35).
58+
adminRoles: []
59+
controlPlane:
60+
allowedCidrs:
61+
- 216.228.127.128/30
62+
- 52.41.222.58/32
63+
- 54.68.11.139/32
64+
- 128.77.49.32/30
65+
addOns:
66+
coreDns: ""
67+
kubeProxy: ""
68+
vpcCni: ""
69+
70+
network:
71+
eks:
72+
cidrs:
73+
host: 10.0.0.0/16
74+
pod: 100.65.0.0/16
75+
subnets:
76+
# Control-plane / system / public subnets MUST be in region AZs (the EKS
77+
# control plane cannot live in a Local Zone). Include us-east-1d — the
78+
# Local Zone's parent — so cross-zone routing to the workers is local.
79+
public:
80+
- cidr: 10.0.1.0/27
81+
zone: us-east-1a
82+
- cidr: 10.0.2.0/27
83+
zone: us-east-1d
84+
system:
85+
- cidr: 10.0.4.0/22
86+
zone: us-east-1a
87+
- cidr: 10.0.8.0/22
88+
zone: us-east-1d
89+
# GPU workers + pods live in the Local Zone where the Capacity Block is.
90+
worker:
91+
- cidr: 10.0.128.0/17
92+
zone: us-east-1-dfw-2a
93+
disableEndpoints: true
94+
pod:
95+
- cidr: 100.65.0.0/16
96+
zone: us-east-1-dfw-2a
97+
disableEndpoints: true
98+
securityGroups:
99+
additionalRules:
100+
- target: system
101+
direction: ingress
102+
fromPort: 4222
103+
toPort: 4222
104+
protocol: tcp
105+
# VPC-CNI does not SNAT in-VPC pod-to-pod traffic, so NATS on the
106+
# system node sees the pod source IP (pod subnet), not the node IP.
107+
cidrBlocks: ["10.0.128.0/17", "100.65.0.0/16"] # worker node + pod subnet CIDRs
108+
description: "Allow NATS from worker nodes and pods"
109+
110+
compute:
111+
eks:
112+
nodeGroups:
113+
system:
114+
instanceType: m7i.xlarge
115+
capacity:
116+
desired: 3
117+
labels:
118+
nodeGroup: system-worker
119+
dedicated: system-workload
120+
workers:
121+
- name: gpu-worker
122+
instanceType: p6e-gb200.36xlarge
123+
architecture: arm64 # GB200 = Grace/arm64 (arm64 Ubuntu EKS AMI)
124+
gpuType: gb200
125+
# REQUIRED: auto-derivation breaks on the "p6e-gb200.*" name — the
126+
# actuator keys its accelerator map on "p6e" but split(".")[0] yields
127+
# "p6e-gb200", so it falls through to the generic-GPU EFA branch and
128+
# emits an invalid launch template (device_index=1 on a 1-device
129+
# network card) → CreateAutoScalingGroup ValidationError. The explicit
130+
# value selects the correct GB200 branch.
131+
accelerator: gb200
132+
capacity:
133+
# Two GPU nodes: activates the multi-node NCCL East-West fabric
134+
# check (nccl-all-reduce-bw-net) and lets the TrainJob (numNodes: 2)
135+
# span both nodes.
136+
desired: 2
137+
reservation:
138+
preference: capacity-reservations-only
139+
target: cr-0e2f3833a602809a6
140+
marketType: capacity-block # REQUIRED — GB200 CRs are Capacity Blocks
141+
labels:
142+
nodeGroup: gpu-worker
143+
dedicated: user-workload
144+
taints:
145+
- key: skyhook.nvidia.com
146+
value: runtime-required
147+
effect: NoSchedule

0 commit comments

Comments
 (0)