Skip to content

Commit abf677c

Browse files
authored
fix: existing user-supplied ns when lacking permissions (#657)
Signed-off-by: Marcin Owsiany <[email protected]>
1 parent b99c542 commit abf677c

File tree

2 files changed

+301
-6
lines changed

2 files changed

+301
-6
lines changed

internal/testcase/case.go

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,13 @@ func (c *Case) deleteNamespace(cl clientWithKubeConfig) error {
192192
return cl.Wrapf(err, "waiting for namespace %q to be deleted timed out", c.ns.name)
193193
}
194194

195-
func (c *Case) createNamespace(test *testing.T, cl clientWithKubeConfig) error {
195+
type T interface {
196+
Context() context.Context
197+
Cleanup(f func())
198+
Error(args ...any)
199+
}
200+
201+
func (c *Case) createNamespace(test T, cl clientWithKubeConfig) error {
196202
cl.Logf("Creating namespace %q", c.ns.name)
197203

198204
ctx := test.Context()
@@ -210,11 +216,15 @@ func (c *Case) createNamespace(test *testing.T, cl clientWithKubeConfig) error {
210216
Kind: "Namespace",
211217
},
212218
})
219+
nsExisted, err := c.didNsExist(ctx, err, cl)
220+
if err != nil {
221+
return fmt.Errorf("failed to create test namespace %q: %w", c.ns.name, err)
222+
}
213223
if !c.skipDelete {
214224
test.Cleanup(func() {
215225
// Namespace cleanup is tracked per-client for multi-cluster tests.
216226
// See KEP-0008 for details on backward compatibility decisions.
217-
if c.ns.userSupplied && k8serrors.IsAlreadyExists(err) {
227+
if c.ns.userSupplied && nsExisted {
218228
cl.Logf("Skipping deletion of pre-existing user supplied namespace %s", c.ns.name)
219229
} else {
220230
if err := c.deleteNamespace(cl); err != nil {
@@ -224,14 +234,35 @@ func (c *Case) createNamespace(test *testing.T, cl clientWithKubeConfig) error {
224234
})
225235
}
226236

237+
return nil
238+
}
239+
240+
func (c *Case) didNsExist(ctx context.Context, err error, cl clientWithKubeConfig) (bool, error) {
227241
if k8serrors.IsAlreadyExists(err) {
228242
cl.Logf("Namespace %q already exists", c.ns.name)
229-
return nil
243+
return true, nil
230244
}
231-
if err != nil {
232-
return fmt.Errorf("failed to create test namespace %q: %w", c.ns.name, err)
245+
if k8serrors.IsForbidden(err) {
246+
// If we got a permission error, check separately if the namespace exists
247+
var existingNs corev1.Namespace
248+
getErr := cl.Get(ctx, client.ObjectKey{Name: c.ns.name}, &existingNs)
249+
if getErr == nil {
250+
cl.Logf("Namespace %q already exists", c.ns.name)
251+
return true, nil
252+
}
253+
if k8serrors.IsNotFound(getErr) {
254+
return false, fmt.Errorf("cannot create namespace %q: %w", c.ns.name, err)
255+
}
256+
cl.Logf("Namespace %q creation forbidden (%v) and cannot check its existence: %v", c.ns.name, err, getErr)
257+
// If the user supplied the namespace, it might be due to restrictive RBAC, so let's trust the user.
258+
// If this is an ephemeral namespace, then:
259+
// a) either our permissions are very wrong and the test is unlikely to pass anyway, or
260+
// b) it's a subtle multi-kubeconfig setup where this client is not meant to manage the namespace, but another is.
261+
// Either way, we log the above for visibility and carry on as if a pre-existing namespace is usable.
262+
// The worst that could happen is that the test will fail slightly later than during setup.
263+
return true, nil
233264
}
234-
return nil
265+
return false, err
235266
}
236267

237268
func (c *Case) maybeReportEvents() {

internal/testcase/case_test.go

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
package testcase
22

33
import (
4+
"context"
45
"fmt"
56
"testing"
67

78
"github.com/stretchr/testify/assert"
89
"github.com/stretchr/testify/require"
910
corev1 "k8s.io/api/core/v1"
11+
k8serrors "k8s.io/apimachinery/pkg/api/errors"
1012
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1113
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1214
"k8s.io/apimachinery/pkg/labels"
15+
"k8s.io/apimachinery/pkg/runtime/schema"
16+
"k8s.io/client-go/kubernetes/scheme"
1317
"sigs.k8s.io/controller-runtime/pkg/client"
18+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
1419

1520
"github.com/kudobuilder/kuttl/internal/kubernetes"
1621
"github.com/kudobuilder/kuttl/internal/step"
@@ -353,3 +358,262 @@ func TestLoadTestSteps(t *testing.T) {
353358
})
354359
}
355360
}
361+
362+
// testMock is an object useful for unit-testing Case.createNamespace().
363+
type testMock struct {
364+
cleanup func()
365+
testErrors [][]any
366+
}
367+
368+
func (t *testMock) Context() context.Context {
369+
return context.Background()
370+
}
371+
372+
func (t *testMock) Cleanup(f func()) {
373+
t.cleanup = f
374+
}
375+
376+
func (t *testMock) Error(args ...any) {
377+
t.testErrors = append(t.testErrors, args)
378+
}
379+
380+
func TestCase_createNamespace(t *testing.T) {
381+
tests := map[string]struct {
382+
options []CaseOption
383+
cl func(*testing.T, string) client.Client
384+
wantErr error
385+
expectedCleanupError func(err error) bool
386+
getNsBeforeCleanup func(*testing.T, error)
387+
getNsAfterCleanup func(*testing.T, error)
388+
}{
389+
"user-supplied exists": {
390+
options: []CaseOption{WithNamespace("foo")},
391+
cl: newClientWithExistingNs,
392+
getNsBeforeCleanup: func(t *testing.T, err error) {
393+
require.NoError(t, err)
394+
},
395+
getNsAfterCleanup: func(t *testing.T, err error) {
396+
require.NoError(t, err)
397+
},
398+
},
399+
"user-supplied absent": {
400+
options: []CaseOption{WithNamespace("foo")},
401+
cl: newClientWithAbsentNs,
402+
getNsBeforeCleanup: func(t *testing.T, err error) {
403+
require.NoError(t, err)
404+
},
405+
getNsAfterCleanup: func(t *testing.T, err error) {
406+
assert.True(t, k8serrors.IsNotFound(err), "expected namespace to be deleted after cleanup, but client returned %v", err)
407+
},
408+
},
409+
"user-supplied absent and no write permission": {
410+
options: []CaseOption{WithNamespace("foo")},
411+
cl: newClientWithAbsentNsNoWritePerm,
412+
wantErr: errCreationForbidden,
413+
getNsBeforeCleanup: func(t *testing.T, err error) {
414+
assert.True(t, k8serrors.IsNotFound(err), "expected namespace to be missing before cleanup, but client returned %v", err)
415+
},
416+
getNsAfterCleanup: func(t *testing.T, err error) {
417+
assert.True(t, k8serrors.IsNotFound(err), "expected namespace to be missing after cleanup, but client returned %v", err)
418+
},
419+
},
420+
"user-supplied exists and no write permission": {
421+
options: []CaseOption{WithNamespace("foo")},
422+
cl: newClientWithExistingNsNoWritePerm,
423+
getNsBeforeCleanup: func(t *testing.T, err error) {
424+
require.NoError(t, err)
425+
},
426+
getNsAfterCleanup: func(t *testing.T, err error) {
427+
require.NoError(t, err)
428+
},
429+
},
430+
"user-supplied exists and no permissions at all": {
431+
options: []CaseOption{WithNamespace("foo")},
432+
cl: newClientWithExistingNsNoPerms,
433+
getNsBeforeCleanup: func(t *testing.T, err error) {
434+
require.NoError(t, err)
435+
},
436+
getNsAfterCleanup: func(t *testing.T, err error) {
437+
require.NoError(t, err)
438+
},
439+
},
440+
"ephemeral exists": {
441+
cl: newClientWithExistingNs,
442+
getNsBeforeCleanup: func(t *testing.T, err error) {
443+
require.NoError(t, err)
444+
},
445+
getNsAfterCleanup: func(t *testing.T, err error) {
446+
assert.True(t, k8serrors.IsNotFound(err), "expected namespace to be deleted after cleanup, but client returned %v", err)
447+
},
448+
},
449+
"ephemeral absent": {
450+
cl: newClientWithAbsentNs,
451+
getNsBeforeCleanup: func(t *testing.T, err error) {
452+
require.NoError(t, err)
453+
},
454+
getNsAfterCleanup: func(t *testing.T, err error) {
455+
assert.True(t, k8serrors.IsNotFound(err), "expected namespace to be deleted after cleanup, but client returned %v", err)
456+
},
457+
},
458+
"ephemeral absent and no write permission": {
459+
cl: newClientWithAbsentNsNoWritePerm,
460+
wantErr: errCreationForbidden,
461+
getNsBeforeCleanup: func(t *testing.T, err error) {
462+
assert.True(t, k8serrors.IsNotFound(err), "expected namespace to be missing before cleanup, but client returned %v", err)
463+
},
464+
getNsAfterCleanup: func(t *testing.T, err error) {
465+
assert.True(t, k8serrors.IsNotFound(err), "expected namespace to be missing after cleanup, but client returned %v", err)
466+
},
467+
},
468+
"ephemeral exists and no write permission": {
469+
cl: newClientWithExistingNsNoWritePerm,
470+
expectedCleanupError: k8serrors.IsForbidden,
471+
getNsBeforeCleanup: func(t *testing.T, err error) {
472+
require.NoError(t, err)
473+
},
474+
getNsAfterCleanup: func(t *testing.T, err error) {
475+
require.NoError(t, err)
476+
},
477+
},
478+
"ephemeral exists and no permissions at all": {
479+
cl: newClientWithExistingNsNoPerms,
480+
expectedCleanupError: k8serrors.IsForbidden,
481+
getNsBeforeCleanup: func(t *testing.T, err error) {
482+
require.NoError(t, err)
483+
},
484+
getNsAfterCleanup: func(t *testing.T, err error) {
485+
require.NoError(t, err)
486+
},
487+
},
488+
}
489+
for name, tt := range tests {
490+
t.Run(name, func(t *testing.T) {
491+
c := NewCase(name, "", tt.options...)
492+
tm := &testMock{}
493+
cl := tt.cl(t, c.ns.name)
494+
if npc, ok := cl.(*noPermClient); ok {
495+
npc.t = t
496+
}
497+
clk := clientWithKubeConfig{
498+
Client: cl,
499+
kubeConfigPath: "kubeconfig/path",
500+
logger: testutils.NewTestLogger(t, ""),
501+
}
502+
503+
gotErr := c.createNamespace(tm, clk)
504+
if tt.wantErr == nil {
505+
assert.NoError(t, gotErr)
506+
} else {
507+
assert.ErrorIs(t, gotErr, tt.wantErr)
508+
}
509+
510+
baseClient := cl
511+
if npc, ok := cl.(*noPermClient); ok {
512+
baseClient = npc.Client
513+
}
514+
515+
ns := &corev1.Namespace{
516+
ObjectMeta: metav1.ObjectMeta{
517+
Name: c.ns.name,
518+
},
519+
}
520+
err := baseClient.Get(t.Context(), kubernetes.ObjectKey(ns), ns)
521+
tt.getNsBeforeCleanup(t, err)
522+
523+
if tm.cleanup != nil {
524+
tm.cleanup()
525+
}
526+
527+
if tt.expectedCleanupError != nil {
528+
// Error should have been called once...
529+
require.Len(t, tm.testErrors, 1)
530+
// ...with one parameter...
531+
require.Len(t, tm.testErrors[0], 1)
532+
// ...which is an error.
533+
err, ok := tm.testErrors[0][0].(error)
534+
require.True(t, ok)
535+
assert.True(t, tt.expectedCleanupError(err))
536+
} else {
537+
assert.Empty(t, tm.testErrors)
538+
}
539+
ns = &corev1.Namespace{
540+
ObjectMeta: metav1.ObjectMeta{
541+
Name: c.ns.name,
542+
},
543+
}
544+
err = baseClient.Get(t.Context(), kubernetes.ObjectKey(ns), ns)
545+
546+
tt.getNsAfterCleanup(t, err)
547+
})
548+
}
549+
}
550+
551+
// noPermClient wraps a client and returns forbidden errors for Create/Delete operations.
552+
// Optionally it also refuses Get operations.
553+
type noPermClient struct {
554+
client.Client
555+
forbidGet bool
556+
t *testing.T
557+
}
558+
559+
var errCreationForbidden = k8serrors.NewForbidden(schema.GroupResource{Group: "", Resource: "namespaces"}, "foo", fmt.Errorf("forbidden: User cannot create resource \"namespaces\""))
560+
561+
func (c *noPermClient) Create(_ context.Context, obj client.Object, _ ...client.CreateOption) error {
562+
c.t.Logf("Create object %v refused", obj.GetObjectKind().GroupVersionKind())
563+
return errCreationForbidden
564+
}
565+
566+
func (c *noPermClient) Delete(_ context.Context, obj client.Object, _ ...client.DeleteOption) error {
567+
c.t.Logf("Delete object %v refused", obj.GetObjectKind().GroupVersionKind())
568+
return k8serrors.NewForbidden(schema.GroupResource{Group: "", Resource: "namespaces"}, obj.GetName(), fmt.Errorf("forbidden: User cannot delete resource \"namespaces\""))
569+
}
570+
571+
func (c *noPermClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
572+
if c.forbidGet {
573+
c.t.Logf("Get object %v refused", key)
574+
return k8serrors.NewForbidden(schema.GroupResource{Group: "", Resource: "namespaces"}, obj.GetName(), fmt.Errorf("forbidden: User cannot get resource \"namespaces\""))
575+
}
576+
return c.Client.Get(ctx, key, obj, opts...)
577+
}
578+
579+
func newClientWithExistingNsNoWritePerm(t *testing.T, nsName string) client.Client {
580+
return &noPermClient{
581+
Client: fake.NewClientBuilder().WithScheme(scheme.Scheme).WithRuntimeObjects(&corev1.Namespace{
582+
ObjectMeta: metav1.ObjectMeta{
583+
Name: nsName,
584+
},
585+
}).Build(),
586+
forbidGet: false,
587+
t: t,
588+
}
589+
}
590+
func newClientWithAbsentNsNoWritePerm(t *testing.T, _ string) client.Client {
591+
return &noPermClient{
592+
Client: fake.NewClientBuilder().WithScheme(scheme.Scheme).Build(),
593+
forbidGet: false,
594+
t: t,
595+
}
596+
}
597+
func newClientWithExistingNsNoPerms(t *testing.T, nsName string) client.Client {
598+
return &noPermClient{
599+
Client: fake.NewClientBuilder().WithScheme(scheme.Scheme).WithRuntimeObjects(&corev1.Namespace{
600+
ObjectMeta: metav1.ObjectMeta{
601+
Name: nsName,
602+
},
603+
}).Build(),
604+
forbidGet: true,
605+
t: t,
606+
}
607+
}
608+
609+
func newClientWithAbsentNs(*testing.T, string) client.Client {
610+
return fake.NewClientBuilder().WithScheme(scheme.Scheme).Build()
611+
}
612+
613+
func newClientWithExistingNs(_ *testing.T, nsName string) client.Client {
614+
return fake.NewClientBuilder().WithScheme(scheme.Scheme).WithRuntimeObjects(&corev1.Namespace{
615+
ObjectMeta: metav1.ObjectMeta{
616+
Name: nsName,
617+
},
618+
}).Build()
619+
}

0 commit comments

Comments
 (0)