Skip to content

Commit 15cfbdc

Browse files
committed
feat(sdk): add support for custom kubeconfig paths for validation jobs to SDK
Signed-off-by: Tjark Gunnar Rasche <trasche@nvidia.com>
1 parent f05cc11 commit 15cfbdc

9 files changed

Lines changed: 181 additions & 10 deletions

File tree

docs/integrator/go-library.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,12 @@ if err != nil {
112112
}
113113

114114
// ValidateState runs the validation phases against the resolved recipe +
115-
// observed snapshot. With no WithValidationPhases option it runs all three
115+
// observed snapshot. Pass the same kubeconfig you used for snapshot collection
116+
// so that namespace, RBAC, ConfigMap, validator Job, and result operations all
117+
// target that cluster. With no WithValidationPhases option it runs all three
116118
// phases (Deployment, Conformance, Performance) in canonical order.
117-
results, err := client.ValidateState(ctx, result, snap)
119+
results, err := client.ValidateState(ctx, result, snap,
120+
aicr.WithValidationKubeconfig("/path/to/target-kubeconfig"))
118121
if err != nil {
119122
log.Fatalf("validate state: %v", err)
120123
}
@@ -123,6 +126,13 @@ for _, r := range results {
123126
}
124127
```
125128

129+
When `WithValidationKubeconfig` is omitted or passed an empty string,
130+
`ValidateState` uses the shared default Kubernetes client and its standard
131+
discovery chain: `KUBECONFIG`, `~/.kube/config`, then in-cluster configuration.
132+
When an explicit path is provided, the SDK reloads that kubeconfig and creates a
133+
fresh client for each validation run. The run reuses that client for all of its
134+
Kubernetes operations.
135+
126136
The `recipe` argument to `ValidateState` MUST be the `*RecipeResult`
127137
returned by the same Client's `ResolveRecipe` (or `LoadRecipe`) call —
128138
the unexported internal recipe state is required for constraint
@@ -169,8 +179,8 @@ reports as "skipped - no-cluster mode" and no Kubernetes resources
169179
are created. Other facade options
170180
(`WithValidationNamespace`, `WithValidationRunID`,
171181
`WithValidationCleanup`, `WithValidationImagePullSecrets`,
172-
`WithValidationTolerations`, `WithValidationNodeSelector`) cover the
173-
production-controller knobs.
182+
`WithValidationTolerations`, `WithValidationNodeSelector`,
183+
`WithValidationKubeconfig`) cover the production-controller knobs.
174184

175185
## Recipe sources
176186

pkg/client/v1/aicr.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,10 +1207,11 @@ func (c *Client) CollectSnapshot(ctx context.Context, cfg *AgentConfig) (*Snapsh
12071207
// from unit tests so no Kubernetes resources are created and every
12081208
// check reports as "skipped". WithValidationNamespace, WithValidationRunID,
12091209
// WithValidationCleanup, WithValidationTolerations,
1210-
// WithValidationNodeSelector, and WithValidationPhases cover the
1211-
// production-controller knobs. The validator catalog loads through this
1212-
// Client's own DataProvider, so a Client built from FilesystemSource
1213-
// validates against that recipe source rather than the package global.
1210+
// WithValidationNodeSelector, WithValidationKubeconfig, and
1211+
// WithValidationPhases cover the production-controller knobs. The validator
1212+
// catalog loads through this Client's own DataProvider, so a Client built from
1213+
// FilesystemSource validates against that recipe source rather than the
1214+
// package global.
12141215
//
12151216
// Errors:
12161217
// - ErrCodeInvalidRequest when the Client, recipe, or snap is nil,

pkg/client/v1/aicr_internal_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,24 @@ func TestWithValidationTolerations_ExplicitNilOverrides(t *testing.T) {
656656
}
657657
}
658658

659+
// TestWithValidationKubeconfig_RoundTrip proves the facade-owned option is
660+
// translated to validator.WithKubeconfig without relying on process-global
661+
// KUBECONFIG state.
662+
func TestWithValidationKubeconfig_RoundTrip(t *testing.T) {
663+
t.Parallel()
664+
665+
const path = "/path/to/target-kubeconfig"
666+
cfg := buildValidateConfig([]ValidateOption{WithValidationKubeconfig(path)})
667+
if cfg.kubeconfig != path {
668+
t.Fatalf("kubeconfig = %q, want %q", cfg.kubeconfig, path)
669+
}
670+
671+
v := validator.New(validateOptionsFromConfig(cfg)...)
672+
if v.Kubeconfig != path {
673+
t.Errorf("validator.Kubeconfig = %q, want %q", v.Kubeconfig, path)
674+
}
675+
}
676+
659677
// TestWithValidationTimeout_OptIn pins FIX D: WithValidationTimeout captures
660678
// a pointer-wrapped duration so the ValidateState switch can distinguish
661679
// unset (nil → default 60m), explicit 0 (no facade cap), and explicit >0.

pkg/client/v1/options.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ type ValidateOption func(*validateConfig)
4646
// nil to mean unset because the empty slice has different semantics
4747
// (e.g., "no tolerations at all" vs "no override; use default").
4848
type validateConfig struct {
49+
kubeconfig string
4950
namespace *string
5051
runID *string
5152
cleanup *bool
@@ -116,7 +117,10 @@ func buildValidateConfig(opts []ValidateOption) *validateConfig {
116117
// here and zero edits on the facade surface. phases is intentionally NOT
117118
// translated here — it is passed directly to ValidatePhases by the caller.
118119
func validateOptionsFromConfig(cfg *validateConfig) []validator.Option {
119-
out := make([]validator.Option, 0, 11)
120+
out := make([]validator.Option, 0, 12)
121+
if cfg.kubeconfig != "" {
122+
out = append(out, validator.WithKubeconfig(cfg.kubeconfig))
123+
}
120124
if cfg.namespace != nil {
121125
out = append(out, validator.WithNamespace(*cfg.namespace))
122126
}
@@ -157,6 +161,16 @@ func validateOptionsFromConfig(cfg *validateConfig) []validator.Option {
157161
return out
158162
}
159163

164+
// WithValidationKubeconfig sets an explicit, run-scoped kubeconfig path for
165+
// every Kubernetes API operation performed by Client.ValidateState, including
166+
// namespace, RBAC, ConfigMap, validator Job, and result operations. The file is
167+
// reloaded for each validation run. Empty uses the shared default Kubernetes
168+
// client and its standard KUBECONFIG, ~/.kube/config, then in-cluster discovery
169+
// chain.
170+
func WithValidationKubeconfig(kubeconfig string) ValidateOption {
171+
return func(c *validateConfig) { c.kubeconfig = kubeconfig }
172+
}
173+
160174
// WithValidationNamespace sets the Kubernetes namespace where
161175
// validation Jobs run. Default: "aicr-validation".
162176
func WithValidationNamespace(namespace string) ValidateOption {

pkg/client/v1/stability_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ func TestStability_Validate(t *testing.T) {
114114
// Element type pins each WithValidation* return to aicr.ValidateOption;
115115
// dropping or retyping any factory becomes a compile error.
116116
_ = []aicr.ValidateOption{
117+
aicr.WithValidationKubeconfig("/path/to/kubeconfig"),
117118
aicr.WithValidationNamespace("ns"),
118119
aicr.WithValidationRunID("rid"),
119120
aicr.WithValidationCleanup(true),

pkg/validator/options.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ func WithCommit(commit string) Option {
3838
}
3939
}
4040

41+
// WithKubeconfig sets an explicit, run-scoped kubeconfig path for all
42+
// Kubernetes API operations performed by the validation run. The file is
43+
// reloaded for each run. Empty uses the shared default Kubernetes client and
44+
// its standard KUBECONFIG, ~/.kube/config, then in-cluster discovery chain.
45+
func WithKubeconfig(kubeconfig string) Option {
46+
return func(v *Validator) {
47+
v.Kubeconfig = kubeconfig
48+
}
49+
}
50+
4151
// WithNamespace sets the Kubernetes namespace for validation Jobs.
4252
// Default: "aicr-validation".
4353
func WithNamespace(namespace string) Option {

pkg/validator/types.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ type Validator struct {
3636
// dev-build validator images to SHA-tagged images pushed by on-push CI.
3737
Commit string
3838

39+
// Kubeconfig is an optional, run-scoped path overriding the Kubernetes
40+
// client configuration used for validation namespace, RBAC, ConfigMap, Job,
41+
// and result operations. Empty uses the shared default client.
42+
Kubeconfig string
43+
3944
// Namespace is the Kubernetes namespace for validation Jobs.
4045
Namespace string
4146

@@ -80,6 +85,10 @@ type Validator struct {
8085
// dataProvider supplies the recipe data files used to load the validator
8186
// catalog. When nil, catalog.Load falls back to the package-global provider.
8287
dataProvider recipe.DataProvider
88+
89+
// kubeClientFactory is an internal test seam for explicit-path client
90+
// creation. Production validators leave it nil and use BuildKubeClient.
91+
kubeClientFactory kubeClientFactory
8392
}
8493

8594
// PhaseResult is the outcome of running all validators in a single phase.

pkg/validator/validator.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ type clusterState struct {
9696
stopCh chan struct{}
9797
}
9898

99+
// kubeClientFactory constructs a run-scoped Kubernetes client for an explicit
100+
// kubeconfig path. Tests inject this seam to verify explicit-path propagation
101+
// without reading kubeconfig files or contacting a live cluster.
102+
type kubeClientFactory func(kubeconfig string) (kubernetes.Interface, error)
103+
99104
// prepareCluster sets up namespace, RBAC, data ConfigMaps, and informer factory.
100105
// The caller must close stopCh and handle cleanup deferrals.
101106
func (v *Validator) prepareCluster(
@@ -107,7 +112,22 @@ func (v *Validator) prepareCluster(
107112
// Use PropagateOrWrap so a coded inner error (e.g. an invalid kubeconfig
108113
// classified as a deterministic config error) survives instead of being
109114
// blanket-relabeled ErrCodeInternal, which would mask it as retryable.
110-
clientset, _, err := k8sclient.GetKubeClient()
115+
var clientset kubernetes.Interface
116+
var err error
117+
kubeconfig := strings.TrimSpace(v.Kubeconfig)
118+
switch {
119+
case kubeconfig == "":
120+
// With no per-run override, use the package-wide default client so all
121+
// consumers retain standard discovery and connection reuse semantics.
122+
clientset, _, err = k8sclient.GetKubeClient()
123+
case v.kubeClientFactory != nil:
124+
clientset, err = v.kubeClientFactory(kubeconfig)
125+
default:
126+
// Explicit overrides are run-scoped: reload the file instead of retaining
127+
// the client in the process-wide path cache. clusterState reuses this client
128+
// for every phase and cleanup operation within the current run.
129+
clientset, _, err = k8sclient.BuildKubeClient(kubeconfig)
130+
}
111131
if err != nil {
112132
return nil, errors.PropagateOrWrap(err, errors.ErrCodeInternal, "failed to create kubernetes client")
113133
}

pkg/validator/validator_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,23 @@ package validator
1616

1717
import (
1818
"context"
19+
stderrors "errors"
1920
"fmt"
2021
"io/fs"
2122
"path/filepath"
2223
"testing"
2324

2425
"gopkg.in/yaml.v3"
2526

27+
"github.com/NVIDIA/aicr/pkg/errors"
2628
"github.com/NVIDIA/aicr/pkg/recipe"
2729
"github.com/NVIDIA/aicr/pkg/snapshotter"
2830
"github.com/NVIDIA/aicr/pkg/validator/catalog"
2931
"github.com/NVIDIA/aicr/pkg/validator/ctrf"
3032
v1 "github.com/NVIDIA/aicr/pkg/validator/v1"
3133
"github.com/NVIDIA/aicr/recipes"
3234
corev1 "k8s.io/api/core/v1"
35+
"k8s.io/client-go/kubernetes"
3336
)
3437

3538
func TestNewDefaults(t *testing.T) {
@@ -47,6 +50,9 @@ func TestNewDefaults(t *testing.T) {
4750
if v.NoCluster {
4851
t.Error("NoCluster should default to false")
4952
}
53+
if v.Kubeconfig != "" {
54+
t.Errorf("Kubeconfig = %q, want empty", v.Kubeconfig)
55+
}
5056
if len(v.Tolerations) != 1 || v.Tolerations[0].Operator != corev1.TolerationOpExists {
5157
t.Errorf("Tolerations should default to tolerate-all, got %v", v.Tolerations)
5258
}
@@ -59,6 +65,7 @@ func TestNewWithOptions(t *testing.T) {
5965
v := New(
6066
WithVersion("1.0.0"),
6167
WithCommit("abc1234"),
68+
WithKubeconfig("/path/to/kubeconfig"),
6269
WithNamespace("custom-ns"),
6370
WithRunID("test-run"),
6471
WithCleanup(false),
@@ -73,6 +80,9 @@ func TestNewWithOptions(t *testing.T) {
7380
if v.Commit != "abc1234" {
7481
t.Errorf("Commit = %q, want %q", v.Commit, "abc1234")
7582
}
83+
if v.Kubeconfig != "/path/to/kubeconfig" {
84+
t.Errorf("Kubeconfig = %q, want %q", v.Kubeconfig, "/path/to/kubeconfig")
85+
}
7686
if v.Namespace != "custom-ns" {
7787
t.Errorf("Namespace = %q, want %q", v.Namespace, "custom-ns")
7888
}
@@ -90,6 +100,84 @@ func TestNewWithOptions(t *testing.T) {
90100
}
91101
}
92102

103+
// TestPrepareClusterPropagatesCustomKubeconfig verifies the run-scoped path
104+
// reaches cluster client creation without reading a kubeconfig file or
105+
// contacting Kubernetes. The injected factory fails before any cluster API
106+
// operation, keeping this regression test hermetic and fail-safe.
107+
func TestPrepareClusterPropagatesCustomKubeconfig(t *testing.T) {
108+
t.Parallel()
109+
110+
const wantKubeconfig = "/path/to/target-kubeconfig"
111+
wantErr := stderrors.New("stop before cluster access")
112+
v := New(WithKubeconfig(" " + wantKubeconfig + " "))
113+
114+
var gotKubeconfig string
115+
v.kubeClientFactory = func(kubeconfig string) (kubernetes.Interface, error) {
116+
gotKubeconfig = kubeconfig
117+
return nil, wantErr
118+
}
119+
120+
_, err := v.prepareCluster(t.Context(), nil, nil)
121+
if !stderrors.Is(err, wantErr) {
122+
t.Fatalf("prepareCluster() error = %v, want wrapped injected error", err)
123+
}
124+
if gotKubeconfig != wantKubeconfig {
125+
t.Errorf("kubeconfig = %q, want %q", gotKubeconfig, wantKubeconfig)
126+
}
127+
}
128+
129+
// TestPrepareClusterEmptyKubeconfigUsesDefaultClient verifies that empty input
130+
// is routed through default discovery without consulting the explicit-path
131+
// client factory. The environment is cleared so default discovery fails before
132+
// any cluster access, keeping the test hermetic.
133+
func TestPrepareClusterEmptyKubeconfigUsesDefaultClient(t *testing.T) {
134+
t.Setenv("KUBECONFIG", "")
135+
t.Setenv("HOME", t.TempDir())
136+
t.Setenv("USERPROFILE", t.TempDir())
137+
t.Setenv("KUBERNETES_SERVICE_HOST", "")
138+
t.Setenv("KUBERNETES_SERVICE_PORT", "")
139+
140+
wantFactoryErr := stderrors.New("explicit-path factory called")
141+
v := New(WithKubeconfig(" \t "))
142+
factoryCalled := false
143+
v.kubeClientFactory = func(string) (kubernetes.Interface, error) {
144+
factoryCalled = true
145+
return nil, wantFactoryErr
146+
}
147+
148+
_, err := v.prepareCluster(t.Context(), nil, nil)
149+
if err == nil {
150+
t.Fatal("prepareCluster() error = nil, want default discovery error")
151+
}
152+
if factoryCalled {
153+
t.Error("prepareCluster() called explicit-path factory for empty kubeconfig")
154+
}
155+
if stderrors.Is(err, wantFactoryErr) {
156+
t.Errorf("prepareCluster() error = %v, want default discovery error", err)
157+
}
158+
}
159+
160+
// TestPrepareClusterRejectsMissingKubeconfig verifies that a typo in a
161+
// caller-supplied path is classified as invalid input before Kubernetes client
162+
// construction can relabel the filesystem error as an internal failure.
163+
func TestPrepareClusterRejectsMissingKubeconfig(t *testing.T) {
164+
t.Parallel()
165+
166+
kubeconfig := filepath.Join(t.TempDir(), "missing-kubeconfig")
167+
v := New(WithKubeconfig(kubeconfig))
168+
169+
_, err := v.prepareCluster(t.Context(), nil, nil)
170+
if err == nil {
171+
t.Fatal("prepareCluster() error = nil, want invalid request")
172+
}
173+
if !stderrors.Is(err, errors.New(errors.ErrCodeInvalidRequest, "")) {
174+
t.Errorf("prepareCluster() error = %v, want ErrCodeInvalidRequest", err)
175+
}
176+
if !stderrors.Is(err, fs.ErrNotExist) {
177+
t.Errorf("prepareCluster() error = %v, want wrapped fs.ErrNotExist", err)
178+
}
179+
}
180+
93181
func loadEmbeddedCatalog(t *testing.T) *catalog.ValidatorCatalog {
94182
t.Helper()
95183
cat, err := catalog.LoadWithDataProvider(context.Background(), nil, "", "")

0 commit comments

Comments
 (0)