sigs.k8s.io/controller-tools@v0.15.1-0.20240515195456-85686cb69316/pkg/webhook/parser.go (about)

     1  /*
     2  Copyright 2018 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  // Package webhook contains libraries for generating webhookconfig manifests
    18  // from markers in Go source files.
    19  //
    20  // The markers take the form:
    21  //
    22  //	+kubebuilder:webhook:webhookVersions=<[]string>,failurePolicy=<string>,matchPolicy=<string>,groups=<[]string>,resources=<[]string>,verbs=<[]string>,versions=<[]string>,name=<string>,path=<string>,mutating=<bool>,sideEffects=<string>,timeoutSeconds=<int>,admissionReviewVersions=<[]string>,reinvocationPolicy=<string>
    23  package webhook
    24  
    25  import (
    26  	"fmt"
    27  	"sort"
    28  	"strings"
    29  
    30  	admissionregv1 "k8s.io/api/admissionregistration/v1"
    31  	"k8s.io/apimachinery/pkg/runtime/schema"
    32  	"k8s.io/apimachinery/pkg/util/sets"
    33  
    34  	"sigs.k8s.io/controller-tools/pkg/genall"
    35  	"sigs.k8s.io/controller-tools/pkg/markers"
    36  )
    37  
    38  // The default {Mutating,Validating}WebhookConfiguration version to generate.
    39  const (
    40  	v1                    = "v1"
    41  	defaultWebhookVersion = v1
    42  )
    43  
    44  var (
    45  	// ConfigDefinition s a marker for defining Webhook manifests.
    46  	// Call ToWebhook on the value to get a Kubernetes Webhook.
    47  	ConfigDefinition = markers.Must(markers.MakeDefinition("kubebuilder:webhook", markers.DescribesPackage, Config{}))
    48  )
    49  
    50  // supportedWebhookVersions returns currently supported API version of {Mutating,Validating}WebhookConfiguration.
    51  func supportedWebhookVersions() []string {
    52  	return []string{defaultWebhookVersion}
    53  }
    54  
    55  // +controllertools:marker:generateHelp:category=Webhook
    56  
    57  // Config specifies how a webhook should be served.
    58  //
    59  // It specifies only the details that are intrinsic to the application serving
    60  // it (e.g. the resources it can handle, or the path it serves on).
    61  type Config struct {
    62  	// Mutating marks this as a mutating webhook (it's validating only if false)
    63  	//
    64  	// Mutating webhooks are allowed to change the object in their response,
    65  	// and are called *before* all validating webhooks.  Mutating webhooks may
    66  	// choose to reject an object, similarly to a validating webhook.
    67  	Mutating bool
    68  	// FailurePolicy specifies what should happen if the API server cannot reach the webhook.
    69  	//
    70  	// It may be either "ignore" (to skip the webhook and continue on) or "fail" (to reject
    71  	// the object in question).
    72  	FailurePolicy string
    73  	// MatchPolicy defines how the "rules" list is used to match incoming requests.
    74  	// Allowed values are "Exact" (match only if it exactly matches the specified rule)
    75  	// or "Equivalent" (match a request if it modifies a resource listed in rules, even via another API group or version).
    76  	MatchPolicy string `marker:",optional"`
    77  	// SideEffects specify whether calling the webhook will have side effects.
    78  	// This has an impact on dry runs and `kubectl diff`: if the sideEffect is "Unknown" (the default) or "Some", then
    79  	// the API server will not call the webhook on a dry-run request and fails instead.
    80  	// If the value is "None", then the webhook has no side effects and the API server will call it on dry-run.
    81  	// If the value is "NoneOnDryRun", then the webhook is responsible for inspecting the "dryRun" property of the
    82  	// AdmissionReview sent in the request, and avoiding side effects if that value is "true."
    83  	SideEffects string `marker:",optional"`
    84  	// TimeoutSeconds allows configuring how long the API server should wait for a webhook to respond before treating the call as a failure.
    85  	// If the timeout expires before the webhook responds, the webhook call will be ignored or the API call will be rejected based on the failure policy.
    86  	// The timeout value must be between 1 and 30 seconds.
    87  	// The timeout for an admission webhook defaults to 10 seconds.
    88  	TimeoutSeconds int `marker:",optional"`
    89  
    90  	// Groups specifies the API groups that this webhook receives requests for.
    91  	Groups []string
    92  	// Resources specifies the API resources that this webhook receives requests for.
    93  	Resources []string
    94  	// Verbs specifies the Kubernetes API verbs that this webhook receives requests for.
    95  	//
    96  	// Only modification-like verbs may be specified.
    97  	// May be "create", "update", "delete", "connect", or "*" (for all).
    98  	Verbs []string
    99  	// Versions specifies the API versions that this webhook receives requests for.
   100  	Versions []string
   101  
   102  	// Name indicates the name of this webhook configuration. Should be a domain with at least three segments separated by dots
   103  	Name string
   104  
   105  	// Path specifies that path that the API server should connect to this webhook on. Must be
   106  	// prefixed with a '/validate-' or '/mutate-' depending on the type, and followed by
   107  	// $GROUP-$VERSION-$KIND where all values are lower-cased and the periods in the group
   108  	// are substituted for hyphens. For example, a validating webhook path for type
   109  	// batch.tutorial.kubebuilder.io/v1,Kind=CronJob would be
   110  	// /validate-batch-tutorial-kubebuilder-io-v1-cronjob
   111  	Path string `marker:"path,optional"`
   112  
   113  	// WebhookVersions specifies the target API versions of the {Mutating,Validating}WebhookConfiguration objects
   114  	// itself to generate. The only supported value is v1. Defaults to v1.
   115  	WebhookVersions []string `marker:"webhookVersions,optional"`
   116  
   117  	// AdmissionReviewVersions is an ordered list of preferred `AdmissionReview`
   118  	// versions the Webhook expects.
   119  	AdmissionReviewVersions []string `marker:"admissionReviewVersions"`
   120  
   121  	// ReinvocationPolicy allows mutating webhooks to request reinvocation after other mutations
   122  	//
   123  	// To allow mutating admission plugins to observe changes made by other plugins,
   124  	// built-in mutating admission plugins are re-run if a mutating webhook modifies
   125  	// an object, and mutating webhooks can specify a reinvocationPolicy to control
   126  	// whether they are reinvoked as well.
   127  	ReinvocationPolicy string `marker:"reinvocationPolicy,optional"`
   128  
   129  	// URL allows mutating webhooks configuration to specify an external URL when generating
   130  	// the manifests, instead of using the internal service communication. Should be in format of
   131  	// https://address:port/path
   132  	// When this option is specified, the serviceConfig.Service is removed from webhook the manifest.
   133  	// The URL configuration should be between quotes.
   134  	// `url` cannot be specified when `path` is specified.
   135  	URL string `marker:"url,optional"`
   136  }
   137  
   138  // verbToAPIVariant converts a marker's verb to the proper value for the API.
   139  // Unrecognized verbs are passed through.
   140  func verbToAPIVariant(verbRaw string) admissionregv1.OperationType {
   141  	switch strings.ToLower(verbRaw) {
   142  	case strings.ToLower(string(admissionregv1.Create)):
   143  		return admissionregv1.Create
   144  	case strings.ToLower(string(admissionregv1.Update)):
   145  		return admissionregv1.Update
   146  	case strings.ToLower(string(admissionregv1.Delete)):
   147  		return admissionregv1.Delete
   148  	case strings.ToLower(string(admissionregv1.Connect)):
   149  		return admissionregv1.Connect
   150  	case strings.ToLower(string(admissionregv1.OperationAll)):
   151  		return admissionregv1.OperationAll
   152  	default:
   153  		return admissionregv1.OperationType(verbRaw)
   154  	}
   155  }
   156  
   157  // ToMutatingWebhook converts this rule to its Kubernetes API form.
   158  func (c Config) ToMutatingWebhook() (admissionregv1.MutatingWebhook, error) {
   159  	if !c.Mutating {
   160  		return admissionregv1.MutatingWebhook{}, fmt.Errorf("%s is a validating webhook", c.Name)
   161  	}
   162  
   163  	matchPolicy, err := c.matchPolicy()
   164  	if err != nil {
   165  		return admissionregv1.MutatingWebhook{}, err
   166  	}
   167  
   168  	clientConfig, err := c.clientConfig()
   169  	if err != nil {
   170  		return admissionregv1.MutatingWebhook{}, err
   171  	}
   172  
   173  	return admissionregv1.MutatingWebhook{
   174  		Name:                    c.Name,
   175  		Rules:                   c.rules(),
   176  		FailurePolicy:           c.failurePolicy(),
   177  		MatchPolicy:             matchPolicy,
   178  		ClientConfig:            clientConfig,
   179  		SideEffects:             c.sideEffects(),
   180  		TimeoutSeconds:          c.timeoutSeconds(),
   181  		AdmissionReviewVersions: c.AdmissionReviewVersions,
   182  		ReinvocationPolicy:      c.reinvocationPolicy(),
   183  	}, nil
   184  }
   185  
   186  // ToValidatingWebhook converts this rule to its Kubernetes API form.
   187  func (c Config) ToValidatingWebhook() (admissionregv1.ValidatingWebhook, error) {
   188  	if c.Mutating {
   189  		return admissionregv1.ValidatingWebhook{}, fmt.Errorf("%s is a mutating webhook", c.Name)
   190  	}
   191  
   192  	matchPolicy, err := c.matchPolicy()
   193  	if err != nil {
   194  		return admissionregv1.ValidatingWebhook{}, err
   195  	}
   196  
   197  	clientConfig, err := c.clientConfig()
   198  	if err != nil {
   199  		return admissionregv1.ValidatingWebhook{}, err
   200  	}
   201  
   202  	return admissionregv1.ValidatingWebhook{
   203  		Name:                    c.Name,
   204  		Rules:                   c.rules(),
   205  		FailurePolicy:           c.failurePolicy(),
   206  		MatchPolicy:             matchPolicy,
   207  		ClientConfig:            clientConfig,
   208  		SideEffects:             c.sideEffects(),
   209  		TimeoutSeconds:          c.timeoutSeconds(),
   210  		AdmissionReviewVersions: c.AdmissionReviewVersions,
   211  	}, nil
   212  }
   213  
   214  // rules returns the configuration of what operations on what
   215  // resources/subresources a webhook should care about.
   216  func (c Config) rules() []admissionregv1.RuleWithOperations {
   217  	whConfig := admissionregv1.RuleWithOperations{
   218  		Rule: admissionregv1.Rule{
   219  			APIGroups:   c.Groups,
   220  			APIVersions: c.Versions,
   221  			Resources:   c.Resources,
   222  		},
   223  		Operations: make([]admissionregv1.OperationType, len(c.Verbs)),
   224  	}
   225  
   226  	for i, verbRaw := range c.Verbs {
   227  		whConfig.Operations[i] = verbToAPIVariant(verbRaw)
   228  	}
   229  
   230  	// fix the group names, since letting people type "core" is nice
   231  	for i, group := range whConfig.APIGroups {
   232  		if group == "core" {
   233  			whConfig.APIGroups[i] = ""
   234  		}
   235  	}
   236  
   237  	return []admissionregv1.RuleWithOperations{whConfig}
   238  }
   239  
   240  // failurePolicy converts the string value to the proper value for the API.
   241  // Unrecognized values are passed through.
   242  func (c Config) failurePolicy() *admissionregv1.FailurePolicyType {
   243  	var failurePolicy admissionregv1.FailurePolicyType
   244  	switch strings.ToLower(c.FailurePolicy) {
   245  	case strings.ToLower(string(admissionregv1.Ignore)):
   246  		failurePolicy = admissionregv1.Ignore
   247  	case strings.ToLower(string(admissionregv1.Fail)):
   248  		failurePolicy = admissionregv1.Fail
   249  	default:
   250  		failurePolicy = admissionregv1.FailurePolicyType(c.FailurePolicy)
   251  	}
   252  	return &failurePolicy
   253  }
   254  
   255  // matchPolicy converts the string value to the proper value for the API.
   256  func (c Config) matchPolicy() (*admissionregv1.MatchPolicyType, error) {
   257  	var matchPolicy admissionregv1.MatchPolicyType
   258  	switch strings.ToLower(c.MatchPolicy) {
   259  	case strings.ToLower(string(admissionregv1.Exact)):
   260  		matchPolicy = admissionregv1.Exact
   261  	case strings.ToLower(string(admissionregv1.Equivalent)):
   262  		matchPolicy = admissionregv1.Equivalent
   263  	case "":
   264  		return nil, nil
   265  	default:
   266  		return nil, fmt.Errorf("unknown value %q for matchPolicy", c.MatchPolicy)
   267  	}
   268  	return &matchPolicy, nil
   269  }
   270  
   271  // clientConfig returns the client config for a webhook.
   272  func (c Config) clientConfig() (admissionregv1.WebhookClientConfig, error) {
   273  	if (c.Path != "" && c.URL != "") || (c.Path == "" && c.URL == "") {
   274  		return admissionregv1.WebhookClientConfig{}, fmt.Errorf("`url` or `path` markers are required and mutually exclusive")
   275  	}
   276  
   277  	path := c.Path
   278  	if path != "" {
   279  		return admissionregv1.WebhookClientConfig{
   280  			Service: &admissionregv1.ServiceReference{
   281  				Name:      "webhook-service",
   282  				Namespace: "system",
   283  				Path:      &path,
   284  			},
   285  		}, nil
   286  	}
   287  
   288  	url := c.URL
   289  	return admissionregv1.WebhookClientConfig{
   290  		URL: &url,
   291  	}, nil
   292  }
   293  
   294  // sideEffects returns the sideEffects config for a webhook.
   295  func (c Config) sideEffects() *admissionregv1.SideEffectClass {
   296  	var sideEffects admissionregv1.SideEffectClass
   297  	switch strings.ToLower(c.SideEffects) {
   298  	case strings.ToLower(string(admissionregv1.SideEffectClassNone)):
   299  		sideEffects = admissionregv1.SideEffectClassNone
   300  	case strings.ToLower(string(admissionregv1.SideEffectClassNoneOnDryRun)):
   301  		sideEffects = admissionregv1.SideEffectClassNoneOnDryRun
   302  	case strings.ToLower(string(admissionregv1.SideEffectClassSome)):
   303  		sideEffects = admissionregv1.SideEffectClassSome
   304  	case "":
   305  		return nil
   306  	default:
   307  		return nil
   308  	}
   309  	return &sideEffects
   310  }
   311  
   312  // timeoutSeconds returns the timeoutSeconds config for a webhook.
   313  func (c Config) timeoutSeconds() *int32 {
   314  	if c.TimeoutSeconds != 0 {
   315  		timeoutSeconds := int32(c.TimeoutSeconds)
   316  		return &timeoutSeconds
   317  	}
   318  	return nil
   319  }
   320  
   321  // reinvocationPolicy returns the reinvocationPolicy config for a mutating webhook.
   322  func (c Config) reinvocationPolicy() *admissionregv1.ReinvocationPolicyType {
   323  	var reinvocationPolicy admissionregv1.ReinvocationPolicyType
   324  	switch strings.ToLower(c.ReinvocationPolicy) {
   325  	case strings.ToLower(string(admissionregv1.NeverReinvocationPolicy)):
   326  		reinvocationPolicy = admissionregv1.NeverReinvocationPolicy
   327  	case strings.ToLower(string(admissionregv1.IfNeededReinvocationPolicy)):
   328  		reinvocationPolicy = admissionregv1.IfNeededReinvocationPolicy
   329  	default:
   330  		return nil
   331  	}
   332  	return &reinvocationPolicy
   333  }
   334  
   335  // webhookVersions returns the target API versions of the {Mutating,Validating}WebhookConfiguration objects for a webhook.
   336  func (c Config) webhookVersions() ([]string, error) {
   337  	// If WebhookVersions is not specified, we default it to `v1`.
   338  	if len(c.WebhookVersions) == 0 {
   339  		return []string{defaultWebhookVersion}, nil
   340  	}
   341  	supportedWebhookVersions := sets.NewString(supportedWebhookVersions()...)
   342  	for _, version := range c.WebhookVersions {
   343  		if !supportedWebhookVersions.Has(version) {
   344  			return nil, fmt.Errorf("unsupported webhook version: %s", version)
   345  		}
   346  	}
   347  	return sets.NewString(c.WebhookVersions...).UnsortedList(), nil
   348  }
   349  
   350  // +controllertools:marker:generateHelp
   351  
   352  // Generator generates (partial) {Mutating,Validating}WebhookConfiguration objects.
   353  type Generator struct {
   354  	// HeaderFile specifies the header text (e.g. license) to prepend to generated files.
   355  	HeaderFile string `marker:",optional"`
   356  
   357  	// Year specifies the year to substitute for " YEAR" in the header file.
   358  	Year string `marker:",optional"`
   359  }
   360  
   361  func (Generator) RegisterMarkers(into *markers.Registry) error {
   362  	if err := into.Register(ConfigDefinition); err != nil {
   363  		return err
   364  	}
   365  	into.AddHelp(ConfigDefinition, Config{}.Help())
   366  	return nil
   367  }
   368  
   369  func (g Generator) Generate(ctx *genall.GenerationContext) error {
   370  	supportedWebhookVersions := supportedWebhookVersions()
   371  	mutatingCfgs := make(map[string][]admissionregv1.MutatingWebhook, len(supportedWebhookVersions))
   372  	validatingCfgs := make(map[string][]admissionregv1.ValidatingWebhook, len(supportedWebhookVersions))
   373  	for _, root := range ctx.Roots {
   374  		markerSet, err := markers.PackageMarkers(ctx.Collector, root)
   375  		if err != nil {
   376  			root.AddError(err)
   377  		}
   378  
   379  		cfgs := markerSet[ConfigDefinition.Name]
   380  		sort.SliceStable(cfgs, func(i, j int) bool {
   381  			return cfgs[i].(Config).Name < cfgs[j].(Config).Name
   382  		})
   383  
   384  		for _, cfg := range cfgs {
   385  			cfg := cfg.(Config)
   386  			webhookVersions, err := cfg.webhookVersions()
   387  			if err != nil {
   388  				return err
   389  			}
   390  			if cfg.Mutating {
   391  				w, err := cfg.ToMutatingWebhook()
   392  				if err != nil {
   393  					return err
   394  				}
   395  				for _, webhookVersion := range webhookVersions {
   396  					mutatingCfgs[webhookVersion] = append(mutatingCfgs[webhookVersion], w)
   397  				}
   398  			} else {
   399  				w, err := cfg.ToValidatingWebhook()
   400  				if err != nil {
   401  					return err
   402  				}
   403  				for _, webhookVersion := range webhookVersions {
   404  					validatingCfgs[webhookVersion] = append(validatingCfgs[webhookVersion], w)
   405  				}
   406  			}
   407  		}
   408  	}
   409  
   410  	versionedWebhooks := make(map[string][]interface{}, len(supportedWebhookVersions))
   411  	for _, version := range supportedWebhookVersions {
   412  		if cfgs, ok := mutatingCfgs[version]; ok {
   413  			// The only possible version in supportedWebhookVersions is v1,
   414  			// so use it for all versioned types in this context.
   415  			objRaw := &admissionregv1.MutatingWebhookConfiguration{}
   416  			objRaw.SetGroupVersionKind(schema.GroupVersionKind{
   417  				Group:   admissionregv1.SchemeGroupVersion.Group,
   418  				Version: version,
   419  				Kind:    "MutatingWebhookConfiguration",
   420  			})
   421  			objRaw.SetName("mutating-webhook-configuration")
   422  			objRaw.Webhooks = cfgs
   423  			for i := range objRaw.Webhooks {
   424  				// SideEffects is required in admissionregistration/v1, if this is not set or set to `Some` or `Known`,
   425  				// return an error
   426  				if err := checkSideEffectsForV1(objRaw.Webhooks[i].SideEffects); err != nil {
   427  					return err
   428  				}
   429  				// TimeoutSeconds must be nil or between 1 and 30 seconds, otherwise,
   430  				// return an error
   431  				if err := checkTimeoutSeconds(objRaw.Webhooks[i].TimeoutSeconds); err != nil {
   432  					return err
   433  				}
   434  				// AdmissionReviewVersions is required in admissionregistration/v1, if this is not set,
   435  				// return an error
   436  				if len(objRaw.Webhooks[i].AdmissionReviewVersions) == 0 {
   437  					return fmt.Errorf("AdmissionReviewVersions is mandatory for v1 {Mutating,Validating}WebhookConfiguration")
   438  				}
   439  			}
   440  			versionedWebhooks[version] = append(versionedWebhooks[version], objRaw)
   441  		}
   442  
   443  		if cfgs, ok := validatingCfgs[version]; ok {
   444  			// The only possible version in supportedWebhookVersions is v1,
   445  			// so use it for all versioned types in this context.
   446  			objRaw := &admissionregv1.ValidatingWebhookConfiguration{}
   447  			objRaw.SetGroupVersionKind(schema.GroupVersionKind{
   448  				Group:   admissionregv1.SchemeGroupVersion.Group,
   449  				Version: version,
   450  				Kind:    "ValidatingWebhookConfiguration",
   451  			})
   452  			objRaw.SetName("validating-webhook-configuration")
   453  			objRaw.Webhooks = cfgs
   454  			for i := range objRaw.Webhooks {
   455  				// SideEffects is required in admissionregistration/v1, if this is not set or set to `Some` or `Known`,
   456  				// return an error
   457  				if err := checkSideEffectsForV1(objRaw.Webhooks[i].SideEffects); err != nil {
   458  					return err
   459  				}
   460  				// TimeoutSeconds must be nil or between 1 and 30 seconds, otherwise,
   461  				// return an error
   462  				if err := checkTimeoutSeconds(objRaw.Webhooks[i].TimeoutSeconds); err != nil {
   463  					return err
   464  				}
   465  				// AdmissionReviewVersions is required in admissionregistration/v1, if this is not set,
   466  				// return an error
   467  				if len(objRaw.Webhooks[i].AdmissionReviewVersions) == 0 {
   468  					return fmt.Errorf("AdmissionReviewVersions is mandatory for v1 {Mutating,Validating}WebhookConfiguration")
   469  				}
   470  			}
   471  			versionedWebhooks[version] = append(versionedWebhooks[version], objRaw)
   472  		}
   473  	}
   474  
   475  	var headerText string
   476  	if g.HeaderFile != "" {
   477  		headerBytes, err := ctx.ReadFile(g.HeaderFile)
   478  		if err != nil {
   479  			return err
   480  		}
   481  		headerText = string(headerBytes)
   482  	}
   483  	headerText = strings.ReplaceAll(headerText, " YEAR", " "+g.Year)
   484  
   485  	for k, v := range versionedWebhooks {
   486  		var fileName string
   487  		if k == defaultWebhookVersion {
   488  			fileName = "manifests.yaml"
   489  		} else {
   490  			fileName = fmt.Sprintf("manifests.%s.yaml", k)
   491  		}
   492  		if err := ctx.WriteYAML(fileName, headerText, v, genall.WithTransform(genall.TransformRemoveCreationTimestamp)); err != nil {
   493  			return err
   494  		}
   495  	}
   496  	return nil
   497  }
   498  
   499  func checkSideEffectsForV1(sideEffects *admissionregv1.SideEffectClass) error {
   500  	if sideEffects == nil {
   501  		return fmt.Errorf("SideEffects is required for creating v1 {Mutating,Validating}WebhookConfiguration")
   502  	}
   503  	if *sideEffects == admissionregv1.SideEffectClassUnknown ||
   504  		*sideEffects == admissionregv1.SideEffectClassSome {
   505  		return fmt.Errorf("SideEffects should not be set to `Some` or `Unknown` for v1 {Mutating,Validating}WebhookConfiguration")
   506  	}
   507  	return nil
   508  }
   509  
   510  func checkTimeoutSeconds(timeoutSeconds *int32) error {
   511  	if timeoutSeconds != nil && (*timeoutSeconds < 1 || *timeoutSeconds > 30) {
   512  		return fmt.Errorf("TimeoutSeconds must be between 1 and 30 seconds")
   513  	}
   514  	return nil
   515  }