Skip to content

Commit 55fd14f

Browse files
authored
feat(ci): per-intent + daytime-lifecycle UAT cluster acquisition (#1586)
Signed-off-by: Nathan Hensley <nhensley@nvidia.com>
1 parent 83d9825 commit 55fd14f

6 files changed

Lines changed: 589 additions & 50 deletions

File tree

.github/workflows/uat-aws.yaml

Lines changed: 150 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,21 @@ on:
2525
workflow_call:
2626
inputs:
2727
reservation:
28-
description: 'Reservation name (lease key) this run targets; used for labeling.'
28+
description: 'Reservation name (lease key) this run targets; used for labeling and the daytime cluster name.'
2929
type: string
3030
required: true
3131
aicr_version:
3232
description: 'Released aicr version to install (e.g. v1.2.3); empty = build from source (main tip).'
3333
type: string
3434
default: ''
35+
intent:
36+
description: 'Recipe intent — selects the per-intent test config (h100-<intent>-config.yaml).'
37+
type: string
38+
default: training
39+
lifecycle:
40+
description: 'nightly (provision→CUJ→teardown) | daytime-up (provision+deploy+HOLD) | daytime-down (tear down the held daytime cluster).'
41+
type: string
42+
default: nightly
3543
cluster_config_path:
3644
description: 'Cluster-config the EKS actuator provisions from (from infra/uat/reservations.yaml).'
3745
type: string
@@ -81,10 +89,20 @@ jobs:
8189
AWS_ACCOUNT_ID: "615299774277"
8290
AWS_REGION: "us-east-1"
8391
GITHUB_ACTIONS_ROLE_NAME: "github-actions-role-aicr"
84-
DEPLOYMENT_ID: "aicr-uat-${{ github.run_id }}"
92+
# Cluster name (EKS actuator derives it from .deployment.id). The nightly
93+
# per-run lifecycle uses a run-scoped name for per-run OCI/state isolation;
94+
# the daytime lifecycles use a STABLE, reservation-tagged name so the
95+
# evening teardown (daytime-down) and the nightly pre-batch guard can find
96+
# the held cluster. Reservation names are lowercase alnum+hyphen, so the
97+
# derived name is a valid EKS cluster name.
98+
DEPLOYMENT_ID: >-
99+
${{ (inputs.lifecycle == 'daytime-up' || inputs.lifecycle == 'daytime-down')
100+
&& format('aicr-uat-day-{0}', inputs.reservation)
101+
|| format('aicr-uat-{0}', github.run_id) }}
85102
CLUSTER_CONFIG: ${{ inputs.cluster_config_path }}
86-
# Training is the DC1 launch intent; DC2 selects the file by intent.
87-
TEST_CONFIG: ${{ inputs.test_config_dir }}/h100-training-config.yaml
103+
# DC2 selects the AICRConfig by intent; both intents drive the same
104+
# cluster-config (GPU pool from the reservation, system/CPU pools dynamic).
105+
TEST_CONFIG: ${{ inputs.test_config_dir }}/h100-${{ inputs.intent }}-config.yaml
88106
# v0.4.23 — required for the .eks-nested cluster-config schema; the
89107
# prior pin (v0.2.6) read .compute.nodeGroups.* directly and failed
90108
# Terraform parse with "object with 1 attribute eks".
@@ -99,6 +117,30 @@ jobs:
99117
with:
100118
persist-credentials: false
101119

120+
# Fail closed on an unrecognized intent/lifecycle before any provisioning:
121+
# workflow_dispatch constrains these to a choice, but workflow_call passes
122+
# free strings, so a caller typo must surface as a clear error rather than
123+
# a missing-file failure deep in a phase. Also assert the per-intent config
124+
# exists so a missing sibling config fails here, not mid-run.
125+
- name: Validate inputs
126+
env:
127+
INTENT: ${{ inputs.intent }}
128+
LIFECYCLE: ${{ inputs.lifecycle }}
129+
run: |
130+
set -euo pipefail
131+
case "${INTENT}" in
132+
training|inference) ;;
133+
*) echo "::error::unknown intent '${INTENT}' (want training|inference)"; exit 1 ;;
134+
esac
135+
case "${LIFECYCLE}" in
136+
nightly|daytime-up|daytime-down) ;;
137+
*) echo "::error::unknown lifecycle '${LIFECYCLE}' (want nightly|daytime-up|daytime-down)"; exit 1 ;;
138+
esac
139+
if [[ ! -f "${TEST_CONFIG}" ]]; then
140+
echo "::error::test config not found: ${TEST_CONFIG}"; exit 1
141+
fi
142+
echo "intent=${INTENT} lifecycle=${LIFECYCLE} test-config=${TEST_CONFIG} cluster=${DEPLOYMENT_ID}"
143+
102144
# Versions
103145
- name: Load versions
104146
id: versions
@@ -119,9 +161,10 @@ jobs:
119161
120162
# Main cell (aicr_version empty): build the aicr binary from the
121163
# checked-out source (tip). Release cells install the released binary
122-
# instead (next step).
164+
# instead (next step). daytime-down tears down the held cluster only — it
165+
# builds nothing and pushes no images.
123166
- name: Build aicr binary
124-
if: inputs.aicr_version == '' && inputs.skip_tests != true
167+
if: inputs.aicr_version == '' && inputs.skip_tests != true && inputs.lifecycle != 'daytime-down'
125168
env:
126169
GOFLAGS: -mod=vendor
127170
run: |
@@ -134,7 +177,7 @@ jobs:
134177
# :<version>) and the snapshot-agent via ghcr.io/nvidia/aicr:<version> — so
135178
# the validator/agent build+push steps below are skipped for release cells.
136179
- name: Install released aicr
137-
if: inputs.aicr_version != '' && inputs.skip_tests != true
180+
if: inputs.aicr_version != '' && inputs.skip_tests != true && inputs.lifecycle != 'daytime-down'
138181
uses: ./.github/actions/install-aicr-release
139182
with:
140183
aicr-version: ${{ inputs.aicr_version }}
@@ -145,7 +188,7 @@ jobs:
145188
# Main cell only: release cells use the released validator images the
146189
# released binary self-resolves to (skipped when aicr_version is set).
147190
- name: Build and push validator images
148-
if: inputs.aicr_version == '' && inputs.skip_tests != true
191+
if: inputs.aicr_version == '' && inputs.skip_tests != true && inputs.lifecycle != 'daytime-down'
149192
run: |
150193
GO_VERSION="$(cat .go-version)"
151194
# CHAINSAW_* args dropped in #1236 — the deployment validator
@@ -168,13 +211,57 @@ jobs:
168211
aws-region: ${{ env.AWS_REGION }}
169212
role-session-name: GitHubActions-UAT-AICR
170213

