From 01c4b78449050b41c7b3965d70770ecbc91b1fe1 Mon Sep 17 00:00:00 2001 From: atbagan Date: Sat, 30 May 2026 09:45:22 -0500 Subject: [PATCH] fix(webhook): tolerate ClusterCreationPolicy CRD not installed --- internal/webhook/tenantcluster_webhook.go | 14 ++++++ .../webhook/tenantcluster_webhook_test.go | 49 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/internal/webhook/tenantcluster_webhook.go b/internal/webhook/tenantcluster_webhook.go index 1b02859..c8f7cfd 100644 --- a/internal/webhook/tenantcluster_webhook.go +++ b/internal/webhook/tenantcluster_webhook.go @@ -26,6 +26,7 @@ import ( admissionv1 "k8s.io/api/admission/v1" authnv1 "k8s.io/api/authentication/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" @@ -675,6 +676,19 @@ func (v *TenantClusterValidator) validatePolicy(ctx context.Context, tc *butlerv policies := &butlerv1alpha1.ClusterCreationPolicyList{} if err := v.Client.List(ctx, policies); err != nil { + // The ClusterCreationPolicy CRD shipped in butler-api v0.21.0 + // and is distributed via the butler-crds Helm chart. A cluster + // that picks up a new butler-controller before the matching + // chart upgrade will not have the CRD installed; the list + // returns "no matches for kind" instead of an empty list. + // Treat that case identically to "no policies installed" + // (the len == 0 path below): no policies means no enforcement. + // Without this guard, an admission webhook wedge blocks every + // TenantCluster apply on the cluster, including the GitOps + // reconciles that would deliver the matching CRD. + if meta.IsNoMatchError(err) { + return errs, nil + } return nil, fmt.Errorf("list ClusterCreationPolicy: %w", err) } if len(policies.Items) == 0 { diff --git a/internal/webhook/tenantcluster_webhook_test.go b/internal/webhook/tenantcluster_webhook_test.go index 916ce6e..a007908 100644 --- a/internal/webhook/tenantcluster_webhook_test.go +++ b/internal/webhook/tenantcluster_webhook_test.go @@ -24,10 +24,13 @@ import ( admissionv1 "k8s.io/api/admission/v1" authnv1 "k8s.io/api/authentication/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" butlerv1alpha1 "github.com/butlerdotdev/butler-api/api/v1alpha1" @@ -612,3 +615,49 @@ func TestTCWebhook_Handle_Create_EnvMaxClustersZero_Allowed(t *testing.T) { req := newTCAdmissionRequest(t, admissionv1.Create, "any@example.com", tc, nil) assertAllowed(t, v.Handle(context.Background(), req)) } + +// validatePolicy must treat "no matches for kind" on the +// ClusterCreationPolicyList listing identically to "no policies installed". +// The CRD ships in butler-api v0.21.0 via the butler-crds chart; a cluster +// that takes a new butler-controller before the matching chart upgrade +// has the type referenced in compiled code but not the resource registered +// in the API server. Without this defensive path, every TenantCluster apply +// is denied at admission, which wedges any GitOps reconcile that would +// deliver the CRD. See 2026-05-29 butler-beta investigation in +// .secrets/butler-beta-vtenantcluster-2026-05-29.md. +func TestTCWebhook_ValidatePolicy_CRDMissing_TreatedAsNoPolicies(t *testing.T) { + s := teamScheme(t) + + // Interceptor returns the same error shape the apiserver produces when + // the CRD is not registered: apimeta.NoKindMatchError. Other List calls + // fall through to the fake client's normal behavior. + noMatchErr := &apimeta.NoKindMatchError{ + GroupKind: schema.GroupKind{ + Group: "butler.butlerlabs.dev", + Kind: "ClusterCreationPolicy", + }, + SearchedVersions: []string{"v1alpha1"}, + } + c := fake.NewClientBuilder(). + WithScheme(s). + WithInterceptorFuncs(interceptor.Funcs{ + List: func(ctx context.Context, inner client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + if _, ok := list.(*butlerv1alpha1.ClusterCreationPolicyList); ok { + return noMatchErr + } + return inner.List(ctx, list, opts...) + }, + }). + Build() + + v := &TenantClusterValidator{Client: c, APIReader: c} + tc := buildTC("tc-1", "team-acme", "prod", "", 3) + + errs, err := v.validatePolicy(context.Background(), tc, butlerv1alpha1.ProviderType("")) + if err != nil { + t.Fatalf("validatePolicy returned error for missing CRD; want nil, got %v", err) + } + if len(errs) != 0 { + t.Fatalf("validatePolicy returned field errors for missing CRD; want none, got %d: %v", len(errs), errs) + } +}