Skip to content

Commit f22dcce

Browse files
committed
fix(cli): fix kubeconfig wiring for validate jobs
Signed-off-by: Tjark Gunnar Rasche <trasche@nvidia.com>
1 parent b5aa988 commit f22dcce

3 files changed

Lines changed: 111 additions & 26 deletions

File tree

docs/user/cli-reference.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -821,7 +821,7 @@ aicr validate [flags]
821821
| `--fail-on-error` | | bool | true | Exit with non-zero status if any constraint fails |
822822
| `--fail-fast` | | bool | false | Stop after the first phase that fails. By default all phases run and produce results. |
823823
| `--output` | `-o` | string | stdout | Output destination: file path, ConfigMap URI (`cm://namespace/name`), or stdout |
824-
| `--kubeconfig` | `-k` | string | ~/.kube/config | Path to kubeconfig file (used when `--recipe`, `--snapshot`, or `--output` is a ConfigMap URI) |
824+
| `--kubeconfig` | `-k` | string | ~/.kube/config | Path to kubeconfig file for the entire validation run: live snapshot capture, ConfigMap recipe/snapshot reads, validation namespace/RBAC/ConfigMaps/Jobs/results/cleanup, and ConfigMap output. Omit it to use standard Kubernetes client discovery. |
825825
| `--namespace` | `-n` | string | aicr-validation | Kubernetes namespace for validation Job deployment |
826826
| `--image` | | string | ghcr.io/nvidia/aicr:latest | Container image for validation Job |
827827
| `--image-pull-secret` | | string[] | | Image pull secrets for private registries (repeatable) |
@@ -937,7 +937,7 @@ aicr validate \
937937
--snapshot snapshot.yaml \
938938
--phase performance
939939
940-
# With custom kubeconfig
940+
# Run every cluster operation against a custom target cluster
941941
aicr validate \
942942
--recipe recipe.yaml \
943943
--snapshot cm://gpu-operator/aicr-snapshot \

