Skip to content

Commit 8f5b4cf

Browse files
committed
test(recipes): pin the k8s-aibom stock-adoption contract
Review on #2328 noted that no automated test asserted the new stock behavior: both parity goldens key on leaf recipes, h100-gke-cos-inference is not a leaf, and the render-parity tests use a synthetic single-component fixture. Flipping the target ref to install: false would have left every existing test green while un-shipping the component. Adds TestK8sAIBOMStockAdoption over the public builder API, asserting the target enables it, the Dynamo descendant declares but declines it, the generation-time opt-out declines it, and a sibling recipe does not declare it at all. DeploymentOrder is asserted alongside IsEnabled because that is what bundlers walk, which covers the emission half. Verified by mutation: setting the target ref to install: false fails both the IsEnabled and DeploymentOrder assertions. Also corrects the uninstall instructions. The documented flow regenerated a bundle without the component and then called helmfile destroy, which tears down every release in the bundle and, against the regenerated bundle, could not remove the one release it was meant to. Replaced with a release-scoped helm uninstall that is correct for both helm and helmfile. Signed-off-by: Mark Chmarny <mark@chmarny.com>
1 parent 816425a commit 8f5b4cf

3 files changed

Lines changed: 168 additions & 10 deletions

File tree

docs/user/component-catalog.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -534,12 +534,25 @@ underneath it, so it reconciles against resources that are disappearing.
534534