171-
# Capacity check (reads desired count and reservation ID from cluster config).
214+
# Pre-batch guard (#1275, DC2). The nightly lifecycle refuses to provision
215+
# into a reservation that still holds an un-torn-down daytime cluster:
216+
# a missed evening teardown must surface as a BLOCKED batch, not as silent
217+
# contention with the daytime deployment. Detect by the stable daytime
218+
# cluster name (aicr-uat-day-<reservation>); we already hold the
219+
# reservation lease (uat-run.yaml) and are authenticated to the cloud, so
220+
# this runs before Bringup and fails fast rather than racing. Only the
221+
# nightly lifecycle guards — daytime-up creates that cluster, daytime-down
222+
# removes it.
223+
- name: Pre-batch guard — refuse if a daytime cluster still holds the reservation
224+
id: guard
225+
if: inputs.lifecycle == 'nightly'
226+
env:
227+
RESERVATION: ${{ inputs.reservation }}
228+
run: |
229+
set -euo pipefail
230+
DAYTIME_NAME="aicr-uat-day-${RESERVATION}"
231+
set +e
232+
OUT=$(aws eks describe-cluster --name "${DAYTIME_NAME}" --region "${AWS_REGION}" 2>&1)
233+
RC=$?
234+
set -e
235+
if [[ ${RC} -eq 0 ]]; then
236+
echo "::error::daytime cluster ${DAYTIME_NAME} still holds reservation ${RESERVATION}; refusing to start the nightly batch. Tear it down first (uat-run lifecycle=daytime-down)."
237+
exit 1
238+
fi
239+
# Fail closed on anything that is NOT a definitive "does not exist":
240+
# a throttle/auth error must not be read as "clear to proceed".
241+
if grep -q 'ResourceNotFoundException' <<<"${OUT}"; then
242+
echo "no daytime cluster on reservation ${RESERVATION}; proceeding"
243+
else
244+
echo "::error::could not determine daytime-cluster state for ${DAYTIME_NAME}: ${OUT}"
245+
exit 1
246+
fi
247+
248+
# Post-lease capacity assertion (#1275, DC2). The reservation lease
249+
# (uat-run.yaml) is now the contention gate, so this is no longer a
250+
# race-and-fail pre-flight: by the time it runs we hold the lease and no
251+
# other UAT run is consuming the reservation. It asserts the reservation
252+
# is provisioned large enough for the desired GPU count — a genuinely
253+
# exhausted/undersized reservation still fails, but transient contention
254+
# (another run's not-yet-released nodes) no longer does, because we check
255+
# the reservation's TOTAL size, not its momentary AvailableInstanceCount.
256+
# Skipped for daytime-down, which provisions nothing.
257+
#
172258
# Uses the runner-preinstalled yq because Install tools runs later; if the
173259
# cluster-config schema ever drifts, the explicit empty-value guard below
174260
# surfaces the mismatch immediately instead of letting yq return empty
175261
# silently and the AWS API reject a malformed reservation ID.
176-
- name: Check EC2 capacity reservation
262+
- name: Assert EC2 capacity reservation (post-lease)
177263
id: capacity
264+
if: inputs.lifecycle != 'daytime-down'
178265
run: |
179266
set -euo pipefail
180267
GPU_WORKER='.compute.eks.nodeGroups.workers[] | select(.name == "gpu-worker")'
@@ -189,14 +276,25 @@ jobs:
189276
fi
190277
echo "Reservation: ${RESERVATION}, desired: ${DESIRED}"
191278
192-
AVAILABLE=$(aws ec2 describe-capacity-reservations \
279+
# TotalInstanceCount = the reservation's provisioned size (fixed by the
280+
# reservation, not by concurrent consumption). AvailableInstanceCount
281+
# is logged for context but is intentionally NOT the assertion: with
282+
# the lease held, "unavailable" means our own prior run, not a rival.
283+
CAP=$(aws ec2 describe-capacity-reservations \
193284
--capacity-reservation-ids "${RESERVATION}" \
194-
--query 'CapacityReservations[0].AvailableInstanceCount' \
285+
--query 'CapacityReservations[0].[TotalInstanceCount,AvailableInstanceCount]' \
195286
--output text)
287+
TOTAL=$(awk '{print $1}' <<<"${CAP}")
288+
AVAILABLE=$(awk '{print $2}' <<<"${CAP}")
289+
echo "total=${TOTAL}" >> "$GITHUB_OUTPUT"
196290
echo "available=${AVAILABLE}" >> "$GITHUB_OUTPUT"
197-
echo "Available instances in reservation ${RESERVATION}: ${AVAILABLE}"
198-
if [[ "${AVAILABLE}" -lt "${DESIRED}" ]]; then
199-
echo "::error::Insufficient capacity: need ${DESIRED} instances, only ${AVAILABLE} available in reservation ${RESERVATION}"
291+
echo "Reservation ${RESERVATION}: total=${TOTAL}, available=${AVAILABLE}, desired=${DESIRED}"
292+
if [[ -z "${TOTAL}" || "${TOTAL}" == "None" ]]; then
293+
echo "::error::could not read TotalInstanceCount for reservation ${RESERVATION}"
294+
exit 1
295+
fi
296+
if [[ "${TOTAL}" -lt "${DESIRED}" ]]; then
297+
echo "::error::Reservation ${RESERVATION} is undersized: need ${DESIRED} instances, reservation holds only ${TOTAL}"
200298
exit 1
201299
fi
202300
@@ -230,7 +328,7 @@ jobs:
230328
# arch: the GPU worker (p5.48xlarge) is amd64. Lives in the aicr-validators
231329
# namespace (not the release ghcr.io/nvidia/aicr repo); cleaned up below.
232330
- name: Build and push snapshot-agent image
233-
if: inputs.aicr_version == '' && inputs.skip_tests != true
331+
if: inputs.aicr_version == '' && inputs.skip_tests != true && inputs.lifecycle != 'daytime-down'
234332
env:
235333
GOFLAGS: -mod=vendor
236334
KO_DOCKER_REPO: ghcr.io/nvidia/aicr-validators/agent
@@ -253,9 +351,10 @@ jobs:
253351
yq -i '.deployment.id = strenv(DEPLOYMENT_ID)' "$CLUSTER_CONFIG"
254352
cat "$CLUSTER_CONFIG"
255353
256-
# Bringup
354+
# Bringup — skipped for daytime-down (teardown-only; nothing to provision).
257355
- name: Bringup Infra
258356
id: infra
357+
if: inputs.lifecycle != 'daytime-down'
259358
run: |
260359
set -euox pipefail
261360
docker run \
@@ -319,9 +418,12 @@ jobs:
319418
# until the GPU stack converges — the readiness barrier the helmfile
320419
# bundle lacks (the helm deploy.sh `kubectl wait`s instead). Step id stays
321420
# `conformance` so the train/verify gating below is unaffected.
421+
# daytime-up provisions and deploys the stack, then HOLDS: the CUJ
422+
# (conformance/train/verify + evidence) is a nightly-batch concern, so
423+
# daytime-up stops after install and leaves the cluster up for handoff.
322424
- name: UAT - validate (all phases) + emit signed evidence
323425
id: conformance
324-
if: steps.install.outcome == 'success'
426+
if: steps.install.outcome == 'success' && inputs.lifecycle != 'daytime-up'
325427
# Larger budget than conformance-only: the deployment phase waits out
326428
# cold-start GPU bring-up (driver install/migration). (The performance
327429
# phase's NCCL benchmark skips on single-GPU-node clusters like this
@@ -333,9 +435,13 @@ jobs:
333435
RUN_ID: ${{ github.run_id }}
334436
run: ./tests/uat/aws/run conformance "${TEST_CONFIG}"
335437

438+
# The TrainJob CUJ is training-intent-specific. For intent=inference the
439+
# served-workload CUJ (phase_serve) is owned by DC3 and out of scope here,
440+
# so the train step is skipped; verify/evidence still cover the deployed
441+
# inference stack.
336442
- name: UAT - train (TrainJob)
337443
id: train
338-
if: steps.conformance.outcome == 'success'
444+
if: steps.conformance.outcome == 'success' && inputs.intent == 'training'
339445
timeout-minutes: 25
340446
shell: bash
341447
env:
@@ -407,16 +513,21 @@ jobs:
407513
env:
408514
SUMMARY_RESERVATION: ${{ inputs.reservation }}
409515
SUMMARY_AICR_VERSION: ${{ inputs.aicr_version }}
516+
SUMMARY_INTENT: ${{ inputs.intent }}
517+
SUMMARY_LIFECYCLE: ${{ inputs.lifecycle }}
410518
run: |
411519
{
412520
echo "## UAT Results (AWS)"
413521
echo ""
414-
printf '**Reservation:** `%s`\n' "$SUMMARY_RESERVATION"
522+
printf '**Reservation:** `%s` · **Intent:** `%s` · **Lifecycle:** `%s`\n' \
523+
"$SUMMARY_RESERVATION" "$SUMMARY_INTENT" "$SUMMARY_LIFECYCLE"
524+
printf '**Cluster:** `%s`\n' "$DEPLOYMENT_ID"
415525
printf '**AICR version:** `%s`\n' "${SUMMARY_AICR_VERSION:-main (build from source)}"
416526
echo "**Build:** \`${{ github.sha }}\` (branch: \`${{ github.ref_name }}\`)"
417527
echo ""
418528
echo "| Phase | Status |"
419529
echo "|-------|--------|"
530+
echo "| Pre-batch guard | ${{ steps.guard.outcome }} |"
420531
echo "| Capacity | ${{ steps.capacity.outcome }} |"
421532
echo "| Dependencies | ${{ steps.deps.outcome }} |"
422533
echo "| Config | ${{ steps.config.outcome }} |"
@@ -437,7 +548,7 @@ jobs:
437548
# Main cell only — release cells reference shared, permanent released image
438549
# tags and must not delete them.
439550
- name: Cleanup validator image
440-
if: always() && inputs.aicr_version == '' && inputs.skip_tests != true
551+
if: always() && inputs.aicr_version == '' && inputs.skip_tests != true && inputs.lifecycle != 'daytime-down'
441552
env:
442553
GH_TOKEN: ${{ github.token }}
443554
run: |
@@ -461,8 +572,19 @@ jobs:
461572
# actuator made every retry fail with ExpiredToken and leaked the GPU
462573
# node. The `id-token: write` permission is granted for the whole job,
463574
# so configure-aws-credentials can mint a fresh session here.
575+
#
576+
# Teardown gating (#1275, DC2): tear down for the nightly lifecycle
577+
# (unless skip_delete) and for daytime-down (the explicit evening
578+
# teardown of the held cluster — daytime-down provisions nothing, so it
579+
# does not gate on steps.infra). NEVER for daytime-up, which holds the
580+
# cluster for handoff. skip_delete is a nightly-only debugging escape and
581+
# is ignored for daytime-down.
464582
- name: Refresh AWS credentials for teardown
465-
if: always() && steps.infra.outcome != 'skipped' && inputs.skip_delete != true
583+
if: >-
584+
always() && (
585+
inputs.lifecycle == 'daytime-down' ||
586+
(inputs.lifecycle == 'nightly' && inputs.skip_delete != true && steps.infra.outcome != 'skipped')
587+
)
466588
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
467589
with:
468590
role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.GITHUB_ACTIONS_ROLE_NAME }}"
@@ -486,7 +608,11 @@ jobs:
486608
# post-loop guard a genuine destroy failure (e.g. orphaned ENIs/SGs
487609
# blocking VPC deletion) would exit 0 and re-leak silently.
488610
- name: Destroy Cluster
489-
if: always() && steps.infra.outcome != 'skipped' && inputs.skip_delete != true
611+
if: >-
612+
always() && (
613+
inputs.lifecycle == 'daytime-down' ||
614+
(inputs.lifecycle == 'nightly' && inputs.skip_delete != true && steps.infra.outcome != 'skipped')
615+
)
490616
shell: bash
491617
run: |
492618
set -euxo pipefail
@@ -529,4 +655,4 @@ jobs:
529655
uses: ./.github/workflows/evidence-ingest.yaml
530656
with:
531657
bundle_ref: ${{ needs.uat-aws.outputs.bundle_ref }}
532-
recipe: eks-h100-training
658+
recipe: eks-h100-${{ inputs.intent }}

0 commit comments

Comments
 (0)