forked from NVIDIA/aicr
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient_test.go
More file actions
377 lines (332 loc) · 13 KB
/
Copy pathclient_test.go
File metadata and controls
377 lines (332 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
// 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.
package client
import (
stderrors "errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/NVIDIA/aicr/pkg/errors"
)
func assertKubeconfigErrorContext(t *testing.T, err error, wantKubeconfig string) {
t.Helper()
var structuredErr *errors.StructuredError
if !stderrors.As(err, &structuredErr) {
t.Fatalf("error = %v, want *errors.StructuredError", err)
}
gotKubeconfig, ok := structuredErr.Context["kubeconfig"].(string)
if !ok {
t.Fatalf("error context kubeconfig = %v, want string", structuredErr.Context["kubeconfig"])
}
if gotKubeconfig != wantKubeconfig {
t.Errorf("error context kubeconfig = %q, want %q", gotKubeconfig, wantKubeconfig)
}
}
// TestBuildKubeClient_PathResolution tests the kubeconfig path resolution logic
// without attempting to connect to a cluster.
func TestBuildKubeClient_PathResolution(t *testing.T) {
// t.Setenv automatically restores prior value via t.Cleanup; safer than
// a manual save/restore that leaks env state if a subtest panics.
t.Setenv("KUBECONFIG", os.Getenv("KUBECONFIG"))
tests := []struct {
name string
kubeconfigArg string
kubeconfigEnv string
wantErr bool
errorContains string
wantCode errors.ErrorCode
wantKubeconfig string
}{
{
name: "explicit invalid path",
kubeconfigArg: " /nonexistent/path/to/kubeconfig ",
wantErr: true,
errorContains: "failed to build kube config",
wantCode: errors.ErrCodeInvalidRequest,
wantKubeconfig: "/nonexistent/path/to/kubeconfig",
},
{
name: "env var with invalid path",
kubeconfigArg: "",
kubeconfigEnv: "/nonexistent/env/kubeconfig",
wantErr: true,
errorContains: "failed to build kube config",
wantCode: errors.ErrCodeInvalidRequest,
wantKubeconfig: "/nonexistent/env/kubeconfig",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// t.Setenv handles unset by passing empty string and restores
// the value via t.Cleanup at subtest end.
t.Setenv("KUBECONFIG", tt.kubeconfigEnv)
if tt.kubeconfigEnv == "" {
_ = os.Unsetenv("KUBECONFIG")
}
_, _, err := BuildKubeClient(tt.kubeconfigArg)
if (err != nil) != tt.wantErr {
t.Errorf("BuildKubeClient() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && tt.errorContains != "" {
if !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("BuildKubeClient() error = %v, want error containing %q", err, tt.errorContains)
}
}
if err != nil && tt.wantCode != "" && !stderrors.Is(err, errors.New(tt.wantCode, "")) {
t.Errorf("BuildKubeClient() error = %v, want code %s", err, tt.wantCode)
}
if err != nil && tt.wantKubeconfig != "" {
assertKubeconfigErrorContext(t, err, tt.wantKubeconfig)
}
})
}
}
// TestBuildKubeClient_AutoDiscovery tests auto-discovery behavior with empty path.
// This test doesn't assert success/failure since it depends on the environment
// (presence of ~/.kube/config, in-cluster config, etc.)
func TestBuildKubeClient_AutoDiscovery(t *testing.T) {
// t.Setenv restores prior value via t.Cleanup. Setting to empty does
// not unset, so explicitly unset for the auto-discovery scenario; the
// prior value is captured for restoration via t.Setenv.
t.Setenv("KUBECONFIG", os.Getenv("KUBECONFIG"))
if err := os.Unsetenv("KUBECONFIG"); err != nil {
t.Fatalf("unset KUBECONFIG: %v", err)
}
_, _, err := BuildKubeClient("")
// Don't assert success or failure - just verify it completes without panic
// and returns a consistent result
if err != nil {
t.Logf("BuildKubeClient() auto-discovery failed (no valid config found): %v", err)
} else {
t.Log("BuildKubeClient() auto-discovery succeeded (valid config found in ~/.kube/config or in-cluster)")
}
}
// TestBuildKubeClient_ExplicitPath tests BuildKubeClient with an explicit kubeconfig path.
func TestBuildKubeClient_ExplicitPath(t *testing.T) {
// Create a temporary invalid kubeconfig file to test error handling
tmpDir := t.TempDir()
invalidConfig := filepath.Join(tmpDir, "invalid-kubeconfig")
if err := os.WriteFile(invalidConfig, []byte("invalid yaml content"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
_, _, err := BuildKubeClient(invalidConfig)
if err == nil {
t.Error("BuildKubeClient() with invalid config should return error")
}
if !strings.Contains(err.Error(), "failed to build kube config") {
t.Errorf("BuildKubeClient() error = %v, want error containing 'failed to build kube config'", err)
}
if !stderrors.Is(err, errors.New(errors.ErrCodeInvalidRequest, "")) {
t.Errorf("BuildKubeClient() error = %v, want ErrCodeInvalidRequest", err)
}
assertKubeconfigErrorContext(t, err, invalidConfig)
}
// TestBuildKubeClient_InvalidClientConfigReturnsInvalidRequest verifies that a
// kubeconfig which parses successfully but cannot initialize a Kubernetes
// client is still classified as caller input rather than an internal failure.
func TestBuildKubeClient_InvalidClientConfigReturnsInvalidRequest(t *testing.T) {
kubeconfig := filepath.Join(t.TempDir(), "invalid-client-config")
content := `apiVersion: v1
kind: Config
clusters:
- name: test
cluster:
server: https://127.0.0.1
certificate-authority-data: bm90IGEgcGVtIGNlcnRpZmljYXRl
contexts:
- name: test
context:
cluster: test
user: test
current-context: test
users:
- name: test
user:
token: test
`
if err := os.WriteFile(kubeconfig, []byte(content), 0o600); err != nil {
t.Fatalf("failed to write test kubeconfig: %v", err)
}
_, _, err := BuildKubeClient(kubeconfig)
if err == nil {
t.Fatal("BuildKubeClient() error = nil, want invalid request")
}
if !stderrors.Is(err, errors.New(errors.ErrCodeInvalidRequest, "")) {
t.Errorf("BuildKubeClient() error = %v, want ErrCodeInvalidRequest", err)
}
if !strings.Contains(err.Error(), "failed to create kubernetes client from kubeconfig") {
t.Errorf("BuildKubeClient() error = %v, want client construction failure", err)
}
assertKubeconfigErrorContext(t, err, kubeconfig)
}
// TestGetKubeClient_Singleton tests that GetKubeClient returns the same instance.
func TestGetKubeClient_Singleton(t *testing.T) {
// Note: This test may fail in test environments without valid kubeconfig.
// The important behavior is that it only attempts initialization once.
// Reset the singleton BEFORE this test (normally you wouldn't do this,
// but it's necessary for isolated testing)
// WARNING: This is not thread-safe and should only be done in isolated tests
clientOnce = sync.Once{}
cachedClient = nil
cachedConfig = nil
clientErr = nil
defer func() {
// Reset singleton state after test
clientOnce = sync.Once{}
cachedClient = nil
cachedConfig = nil
clientErr = nil
}()
// First call
client1, config1, err1 := GetKubeClient()
// Second call
client2, config2, err2 := GetKubeClient()
// The key requirement: both calls should return the EXACT SAME results (singleton behavior)
// This is true regardless of whether initialization succeeded or failed
// Both calls should return the same error state
if (err1 != nil) != (err2 != nil) {
t.Errorf("GetKubeClient() error consistency: first call err=%v, second call err=%v", err1, err2)
}
// Both calls should return the same error value
// nolint:errorlint // intentionally checking pointer equality (singleton pattern)
if err1 != err2 {
t.Errorf("GetKubeClient() should return same error instance: first=%v, second=%v", err1, err2)
}
// Both calls should return the same client instance (could be nil or non-nil)
if client1 != client2 {
t.Error("GetKubeClient() should return the same client instance")
}
// Both calls should return the same config instance (could be nil or non-nil)
if config1 != config2 {
t.Error("GetKubeClient() should return the same config instance")
}
}
// TestBuildKubeClient_WhitespaceTreatedAsEmpty verifies a stray space in a
// kubeconfig flag/env doesn't bypass the default-discovery chain into a
// guaranteed "stat : no such file" error from clientcmd.
func TestBuildKubeClient_WhitespaceTreatedAsEmpty(t *testing.T) {
t.Setenv("KUBECONFIG", " ")
// Pin HOME so a malformed real ~/.kube/config on the dev box can't trip
// the "failed to build kube config" assertion below as a false positive.
// USERPROFILE covers the Windows equivalent that homedir.HomeDir consults.
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
// Whitespace-only KUBECONFIG must be treated like unset and fall through
// to ~/.kube/config / in-cluster discovery. The clientcmd-specific error
// "failed to build kube config" would mean we passed whitespace straight
// through, which is exactly the regression we are guarding.
_, _, err := BuildKubeClient(" ")
if err != nil && strings.Contains(err.Error(), "failed to build kube config") {
t.Errorf("whitespace kubeconfig was not normalized to empty: %v", err)
}
}
// TestGetKubeClientWithConfig_ErrorsNotCached verifies that an invalid kubeconfig
// is retried on every call rather than memoized for the process lifetime — a
// transient first-call failure (EAGAIN, token-rotation race) must not become
// permanent. We assert the cache stays empty after error returns; the alternative
// (success caching) is verified end-to-end in the kind-cluster manual tests.
func TestGetKubeClientWithConfig_ErrorsNotCached(t *testing.T) {
tmpDir := t.TempDir()
invalidConfig := filepath.Join(tmpDir, "invalid-kubeconfig")
if err := os.WriteFile(invalidConfig, []byte("invalid yaml content"), 0644); err != nil {
t.Fatalf("failed to write test kubeconfig: %v", err)
}
t.Cleanup(func() {
pathClientMu.Lock()
delete(pathClientCache, invalidConfig)
pathClientMu.Unlock()
})
client1, cfg1, err1 := GetKubeClientWithConfig(invalidConfig)
if err1 == nil {
t.Fatal("expected error from invalid kubeconfig, got nil")
}
if client1 != nil || cfg1 != nil {
t.Errorf("expected nil client and config on error; got client=%v cfg=%v", client1, cfg1)
}
pathClientMu.Lock()
_, cached := pathClientCache[invalidConfig]
pathClientMu.Unlock()
if cached {
t.Error("error path populated the cache; transient failures must not be memoized")
}
// Second call must re-attempt (and re-fail the same way) — not short-circuit
// on a stale cached error.
_, _, err2 := GetKubeClientWithConfig(invalidConfig)
if err2 == nil {
t.Fatal("expected error from invalid kubeconfig on retry, got nil")
}
}
// TestGetKubeClientWithConfig_EmptyDelegatesToSingleton verifies the empty
// (and whitespace-only) path takes the GetKubeClient branch rather than
// populating the per-path cache.
func TestGetKubeClientWithConfig_EmptyDelegatesToSingleton(t *testing.T) {
t.Cleanup(func() {
pathClientMu.Lock()
pathClientCache = map[string]*cachedPathClient{}
pathClientMu.Unlock()
})
// Discard the client/config; both inputs go through GetKubeClient whose
// environment-dependent outcome we explicitly do not assert on (see
// TestBuildKubeClient_AutoDiscovery). The assertion below is purely
// about cache-key behavior.
for _, kubeconfig := range []string{"", " "} {
if client, _, err := GetKubeClientWithConfig(kubeconfig); err == nil && client == nil {
t.Errorf("GetKubeClientWithConfig(%q) succeeded with nil client", kubeconfig)
}
}
pathClientMu.Lock()
defer pathClientMu.Unlock()
if len(pathClientCache) != 0 {
t.Errorf("empty/whitespace kubeconfig polluted per-path cache: %d entries", len(pathClientCache))
}
}
// TestGetKubeClient_CallsOnce tests that GetKubeClient only initializes once
// even when called multiple times concurrently.
func TestGetKubeClient_CallsOnce(t *testing.T) {
// Reset singleton state
defer func() {
clientOnce = sync.Once{}
cachedClient = nil
cachedConfig = nil
clientErr = nil
}()
// Call GetKubeClient multiple times concurrently
const numGoroutines = 10
results := make(chan bool, numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func() {
client, _, _ := GetKubeClient()
// Record whether client is non-nil (success) or nil (failure)
results <- (client != nil)
}()
}
// Collect results
successCount := 0
failCount := 0
for i := 0; i < numGoroutines; i++ {
if <-results {
successCount++
} else {
failCount++
}
}
// All goroutines should get the same result (all success or all failure)
if successCount > 0 && failCount > 0 {
t.Errorf("GetKubeClient() returned inconsistent results: %d successes, %d failures", successCount, failCount)
}
}