Skip to content

feat(validation): detect missing GKE GPU NIC networks at deployment p… #47

feat(validation): detect missing GKE GPU NIC networks at deployment p…

feat(validation): detect missing GKE GPU NIC networks at deployment p… #47

Workflow file for this run

# 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: KWOK Cluster Validation
on:
push:
branches:
- main
paths:
- 'recipes/**'
- 'kwok/**'
- 'tests/chainsaw/kwok/**'
# The aicr binary that renders/bundles recipes is built from Go source;
# a recipe-rendering regression or dependency bump must trigger KWOK e2e.
- 'pkg/recipe/**'
- 'pkg/bundler/**'
- 'pkg/cli/**'
- 'cmd/aicr/**'
- 'go.mod'
- 'go.sum'
- 'vendor/**'
- '.github/workflows/kwok-recipes.yaml'
- '.github/workflows/kwok-test-run.yaml'
- '.github/actions/kwok-test/**'
- '!**.md'
pull_request:
branches:
- main
paths:
- 'recipes/**'
- 'kwok/**'
- 'tests/chainsaw/kwok/**'
# The aicr binary that renders/bundles recipes is built from Go source;
# a recipe-rendering regression or dependency bump must trigger KWOK e2e.
- 'pkg/recipe/**'
- 'pkg/bundler/**'
- 'pkg/cli/**'
- 'cmd/aicr/**'
- 'go.mod'
- 'go.sum'
- 'vendor/**'
- '.github/workflows/kwok-recipes.yaml'
- '.github/workflows/kwok-test-run.yaml'
- '.github/actions/kwok-test/**'
- '!**.md'
schedule:
# Nightly at 03:00 UTC — Tier 3 full matrix backstop
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
recipe:
description: 'Specific recipe to test (leave empty to test all with service criteria)'
required: false
type: string
permissions:
contents: read
# PR concurrency: cancel in-progress runs for the same PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
discover:
name: Discover Recipes
if: github.event_name != 'schedule' || github.repository == 'nvidia/aicr'
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
tier1_pairs: ${{ steps.classify.outputs.tier1_pairs }}
tier2_pairs: ${{ steps.classify.outputs.tier2_pairs }}
tier3_batches: ${{ steps.classify.outputs.tier3_batches }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Full checkout needed for diff-aware Tier 2 discovery
fetch-depth: 0
- name: Script unit tests (kwok/scripts/lib)
run: |
# yq is required by profile-select_test.sh; fail closed here so a
# runner image without yq cannot silently skip the assertions and
# let the CI light lie about coverage (#1997).
command -v yq >/dev/null || { echo "::error::yq is required for kwok/scripts/lib tests"; exit 1; }
bash kwok/scripts/lib/sync-budget_test.sh
bash kwok/scripts/lib/profile-select_test.sh
bash kwok/scripts/run-all-recipes_test.sh
- name: Classify recipes into tiers
id: classify
shell: bash
env:
DISPATCH_RECIPE: ${{ github.event.inputs.recipe }}
run: |
set -euo pipefail
# Shared profile-selection helpers. Discovery filters out any
# recipe whose (service, accelerator) has no matching KWOK profile
# on disk, so the matrix never dispatches a job that would silently
# "SKIP" and return green without validating anything (#1997).
# The library defines read_criteria + profile_status (used below)
# alongside the direct-path selector; keeping them there lets
# profile-select_test.sh cover both wrappers.
# shellcheck source=kwok/scripts/lib/profile-select.sh
source kwok/scripts/lib/profile-select.sh
PROFILES_ROOT="${GITHUB_WORKSPACE}/kwok/profiles"
dropped="[]" # recipes filtered out of the matrix (no profile)
# --- Deployer list: single source of truth (#1172) ---
# Defined once, here (before dispatch, since dispatch also needs it).
# Tier 1 and Tier 3 both consume it to build their {recipe, deployer}
# pairs. Tier 2 stays helm-only by deliberate policy (see ADR-003
# "Tier 2 deployer coverage"). Keep .github/actions/kwok-test/action.yml's
# deployer input doc-string in sync with this list (prose only, not enforced).
deployers='["helm","argocd-oci","argocd-helm-oci","argocd-git","flux-oci","flux-git"]'
# --- workflow_dispatch: test exactly the requested recipe ---
if [[ -n "${DISPATCH_RECIPE}" ]]; then
dispatch_overlay="recipes/overlays/${DISPATCH_RECIPE}.yaml"
if [[ ! -f "${dispatch_overlay}" ]]; then
echo "::error::Dispatch recipe '${DISPATCH_RECIPE}' has no overlay at ${dispatch_overlay}"
exit 1
fi
# workflow_dispatch is an explicit ask; an unmapped recipe here
# must fail (not silently produce an empty matrix) so the
# operator knows their request cannot be validated.
# Capture rc separately: profile_status's return 1 on a fatal
# path (malformed profile / ambiguous match) only manifests
# as the substitution's rc, so an unchecked call would be
# misread as the unmapped-skip case below.
status=$(profile_status "${dispatch_overlay}" "${PROFILES_ROOT}") || exit 1
if [[ "${status}" == "${PROFILE_SELECT_RC_NO_MATCH}" ]]; then
echo "::error::Dispatch recipe '${DISPATCH_RECIPE}' has no KWOK profile for its"
echo "::error::(service, accelerator). Add one under kwok/profiles/<service>/ or"
echo "::error::dispatch a different recipe."
exit 1
fi
single=$(jq -cn --arg r "${DISPATCH_RECIPE}" --argjson deployers "$deployers" \
'[ $deployers[] as $d | {recipe: $r, deployer: $d} ]')
echo "tier1_pairs=${single}" >> "$GITHUB_OUTPUT"
echo "tier2_pairs=[]" >> "$GITHUB_OUTPUT"
echo "tier3_batches=[]" >> "$GITHUB_OUTPUT"
echo "Manual dispatch: ${DISPATCH_RECIPE}"
exit 0
fi
# --- Scan all overlays and classify ---
tier1="[]" # generic overlays (no accelerator specialization)
all="[]" # every testable overlay
for overlay in recipes/overlays/*.yaml; do
name=$(basename "$overlay" .yaml)
# Read BOTH criteria up front so type validation runs on the
# accelerator even when the overlay would be skipped for
# service reasons below — an ocp / empty-service recipe with
# `accelerator: false` must still fail closed, since the
# invalid type is a tree fault regardless of testability.
# Same fail-closed contract as the direct path.
service=$(read_criteria "$overlay" service) || exit 1
accel=$(read_criteria "$overlay" accelerator) || exit 1
# Skip non-testable overlays (no service, or OCP — needs OpenShift operators).
if [[ -z "$service" || "$service" == "ocp" ]]; then
continue
fi
# Drop recipes with no matching KWOK profile so the matrix cell
# can't report green without running anything. Fatal selector
# errors (ambiguous match, malformed profile) surface as
# profile_status returning 1 — that only manifests as the
# substitution's rc, so the outer script has to check it
# explicitly and propagate, otherwise a broken tree gets
# silently reclassified as an unmapped-skip and hidden.
status=$(profile_status "${overlay}" "${PROFILES_ROOT}") || exit 1
if [[ "${status}" == "${PROFILE_SELECT_RC_NO_MATCH}" ]]; then
dropped=$(echo "$dropped" | jq -c --arg r "$name" '. + [$r]')
echo "::notice file=${overlay}::${name} filtered from KWOK matrix — no profile for its (service, accelerator)"
continue
fi
all=$(echo "$all" | jq -c --arg r "$name" '. + [$r]')
if [[ -z "$accel" ]]; then
tier1=$(echo "$tier1" | jq -c --arg r "$name" '. + [$r]')
fi
done
# --- Tier 2: diff-aware accelerator tests (PR only) ---
tier2="[]"
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
base_ref="${{ github.event.pull_request.base.sha }}"
head_ref="${{ github.event.pull_request.head.sha }}"
if ! changed_files=$(git diff --name-only "${base_ref}..${head_ref}" 2>&1); then
echo "::error::git diff failed — cannot compute Tier 2 affected overlays"
echo "git diff output: ${changed_files}"
exit 1
fi
# If registry.yaml or base.yaml changed, all accelerator overlays are
# potentially affected — promote the full accelerator set to Tier 2.
promote_all=false
if echo "$changed_files" | grep -qE '^recipes/(registry\.yaml|overlays/base\.yaml)$'; then
promote_all=true
echo "registry.yaml or base.yaml changed — all accelerator overlays promoted to Tier 2"
fi
# Build set of affected accelerator-specific overlays
for overlay in recipes/overlays/*.yaml; do
name=$(basename "$overlay" .yaml)
service=$(read_criteria "$overlay" service) || exit 1
accel=$(read_criteria "$overlay" accelerator) || exit 1
# Only accelerator-specific, KWOK-testable overlays belong in Tier 2
if [[ -z "$service" || "$service" == "ocp" ]]; then
continue
fi
if [[ -z "$accel" ]]; then
continue
fi
# Same profile-availability filter as the main loop — an
# unmapped accelerator recipe must not enter the matrix, or
# its Tier 2 cell reports green after skipping (#1997).
# Same substitution-rc trap as above: capture rc explicitly
# so fatal selector errors propagate instead of being
# silently reclassified as unmapped-skips.
status=$(profile_status "${overlay}" "${PROFILES_ROOT}") || exit 1
if [[ "${status}" == "${PROFILE_SELECT_RC_NO_MATCH}" ]]; then
continue
fi
# If registry.yaml or base.yaml changed, include all accelerator overlays
if [[ "$promote_all" == "true" ]]; then
tier2=$(echo "$tier2" | jq -c --arg r "$name" '. + [$r]')
continue
fi
include=false
# Rule 1: overlay file itself changed
if echo "$changed_files" | grep -qF "recipes/overlays/${name}.yaml"; then
include=true
fi
# Rule 2: check if any ancestor overlay in the base chain changed
if [[ "$include" == "false" ]]; then
current="$overlay"
while true; do
parent=$(yq eval '.spec.base // ""' "$current" 2>/dev/null || true)
if [[ -z "$parent" || "$parent" == "null" ]]; then
break
fi
if echo "$changed_files" | grep -qF "recipes/overlays/${parent}.yaml"; then
include=true
break
fi
current="recipes/overlays/${parent}.yaml"
if [[ ! -f "$current" ]]; then
break
fi
done
fi
# Rule 3: a component values file referenced by this overlay changed
if [[ "$include" == "false" ]]; then
# Collect valuesFile references from this overlay and its base chain
values_files=""
current="$overlay"
while true; do
vf=$(yq eval '.spec.componentRefs[].valuesFile // ""' "$current" 2>/dev/null | grep -v '^$' || true)
if [[ -n "$vf" ]]; then
values_files="${values_files}"$'\n'"${vf}"
fi
parent=$(yq eval '.spec.base // ""' "$current" 2>/dev/null || true)
if [[ -z "$parent" || "$parent" == "null" ]]; then
break
fi
current="recipes/overlays/${parent}.yaml"
if [[ ! -f "$current" ]]; then
break
fi
done
# Check if any referenced values file changed
for vf in $values_files; do
# valuesFile paths are relative to recipes/
if echo "$changed_files" | grep -qF "recipes/${vf}"; then
include=true
break
fi
done
fi
if [[ "$include" == "true" ]]; then
tier2=$(echo "$tier2" | jq -c --arg r "$name" '. + [$r]')
fi
done
fi
# --- Tier 3: full matrix (all testable overlays) ---
tier3="$all"
tier1_pairs=$(jq -cn --argjson recipes "$tier1" --argjson deployers "$deployers" '
[ $recipes[] as $r | $deployers[] as $d | {recipe: $r, deployer: $d} ]
')
tier2_pairs=$(jq -cn --argjson recipes "$tier2" '
[ $recipes[] as $r | {recipe: $r, deployer: "helm"} ]
')
# --- Tier 3 batching ---
# GitHub caps a single job's matrix at 256 configurations. Tier 3
# crosses every testable recipe with every deployer, so the raw
# cross-product (recipes × deployers) outgrew the cap. Split the
# {recipe, deployer} pairs into batches of <= TIER3_BATCH_SIZE; the
# caller fans each batch out to the shared kwok-test-run.yaml
# reusable workflow (one call per batch), keeping every shard's
# matrix under the limit.
readonly TIER3_BATCH_SIZE=200 # headroom under GitHub's 256 cap
# Fail closed if the batch size is ever raised past the hard limit —
# a too-large batch would otherwise resurface the opaque
# "produced N configurations which exceeds the maximum of 256" error.
if (( TIER3_BATCH_SIZE > 256 )); then
echo "::error::TIER3_BATCH_SIZE=${TIER3_BATCH_SIZE} exceeds GitHub's 256-configuration matrix limit"
exit 1
fi
tier3_batches=$(jq -cn \
--argjson recipes "$tier3" \
--argjson deployers "$deployers" \
--argjson size "$TIER3_BATCH_SIZE" '
[ $recipes[] as $r | $deployers[] as $d | {recipe: $r, deployer: $d} ]
| [ range(0; length; $size) as $i
# id = zero-based batch index; used as the concurrency-group suffix
| {id: ($i / $size | floor), pairs: .[$i:$i+$size]} ]
')
# --- Output ---
echo "tier1_pairs=${tier1_pairs}" >> "$GITHUB_OUTPUT"
echo "tier2_pairs=${tier2_pairs}" >> "$GITHUB_OUTPUT"
echo "tier3_batches=${tier3_batches}" >> "$GITHUB_OUTPUT"
tier1_count=$(echo "$tier1_pairs" | jq 'length')
if (( tier1_count > 200 )); then
echo "::warning::Tier 1 contains ${tier1_count} pairs and is approaching GitHub's 256-configuration matrix limit"
fi
if (( tier1_count > 256 )); then
echo "::error::Tier 1 has grown to ${tier1_count} pairs (>256) — exceeds GitHub's matrix cap. Batch Tier 1 like Tier 3, or tighten the generic-overlay criteria."
exit 1
fi
tier2_count=$(echo "$tier2_pairs" | jq 'length')
tier3_pairs=$(echo "$tier3_batches" | jq '[.[].pairs[]] | length')
tier3_batch_count=$(echo "$tier3_batches" | jq 'length')
echo "Tier 1 (generic): $(echo "$tier1" | jq 'length') recipe(s) × $(echo "$deployers" | jq 'length') deployer(s) = ${tier1_count} pair(s)"
echo "Tier 2 (diff-aware): $(echo "$tier2" | jq 'length') recipe(s) × 1 deployer (helm) = ${tier2_count} pair(s)"
echo "Tier 3 (full matrix): $(echo "$tier3" | jq 'length') recipe(s) × $(echo "$deployers" | jq 'length') deployer(s) = ${tier3_pairs} pair(s) in ${tier3_batch_count} batch(es)"
dropped_count=$(echo "$dropped" | jq 'length')
if (( dropped_count > 0 )); then
echo "Filtered from matrix: ${dropped_count} recipe(s) with no matching KWOK profile — $(echo "$dropped" | jq -r 'join(", ")')"
fi
# Thin caller of the shared kwok-test-run.yaml reusable workflow (#1172).
# tier1_pairs is well under 256 entries, so it's passed in a single call —
# no batching needed.
test-tier1:
name: 'Tier 1'
needs: discover
if: >-
github.event_name != 'schedule' &&
needs.discover.outputs.tier1_pairs != '[]' &&
needs.discover.outputs.tier1_pairs != ''
uses: ./.github/workflows/kwok-test-run.yaml
with:
pairs: ${{ needs.discover.outputs.tier1_pairs }}
# ── Tier 2: diff-aware accelerator tests (PR only, conditional) ──
# Helm-only by deliberate policy — see ADR-003 "Tier 2 deployer coverage".
test-tier2:
name: 'Tier 2'
needs: discover
if: >-
github.event_name == 'pull_request' &&
needs.discover.outputs.tier2_pairs != '[]' &&
needs.discover.outputs.tier2_pairs != ''
uses: ./.github/workflows/kwok-test-run.yaml
with:
pairs: ${{ needs.discover.outputs.tier2_pairs }}
# ── Tier 3: full matrix (push to main + nightly schedule) ──
# The recipe × deployer cross-product exceeds GitHub's 256-config matrix cap,
# so discover splits it into batches and we fan each batch out to the shared
# kwok-test-run.yaml reusable workflow (one call per batch, each <= 256).
# Per ADR-003: the concurrency group is keyed by SHA so successive merges to
# main never cancel in-flight Tier 3 runs; the batch id keeps every shard of a
# single run in its own group so they all run in parallel.
test-tier3:
name: 'Tier 3'
needs: discover
concurrency:
group: kwok-tier3-${{ github.sha }}-${{ matrix.batch.id }}
cancel-in-progress: false
if: >-
(github.event_name == 'push' || (github.event_name == 'schedule' && github.repository == 'nvidia/aicr')) &&
needs.discover.outputs.tier3_batches != '[]' &&
needs.discover.outputs.tier3_batches != ''
strategy:
fail-fast: false
matrix:
batch: ${{ fromJSON(needs.discover.outputs.tier3_batches) }}
uses: ./.github/workflows/kwok-test-run.yaml
with:
pairs: ${{ toJSON(matrix.batch.pairs) }}
# ── Summary: aggregate all tiers ──
summary:
name: KWOK Test Summary
needs: [test-tier1, test-tier2, test-tier3]
runs-on: ubuntu-latest
timeout-minutes: 5
if: always()
steps:
- name: Check test results
run: |
echo "## KWOK Cluster Validation Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
tier1="${{ needs.test-tier1.result }}"
tier2="${{ needs.test-tier2.result }}"
tier3="${{ needs.test-tier3.result }}"
echo "| Tier | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Tier 1 (generic) | ${tier1} |" >> $GITHUB_STEP_SUMMARY
echo "| Tier 2 (diff-aware) | ${tier2} |" >> $GITHUB_STEP_SUMMARY
echo "| Tier 3 (full matrix) | ${tier3} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
failed=false
# Tier 1 must pass (unless skipped on schedule-only with no recipes)
if [[ "$tier1" == "failure" || "$tier1" == "cancelled" ]]; then
echo "Tier 1 (generic) failed or was cancelled" >> $GITHUB_STEP_SUMMARY
failed=true
fi
# Tier 2 must pass when it runs (skipped is OK — means no diff-affected overlays)
if [[ "$tier2" == "failure" || "$tier2" == "cancelled" ]]; then
echo "Tier 2 (diff-aware) failed or was cancelled" >> $GITHUB_STEP_SUMMARY
failed=true
fi
# Tier 3 must pass on push/schedule (skipped is OK on PR)
if [[ "$tier3" == "failure" || "$tier3" == "cancelled" ]]; then
echo "Tier 3 (full matrix) failed or was cancelled" >> $GITHUB_STEP_SUMMARY
failed=true
fi
if [[ "$failed" == "true" ]]; then
exit 1
fi
echo "All recipe validations passed" >> $GITHUB_STEP_SUMMARY