535535
1. Remove the component reference from the custom overlay and regenerate the
536536
recipe and bundle.
537-
2. Uninstall the release with the deployer-appropriate procedure in
538-
[Bundle Uninstall](cli-reference.md#bundle-uninstall) — `helm uninstall
539-
k8s-aibom -n k8s-aibom-system` for the `helm` deployer, `helmfile destroy`
540-
for Helmfile bundles, deleting the owning `Application` for Argo CD, and the
541-
`HelmRelease` for Flux. Confirm the controller Deployment is gone before
542-
continuing.
537+
2. Uninstall the release, scoped to this component only:
538+
539+
```bash
540+
# helm and helmfile bundles alike: helmfile installs through Helm, so the
541+
# release is an ordinary Helm release and this removes exactly one.
542+
helm uninstall k8s-aibom -n k8s-aibom-system
543+
```
544+
545+
For Argo CD, delete the owning `Application`; for Flux, the `HelmRelease`.
546+
Confirm the controller Deployment is gone before continuing.
547+
548+
**Do not use `helmfile destroy` for this.** It tears down *every* release in
549+
the bundle in reverse dependency order, not just this component. It is also
550+
ineffective here: step 1 regenerated the bundle without `k8s-aibom`, so the
551+
release is no longer declared in it and `destroy` would not remove the one
552+
release you actually want gone while removing all the ones you do not. If
553+
you prefer a Helmfile-native command, run it against a bundle that still
554+
declares the component and scope it explicitly with
555+
`helmfile destroy --selector name=k8s-aibom`.
543556
3. Only then delete retained AIBOMs and, last, the CRDs.
544557

545558
Deleting the CRDs cascades to every AIBOM stored cluster-wide, including any

pkg/bundler/k8s_aibom_render_parity_test.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,17 @@ import (
4141
// leaves only, so no golden pins this component's rendered bytes and no KWOK
4242
// deployer lane exercises it per deployer.
4343
//
44-
// These tests remain the substitute — they build the same single-component
45-
// recipe an adopter would, from the live registry entry rather than from
46-
// hardcoded coordinates, so a registry edit flows into the assertions instead
47-
// of silently diverging from them.
44+
// These tests cover the per-deployer render: they build the same
45+
// single-component recipe an adopter would, from the live registry entry rather
46+
// than from hardcoded coordinates, so a registry edit flows into the assertions
47+
// instead of silently diverging from them.
48+
//
49+
// They deliberately do not assert the stock-adoption contract — that the target
50+
// recipe enables the component, the Dynamo descendant declines it, and the
51+
// generation-time opt-out works. A synthetic single-component fixture cannot
52+
// see any of that, so flipping the target ref to `install: false` would leave
53+
// every assertion here green. That contract is pinned separately by
54+
// TestK8sAIBOMStockAdoption in pkg/recipe.
4855
const (
4956
k8sAIBOMComponentName = "k8s-aibom"
5057
k8sAIBOMValuesFile = "components/k8s-aibom/values.yaml"
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package recipe_test
16+
17+
import (
18+
"context"
19+
"slices"
20+
"testing"
21+
22+
"github.com/NVIDIA/aicr/pkg/recipe"
23+
)
24+
25+
const (
26+
stockAdoptionComponent = "k8s-aibom"
27+
stockAdoptionVersion = "k8s-aibom-stock-adoption-test"
28+
)
29+
30+
// TestK8sAIBOMStockAdoption pins the stock-adoption contract from ADR-019's
31+
// amendment: exactly one stock recipe installs k8s-aibom, its only descendant
32+
// declines it, and the generation-time flag declines it too.
33+
//
34+
// Why this test rather than the parity goldens: both goldens key on *leaf*
35+
// recipes, and `h100-gke-cos-inference` is not a leaf — `-dynamo` bases on it.
36+
// So the target recipe of this whole amendment has no golden coverage, and the
37+
// only golden that moves is the collateral one on the descendant. Without this
38+
// test, flipping the target ref to `install: false` would leave every existing
39+
// test green while silently un-shipping the component.
40+
//
41+
// DeploymentOrder is asserted alongside IsEnabled because the order is what
42+
// bundlers walk. A ref that is declared-but-declined must be absent from it,
43+
// which is the emission-level half of the claim.
44+
func TestK8sAIBOMStockAdoption(t *testing.T) {
45+
target := func() *recipe.Criteria {
46+
return &recipe.Criteria{
47+
Service: recipe.CriteriaServiceGKE,
48+
Accelerator: recipe.CriteriaAcceleratorH100,
49+
OS: recipe.CriteriaOSCOS,
50+
Intent: recipe.CriteriaIntentInference,
51+
}
52+
}
53+
54+
tests := []struct {
55+
name string
56+
criteria *recipe.Criteria
57+
opts []recipe.BuildOption
58+
wantDeclared bool
59+
wantEnabled bool
60+
}{
61+
{
62+
name: "target stock recipe declares and enables the component",
63+
criteria: target(),
64+
wantDeclared: true,
65+
wantEnabled: true,
66+
},
67+
{
68+
name: "dynamo descendant declares but declines it",
69+
criteria: func() *recipe.Criteria {
70+
c := target()
71+
c.Platform = recipe.CriteriaPlatformDynamo
72+
return c
73+
}(),
74+
wantDeclared: true,
75+
wantEnabled: false,
76+
},
77+
{
78+
name: "generation-time opt-out declines it on the target",
79+
criteria: target(),
80+
opts: []recipe.BuildOption{recipe.WithRuntimeInventoryMode(recipe.RuntimeInventoryDisabled)},
81+
wantDeclared: true,
82+
wantEnabled: false,
83+
},
84+
{
85+
name: "a sibling stock recipe does not declare it at all",
86+
criteria: func() *recipe.Criteria {
87+
c := target()
88+
c.Intent = recipe.CriteriaIntentTraining
89+
return c
90+
}(),
91+
wantDeclared: false,
92+
wantEnabled: false,
93+
},
94+
}
95+
96+
for _, tt := range tests {
97+
t.Run(tt.name, func(t *testing.T) {
98+
builder := recipe.NewBuilder(recipe.WithVersion(stockAdoptionVersion))
99+
100+
result, err := builder.BuildFromCriteria(context.Background(), tt.criteria, tt.opts...)
101+
if err != nil {
102+
t.Fatalf("BuildFromCriteria() error = %v", err)
103+
}
104+
105+
var ref *recipe.ComponentRef
106+
for i := range result.ComponentRefs {
107+
if result.ComponentRefs[i].Name == stockAdoptionComponent {
108+
ref = &result.ComponentRefs[i]
109+
break
110+
}
111+
}
112+
113+
if !tt.wantDeclared {
114+
if ref != nil {
115+
t.Fatalf("component %q is declared, want absent: adoption leaked beyond the target recipe",
116+
stockAdoptionComponent)
117+
}
118+
if slices.Contains(result.DeploymentOrder, stockAdoptionComponent) {
119+
t.Errorf("component %q is in DeploymentOrder despite not being declared",
120+
stockAdoptionComponent)
121+
}
122+
return
123+
}
124+
125+
if ref == nil {
126+
t.Fatalf("component %q is absent, want declared: the target recipe no longer ships it",
127+
stockAdoptionComponent)
128+
}
129+
if got := ref.IsEnabled(); got != tt.wantEnabled {
130+
t.Errorf("IsEnabled() = %v, want %v", got, tt.wantEnabled)
131+
}
132+
if got := slices.Contains(result.DeploymentOrder, stockAdoptionComponent); got != tt.wantEnabled {
133+
t.Errorf("in DeploymentOrder = %v, want %v: enabled state and emission disagree",
134+
got, tt.wantEnabled)
135+
}
136+
})
137+
}
138+
}

0 commit comments

Comments
 (0)