diff --git a/cmd/main.go b/cmd/main.go index 3f06e5e..782122c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -287,6 +287,10 @@ func main() { setupLog.Error(err, "unable to create webhook", "webhook", "ProviderConfig") os.Exit(1) } + if err = (&webhook.ClusterCreationPolicyValidator{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "ClusterCreationPolicy") + os.Exit(1) + } setupLog.Info("admission webhooks enabled") } else { setupLog.Info("admission webhooks disabled") diff --git a/go.mod b/go.mod index 00aa80a..1d666e4 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/butlerdotdev/butler-controller go 1.24.6 require ( - github.com/butlerdotdev/butler-api v0.20.0 + github.com/butlerdotdev/butler-api v0.21.0 github.com/onsi/ginkgo/v2 v2.22.0 github.com/onsi/gomega v1.36.1 github.com/prometheus/client_golang v1.22.0 diff --git a/go.sum b/go.sum index ffae5a0..2854157 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/butlerdotdev/butler-api v0.20.0 h1:dcDDiaejo5YN1a7ZZnsxJZhr/kDQk/IUcmPCYsT/PNA= -github.com/butlerdotdev/butler-api v0.20.0/go.mod h1:q/RjPFM/r2ELq4DqR78OiAxerKBvH5D4nTGsVLKbjqY= +github.com/butlerdotdev/butler-api v0.21.0 h1:zVI4doowHOHWUMVV6s+N0LcrbGasvNA5kP26Sf+kWnc= +github.com/butlerdotdev/butler-api v0.21.0/go.mod h1:q/RjPFM/r2ELq4DqR78OiAxerKBvH5D4nTGsVLKbjqY= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/internal/webhook/clustercreationpolicy_webhook.go b/internal/webhook/clustercreationpolicy_webhook.go new file mode 100644 index 0000000..37242a5 --- /dev/null +++ b/internal/webhook/clustercreationpolicy_webhook.go @@ -0,0 +1,327 @@ +/* +Copyright 2026 The Butler Authors. + +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 webhook + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + admissionv1 "k8s.io/api/admission/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + butlerv1alpha1 "github.com/butlerdotdev/butler-api/api/v1alpha1" +) + +// ClusterCreationPolicyValidator validates ClusterCreationPolicy +// resources on admission. See ADR-018 Decision section 7 and 8. +// +// Structural validation (exactly-one-of on spec.scope, enum values on +// OptionMode and OptionType) is enforced by the CRD schema before the +// webhook runs. This webhook layers referential integrity checks, +// mode-specific value requirements with operator-facing error messages, +// and intra-tier conflict detection that the schema cannot express. +// +// Provider-entry existence is intentionally not validated here. A +// provider outage at admission time would block legitimate policy +// authoring; stale references are surfaced by the deferred status +// reconciler instead (ADR-018 Deferred). +type ClusterCreationPolicyValidator struct { + Client client.Client + APIReader client.Reader +} + +// +kubebuilder:webhook:path=/validate-butler-butlerlabs-dev-v1alpha1-clustercreationpolicy,mutating=false,failurePolicy=fail,sideEffects=None,groups=butler.butlerlabs.dev,resources=clustercreationpolicies,verbs=create;update,versions=v1alpha1,name=vclustercreationpolicy.kb.io,admissionReviewVersions=v1 + +// Handle implements admission.Handler. +func (v *ClusterCreationPolicyValidator) Handle(ctx context.Context, req admission.Request) admission.Response { + log.FromContext(ctx).Info("ccp admission request received", + "op", string(req.Operation), + "name", req.Name, + "user", req.UserInfo.Username, + ) + switch req.Operation { + case admissionv1.Create, admissionv1.Update: + policy := &butlerv1alpha1.ClusterCreationPolicy{} + if err := json.Unmarshal(req.Object.Raw, policy); err != nil { + return admission.Errored(http.StatusBadRequest, fmt.Errorf("decode ClusterCreationPolicy: %w", err)) + } + if err := v.validate(ctx, policy); err != nil { + return admission.Denied(err.Error()) + } + return admission.Allowed("") + default: + return admission.Allowed("") + } +} + +// validate runs the four referential and structural checks. Returns nil +// when the policy is admissible. +func (v *ClusterCreationPolicyValidator) validate(ctx context.Context, policy *butlerv1alpha1.ClusterCreationPolicy) error { + var errs field.ErrorList + + errs = append(errs, v.validateScopeReferences(ctx, policy)...) + errs = append(errs, validateOptionRules(policy)...) + conflictErrs, err := v.detectIntraTierConflict(ctx, policy) + if err != nil { + return fmt.Errorf("scan existing policies for conflict: %w", err) + } + errs = append(errs, conflictErrs...) + + if len(errs) == 0 { + return nil + } + return errs.ToAggregate() +} + +// validateScopeReferences checks that any referenced Team and +// environment exist. Uses APIReader (uncached) so a just-created Team +// is visible. +func (v *ClusterCreationPolicyValidator) validateScopeReferences(ctx context.Context, policy *butlerv1alpha1.ClusterCreationPolicy) field.ErrorList { + var errs field.ErrorList + scope := policy.Spec.Scope + + if scope.Team != nil { + team := &butlerv1alpha1.Team{} + if err := v.APIReader.Get(ctx, types.NamespacedName{Name: scope.Team.TeamRef.Name}, team); err != nil { + if apierrors.IsNotFound(err) { + errs = append(errs, field.NotFound( + field.NewPath("spec", "scope", "team", "teamRef", "name"), + scope.Team.TeamRef.Name, + )) + } else { + errs = append(errs, field.InternalError( + field.NewPath("spec", "scope", "team", "teamRef", "name"), + fmt.Errorf("fetch team: %w", err), + )) + } + } + return errs + } + + if scope.TeamAndEnvironment != nil { + team := &butlerv1alpha1.Team{} + teamPath := field.NewPath("spec", "scope", "teamAndEnvironment") + if err := v.APIReader.Get(ctx, types.NamespacedName{Name: scope.TeamAndEnvironment.TeamRef.Name}, team); err != nil { + if apierrors.IsNotFound(err) { + errs = append(errs, field.NotFound( + teamPath.Child("teamRef", "name"), + scope.TeamAndEnvironment.TeamRef.Name, + )) + return errs + } + errs = append(errs, field.InternalError( + teamPath.Child("teamRef", "name"), + fmt.Errorf("fetch team: %w", err), + )) + return errs + } + envFound := false + for _, env := range team.Spec.Environments { + if env.Name == scope.TeamAndEnvironment.EnvironmentName { + envFound = true + break + } + } + if !envFound { + errs = append(errs, field.NotFound( + teamPath.Child("environmentName"), + scope.TeamAndEnvironment.EnvironmentName, + )) + } + } + return errs +} + +// validOptionTypes is the closed v1 set per ADR-018 Decision section 4. +// CRD OpenAPI v3 schema enforces enum validation on map values but not +// on map keys, so kubebuilder Enum on OptionType does not stop unknown +// keys from passing CRD admission. The webhook closes that gap. +var validOptionTypes = map[butlerv1alpha1.OptionType]bool{ + butlerv1alpha1.OptionTypeImage: true, + butlerv1alpha1.OptionTypeNetwork: true, + butlerv1alpha1.OptionTypeCluster: true, + butlerv1alpha1.OptionTypeStorageContainer: true, +} + +// validateOptionRules enforces the mode-specific value requirements that +// produce better operator-facing error messages than CRD schema CEL. +// Per ADR-018 Decision section 7. +func validateOptionRules(policy *butlerv1alpha1.ClusterCreationPolicy) field.ErrorList { + var errs field.ErrorList + for optType, rule := range policy.Spec.Options { + path := field.NewPath("spec", "options").Key(string(optType)) + if !validOptionTypes[optType] { + errs = append(errs, field.NotSupported( + path, + string(optType), + []string{ + string(butlerv1alpha1.OptionTypeImage), + string(butlerv1alpha1.OptionTypeNetwork), + string(butlerv1alpha1.OptionTypeCluster), + string(butlerv1alpha1.OptionTypeStorageContainer), + }, + )) + continue + } + switch rule.Mode { + case butlerv1alpha1.OptionModePin: + if len(rule.Values) != 1 { + errs = append(errs, field.Invalid( + path.Child("values"), + rule.Values, + fmt.Sprintf("mode \"pin\" requires exactly one value, got %d", len(rule.Values)), + )) + } + case butlerv1alpha1.OptionModeAllowList: + if len(rule.Values) == 0 { + errs = append(errs, field.Required( + path.Child("values"), + "at least one value is required when mode is \"allowList\"", + )) + } + case butlerv1alpha1.OptionModeDefault: + if rule.Default == "" { + errs = append(errs, field.Required( + path.Child("default"), + "default value is required when mode is \"default\"", + )) + } + case butlerv1alpha1.OptionModeRecommended: + if len(rule.Values) == 0 { + errs = append(errs, field.Required( + path.Child("values"), + "at least one value is required when mode is \"recommended\"", + )) + } + } + } + return errs +} + +// detectIntraTierConflict scans existing policies for a conflict with +// the policy being admitted. Conflict scope per ADR-018 Decision section +// 6 step 4: same tier (platformWide vs team vs teamAndEnvironment), +// overlapping targetProviders, AND shared option-type keys. +func (v *ClusterCreationPolicyValidator) detectIntraTierConflict(ctx context.Context, policy *butlerv1alpha1.ClusterCreationPolicy) (field.ErrorList, error) { + var errs field.ErrorList + existing := &butlerv1alpha1.ClusterCreationPolicyList{} + if err := v.Client.List(ctx, existing); err != nil { + return nil, err + } + tier := tierOfScope(policy.Spec.Scope) + for i := range existing.Items { + other := &existing.Items[i] + if other.Name == policy.Name { + continue + } + if tierOfScope(other.Spec.Scope) != tier { + continue + } + if !sameScopeTarget(policy.Spec.Scope, other.Spec.Scope) { + continue + } + if !providersOverlap(policy.Spec.TargetProviders, other.Spec.TargetProviders) { + continue + } + shared := sharedOptionTypes(policy.Spec.Options, other.Spec.Options) + if len(shared) == 0 { + continue + } + errs = append(errs, field.Forbidden( + field.NewPath("spec", "options"), + fmt.Sprintf("conflicts with ClusterCreationPolicy %q on option types %v at the same tier", other.Name, shared), + )) + } + return errs, nil +} + +type scopeTier int + +const ( + stPlatformWide scopeTier = iota + 1 + stTeam + stTeamAndEnv +) + +func tierOfScope(s butlerv1alpha1.PolicyScope) scopeTier { + switch { + case s.TeamAndEnvironment != nil: + return stTeamAndEnv + case s.Team != nil: + return stTeam + case s.PlatformWide != nil: + return stPlatformWide + } + return 0 +} + +func sameScopeTarget(a, b butlerv1alpha1.PolicyScope) bool { + switch { + case a.PlatformWide != nil && b.PlatformWide != nil: + return true + case a.Team != nil && b.Team != nil: + return a.Team.TeamRef.Name == b.Team.TeamRef.Name + case a.TeamAndEnvironment != nil && b.TeamAndEnvironment != nil: + return a.TeamAndEnvironment.TeamRef.Name == b.TeamAndEnvironment.TeamRef.Name && + a.TeamAndEnvironment.EnvironmentName == b.TeamAndEnvironment.EnvironmentName + } + return false +} + +func providersOverlap(a, b []butlerv1alpha1.ProviderType) bool { + if len(a) == 0 || len(b) == 0 { + return true + } + for _, x := range a { + for _, y := range b { + if x == y { + return true + } + } + } + return false +} + +func sharedOptionTypes(a, b map[butlerv1alpha1.OptionType]butlerv1alpha1.OptionRule) []butlerv1alpha1.OptionType { + var shared []butlerv1alpha1.OptionType + for k := range a { + if _, ok := b[k]; ok { + shared = append(shared, k) + } + } + return shared +} + +// SetupWebhookWithManager registers the ClusterCreationPolicy validating +// webhook with the manager. +func (v *ClusterCreationPolicyValidator) SetupWebhookWithManager(mgr ctrl.Manager) error { + v.Client = mgr.GetClient() + v.APIReader = mgr.GetAPIReader() + mgr.GetWebhookServer().Register( + "/validate-butler-butlerlabs-dev-v1alpha1-clustercreationpolicy", + &admission.Webhook{Handler: v}, + ) + return nil +} diff --git a/internal/webhook/clustercreationpolicy_webhook_test.go b/internal/webhook/clustercreationpolicy_webhook_test.go new file mode 100644 index 0000000..d15abb8 --- /dev/null +++ b/internal/webhook/clustercreationpolicy_webhook_test.go @@ -0,0 +1,115 @@ +/* +Copyright 2026 The Butler Authors. + +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 webhook + +import ( + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + butlerv1alpha1 "github.com/butlerdotdev/butler-api/api/v1alpha1" +) + +// TestValidateOptionRules_UnknownOptionType verifies the CRD schema gap +// where kubebuilder Enum on OptionType applies to map values but not to +// map keys. The webhook closes that gap. +func TestValidateOptionRules_UnknownOptionType(t *testing.T) { + cases := []struct { + name string + policy *butlerv1alpha1.ClusterCreationPolicy + wantErr bool + errContains string + }{ + { + name: "known option type passes", + policy: &butlerv1alpha1.ClusterCreationPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "ok-image-pin"}, + Spec: butlerv1alpha1.ClusterCreationPolicySpec{ + Scope: butlerv1alpha1.PolicyScope{ + PlatformWide: &butlerv1alpha1.PlatformWideScope{}, + }, + TargetProviders: []butlerv1alpha1.ProviderType{butlerv1alpha1.ProviderTypeNutanix}, + Options: map[butlerv1alpha1.OptionType]butlerv1alpha1.OptionRule{ + butlerv1alpha1.OptionTypeImage: { + Mode: butlerv1alpha1.OptionModePin, + Values: []string{"img-uuid"}, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "unknown option type rejected", + policy: &butlerv1alpha1.ClusterCreationPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "bad-unknown-type"}, + Spec: butlerv1alpha1.ClusterCreationPolicySpec{ + Scope: butlerv1alpha1.PolicyScope{ + PlatformWide: &butlerv1alpha1.PlatformWideScope{}, + }, + TargetProviders: []butlerv1alpha1.ProviderType{butlerv1alpha1.ProviderTypeNutanix}, + Options: map[butlerv1alpha1.OptionType]butlerv1alpha1.OptionRule{ + butlerv1alpha1.OptionType("unknownOptionType"): { + Mode: butlerv1alpha1.OptionModePin, + Values: []string{"v"}, + }, + }, + }, + }, + wantErr: true, + errContains: "Unsupported value", + }, + { + name: "pin with two values rejected", + policy: &butlerv1alpha1.ClusterCreationPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "bad-pin-two-values"}, + Spec: butlerv1alpha1.ClusterCreationPolicySpec{ + Scope: butlerv1alpha1.PolicyScope{ + PlatformWide: &butlerv1alpha1.PlatformWideScope{}, + }, + Options: map[butlerv1alpha1.OptionType]butlerv1alpha1.OptionRule{ + butlerv1alpha1.OptionTypeImage: { + Mode: butlerv1alpha1.OptionModePin, + Values: []string{"a", "b"}, + }, + }, + }, + }, + wantErr: true, + errContains: "exactly one value", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + errs := validateOptionRules(tc.policy) + if tc.wantErr { + if len(errs) == 0 { + t.Fatalf("expected validation error, got none") + } + if tc.errContains != "" && !strings.Contains(errs.ToAggregate().Error(), tc.errContains) { + t.Errorf("error message %q does not contain %q", errs.ToAggregate().Error(), tc.errContains) + } + return + } + if len(errs) > 0 { + t.Fatalf("expected no error, got: %s", errs.ToAggregate().Error()) + } + }) + } +} diff --git a/internal/webhook/tenantcluster_webhook.go b/internal/webhook/tenantcluster_webhook.go index 211dac8..1b02859 100644 --- a/internal/webhook/tenantcluster_webhook.go +++ b/internal/webhook/tenantcluster_webhook.go @@ -35,6 +35,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" butlerv1alpha1 "github.com/butlerdotdev/butler-api/api/v1alpha1" + policypkg "github.com/butlerdotdev/butler-api/pkg/policy" ) const defaultProviderConfigNamespace = "butler-system" @@ -229,6 +230,14 @@ func (v *TenantClusterValidator) validateCreateUpdate(ctx context.Context, tc, o } } + // ADR-018: enforce ClusterCreationPolicy at admission time. Runs + // after PC fetch because provider type drives field-map resolution. + policyErrs, err := v.validatePolicy(ctx, tc, pc.Spec.Provider) + if err != nil { + return nil, err + } + allErrs = append(allErrs, policyErrs...) + // Enforce per-team cluster limit. Create-only: on update the TC is // already counted and re-simulation would deadlock operations when // the cap is reduced below existing usage. @@ -650,6 +659,44 @@ func (v *TenantClusterValidator) validateEnvironment(ctx context.Context, tc, ol return allErrs, nil } +// validatePolicy resolves applicable ClusterCreationPolicy resources for +// the TC's (team, environment, provider) tuple and rejects when the TC +// spec violates a pin or allowList rule. default and recommended modes +// are presentation-only and do not enforce here. See ADR-018 Decision +// section 7. +func (v *TenantClusterValidator) validatePolicy(ctx context.Context, tc *butlerv1alpha1.TenantCluster, providerType butlerv1alpha1.ProviderType) (field.ErrorList, error) { + var errs field.ErrorList + + teamName := "" + if tc.Spec.TeamRef != nil { + teamName = tc.Spec.TeamRef.Name + } + envName := tc.Labels[butlerv1alpha1.LabelEnvironment] + + policies := &butlerv1alpha1.ClusterCreationPolicyList{} + if err := v.Client.List(ctx, policies); err != nil { + return nil, fmt.Errorf("list ClusterCreationPolicy: %w", err) + } + if len(policies.Items) == 0 { + return errs, nil + } + + resCtx := policypkg.ResolutionContext{ + TeamName: teamName, + EnvironmentName: envName, + ProviderType: providerType, + } + rules, sources := policypkg.ResolveWithSources(resCtx, policies.Items) + for optType, rule := range rules { + ok, fieldPath, msg := policypkg.ValidateAgainstRule(tc, providerType, optType, rule, sources[optType]) + if !ok { + path := field.NewPath(fieldPath) + errs = append(errs, field.Forbidden(path, msg)) + } + } + return errs, nil +} + // SetupWebhookWithManager registers the TenantCluster validating webhook // with the manager. Uses the raw admission.Handler path (not // CustomValidator) so the handler can read UserInfo from the admission