Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions internal/webhook/tenantcluster_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions internal/webhook/tenantcluster_webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}
Loading