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