-
Notifications
You must be signed in to change notification settings - Fork 72
308 lines (292 loc) · 16.1 KB
/
Copy pathuat-nightly-batch.yaml
File metadata and controls
308 lines (292 loc) · 16.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: UAT Nightly Batch
# The night side of the day/night cycle (#1274, DC1 + DC5): on a nightly cron,
# run the version matrix per reservation. For each reservation the controller
# expands an ordered, time-boxed schedule — main first, then the previous N
# stable releases in descending semver order (uat-broker schedule) — and runs
# the cells SEQUENTIALLY, dispatching the shared uat-run.yaml per cell and
# waiting for it before the next. Different reservations run in parallel
# (independent hardware); uat-run.yaml's per-reservation lease serializes runs
# that share a reservation. When the time-box closes, remaining cells are
# dropped oldest-release-first (they are ordered oldest-last, so the controller
# simply stops). Dispatch (not workflow_call) keeps uat-run.yaml top-level, so
# its own lease + superseded observer apply and the reusable-nesting depth stays
# at 3 (uat-run -> uat-{aws,gcp} -> evidence-ingest).
#
# aicr_version is threaded per cell; the release cells install the released
# aicr + validator/agent images at that version (DC5), main builds from source.
on:
schedule:
- cron: '0 4 * * *' # 04:00 UTC daily (evening in US Pacific); default branch only
workflow_dispatch:
inputs:
previous_n:
description: 'Previous stable releases below main to run per reservation (0 = main only).'
type: string
default: '1'
deadline_offset_hours:
description: 'Time-box: hours after batch start to stop dispatching new cells. Keep below the drive job timeout (GitHub caps a hosted job at 6h) so the graceful drop-oldest is reachable.'
type: string
default: '5'
permissions:
contents: read
# Serialize batch runs: a manual dispatch should not fan out a second batch
# alongside the cron one. (The per-reservation leases in uat-run.yaml would
# queue the duplicates anyway, but there is no reason to start them.)
concurrency:
group: uat-nightly-batch
cancel-in-progress: false
jobs:
# Enumerate reservation names from the registry into a JSON array for the
# matrix below — keeps the batch data-driven (no per-reservation YAML).
enumerate:
if: github.repository == 'nvidia/aicr'
runs-on: ubuntu-latest
outputs:
reservations: ${{ steps.list.outputs.reservations }}
# Single batch-start timestamp shared by every matrix leg, so the time-box
# deadline is identical regardless of when each leg's runner starts.
batch_start: ${{ steps.list.outputs.batch_start }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Load versions
id: versions
uses: ./.github/actions/load-versions
- name: Setup Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '${{ steps.versions.outputs.go }}'
cache: true
cache-dependency-path: |
go.sum
vendor/modules.txt
- name: Enumerate reservations
id: list
env:
GOFLAGS: -mod=vendor
run: |
set -euo pipefail
go build -o ./bin/uat-broker ./tools/uat-broker
mapfile -t names < <(./bin/uat-broker reservations --list)
if [ "${#names[@]}" -eq 0 ]; then
echo "::error::no reservations found in infra/uat/reservations.yaml"
exit 1
fi
json=$(printf '%s\n' "${names[@]}" | jq -R . | jq -cs .)
echo "reservations=${json}" >> "$GITHUB_OUTPUT"
echo "Reservations: ${json}"
# Capture the batch start once; every leg derives its deadline from this.
echo "batch_start=$(date +%s)" >> "$GITHUB_OUTPUT"
# One matrix leg per reservation (parallel). Each leg drives its own version
# matrix sequentially. It only DISPATCHES uat-run.yaml (workflow_dispatch is
# exempt from the GITHUB_TOKEN recursion rule and always creates a run), so
# this job needs actions:write to dispatch + read to watch; the dispatched
# uat-run.yaml runs top-level with its own permissions.
drive:
needs: enumerate
runs-on: ubuntu-latest
# The controller blocks on `gh run watch` per cell sequentially, so this job
# runs as long as the batch. GitHub hard-caps a hosted job at 6h; set an
# explicit timeout just under that so the job ends cleanly, and keep
# deadline_offset_hours below it so the graceful drop-oldest fires first —
# otherwise the job is killed mid-cell and the leg hard-fails.
timeout-minutes: 350
strategy:
fail-fast: false
matrix:
reservation: ${{ fromJSON(needs.enumerate.outputs.reservations) }}
permissions:
contents: read # checkout + build uat-broker
actions: write # dispatch uat-run.yaml and watch the dispatched runs
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0 # full tag history for `git tag` -> uat-broker schedule
- name: Load versions
id: versions
uses: ./.github/actions/load-versions
- name: Setup Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '${{ steps.versions.outputs.go }}'
cache: true
cache-dependency-path: |
go.sum
vendor/modules.txt
- name: Run version-matrix cells (sequential, time-boxed)
env:
GH_TOKEN: ${{ github.token }}
GOFLAGS: -mod=vendor
REPO: ${{ github.repository }}
REF: ${{ github.ref_name }}
RESERVATION: ${{ matrix.reservation }}
PREVIOUS_N: ${{ inputs.previous_n || '1' }}
DEADLINE_OFFSET_HOURS: ${{ inputs.deadline_offset_hours || '5' }}
BATCH_START: ${{ needs.enumerate.outputs.batch_start }}
run: |
set -euo pipefail
# Poll budget for finding a dispatched run (gh workflow run returns no
# run id): POLL_MAX_ATTEMPTS x POLL_INTERVAL_SECONDS seconds.
POLL_MAX_ATTEMPTS=24
POLL_INTERVAL_SECONDS=5
go build -o ./bin/uat-broker ./tools/uat-broker
# Nightly intents for this reservation (#1276, DC3). The batch runs
# EACH listed intent (training and/or inference) as its own full CUJ
# cell per version, dispatched SEQUENTIALLY through the shared
# per-reservation lease — so both intents run nightly with no
# contention and no second cron. The broker resolves an ABSENT
# nightly-intents to the training default; an EXPLICIT empty list is
# a bring-up opt-out (manual dispatch only) and resolves to an empty
# value. Distinguish the two failure shapes: a MISSING key is a
# broker/registry regression (fail closed); a present-but-EMPTY
# value is the opt-out (skip this leg gracefully).
ROW=$(./bin/uat-broker reservations --name "$RESERVATION")
if ! grep -q '^nightly-intents=' <<<"$ROW"; then
echo "::error::could not resolve nightly-intents for ${RESERVATION} (key missing from broker output)" >&2
exit 1
fi
NIGHTLY_INTENTS_CSV=$(sed -n 's/^nightly-intents=//p' <<<"$ROW")
if [[ -z "$NIGHTLY_INTENTS_CSV" ]]; then
echo "::notice::Reservation ${RESERVATION} opts out of the nightly batch (nightly-intents: []); skipping this leg."
exit 0
fi
# The actual per-cell intents come from the schedule below, which
# applies the reservation's nightly-intent-min-versions gate per
# version; this line is just the leg-level summary of what's enrolled.
echo "Nightly intents for ${RESERVATION}: ${NIGHTLY_INTENTS_CSV} (release cells further gated by nightly-intent-min-versions)"
# Validate previous_n before the broker consumes it. `--previous-n` is
# an int flag so it rejects non-integers, but it would accept a
# negative and mis-shape the schedule; reject non-integer or negative
# values here too, symmetric with the deadline_offset_hours guard.
if [[ ! "$PREVIOUS_N" =~ ^[0-9]+$ ]]; then
echo "::error::previous_n must be a non-negative integer; got '${PREVIOUS_N}'." >&2
exit 1
fi
# Ordered cells for this reservation: main first, then the previous-N
# stable releases in descending semver order (oldest last).
schedule=$(git tag -l 'v*' | ./bin/uat-broker schedule \
--reservations "$RESERVATION" --previous-n "$PREVIOUS_N")
mapfile -t versions < <(jq -r --arg r "$RESERVATION" '.[$r][].aicr_version' <<<"$schedule")
echo "Cells for ${RESERVATION}: ${versions[*]:-<none>}"
# Reject a non-integer / negative deadline_offset_hours before the
# arithmetic below — workflow_dispatch passes it as a free-form string
# and Bash would silently accept e.g. -1, moving the cutoff before
# batch start and dropping every cell immediately.
if [[ ! "$DEADLINE_OFFSET_HOURS" =~ ^[0-9]+$ ]]; then
echo "::error::deadline_offset_hours must be a non-negative integer; got '${DEADLINE_OFFSET_HOURS}'." >&2
exit 1
fi
# Anchor the time-box to the single batch-start timestamp (captured
# once in the enumerate job) so every reservation leg shares one cutoff
# regardless of when its runner started.
deadline=$(( BATCH_START + DEADLINE_OFFSET_HOURS * 3600 ))
cell=0
leg_failed=""
# Version outer-loop, intent inner-loop: `main` runs every intent
# before any release cell, so a time-box drop always sheds the OLDEST
# release cells first — never main's inference. Each (version × intent)
# is a full provision→CUJ→teardown dispatched through the shared lease,
# so the intents serialize automatically (no contention).
#
# Intents are per-CELL: the schedule already applied each
# reservation's nightly-intent-min-versions gate, so a release that
# predates an intent's minimum version carries a shorter (or empty)
# .intents list and that (version × intent) never dispatches; `main`
# always carries every listed intent.
for ver in "${versions[@]}"; do
mapfile -t cell_intents < <(jq -r --arg r "$RESERVATION" --arg v "$ver" \
'.[$r][] | select(.aicr_version == $v) | .intents[]' <<<"$schedule")
# A fully-gated release cell (no eligible intents) contributes
# nothing — skip it without consuming the time-box or cell counter.
(( ${#cell_intents[@]} == 0 )) && continue
for intent in "${cell_intents[@]}"; do
if (( $(date +%s) >= deadline )); then
echo "::notice::Time-box reached — dropping remaining cells for ${RESERVATION} (oldest release first)."
break 2
fi
cell=$((cell + 1))
label="${ver:-main}"
# Unique per-dispatch key (controller run id + attempt + reservation
# + cell index) so the resolver below watches THIS run and never a
# concurrent manual dispatch of the same reservation/version. The
# cell index increments per (version × intent), so the two intents
# of one version get distinct keys and distinct run titles.
dispatch_key="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${RESERVATION}-${cell}"
title="UAT ${RESERVATION} @ ${label} #${dispatch_key}"
echo "::group::${title} [intent=${intent}]"
gh workflow run uat-run.yaml --repo "$REPO" --ref "$REF" \
-f reservation="$RESERVATION" -f aicr_version="$ver" \
-f intent="$intent" \
-f dispatch_key="$dispatch_key"
# Resolve the dispatched run by its run-name — this title MUST match
# uat-run.yaml's `run-name:` (which appends the same dispatch_key).
# dispatch_key makes the title globally unique, so an exact title
# match is unambiguous; no createdAt filter (a runner clock running
# ahead of GitHub's API clock would falsely reject this dispatch).
run_id=""
for _ in $(seq 1 "$POLL_MAX_ATTEMPTS"); do
sleep "$POLL_INTERVAL_SECONDS"
run_id=$(gh run list --repo "$REPO" --workflow uat-run.yaml \
--event workflow_dispatch --limit 50 \
--json databaseId,displayTitle,createdAt 2>/dev/null \
| jq -r --arg t "$title" \
'map(select(.displayTitle == $t))
| sort_by(.createdAt) | .[-1].databaseId // empty' || true)
[[ -n "$run_id" ]] && break
done
if [[ -z "$run_id" ]]; then
# Fail the leg rather than continue: an unwatched run can still
# hold the reservation's pending slot and let a later (lower-
# priority) cell supersede it, breaking version ordering.
echo "::error::Dispatched ${title} but could not resolve its run id; stopping this reservation leg to preserve version ordering."
echo "::endgroup::"
exit 1
fi
echo "Waiting on run ${run_id} ..."
# --exit-status is intentionally not fatal here; classify the run's
# conclusion below so we can distinguish a benign superseded run from
# a real failure without aborting mid-loop.
# Output is discarded: in a non-TTY log `gh run watch` APPENDS a full
# status render every refresh (no in-place redraw), which flooded the
# drive log with tens of thousands of duplicate lines per multi-hour
# cell. We only need it to BLOCK until the run finishes; the
# conclusion is read from `gh run view` below, so the live render is
# redundant. --interval 60 keeps the polling gentle on the API.
gh run watch "$run_id" --repo "$REPO" --interval 60 --exit-status >/dev/null 2>&1 || true
conclusion=$(gh run view "$run_id" --repo "$REPO" --json conclusion --jq '.conclusion' 2>/dev/null || echo "")
njobs=$(gh api "repos/${REPO}/actions/runs/${run_id}/jobs" --jq '.total_count' 2>/dev/null || echo "")
if [[ "$conclusion" == "cancelled" && "$njobs" == "0" ]]; then
# Benign: the single-slot reservation lease dropped a pending run.
# Surface it (must not be silent) but do not fail the leg — the
# superseded-run observer complements this (DC1 superseded surfacing).
echo "::warning::${title} [intent=${intent}] was superseded while pending (dropped by the reservation lease); re-dispatch when the reservation frees."
elif [[ "$conclusion" != "success" ]]; then
# Real cell failure/timeout — record it so the leg (and thus the
# nightly batch) fails, but keep testing the remaining cells.
echo "::error::${title} [intent=${intent}] finished with conclusion '${conclusion:-unknown}'."
leg_failed=1
fi
echo "::endgroup::"
done
done
if [[ -n "$leg_failed" ]]; then
echo "::error::One or more version cells failed for ${RESERVATION}."
exit 1
fi