internal/api/v1/authproxyworkload_webhook.go (240 lines of code) (raw):
// Copyright 2022 Google LLC.
//
// 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 v1
import (
"context"
"fmt"
"path"
"reflect"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/validation"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
apivalidation "k8s.io/apimachinery/pkg/util/validation"
"k8s.io/apimachinery/pkg/util/validation/field"
ctrl "sigs.k8s.io/controller-runtime"
logf "sigs.k8s.io/controller-runtime/pkg/log"
)
// log is for logging in this package.
var authproxyworkloadlog = logf.Log.WithName("authproxyworkload-resource")
func (r *AuthProxyWorkload) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).
For(r).
WithDefaulter(&AuthProxyWorkloadDefaulter{}).
WithValidator(&AuthProxyWorkloadValidator{}).
Complete()
}
// +kubebuilder:webhook:path=/mutate-cloudsql-cloud-google-com-v1-authproxyworkload,mutating=true,failurePolicy=fail,sideEffects=None,groups=cloudsql.cloud.google.com,resources=authproxyworkloads,verbs=create;update,versions=v1,name=mauthproxyworkload.kb.io,admissionReviewVersions=v1
type AuthProxyWorkloadDefaulter struct {
}
// Default implements webhook.Defaulter so a webhook will be registered for the type
func (*AuthProxyWorkloadDefaulter) Default(_ context.Context, obj runtime.Object) error {
r, ok := obj.(*AuthProxyWorkload)
if !ok {
return fmt.Errorf("expected an AuthProxyWorkload object but got %T", obj)
}
authproxyworkloadlog.Info("default", "name", r.Name)
if r.Spec.AuthProxyContainer != nil &&
r.Spec.AuthProxyContainer.RolloutStrategy == "" {
r.Spec.AuthProxyContainer.RolloutStrategy = WorkloadStrategy
}
return nil
}
// +kubebuilder:webhook:path=/validate-cloudsql-cloud-google-com-v1-authproxyworkload,mutating=false,failurePolicy=fail,sideEffects=None,groups=cloudsql.cloud.google.com,resources=authproxyworkloads,verbs=create;update,versions=v1,name=vauthproxyworkload.kb.io,admissionReviewVersions=v1
type AuthProxyWorkloadValidator struct {
}
// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
func (*AuthProxyWorkloadValidator) ValidateCreate(_ context.Context, obj runtime.Object) (warnings admission.Warnings, err error) {
r, ok := obj.(*AuthProxyWorkload)
if !ok {
return nil, fmt.Errorf("expected an AuthProxyWorkload object but got %T", obj)
}
allErrs := r.validate()
if len(allErrs) > 0 {
return nil, apierrors.NewInvalid(
schema.GroupKind{
Group: GroupVersion.Group,
Kind: "AuthProxyWorkload"},
r.Name, allErrs)
}
return nil, nil
}
// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
func (*AuthProxyWorkloadValidator) ValidateUpdate(_ context.Context, old, newObj runtime.Object) (warnings admission.Warnings, err error) {
o, ok := old.(*AuthProxyWorkload)
if !ok {
return nil, fmt.Errorf("bad request, expected old to be an AuthProxyWorkload")
}
r, ok := newObj.(*AuthProxyWorkload)
if !ok {
return nil, fmt.Errorf("expected an AuthProxyWorkload object but got %T", newObj)
}
allErrs := r.validate()
allErrs = append(allErrs, r.validateUpdateFrom(o)...)
if len(allErrs) > 0 {
return nil, apierrors.NewInvalid(
schema.GroupKind{
Group: GroupVersion.Group,
Kind: "AuthProxyWorkload"},
r.Name, allErrs)
}
return nil, nil
}
// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
func (*AuthProxyWorkloadValidator) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) {
return nil, nil
}
func (r *AuthProxyWorkload) validate() field.ErrorList {
var allErrs field.ErrorList
allErrs = append(allErrs, validation.ValidateLabelName(r.Name, field.NewPath("metadata", "name"))...)
allErrs = append(allErrs, validateWorkload(&r.Spec.Workload, field.NewPath("spec", "workload"))...)
allErrs = append(allErrs, validateInstances(&r.Spec.Instances, field.NewPath("spec", "instances"))...)
allErrs = append(allErrs, validateContainer(r.Spec.AuthProxyContainer, field.NewPath("spec", "authProxyContainer"))...)
return allErrs
}
func validateContainer(spec *AuthProxyContainerSpec, f *field.Path) field.ErrorList {
if spec == nil {
return nil
}
var allErrs field.ErrorList
if spec.AdminServer != nil {
if len(spec.AdminServer.EnableAPIs) == 0 {
allErrs = append(allErrs, field.Invalid(
f.Child("adminServer", "enableAPIs"), nil,
"enableAPIs must have at least one valid element: Debug or QuitQuitQuit"))
}
for i, v := range spec.AdminServer.EnableAPIs {
if v != "Debug" && v != "QuitQuitQuit" {
allErrs = append(allErrs, field.Invalid(
f.Child("adminServer", "enableAPIs", fmt.Sprintf("%d", i)), v,
"enableAPIs may contain the values \"Debug\" or \"QuitQuitQuit\""))
}
}
}
if spec.AdminServer != nil {
errors := apivalidation.IsValidPortNum(int(spec.AdminServer.Port))
for _, e := range errors {
allErrs = append(allErrs, field.Invalid(
f.Child("adminServer", "port"),
spec.AdminServer.Port, e))
}
}
return allErrs
}
// validateUpdateFrom checks that an update to an AuthProxyWorkload resource
// adheres to these rules:
// - No changes to the workload selector
// - No changes to the RolloutStrategy
func (r *AuthProxyWorkload) validateUpdateFrom(op *AuthProxyWorkload) field.ErrorList {
var allErrs field.ErrorList
if r.Spec.Workload.Kind != op.Spec.Workload.Kind {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "workload", "kind"), r.Spec.Workload.Kind,
"kind cannot be changed on update"))
}
if r.Spec.Workload.Name != op.Spec.Workload.Name {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "workload", "name"), r.Spec.Workload.Name,
"kind cannot be changed on update"))
}
if selectorNotEqual(r.Spec.Workload.Selector, op.Spec.Workload.Selector) {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "workload", "selector"), r.Spec.Workload.Selector,
"selector cannot be changed on update"))
}
allErrs = append(allErrs, validateRolloutStrategyChange(r.Spec.AuthProxyContainer, op.Spec.AuthProxyContainer)...)
return allErrs
}
// validateRolloutStrategyChange ensures that the rollout strategy does not
// change on update, taking default values into account.
func validateRolloutStrategyChange(c *AuthProxyContainerSpec, oc *AuthProxyContainerSpec) []*field.Error {
var allErrs field.ErrorList
var (
s = WorkloadStrategy
os = WorkloadStrategy
)
if c != nil && c.RolloutStrategy != "" {
s = c.RolloutStrategy
}
if oc != nil && oc.RolloutStrategy != "" {
os = oc.RolloutStrategy
}
if s != os {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "authProxyContainer", "rolloutStrategy"), s,
fmt.Sprintf("rolloutStrategy cannot be changed on update from %s", os)))
}
return allErrs
}
func selectorNotEqual(s *metav1.LabelSelector, os *metav1.LabelSelector) bool {
if s == nil && os == nil {
return false
}
if s != nil && os != nil {
return !reflect.DeepEqual(s, os)
}
return true
}
var supportedKinds = []string{"CronJob", "Job", "StatefulSet", "Deployment", "DaemonSet", "ReplicaSet", "Pod"}
// validateWorkload ensures that the WorkloadSelectorSpec follows these rules:
// - Either Name or Selector is set
// - Kind is one of the supported kinds: "CronJob", "Job", "StatefulSet",
// "Deployment", "DaemonSet", "ReplicaSet", "Pod"
// - Selector is valid according to the k8s validation rules for LabelSelector
func validateWorkload(spec *WorkloadSelectorSpec, f *field.Path) field.ErrorList {
var errs field.ErrorList
if spec.Selector != nil {
verr := validation.ValidateLabelSelector(spec.Selector, validation.LabelSelectorValidationOptions{}, f.Child("selector"))
errs = append(errs, verr...)
}
if spec.Name != "" && spec.Selector != nil {
errs = append(errs, field.Invalid(f.Child("name"), spec,
"WorkloadSelectorSpec must specify either name or selector. Both were set."))
}
if spec.Name == "" && spec.Selector == nil {
errs = append(errs, field.Invalid(f.Child("name"), spec,
"WorkloadSelectorSpec must specify either name or selector. Neither was set."))
}
_, gk := schema.ParseKindArg(spec.Kind)
var found bool
for _, kind := range supportedKinds {
if kind == gk.Kind {
found = true
break
}
}
if !found {
errs = append(errs, field.Invalid(f.Child("kind"), spec.Kind,
fmt.Sprintf("Kind was %q, must be one of CronJob, Job, StatefulSet, Deployment, DaemonSet or Pod", gk.Kind)))
}
return errs
}
// validateInstances ensures that InstanceSpec follows these rule:
// - There is at least 1 InstanceSpec
// - portEnvName, hostEnvName, and unixSocketPathEnvName have values that adhere
// to the standard k8s EnvName field validation.
// - Port has a valid port number according to the standard k8s Port field
// validation.
// - UnixSocketPath contains an absolute path.
// - The configuration clearly specifies either a TCP or a Unix socket but not
// both.
func validateInstances(spec *[]InstanceSpec, f *field.Path) field.ErrorList {
var errs field.ErrorList
if len(*spec) == 0 {
errs = append(errs, field.Invalid(f,
nil,
"at least one database instance must be declared"))
return errs
}
for i, inst := range *spec {
ff := f.Child(fmt.Sprintf("%d", i))
if inst.Port != nil {
for _, s := range apivalidation.IsValidPortNum(int(*inst.Port)) {
errs = append(errs, field.Invalid(ff.Child("port"), inst.Port, s))
}
}
errs = append(errs, validateEnvName(ff.Child("portEnvName"),
inst.PortEnvName)...)
errs = append(errs, validateEnvName(ff.Child("hostEnvName"),
inst.HostEnvName)...)
errs = append(errs, validateEnvName(ff.Child("unixSocketPathEnvName"),
inst.UnixSocketPathEnvName)...)
if inst.UnixSocketPath != "" && !path.IsAbs(inst.UnixSocketPath) {
errs = append(errs, field.Invalid(ff.Child("unixSocketPath"),
inst.UnixSocketPath, "must be an absolute path"))
}
if inst.UnixSocketPath != "" && (inst.Port != nil || inst.PortEnvName != "") {
errs = append(errs, field.Invalid(ff.Child("unixSocketPath"),
inst.UnixSocketPath,
"unixSocketPath cannot be set when portEnvName or port are set. Databases can be configured to listen for either TCP or Unix socket connections, not both"))
}
if inst.UnixSocketPath == "" && inst.Port == nil && inst.PortEnvName == "" {
errs = append(errs, field.Invalid(f,
inst.UnixSocketPath,
"instance must specify at least one of the following: portEnvName, port, or unixSocketPath"))
}
}
return errs
}
func validateEnvName(f *field.Path, envName string) field.ErrorList {
var errs field.ErrorList
if envName != "" {
for _, s := range apivalidation.IsEnvVarName(envName) {
errs = append(errs, field.Invalid(f, envName, s))
}
}
return errs
}