Skip to content

Commit 6fff9f1

Browse files
committed
ci(uat): gate nightly intents by minimum release; enroll azure inference
Add nightly-intent-min-versions to the UAT reservation registry: a per-intent minimum AICR release that gates RELEASE cells of the nightly version matrix. The tip-of-main cell always runs every listed intent; a release cell drops any intent whose minimum version is newer than the tag (semver). This generalizes the informal 'a released version that predates a platform is a premature cell' guidance into checked config, so a release that predates an intent's support is skipped instead of running a permanently-red cell. The gate is resolved in the schedule — uat-broker attaches each cell's eligible intents — so the nightly controller dispatches a cell's own intents with no per-version semver logic in workflow bash. Validate rejects a gate on an unrun/unknown intent or a non-semver value. Enroll azure-h100 for [training, inference] with inference gated to v0.18.0: the AKS ND H100 v5 perf fix (nvidia-tuned 0.3.2, #1767) is on main but not yet in a release tag (v0.17.0 pins 0.3.1). Today only main runs Azure inference nightly (green); older releases are skipped and the gated release enrolls automatically once it ships. Signed-off-by: Nathan Hensley <nhensley@nvidia.com>
1 parent 2f6367b commit 6fff9f1

12 files changed

Lines changed: 424 additions & 56 deletions

File tree

