Skip to content

Commit 0bea785

Browse files
committed
feat(validator): add AICR_VALIDATOR_IMAGE_TAG env-var override
Running `aicr validate` from a feature-branch dev build fails with ImagePullBackOff on every validator pod because on-push.yaml only pushes `:sha-<commit>` images for commits merged to `main`, while PR #655 (rightly) made non-release builds resolve to `:sha-<commit>` instead of `:latest`. Contributors dog-fooding a PR before merge hit a "NotFound" dead-end with no escape hatch. This adds AICR_VALIDATOR_IMAGE_TAG as an opt-in override. When set, the resolved tag is replaced on every validator image — including explicit catalog tags like `:v1.2.3` — so a feature-branch build can point at a known-published tag: AICR_VALIDATOR_IMAGE_TAG=latest aicr validate --phase performance ... Default behavior is unchanged: release builds still resolve to `:v<version>`, main-branch dev builds still resolve to `:sha-<commit>`, and reproducibility for CI paths is preserved. The override is strictly additive and strictly opt-in. The override is forwarded from the CLI invocation into the validator container (alongside AICR_CLI_VERSION, AICR_CLI_COMMIT, and AICR_VALIDATOR_IMAGE_REGISTRY), so validators that resolve inner workload images at runtime (inference-perf's AIPerf benchmark Job) apply the same semantics as catalog.Load. Without this, the outer validator pod would get `:latest` while the inner benchmark pod would still resolve to the same unpublished `:sha-<commit>` and ImagePullBackOff — defeating the motivating feature-branch workflow. Digest-pinned references (`name@sha256:…`) are preserved verbatim. A tag override is meaningless against a content-addressable pin, and naive last-colon splitting would corrupt the digest hash into the tag slot. replaceTag detects the `@` separator and returns the image unchanged; the registry override still applies to digest refs. Tests: - catalog_test.go: 10 new cases covering the tag-override escape hatch, composition with registry override, the no-tag-append case, the empty-env-var no-op, the localhost:5001 port-preservation edge case, digest-only and mixed `name:tag@digest` forms - deployer_test.go: 2 new cases asserting the env var is forwarded into the validator container when set, and strictly omitted when unset (so the default release / main-branch paths are untouched) - docs/contributor/validator.md updated with the resolution order, digest behavior, and env-var forwarding
1 parent 9d57dfb commit 0bea785

5 files changed

Lines changed: 251 additions & 15 deletions

File tree

docs/contributor/validator.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ The validator engine mounts snapshot and recipe data as ConfigMaps:
136136
| `AICR_SNAPSHOT_PATH` | Override snapshot mount path |
137137
| `AICR_RECIPE_PATH` | Override recipe mount path |
138138
| `AICR_VALIDATOR_IMAGE_REGISTRY` | Override image registry prefix (set by user) |
139+
| `AICR_VALIDATOR_IMAGE_TAG` | Override the resolved image tag (e.g. `latest`). Bypasses the default `:v<version>` / `:sha-<commit>` resolution for feature-branch dev builds whose commit has no published image. |
139140
| `AICR_NODE_SELECTOR` | User-provided node selector override for inner workloads (comma-separated `key=value` pairs). Set by the `--node-selector` CLI flag. Use `ctx.NodeSelector` to access the parsed value. |
140141
| `AICR_TOLERATIONS` | User-provided toleration override for inner workloads (comma-separated `key=value:effect` entries). Set by the `--toleration` CLI flag. Use `ctx.Tolerations` to access the parsed value. |
141142

@@ -227,8 +228,16 @@ Each entry in `recipes/validators/catalog.yaml`:
227228
**Image tag resolution** (applied by `catalog.Load`):
228229