pkg/cli/validate.go

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,13 @@ import (
2424

2525
"github.com/urfave/cli/v3"
2626
corev1 "k8s.io/api/core/v1"
27-
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2827

2928
aicr "github.com/NVIDIA/aicr/pkg/client/v1"
3029
"github.com/NVIDIA/aicr/pkg/config"
3130
"github.com/NVIDIA/aicr/pkg/defaults"
32-
apierrors "k8s.io/apimachinery/pkg/api/errors"
3331

3432
"github.com/NVIDIA/aicr/pkg/errors"
3533
"github.com/NVIDIA/aicr/pkg/evidence/cncf"
36-
k8sclient "github.com/NVIDIA/aicr/pkg/k8s/client"
3734
"github.com/NVIDIA/aicr/pkg/serializer"
3835
"github.com/NVIDIA/aicr/pkg/snapshotter"
3936
"github.com/NVIDIA/aicr/pkg/validator"
@@ -152,21 +149,10 @@ func resolveValidateTolerations(cmd *cli.Command, resolved *config.ValidateResol
152149
return resolved.Tolerations, nil
153150
}
154151

155-
// deployAgentForValidation deploys an agent to capture a snapshot and returns the Snapshot.
156-
// Creates the namespace if it does not exist.
157-
func deployAgentForValidation(ctx context.Context, cfg *validateAgentConfig) (*snapshotter.Snapshot, string, error) {
158-
// Ensure namespace exists before deploying the agent Job.
159-
clientset, _, err := k8sclient.GetKubeClient()
160-
if err != nil {
161-
return nil, "", errors.PropagateOrWrap(err, errors.ErrCodeInternal, "failed to create kubernetes client")
162-
}
163-
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: cfg.namespace}}
164-
if _, nsErr := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}); nsErr != nil {
165-
if !apierrors.IsAlreadyExists(nsErr) {
166-
return nil, "", errors.Wrap(errors.ErrCodeInternal, "failed to create namespace", nsErr)
167-
}
168-
}
169-
152+
// deployAgentForValidation deploys an agent to capture a snapshot and returns
153+
// the Snapshot. The snapshotter owns namespace creation using the same
154+
// run-scoped kubeconfig as the rest of the agent lifecycle.
155+
func deployAgentForValidation(ctx context.Context, cfg *validateAgentConfig) (*snapshotter.Snapshot, error) {
170156
agentConfig := &snapshotter.AgentConfig{
171157
Kubeconfig: cfg.kubeconfig,
172158
Namespace: cfg.namespace,
@@ -185,20 +171,19 @@ func deployAgentForValidation(ctx context.Context, cfg *validateAgentConfig) (*s
185171

186172
snap, err := snapshotter.DeployAndGetSnapshot(ctx, agentConfig)
187173
if err != nil {
188-
return nil, "", errors.Wrap(errors.ErrCodeInternal, "failed to capture snapshot", err)
174+
return nil, errors.PropagateOrWrap(err, errors.ErrCodeInternal, "failed to capture snapshot")
189175
}
190176

191-
source := fmt.Sprintf("agent:%s/%s", cfg.namespace, cfg.jobName)
192-
return snap, source, nil
177+
return snap, nil
193178
}
194179

195180
// validationConfig holds all parameters for a validation run.
196181
type validationConfig struct {
197182
// Input
198183
phases []validator.Phase
199184

200-
// Kubeconfig path; propagated to ConfigMap reads/writes so a single
201-
// validate invocation can target a non-default cluster end-to-end.
185+
// Kubeconfig path; propagated to every Kubernetes operation so a single
186+
// validate invocation targets one cluster end-to-end.
202187
kubeconfig string
203188

204189
// Output
@@ -261,6 +246,7 @@ func runValidation(
261246
// validator.With* calls); the image/commit overrides are passed verbatim
262247
// (empty string is the validator's "unset" sentinel).
263248
opts := []aicr.ValidateOption{
249+
aicr.WithValidationKubeconfig(cfg.kubeconfig),
264250
aicr.WithValidationNamespace(cfg.validationNamespace),
265251
aicr.WithValidationRunID(runID),
266252
aicr.WithValidationCleanup(cfg.cleanup),
@@ -733,7 +719,7 @@ Run validation without failing on check errors (informational mode):
733719
agentCfg := parseValidateAgentConfig(cmd, resolved, shared)
734720

735721
var deployErr error
736-
snap, _, deployErr = deployAgentForValidation(ctx, agentCfg)
722+
snap, deployErr = deployAgentForValidation(ctx, agentCfg)
737723
if deployErr != nil {
738724
return deployErr
739725
}

pkg/cli/validate_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package cli
1616

1717
import (
1818
"context"
19+
stderrors "errors"
1920
"os"
2021
"path/filepath"
2122
"slices"
@@ -24,9 +25,107 @@ import (
2425

2526
"github.com/urfave/cli/v3"
2627

28+
aicr "github.com/NVIDIA/aicr/pkg/client/v1"
29+
"github.com/NVIDIA/aicr/pkg/errors"
30+
"github.com/NVIDIA/aicr/pkg/serializer"
31+
"github.com/NVIDIA/aicr/pkg/snapshotter"
2732
v1 "github.com/NVIDIA/aicr/pkg/validator/v1"
2833
)
2934

35+
// isolateValidationKubeconfigs gives validation two different invalid paths:
36+
// target is the explicit --kubeconfig value under test, while fallback is what
37+
// default discovery would choose. Both fail before any Kubernetes API call,
38+
// keeping tests hermetic while making a regression to the default client
39+
// observable in the resulting error.
40+
func isolateValidationKubeconfigs(t *testing.T) (target, fallback string) {
41+
t.Helper()
42+
43+
dir := t.TempDir()
44+
target = filepath.Join(dir, "target-kubeconfig")
45+
fallback = filepath.Join(dir, "default-kubeconfig")
46+
t.Setenv("KUBECONFIG", fallback)
47+
t.Setenv("HOME", t.TempDir())
48+
t.Setenv("USERPROFILE", t.TempDir())
49+
t.Setenv("KUBERNETES_SERVICE_HOST", "")
50+
t.Setenv("KUBERNETES_SERVICE_PORT", "")
51+
return target, fallback
52+
}
53+
54+
func assertExplicitKubeconfigError(t *testing.T, err error, target, fallback string) {
55+
t.Helper()
56+
57+
if err == nil {
58+
t.Fatal("error = nil, want explicit kubeconfig error")
59+
}
60+
if !stderrors.Is(err, errors.New(errors.ErrCodeInvalidRequest, "")) {
61+
t.Errorf("error = %v, want ErrCodeInvalidRequest", err)
62+
}
63+
if !strings.Contains(err.Error(), target) {
64+
t.Errorf("error = %v, want explicit kubeconfig path %q", err, target)
65+
}
66+
if strings.Contains(err.Error(), fallback) {
67+
t.Errorf("error = %v, unexpectedly used default kubeconfig %q", err, fallback)
68+
}
69+
}
70+
71+
// TestRunValidationPropagatesKubeconfigToValidator pins the missing CLI facade
72+
// wiring: the kubeconfig used for inputs and output must also select the client
73+
// used for validation namespace, RBAC, ConfigMaps, Jobs, results, and cleanup.
74+
func TestRunValidationPropagatesKubeconfigToValidator(t *testing.T) {
75+
target, fallback := isolateValidationKubeconfigs(t)
76+
77+
const recipeYAML = `kind: RecipeResult
78+
apiVersion: aicr.run/v1alpha2
79+
metadata:
80+
version: test
81+
componentRefs:
82+
- name: gpu-operator
83+
type: helm
84+
chart: gpu-operator
85+
source: https://helm.ngc.nvidia.com/nvidia
86+
version: v25.3.4
87+
deploymentOrder:
88+
- gpu-operator
89+
`
90+
recipePath := filepath.Join(t.TempDir(), "recipe.yaml")
91+
if err := os.WriteFile(recipePath, []byte(recipeYAML), 0o600); err != nil {
92+
t.Fatalf("write recipe: %v", err)
93+
}
94+
95+
client, err := aicr.NewClient(aicr.WithRecipeSource(aicr.EmbeddedSource()))
96+
if err != nil {
97+
t.Fatalf("NewClient: %v", err)
98+
}
99+
t.Cleanup(func() { _ = client.Close() })
100+
101+
rec, err := client.LoadRecipe(t.Context(), recipePath, "")
102+
if err != nil {
103+
t.Fatalf("LoadRecipe: %v", err)
104+
}
105+
106+
err = runValidation(t.Context(), client, rec, &snapshotter.Snapshot{}, validationConfig{
107+
kubeconfig: target,
108+
output: filepath.Join(t.TempDir(), "report.yaml"),
109+
outFormat: serializer.FormatYAML,
110+
validationNamespace: "aicr-validation",
111+
cleanup: true,
112+
})
113+
assertExplicitKubeconfigError(t, err, target, fallback)
114+
}
115+
116+
// TestDeployAgentForValidationUsesExplicitKubeconfigFromStart ensures live
117+
// snapshot capture does not pre-create its namespace through the default
118+
// client before handing the explicit path to snapshotter.
119+
func TestDeployAgentForValidationUsesExplicitKubeconfigFromStart(t *testing.T) {
120+
target, fallback := isolateValidationKubeconfigs(t)
121+
122+
_, err := deployAgentForValidation(t.Context(), &validateAgentConfig{
123+
kubeconfig: target,
124+
namespace: "aicr-validation",
125+
})
126+
assertExplicitKubeconfigError(t, err, target, fallback)
127+
}
128+
30129
// TestResolveCNCFAllocationPolicy exercises the #1629 policy threading for
31130
// --cncf-submission runs: no recipe context resolves to an empty policy
32131
// (standalone runs keep the evidence script's capability detection), a

0 commit comments

Comments
 (0)