.github/workflows/uat-nightly-batch.yaml

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,10 @@ jobs:
174174
echo "::notice::Reservation ${RESERVATION} opts out of the nightly batch (nightly-intents: []); skipping this leg."
175175
exit 0
176176
fi
177-
IFS=',' read -r -a intents <<< "$NIGHTLY_INTENTS_CSV"
178-
echo "Nightly intents for ${RESERVATION}: ${intents[*]}"
177+
# The actual per-cell intents come from the schedule below, which
178+
# applies the reservation's nightly-intent-min-versions gate per
179+
# version; this line is just the leg-level summary of what's enrolled.
180+
echo "Nightly intents for ${RESERVATION}: ${NIGHTLY_INTENTS_CSV} (release cells further gated by nightly-intent-min-versions)"
179181
180182
# Validate previous_n before the broker consumes it. `--previous-n` is
181183
# an int flag so it rejects non-integers, but it would accept a
@@ -213,8 +215,19 @@ jobs:
213215
# release cells first — never main's inference. Each (version × intent)
214216
# is a full provision→CUJ→teardown dispatched through the shared lease,
215217
# so the intents serialize automatically (no contention).
218+
#
219+
# Intents are per-CELL: the schedule already applied each
220+
# reservation's nightly-intent-min-versions gate, so a release that
221+
# predates an intent's minimum version carries a shorter (or empty)
222+
# .intents list and that (version × intent) never dispatches; `main`
223+
# always carries every listed intent.
216224
for ver in "${versions[@]}"; do
217-
for intent in "${intents[@]}"; do
225+
mapfile -t cell_intents < <(jq -r --arg r "$RESERVATION" --arg v "$ver" \
226+
'.[$r][] | select(.aicr_version == $v) | .intents[]' <<<"$schedule")
227+
# A fully-gated release cell (no eligible intents) contributes
228+
# nothing — skip it without consuming the time-box or cell counter.
229+
(( ${#cell_intents[@]} == 0 )) && continue
230+
for intent in "${cell_intents[@]}"; do
218231
if (( $(date +%s) >= deadline )); then
219232
echo "::notice::Time-box reached — dropping remaining cells for ${RESERVATION} (oldest release first)."
220233
break 2

docs/contributor/uat.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,20 +56,31 @@ In both cases the signed evidence bundle is emitted by the earlier conformance s
5656

5757
An unrecognized `intent` (or a missing sibling config) fails closed in the pipeline's `Validate inputs` step before any provisioning.
5858

59-
### Nightly intent cadence (both intents, both clouds)
59+
### Nightly intent cadence (both intents, all three clouds)
6060

61-
The single nightly cron (`uat-nightly-batch.yaml`, `0 4 * * *`) runs **both intents on every nightly-enrolled reservation**, so training *and* inference are exercised nightly on AWS and GCP, and training on Azure (`azure-h100` enrolls `[training]` only until a manual inference run goes green — see the table below). (Note: an inference cell currently provisions and validates the inference platform; the `phase_serve` serving-CUJ step itself remains disabled in both cloud workflows pending #1644, so nightly runs do not yet execute the serving request path.) The set of intents per reservation is data — the `nightly-intents` list in `infra/uat/reservations.yaml` (absent defaults to `[training]`; an explicit empty list `[]` opts the reservation out of the nightly batch entirely — bring-up mode, manual dispatch only):
61+
The single nightly cron (`uat-nightly-batch.yaml`, `0 4 * * *`) runs **both intents on every nightly-enrolled reservation**, so training *and* inference are exercised nightly on AWS, GCP, and Azure (see the table below). (Note: an inference cell currently provisions and validates the inference platform; the `phase_serve` serving-CUJ step itself remains disabled in both cloud workflows pending #1644, so nightly runs do not yet execute the serving request path.) The set of intents per reservation is data — the `nightly-intents` list in `infra/uat/reservations.yaml` (absent defaults to `[training]`; an explicit empty list `[]` opts the reservation out of the nightly batch entirely — bring-up mode, manual dispatch only):
6262

6363
| Reservation | Cloud | `nightly-intents` | Nightly CUJs |
6464
|-------------|-------|-------------------|--------------|
6565
| `aws-h100` | AWS | `[training, inference]` | `phase_train` + `phase_serve` (serve step disabled pending #1644) |
6666
| `gcp-h100` | GCP | `[training, inference]` | `phase_train` + `phase_serve` (serve step disabled pending #1644) |
67-
| `azure-h100` | Azure | `[training]` | `phase_train` (inference joins after a green manual `intent=inference` run) |
67+
| `azure-h100` | Azure | `[training, inference]` | `phase_train` + `phase_serve` (serve step disabled pending #1644); inference gated to `>= v0.18.0` via `nightly-intent-min-versions` (see **Cost / tuning** below) |
6868

6969
**How it stays contention-free — serialize, don't add a second cron.** The intents are folded into the existing [version matrix](#the-version-matrix) as extra cells rather than a second scheduled job. The controller's drive loop is **version outer / intent inner**: for each version it dispatches one intent's full provision→CUJ→teardown cell (inference cells currently run provision→validate→teardown; the serve CUJ is disabled pending #1644), waits for it (`gh run watch`), then dispatches the next — all through the *same* per-reservation lease. So the intents serialize naturally, and because `main` runs every intent before any release cell, a time-box drop only ever sheds the oldest *release* cells (never `main`'s inference). This is the deliberate DC3 cadence decision: **never schedule two daily crons against one reservation** — the lease is a single-slot queue (one in-progress + one pending), so a second cron plus an occasional human dispatch on the same reservation is a routine three-contender case whose loser is silently [superseded](#how-queuing-works-the-reservation-lease). One cron dispatching serialized cells sidesteps that entirely.
7070

7171
**Cost / tuning.** Listing both intents roughly **doubles a reservation's nightly cell count** (each version now runs two full cluster lifecycles). If the batch [time-box](#the-version-matrix) is exceeded the oldest cells are dropped first, so `main`+freshest always land; tune `previous_n` (fewer release versions) or `deadline_offset_hours` to fit the window. A released version that predates a platform (e.g. `dynamo`) fails its inference cell's recipe resolution as a genuine regression signal — drop `previous_n` if that coverage is premature. Changing which intents a reservation runs is a registry edit — no workflow change; the `uatbroker` committed-registry test pins the launch set.
7272

73+
**Gating an intent to a minimum release — `nightly-intent-min-versions`.** When an intent only became *supported* on a reservation at a particular release — a fix or platform that older releases lack — running it on the pre-support releases produces a permanently-red cell, not a regression signal. Express the floor per intent in the registry row:
74+
75+
```yaml
76+
- name: azure-h100
77+
nightly-intents: [training, inference]
78+
nightly-intent-min-versions:
79+
inference: v0.18.0 # first release that carries the AKS perf fix (#1767)
80+
```
81+
82+
Semantics: **`main` is never gated** (it is built from source and carries the newest fixes, so it always runs every listed intent); a **release** cell drops any intent whose minimum version is newer than the tag (semver; a tag `>=` the minimum runs). The gate lives in the schedule (`uat-broker schedule` attaches each cell's eligible `intents`), so the controller simply never dispatches a gated `(version × intent)` — no per-version workflow logic. Pointing the floor at a **not-yet-tagged** release is intentional and self-resolving: until that release ships, the intent runs on **`main` only** (green, continuous coverage of the fix), and the release enrolls automatically once it exists. `Validate` rejects a floor for an intent the row does not run, or a non-semver value. Bump the floor if the real first-fixed tag differs — an over-low floor surfaces as a visible red (safe), an over-high floor silently skips a good release (bump down).
83+
7384
## Cluster lifecycles
7485

7586
The `lifecycle` input selects one of three cluster lifecycles, all sharing the reservation lease:

infra/uat/reservations.yaml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,18 @@ reservations:
105105
test-config-dir: tests/uat/azure/tests
106106
# Enrolled with [training] after the green manual acceptance run
107107
# (29125390442: full provision → CUJ → evidence → teardown). Inference
108-
# joins after a green manual intent=inference dispatch — the AWS/GCP
109-
# rows run both, so add it here rather than removing the key (a bare
110-
# `nightly-intents:` decodes as absent and defaults to [training]).
111-
nightly-intents: [training]
108+
# joined after a green manual intent=inference dispatch, matching the
109+
# AWS/GCP rows which run both.
110+
nightly-intents: [training, inference]
111+
# The AKS ND H100 v5 inference perf fix (nvidia-tuned 0.3.2, #1767) is on
112+
# main but not in any release tag yet — v0.17.0 and older pin 0.3.1 and
113+
# fail inference-perf. Gate release inference cells to the first release
114+
# that will carry the fix, so today only `main` runs Azure inference
115+
# nightly (green); older releases are skipped instead of running red, and
116+
# the gated release enrolls automatically once it ships. Bump this when the
117+
# actual first-fixed tag differs.
118+
nightly-intent-min-versions:
119+
inference: v0.18.0
112120
- name: aws-gb200
113121
cloud: aws
114122
# CROSS-ACCOUNT: the GB200 Capacity Block lives in a SEPARATE tenancy

pkg/uatbroker/doc.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
// drops the OLDEST releases first when its time-box closes — it simply stops
3131
// at the cursor. Release cells carry their tag in AICRVersion for DC5's
3232
// version-parameterized install; until DC5 lands they install from source.
33+
// Each cell also carries the nightly intents ELIGIBLE at its version: the
34+
// main cell runs every listed intent, while a release cell drops any intent
35+
// whose nightly-intent-min-versions floor is newer than the tag, so a release
36+
// that predates an intent's support never contributes a permanently-red cell.
3337
//
3438
// The package performs no network or git I/O and holds no credentials: the
3539
// CLI feeds it the registry bytes and the raw `git tag` list.

pkg/uatbroker/model.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,25 @@ type Reservation struct {
7272
// GPU capacity. KnownFields cannot catch this (the key is valid);
7373
// TestParseRegistryBareNullNightlyIntents locks the behavior.
7474
NightlyIntents []string `yaml:"nightly-intents"`
75+
// NightlyIntentMinVersions gates RELEASE cells of the nightly version matrix
76+
// by a per-intent minimum AICR release: intent -> minimum version (a semver
77+
// tag like "v0.18.0"). It expresses "the first released version known to
78+
// support this intent on this reservation", so a released version that
79+
// predates a fix or platform is not run for that intent and does not
80+
// contribute a predictably-red cell.
81+
//
82+
// Only RELEASE cells are gated. The tip-of-main cell (Cell.IsMain) always
83+
// runs every listed intent — it is built from source and carries the newest
84+
// fixes — so a min-version pointing at a not-yet-tagged release (the fix is
85+
// on main but unreleased) correctly runs the intent on main-only until that
86+
// release ships, then enrolls it automatically. A release tag >= the min
87+
// runs; a tag below it is dropped for that intent only.
88+
//
89+
// OPTIONAL: absent or empty means no gate (every listed intent runs on every
90+
// cell — the pre-#1789 behavior). Validate requires each key to be an intent
91+
// this reservation actually lists in NightlyIntents (a min-version for an
92+
// unrun intent is dead config / a typo) and each value to parse as semver.
93+
NightlyIntentMinVersions map[string]string `yaml:"nightly-intent-min-versions"`
7594
// DaytimeIntent opts this reservation into the daytime human-access
7695
// rotation (#1281, DC8) and picks the flavor stood up on it during the
7796
// working day: "training" or "inference". Empty means the reservation is
@@ -121,4 +140,10 @@ type Cell struct {
121140
Reservation string `json:"reservation"`
122141
AICRVersion string `json:"aicr_version"`
123142
IsMain bool `json:"is_main"`
143+
// Intents are the nightly intents eligible at this cell's version, in
144+
// registry order (see EligibleNightlyIntents). The main cell carries every
145+
// listed intent; a release cell drops any intent gated off by
146+
// nightly-intent-min-versions. The controller dispatches one run per entry,
147+
// so an empty list means the cell dispatches nothing.
148+
Intents []string `json:"intents"`
124149
}

pkg/uatbroker/registry.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"os"
2323
"strings"
2424

25+
"github.com/Masterminds/semver/v3"
2526
"github.com/NVIDIA/aicr/pkg/errors"
2627
"gopkg.in/yaml.v3"
2728
)
@@ -133,6 +134,33 @@ func (r *Registry) Validate() error {
133134
}
134135
seenIntent[intent] = true
135136
}
137+
// nightly-intent-min-versions gates RELEASE cells per intent. Each key
138+
// must be an intent this reservation actually runs (a gate on an unrun
139+
// intent — including any gate on an explicitly opted-out row — is dead
140+
// config / a typo that would silently never apply) and each value must
141+
// parse as semver (else the gate is inert and every release cell runs
142+
// the intent, defeating the gate). Fail closed on both.
143+
runsIntent := make(map[string]bool, len(res.NightlyIntents))
144+
for _, intent := range res.NightlyIntentsOrDefault() {
145+
runsIntent[intent] = true
146+
}
147+
for intent, minVer := range res.NightlyIntentMinVersions {
148+
if !validIntents[intent] {
149+
return errors.New(errors.ErrCodeInvalidRequest,
150+
fmt.Sprintf("reservation %s has a nightly-intent-min-version for unknown intent %q (want %s or %s)",
151+
res.Name, intent, IntentTraining, IntentInference))
152+
}
153+
if !runsIntent[intent] {
154+
return errors.New(errors.ErrCodeInvalidRequest,
155+
fmt.Sprintf("reservation %s gates intent %q via nightly-intent-min-versions but does not run it nightly (add %q to nightly-intents or drop the gate)",
156+
res.Name, intent, intent))
157+
}
158+
if _, err := semver.NewVersion(minVer); err != nil {
159+
return errors.New(errors.ErrCodeInvalidRequest,
160+
fmt.Sprintf("reservation %s has an invalid nightly-intent-min-version %q for intent %q (want a semver tag like v0.18.0)",
161+
res.Name, minVer, intent))
162+
}
163+
}
136164
// daytime-intent is optional (empty = not in the daytime rotation), but
137165
// when set it must be a recognized intent — a typo would otherwise
138166
// silently drop the reservation from the daytime rotation or dispatch a

pkg/uatbroker/registry_test.go

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,75 @@ reservations:
314314
wantErr: true,
315315
code: errors.ErrCodeInvalidRequest,
316316
},
317+
{
318+
// A min-version gate for an intent the reservation does not run is
319+
// dead config / a typo — reject it.
320+
name: "min-version for an unrun intent",
321+
yaml: `
322+
reservations:
323+
- name: azure-h100
324+
cloud: azure
325+
accelerator: h100
326+
gpu-count: 8
327+
cluster-config-path: c.yaml
328+
test-config-dir: t
329+
nightly-intents: [training]
330+
nightly-intent-min-versions:
331+
inference: v0.18.0
332+
`,
333+
wantErr: true,
334+
code: errors.ErrCodeInvalidRequest,
335+
},
336+
{
337+
name: "min-version for an unknown intent",
338+
yaml: `
339+
reservations:
340+
- name: azure-h100
341+
cloud: azure
342+
accelerator: h100
343+
gpu-count: 8
344+
cluster-config-path: c.yaml
345+
test-config-dir: t
346+
nightly-intents: [training, inference]
347+
nightly-intent-min-versions:
348+
serving: v0.18.0
349+
`,
350+
wantErr: true,
351+
code: errors.ErrCodeInvalidRequest,
352+
},
353+
{
354+
name: "min-version that is not semver",
355+
yaml: `
356+
reservations:
357+
- name: azure-h100
358+
cloud: azure
359+
accelerator: h100
360+
gpu-count: 8
361+
cluster-config-path: c.yaml
362+
test-config-dir: t
363+
nightly-intents: [training, inference]
364+
nightly-intent-min-versions:
365+
inference: not-a-tag
366+
`,
367+
wantErr: true,
368+
code: errors.ErrCodeInvalidRequest,
369+
},
370+
{
371+
// A valid gate on a listed intent parses cleanly.
372+
name: "valid nightly-intent-min-versions",
373+
yaml: `
374+
reservations:
375+
- name: azure-h100
376+
cloud: azure
377+
accelerator: h100
378+
gpu-count: 8
379+
cluster-config-path: c.yaml
380+
test-config-dir: t
381+
nightly-intents: [training, inference]
382+
nightly-intent-min-versions:
383+
inference: v0.18.0
384+
`,
385+
},
317386
{
318387
// Two daytime reservations on one cloud would contend for the same
319388
// reservation (a cloud cannot hold both a held daytime cluster and the
@@ -567,9 +636,9 @@ func TestCommittedRegistryValid(t *testing.T) {
567636
"aws-h100": {IntentTraining, IntentInference},
568637
"gcp-h100": {IntentTraining, IntentInference},
569638
// azure-h100 enrolled with [training] after the green manual
570-
// acceptance run (29125390442); inference joins after a green
639+
// acceptance run (29125390442); inference joined after a green
571640
// manual intent=inference dispatch.
572-
"azure-h100": {IntentTraining},
641+
"azure-h100": {IntentTraining, IntentInference},
573642
// aws-gb200 is OPTED OUT of the nightly batch during bring-up (explicit
574643
// empty list). Locked here so an accidental edit to a bare
575644
// `nightly-intents:` (which defaults to [training]) — provisioning real

0 commit comments

Comments
 (0)