229230
1. `:latest` tags are replaced with the CLI version (e.g., `:v0.9.5`) for release builds
230-
2. Explicit version tags (e.g., `:v1.2.3`) are never modified
231-
3. `AICR_VALIDATOR_IMAGE_REGISTRY` overrides the registry prefix
231+
2. On non-release dev builds with a valid commit, `:latest` becomes `:sha-<commit>` (matches the tags `on-push.yaml` pushes for merges to `main`)
232+
3. Explicit version tags (e.g., `:v1.2.3`) are not modified by steps 1-2
233+
4. `AICR_VALIDATOR_IMAGE_TAG` overrides the resolved tag on every validator image, including explicit catalog tags. Use this when running `aicr validate` from a feature-branch dev build whose commit has not been merged to `main` (no `:sha-<commit>` image has been published). Typical value: `latest`. Example: `AICR_VALIDATOR_IMAGE_TAG=latest aicr validate --phase performance ...`
234+
5. `AICR_VALIDATOR_IMAGE_REGISTRY` overrides the registry prefix
235+
236+
**Digest-pinned references** (`name@sha256:…`) are not rewritten by step 4. A tag override is meaningless against a content-addressable pin, and naive rewriting would corrupt the digest. Step 5's registry override still applies — only the registry prefix changes, the digest is preserved verbatim.
237+
238+
**Env-var forwarding to the validator pod:** `AICR_CLI_VERSION`, `AICR_CLI_COMMIT`, `AICR_VALIDATOR_IMAGE_REGISTRY`, and `AICR_VALIDATOR_IMAGE_TAG` are forwarded from the CLI invocation into the validator container so that validators resolving inner workload images at runtime (e.g. `inference-perf`'s AIPerf benchmark Job) apply the same semantics as `catalog.Load`. If you set `AICR_VALIDATOR_IMAGE_TAG=latest` on the CLI, the override reaches both the outer validator Job and the inner benchmark Job — they always travel together.
239+
240+
**Pull-policy behavior when the override is set:** when `AICR_VALIDATOR_IMAGE_TAG` is set, the outer validator Job uses `imagePullPolicy: Always` regardless of the resolved tag. Override values are typically mutable (`latest`, `edge`, `main`, or any tag `on-push.yaml` recreates on every merge), so a default `IfNotPresent` would let a node's previously cached image win over the tag's current target. The inner workload Job in each validator (e.g. the AIPerf benchmark pod dispatched by `inference-perf`, implemented in the `NVIDIA/aicr-validators` repo) should mirror this behavior when consuming `AICR_VALIDATOR_IMAGE_TAG`; see that repo for the current state.
232241

233242
**Performance phase example — inference perf:**
234243

pkg/validator/catalog/catalog.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,14 @@ type EnvVar struct {
103103
// the tag is replaced with the CLI version for reproducibility.
104104
// 2. If version is a non-release dev build and commit is a valid short SHA,
105105
// the tag is replaced with :sha-<commit> to match on-push.yaml image tags.
106-
// 3. If AICR_VALIDATOR_IMAGE_REGISTRY is set, the registry prefix is replaced.
106+
// 3. If AICR_VALIDATOR_IMAGE_TAG is set, the resolved tag is overridden.
107+
// Useful for feature-branch dev builds whose commit SHA has no published
108+
// image (on-push.yaml only pushes SHA tags for commits merged to main).
109+
// Common value: `latest`.
110+
// 4. If AICR_VALIDATOR_IMAGE_REGISTRY is set, the registry prefix is replaced.
107111
//
108-
// Entries with explicit version tags (e.g., :v1.2.3) are never modified.
112+
// Entries with explicit version tags (e.g., :v1.2.3) are never modified by
113+
// steps 1-2 but are replaced by step 3 if that env var is set.
109114
func Load(version, commit string) (*ValidatorCatalog, error) {
110115
data, err := recipe.GetDataProvider().ReadFile("validators/catalog.yaml")
111116
if err != nil {
@@ -131,7 +136,11 @@ func Load(version, commit string) (*ValidatorCatalog, error) {
131136
//
132137
// 1. :latest tag replacement with version if version is a release (vX.Y.Z).
133138
// 2. If non-release and commit is a valid SHA, :latest → :sha-<commit>.
134-
// 3. Registry prefix override if AICR_VALIDATOR_IMAGE_REGISTRY is set.
139+
// 3. Tag override if AICR_VALIDATOR_IMAGE_TAG is set (overrides steps 1-2
140+
// AND explicit catalog tags). Intended for feature-branch dev builds
141+
// where no :sha-<commit> image has been published; typical value:
142+
// `latest`.
143+
// 4. Registry prefix override if AICR_VALIDATOR_IMAGE_REGISTRY is set.
135144
//
136145
// Images with explicit version tags are not modified by steps 1-2.
137146
func ResolveImage(image, version, commit string) string {
@@ -141,6 +150,9 @@ func ResolveImage(image, version, commit string) string {
141150
} else if isValidCommit(commit) {
142151
image = replaceLatestWithSHA(image, commit)
143152
}
153+
if tag := os.Getenv("AICR_VALIDATOR_IMAGE_TAG"); tag != "" {
154+
image = replaceTag(image, tag)
155+
}
144156
if override := os.Getenv("AICR_VALIDATOR_IMAGE_REGISTRY"); override != "" {
145157
image = replaceRegistry(image, override)
146158
}
@@ -192,6 +204,35 @@ func isValidCommit(commit string) bool {
192204
return true
193205
}
194206

207+
// replaceTag forces the image's tag to newTag, regardless of what tag (if
208+
// any) the image currently carries. Unlike replaceLatestTag / replaceLatestWithSHA,
209+
// which only rewrite :latest, this helper supports the AICR_VALIDATOR_IMAGE_TAG
210+
// env-var escape hatch: a user running a feature-branch dev build (where no
211+
// :sha-<commit> image was published by on-push.yaml) can set the env var
212+
// to `latest` and force every validator image to a published tag.
213+
//
214+
// Digest-pinned references (`name@sha256:…`) are cryptographic pins and are
215+
// intentionally left untouched — a tag override is meaningless against a
216+
// content-addressable ref, and naively rewriting would corrupt the digest.
217+
// For non-digest refs, the tag separator is found as the last ':' that sits
218+
// after the last '/' to avoid colliding with the registry port (`:5001` in
219+
// `localhost:5001/...`).
220+
func replaceTag(image, newTag string) string {
221+
if strings.Contains(image, "@") {
222+
// Digest-pinned ref (e.g. ghcr.io/foo/bar@sha256:deadbeef, or the
223+
// mixed form name:tag@sha256:…). The digest is the authoritative
224+
// pin; preserve it verbatim.
225+
return image
226+
}
227+
slash := strings.LastIndex(image, "/")
228+
colon := strings.LastIndex(image, ":")
229+
if colon <= slash {
230+
// No tag on the image (just an image reference) — append one.
231+
return image + ":" + newTag
232+
}
233+
return image[:colon] + ":" + newTag
234+
}
235+
195236
// replaceLatestWithSHA replaces :latest with :sha-<commit> to match the
196237
// image tags pushed by the on-push CI workflow.
197238
// Images with explicit version tags are not modified.

pkg/validator/catalog/catalog_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ func TestResolveImage(t *testing.T) {
492492
version string
493493
commit string
494494
registry string // if non-empty, sets AICR_VALIDATOR_IMAGE_REGISTRY for the test
495+
tag string // if non-empty, sets AICR_VALIDATOR_IMAGE_TAG for the test
495496
want string
496497
}{
497498
{
@@ -642,6 +643,107 @@ func TestResolveImage(t *testing.T) {
642643
commit: "ABC1234",
643644
want: "ghcr.io/nvidia/aicr-validators/aiperf-bench:sha-abc1234",
644645
},
646+
// --- AICR_VALIDATOR_IMAGE_TAG escape hatch -----------------------
647+
// Motivating scenario: a contributor building aicr from an
648+
// un-merged feature-branch checkout. The commit isn't on main,
649+
// so on-push.yaml never pushed :sha-<commit> to ghcr, and
650+
// `aicr validate` pod ImagePullBackOffs. Setting
651+
// AICR_VALIDATOR_IMAGE_TAG=latest (or any published tag) forces
652+
// every validator image to a reachable tag without losing the
653+
// registry / version-based resolution as the default.
654+
{
655+
name: "tag override rewrites :latest on dev build (feature-branch dogfooding)",
656+
image: imgLatest,
657+
version: "dev",
658+
commit: "abc1234",
659+
tag: "latest",
660+
want: "ghcr.io/nvidia/aicr-validators/aiperf-bench:latest",
661+
},
662+
{
663+
name: "tag override replaces :sha-<commit> when both resolve",
664+
image: imgLatest,
665+
version: "v0.11.1-next",
666+
commit: "abc1234",
667+
tag: "latest",
668+
want: "ghcr.io/nvidia/aicr-validators/aiperf-bench:latest",
669+
},
670+
{
671+
name: "tag override replaces the release :vX.Y.Z tag",
672+
image: imgLatest,
673+
version: "v0.11.1",
674+
tag: "latest",
675+
want: "ghcr.io/nvidia/aicr-validators/aiperf-bench:latest",
676+
},
677+
{
678+
name: "tag override replaces an explicit catalog tag",
679+
image: imgPinned, // :v1.2.3 is normally untouched
680+
version: "v0.11.1",
681+
tag: "latest",
682+
want: "ghcr.io/nvidia/aicr-validators/aiperf-bench:latest",
683+
},
684+
{
685+
name: "tag override with no tag on image appends the tag",
686+
image: "ghcr.io/nvidia/aicr-validators/aiperf-bench",
687+
version: "dev",
688+
tag: "latest",
689+
want: "ghcr.io/nvidia/aicr-validators/aiperf-bench:latest",
690+
},
691+
{
692+
name: "empty tag env var leaves image untouched (no-op)",
693+
image: imgPinned,
694+
version: "v0.11.1",
695+
tag: "", // explicitly empty — should behave like unset
696+
want: imgPinned,
697+
},
698+
{
699+
name: "tag override preserves registry port (localhost:5001 edge case)",
700+
image: "localhost:5001/aicr-validators/aiperf-bench:sha-abc1234",
701+
version: "dev",
702+
commit: "abc1234",
703+
tag: "latest",
704+
want: "localhost:5001/aicr-validators/aiperf-bench:latest",
705+
},
706+
{
707+
name: "tag override and registry override compose",
708+
image: imgLatest,
709+
version: "dev",
710+
commit: "abc1234",
711+
tag: "v0.11.0",
712+
registry: "localhost:5001",
713+
want: "localhost:5001/aicr-validators/aiperf-bench:v0.11.0",
714+
},
715+
// --- digest-pinned refs must never be tag-rewritten --------------
716+
// A tag override is incompatible with a content-addressable digest
717+
// pin. Naive last-colon splitting would corrupt `@sha256:<hash>`
718+
// into `@sha256:<newTag>`, emitting an invalid reference.
719+
{
720+
name: "digest-pinned image is not rewritten by tag override",
721+
image: "ghcr.io/foo/bar@sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
722+
version: "dev",
723+
commit: "abc1234",
724+
tag: "latest",
725+
want: "ghcr.io/foo/bar@sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
726+
},
727+
{
728+
name: "mixed ref (name:tag@digest) is not rewritten by tag override",
729+
image: "ghcr.io/foo/bar:v1.0.0@sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
730+
version: "dev",
731+
commit: "abc1234",
732+
tag: "latest",
733+
want: "ghcr.io/foo/bar:v1.0.0@sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
734+
},
735+
{
736+
// Registry override still applies to digest refs (existing
737+
// replaceRegistry behavior), so compose test verifies the two
738+
// env vars don't step on each other on a digest-pinned image.
739+
name: "digest ref + registry override: digest preserved, prefix replaced",
740+
image: "ghcr.io/nvidia/aicr-validators/aiperf-bench@sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
741+
version: "dev",
742+
commit: "abc1234",
743+
tag: "latest",
744+
registry: "localhost:5001",
745+
want: "localhost:5001/aicr-validators/aiperf-bench@sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
746+
},
645747
}
646748

647749
for _, tt := range tests {
@@ -651,6 +753,7 @@ func TestResolveImage(t *testing.T) {
651753
} else {
652754
t.Setenv("AICR_VALIDATOR_IMAGE_REGISTRY", "")
653755
}
756+
t.Setenv("AICR_VALIDATOR_IMAGE_TAG", tt.tag)
654757
got := ResolveImage(tt.image, tt.version, tt.commit)
655758
if got != tt.want {
656759
t.Errorf("ResolveImage(%q, %q, %q) = %q, want %q", tt.image, tt.version, tt.commit, got, tt.want)

pkg/validator/job/deployer.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,10 +221,15 @@ func (d *Deployer) buildEnvApply() []*applycorev1.EnvVarApplyConfiguration {
221221
if len(d.tolerations) > 0 {
222222
env = append(env, applycorev1.EnvVar().WithName("AICR_TOLERATIONS").WithValue(serializeTolerations(d.tolerations)))
223223
}
224-
// Forward CLI version and image-registry override so the validator can
225-
// resolve images it references outside the catalog (e.g. inference-perf's
226-
// aiperf-bench benchmark image) with the same :latest→version pinning and
227-
// registry override that catalog.Load applies to catalog entries.
224+
// Forward CLI version, image-registry override, and image-tag override so
225+
// the validator can resolve images it references outside the catalog
226+
// (e.g. inference-perf's aiperf-bench benchmark image) with the same
227+
// resolution semantics that catalog.Load applies to catalog entries.
228+
// All three must travel together: if a feature-branch dev build set
229+
// AICR_VALIDATOR_IMAGE_TAG=latest on the CLI side to get a published
230+
// outer validator image, the inner benchmark pod needs the same
231+
// override or it will resolve to the same unpublished :sha-<commit>
232+
// the outer pod would have hit without the override.
228233
if d.cliVersion != "" {
229234
env = append(env, applycorev1.EnvVar().WithName("AICR_CLI_VERSION").WithValue(d.cliVersion))
230235
}
@@ -234,6 +239,9 @@ func (d *Deployer) buildEnvApply() []*applycorev1.EnvVarApplyConfiguration {
234239
if override := os.Getenv("AICR_VALIDATOR_IMAGE_REGISTRY"); override != "" {
235240
env = append(env, applycorev1.EnvVar().WithName("AICR_VALIDATOR_IMAGE_REGISTRY").WithValue(override))
236241
}
242+
if tag := os.Getenv("AICR_VALIDATOR_IMAGE_TAG"); tag != "" {
243+
env = append(env, applycorev1.EnvVar().WithName("AICR_VALIDATOR_IMAGE_TAG").WithValue(tag))
244+
}
237245
for _, e := range d.entry.Env {
238246
env = append(env, applycorev1.EnvVar().WithName(e.Name).WithValue(e.Value))
239247
}
@@ -281,6 +289,12 @@ func serializeTolerations(tols []corev1.Toleration) string {
281289
// via `kind load docker-image` and no registry exists to pull from.
282290
// All other images (including localhost registry) follow the standard policy:
283291
// :latest tag uses Always to ensure fresh images, versioned tags use IfNotPresent.
292+
//
293+
// When AICR_VALIDATOR_IMAGE_TAG is set, the resolved tag is user-supplied and
294+
// is typically mutable (e.g. `latest`, `edge`, `main` — tags that on-push.yaml
295+
// recreates on every merge). Treat override-pinned images as mutable and use
296+
// PullAlways so node-local caches can't serve a stale image instead of the
297+
// tag's current target.
284298
func (d *Deployer) imagePullPolicy() corev1.PullPolicy {
285299
img := d.entry.Image
286300
// Side-loaded images via kind load — no registry exists, never pull.
@@ -289,6 +303,10 @@ func (d *Deployer) imagePullPolicy() corev1.PullPolicy {
289303

290304
return corev1.PullNever
291305
}
306+
// Override-pinned tags are assumed mutable — always re-pull.
307+
if os.Getenv("AICR_VALIDATOR_IMAGE_TAG") != "" {
308+
return corev1.PullAlways
309+
}
292310
if strings.HasSuffix(img, ":latest") {
293311
return corev1.PullAlways
294312
}

0 commit comments

Comments
 (0)