Implement autobackup-operator controllers, tests, CI and packaging
PVCs and CloudNativePG Clusters need S3 buckets and backup schedules
provisioned consistently. This operator watches the
backups.unkin.net/{schedule,destination} annotations on those objects and
provisions everything needed to back them up, with no new CRDs.
- Add a PVC controller that provisions cephrgw ObjectStoreUser/Bucket/BucketAccess,
auto-generates a restic repo-password Secret and creates a k8up Schedule scoped
to the PVC via spec.backup.volumes[].persistentVolumeClaim.claimName.
- Add a CNPG Cluster controller that provisions the same bucket stack, idempotently
patches spec.backup.barmanObjectStore (leaving a user-set destinationPath alone
with a Warning event) and creates a ScheduledBackup.
- Resolve destinations through a ConfigMap lookup table; requeue until the
BucketAccess is Ready before creating schedule resources; own-reference created
resources and retain bucket data by default.
- Add schedule-mapping helpers (k8up 5-field/shortcut pass-through, CNPG 6-field
seconds-first) and deterministic, length-bounded name derivation.
- Add unit tests (schedule mapping, name derivation, destination resolution) and
envtest controller tests for both paths, wiring the external CRDs into envtest.
- Add kubebuilder-generated RBAC, a Dockerfile (distroless/nonroot), Woodpecker
lint/test/build pipelines and a tag-triggered image push to the artifactapi
docker-internal registry, plus a version-bump Makefile and deploy manifests.
This commit is contained in:
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright 2018 The Kubernetes 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 admission
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
"k8s.io/apimachinery/pkg/util/json"
|
||||
)
|
||||
|
||||
// Decoder knows how to decode the contents of an admission
|
||||
// request into a concrete object.
|
||||
type Decoder interface {
|
||||
// Decode decodes the inlined object in the AdmissionRequest into the passed-in runtime.Object.
|
||||
// If you want decode the OldObject in the AdmissionRequest, use DecodeRaw.
|
||||
// It errors out if req.Object.Raw is empty i.e. containing 0 raw bytes.
|
||||
Decode(req Request, into runtime.Object) error
|
||||
|
||||
// DecodeRaw decodes a RawExtension object into the passed-in runtime.Object.
|
||||
// It errors out if rawObj is empty i.e. containing 0 raw bytes.
|
||||
DecodeRaw(rawObj runtime.RawExtension, into runtime.Object) error
|
||||
}
|
||||
|
||||
// decoder knows how to decode the contents of an admission
|
||||
// request into a concrete object.
|
||||
type decoder struct {
|
||||
codecs serializer.CodecFactory
|
||||
}
|
||||
|
||||
// NewDecoder creates a decoder given the runtime.Scheme.
|
||||
func NewDecoder(scheme *runtime.Scheme) Decoder {
|
||||
if scheme == nil {
|
||||
panic("scheme should never be nil")
|
||||
}
|
||||
return &decoder{codecs: serializer.NewCodecFactory(scheme)}
|
||||
}
|
||||
|
||||
// Decode decodes the inlined object in the AdmissionRequest into the passed-in runtime.Object.
|
||||
// If you want decode the OldObject in the AdmissionRequest, use DecodeRaw.
|
||||
// It errors out if req.Object.Raw is empty i.e. containing 0 raw bytes.
|
||||
func (d *decoder) Decode(req Request, into runtime.Object) error {
|
||||
// we error out if rawObj is an empty object.
|
||||
if len(req.Object.Raw) == 0 {
|
||||
return fmt.Errorf("there is no content to decode")
|
||||
}
|
||||
return d.DecodeRaw(req.Object, into)
|
||||
}
|
||||
|
||||
// DecodeRaw decodes a RawExtension object into the passed-in runtime.Object.
|
||||
// It errors out if rawObj is empty i.e. containing 0 raw bytes.
|
||||
func (d *decoder) DecodeRaw(rawObj runtime.RawExtension, into runtime.Object) error {
|
||||
// NB(directxman12): there's a bug/weird interaction between decoders and
|
||||
// the API server where the API server doesn't send a GVK on the embedded
|
||||
// objects, which means the unstructured decoder refuses to decode. It
|
||||
// also means we can't pass the unstructured directly in, since it'll try
|
||||
// and call unstructured's special Unmarshal implementation, which calls
|
||||
// back into that same decoder :-/
|
||||
// See kubernetes/kubernetes#74373.
|
||||
|
||||
// we error out if rawObj is an empty object.
|
||||
if len(rawObj.Raw) == 0 {
|
||||
return fmt.Errorf("there is no content to decode")
|
||||
}
|
||||
if unstructuredInto, isUnstructured := into.(runtime.Unstructured); isUnstructured {
|
||||
// unmarshal into unstructured's underlying object to avoid calling the decoder
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(rawObj.Raw, &object); err != nil {
|
||||
return err
|
||||
}
|
||||
unstructuredInto.SetUnstructuredContent(object)
|
||||
return nil
|
||||
}
|
||||
|
||||
deserializer := d.codecs.UniversalDeserializer()
|
||||
return runtime.DecodeInto(deserializer, rawObj.Raw, into)
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
Copyright 2021 The Kubernetes 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 admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
|
||||
"gomodules.xyz/jsonpatch/v2"
|
||||
admissionv1 "k8s.io/api/admission/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
)
|
||||
|
||||
// Defaulter defines functions for setting defaults on resources.
|
||||
type Defaulter[T runtime.Object] interface {
|
||||
Default(ctx context.Context, obj T) error
|
||||
}
|
||||
|
||||
// CustomDefaulter defines functions for setting defaults on resources.
|
||||
//
|
||||
// Deprecated: CustomDefaulter is deprecated, use Defaulter instead
|
||||
type CustomDefaulter = Defaulter[runtime.Object]
|
||||
|
||||
type defaulterOptions struct {
|
||||
removeUnknownOrOmitableFields bool
|
||||
}
|
||||
|
||||
// DefaulterOption defines the type of a CustomDefaulter's option
|
||||
type DefaulterOption func(*defaulterOptions)
|
||||
|
||||
// DefaulterRemoveUnknownOrOmitableFields makes the defaulter prune fields that are in the json object retrieved by the
|
||||
// webhook but not in the local go type json representation. This happens for example when the CRD in the apiserver has
|
||||
// fields that our go type doesn't know about, because it's outdated, or the field has a zero value and is `omitempty`.
|
||||
func DefaulterRemoveUnknownOrOmitableFields(o *defaulterOptions) {
|
||||
o.removeUnknownOrOmitableFields = true
|
||||
}
|
||||
|
||||
// WithDefaulter creates a new Webhook for a Defaulter interface.
|
||||
func WithDefaulter[T runtime.Object](scheme *runtime.Scheme, defaulter Defaulter[T], opts ...DefaulterOption) *Webhook {
|
||||
options := &defaulterOptions{}
|
||||
for _, o := range opts {
|
||||
o(options)
|
||||
}
|
||||
return &Webhook{
|
||||
Handler: &defaulterForType[T]{
|
||||
defaulter: defaulter,
|
||||
decoder: NewDecoder(scheme),
|
||||
removeUnknownOrOmitableFields: options.removeUnknownOrOmitableFields,
|
||||
new: func() T {
|
||||
var zero T
|
||||
typ := reflect.TypeOf(zero)
|
||||
if typ.Kind() == reflect.Ptr {
|
||||
return reflect.New(typ.Elem()).Interface().(T)
|
||||
}
|
||||
return zero
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// WithCustomDefaulter creates a new Webhook for a CustomDefaulter interface.
|
||||
func WithCustomDefaulter(scheme *runtime.Scheme, obj runtime.Object, defaulter CustomDefaulter, opts ...DefaulterOption) *Webhook {
|
||||
options := &defaulterOptions{}
|
||||
for _, o := range opts {
|
||||
o(options)
|
||||
}
|
||||
return &Webhook{
|
||||
Handler: &defaulterForType[runtime.Object]{
|
||||
defaulter: defaulter,
|
||||
decoder: NewDecoder(scheme),
|
||||
removeUnknownOrOmitableFields: options.removeUnknownOrOmitableFields,
|
||||
new: func() runtime.Object { return obj.DeepCopyObject() },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type defaulterForType[T runtime.Object] struct {
|
||||
defaulter Defaulter[T]
|
||||
decoder Decoder
|
||||
removeUnknownOrOmitableFields bool
|
||||
new func() T
|
||||
}
|
||||
|
||||
// Handle handles admission requests.
|
||||
func (h *defaulterForType[T]) Handle(ctx context.Context, req Request) Response {
|
||||
if h.decoder == nil {
|
||||
panic("decoder should never be nil")
|
||||
}
|
||||
if h.defaulter == nil {
|
||||
panic("defaulter should never be nil")
|
||||
}
|
||||
|
||||
// Always skip when a DELETE operation received in custom mutation handler.
|
||||
if req.Operation == admissionv1.Delete {
|
||||
return Response{AdmissionResponse: admissionv1.AdmissionResponse{
|
||||
Allowed: true,
|
||||
Result: &metav1.Status{
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
ctx = NewContextWithRequest(ctx, req)
|
||||
|
||||
// Get the object in the request
|
||||
obj := h.new()
|
||||
if err := h.decoder.Decode(req, obj); err != nil {
|
||||
return Errored(http.StatusBadRequest, err)
|
||||
}
|
||||
|
||||
originalObj := obj.DeepCopyObject().(T)
|
||||
|
||||
// Default the object
|
||||
if err := h.defaulter.Default(ctx, obj); err != nil {
|
||||
var apiStatus apierrors.APIStatus
|
||||
if errors.As(err, &apiStatus) {
|
||||
return validationResponseFromStatus(false, apiStatus.Status())
|
||||
}
|
||||
return Denied(err.Error())
|
||||
}
|
||||
|
||||
// If the object is not changed, there's no reason to go through the expensive patch calculation below.
|
||||
// Note: While jsonpatch.CreatePatch short-circuits if both byte arrays are equal this is likely never the case.
|
||||
// * json.Marshal that we use below sorts fields alphabetically
|
||||
// * for builtin types the apiserver also sorts alphabetically (but it seems like it adds an empty line at the end)
|
||||
// * for CRDs the apiserver uses the field order in the OpenAPI schema which very likely is not alphabetically sorted
|
||||
// Note: If removeUnknownOrOmitableFields is set we have to compute a patch to remove unknown or omitable fields even
|
||||
// if the objects are equal
|
||||
if !h.removeUnknownOrOmitableFields && reflect.DeepEqual(originalObj, obj) {
|
||||
return Response{
|
||||
AdmissionResponse: admissionv1.AdmissionResponse{
|
||||
Allowed: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Create the patch
|
||||
marshalled, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return Errored(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
handlerResponse := PatchResponseFromRaw(req.Object.Raw, marshalled)
|
||||
if !h.removeUnknownOrOmitableFields {
|
||||
handlerResponse = h.dropSchemeRemovals(handlerResponse, originalObj, req.Object.Raw)
|
||||
}
|
||||
return handlerResponse
|
||||
}
|
||||
|
||||
func (h *defaulterForType[T]) dropSchemeRemovals(r Response, original T, raw []byte) Response {
|
||||
const opRemove = "remove"
|
||||
if !r.Allowed || r.PatchType == nil {
|
||||
return r
|
||||
}
|
||||
|
||||
// If we don't have removals in the patch.
|
||||
if !slices.ContainsFunc(r.Patches, func(o jsonpatch.JsonPatchOperation) bool { return o.Operation == opRemove }) {
|
||||
return r
|
||||
}
|
||||
|
||||
// Get the raw to original patch
|
||||
marshalledOriginal, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
return Errored(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
patchOriginal, err := jsonpatch.CreatePatch(raw, marshalledOriginal)
|
||||
if err != nil {
|
||||
return Errored(http.StatusInternalServerError, err)
|
||||
}
|
||||
removedByScheme := sets.New(slices.DeleteFunc(patchOriginal, func(p jsonpatch.JsonPatchOperation) bool { return p.Operation != opRemove })...)
|
||||
|
||||
r.Patches = slices.DeleteFunc(r.Patches, func(p jsonpatch.JsonPatchOperation) bool {
|
||||
return p.Operation == opRemove && removedByScheme.Has(p)
|
||||
})
|
||||
|
||||
if len(r.Patches) == 0 {
|
||||
r.PatchType = nil
|
||||
}
|
||||
return r
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
Copyright 2018 The Kubernetes 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 admission provides implementation for admission webhook and methods to implement admission webhook handlers.
|
||||
|
||||
See examples/mutatingwebhook.go and examples/validatingwebhook.go for examples of admission webhooks.
|
||||
*/
|
||||
package admission
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
Copyright 2018 The Kubernetes 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 admission
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
v1 "k8s.io/api/admission/v1"
|
||||
"k8s.io/api/admission/v1beta1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
)
|
||||
|
||||
var admissionScheme = runtime.NewScheme()
|
||||
var admissionCodecs = serializer.NewCodecFactory(admissionScheme)
|
||||
|
||||
// adapted from https://github.com/kubernetes/kubernetes/blob/c28c2009181fcc44c5f6b47e10e62dacf53e4da0/staging/src/k8s.io/pod-security-admission/cmd/webhook/server/server.go
|
||||
//
|
||||
// From https://github.com/kubernetes/apiserver/blob/d6876a0600de06fef75968c4641c64d7da499f25/pkg/server/config.go#L433-L442C5:
|
||||
//
|
||||
// 1.5MB is the recommended client request size in byte
|
||||
// the etcd server should accept. See
|
||||
// https://github.com/etcd-io/etcd/blob/release-3.4/embed/config.go#L56.
|
||||
// A request body might be encoded in json, and is converted to
|
||||
// proto when persisted in etcd, so we allow 2x as the largest request
|
||||
// body size to be accepted and decoded in a write request.
|
||||
//
|
||||
// For the admission request, we can infer that it contains at most two objects
|
||||
// (the old and new versions of the object being admitted), each of which can
|
||||
// be at most 3MB in size. For the rest of the request, we can assume that
|
||||
// it will be less than 1MB in size. Therefore, we can set the max request
|
||||
// size to 7MB.
|
||||
// If your use case requires larger max request sizes, please
|
||||
// open an issue (https://github.com/kubernetes-sigs/controller-runtime/issues/new).
|
||||
const maxRequestSize = int64(7 * 1024 * 1024)
|
||||
|
||||
func init() {
|
||||
utilruntime.Must(v1.AddToScheme(admissionScheme))
|
||||
utilruntime.Must(v1beta1.AddToScheme(admissionScheme))
|
||||
}
|
||||
|
||||
var _ http.Handler = &Webhook{}
|
||||
|
||||
func (wh *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if wh.WithContextFunc != nil {
|
||||
ctx = wh.WithContextFunc(ctx, r)
|
||||
}
|
||||
|
||||
if r.Body == nil || r.Body == http.NoBody {
|
||||
err := errors.New("request body is empty")
|
||||
wh.getLogger(nil).Error(err, "bad request")
|
||||
wh.writeResponse(w, Errored(http.StatusBadRequest, err))
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
limitedReader := &io.LimitedReader{R: r.Body, N: maxRequestSize}
|
||||
body, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
wh.getLogger(nil).Error(err, "unable to read the body from the incoming request")
|
||||
wh.writeResponse(w, Errored(http.StatusBadRequest, err))
|
||||
return
|
||||
}
|
||||
if limitedReader.N <= 0 {
|
||||
err := fmt.Errorf("request entity is too large; limit is %d bytes", maxRequestSize)
|
||||
wh.getLogger(nil).Error(err, "unable to read the body from the incoming request; limit reached")
|
||||
wh.writeResponse(w, Errored(http.StatusRequestEntityTooLarge, err))
|
||||
return
|
||||
}
|
||||
|
||||
// verify the content type is accurate
|
||||
if contentType := r.Header.Get("Content-Type"); contentType != "application/json" {
|
||||
err = fmt.Errorf("contentType=%s, expected application/json", contentType)
|
||||
wh.getLogger(nil).Error(err, "unable to process a request with unknown content type")
|
||||
wh.writeResponse(w, Errored(http.StatusBadRequest, err))
|
||||
return
|
||||
}
|
||||
|
||||
// Both v1 and v1beta1 AdmissionReview types are exactly the same, so the v1beta1 type can
|
||||
// be decoded into the v1 type. However the runtime codec's decoder guesses which type to
|
||||
// decode into by type name if an Object's TypeMeta isn't set. By setting TypeMeta of an
|
||||
// unregistered type to the v1 GVK, the decoder will coerce a v1beta1 AdmissionReview to v1.
|
||||
// The actual AdmissionReview GVK will be used to write a typed response in case the
|
||||
// webhook config permits multiple versions, otherwise this response will fail.
|
||||
req := Request{}
|
||||
ar := unversionedAdmissionReview{}
|
||||
// avoid an extra copy
|
||||
ar.Request = &req.AdmissionRequest
|
||||
ar.SetGroupVersionKind(v1.SchemeGroupVersion.WithKind("AdmissionReview"))
|
||||
_, actualAdmRevGVK, err := admissionCodecs.UniversalDeserializer().Decode(body, nil, &ar)
|
||||
if err != nil {
|
||||
wh.getLogger(nil).Error(err, "unable to decode the request")
|
||||
wh.writeResponse(w, Errored(http.StatusBadRequest, err))
|
||||
return
|
||||
}
|
||||
wh.getLogger(&req).V(5).Info("received request")
|
||||
|
||||
wh.writeResponseTyped(w, wh.Handle(ctx, req), actualAdmRevGVK)
|
||||
}
|
||||
|
||||
// writeResponse writes response to w generically, i.e. without encoding GVK information.
|
||||
func (wh *Webhook) writeResponse(w io.Writer, response Response) {
|
||||
wh.writeAdmissionResponse(w, v1.AdmissionReview{Response: &response.AdmissionResponse})
|
||||
}
|
||||
|
||||
// writeResponseTyped writes response to w with GVK set to admRevGVK, which is necessary
|
||||
// if multiple AdmissionReview versions are permitted by the webhook.
|
||||
func (wh *Webhook) writeResponseTyped(w io.Writer, response Response, admRevGVK *schema.GroupVersionKind) {
|
||||
ar := v1.AdmissionReview{
|
||||
Response: &response.AdmissionResponse,
|
||||
}
|
||||
// Default to a v1 AdmissionReview, otherwise the API server may not recognize the request
|
||||
// if multiple AdmissionReview versions are permitted by the webhook config.
|
||||
// TODO(estroz): this should be configurable since older API servers won't know about v1.
|
||||
if admRevGVK == nil || *admRevGVK == (schema.GroupVersionKind{}) {
|
||||
ar.SetGroupVersionKind(v1.SchemeGroupVersion.WithKind("AdmissionReview"))
|
||||
} else {
|
||||
ar.SetGroupVersionKind(*admRevGVK)
|
||||
}
|
||||
wh.writeAdmissionResponse(w, ar)
|
||||
}
|
||||
|
||||
// writeAdmissionResponse writes ar to w.
|
||||
func (wh *Webhook) writeAdmissionResponse(w io.Writer, ar v1.AdmissionReview) {
|
||||
if err := json.NewEncoder(w).Encode(ar); err != nil {
|
||||
wh.getLogger(nil).Error(err, "unable to encode and write the response")
|
||||
// Since the `ar v1.AdmissionReview` is a clear and legal object,
|
||||
// it should not have problem to be marshalled into bytes.
|
||||
// The error here is probably caused by the abnormal HTTP connection,
|
||||
// e.g., broken pipe, so we can only write the error response once,
|
||||
// to avoid endless circular calling.
|
||||
serverError := Errored(http.StatusInternalServerError, err)
|
||||
if err = json.NewEncoder(w).Encode(v1.AdmissionReview{Response: &serverError.AdmissionResponse}); err != nil {
|
||||
wh.getLogger(nil).Error(err, "still unable to encode and write the InternalServerError response")
|
||||
}
|
||||
} else {
|
||||
res := ar.Response
|
||||
if log := wh.getLogger(nil); log.V(5).Enabled() {
|
||||
if res.Result != nil {
|
||||
log = log.WithValues("code", res.Result.Code, "reason", res.Result.Reason, "message", res.Result.Message)
|
||||
}
|
||||
log.V(5).Info("wrote response", "requestID", res.UID, "allowed", res.Allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// unversionedAdmissionReview is used to decode both v1 and v1beta1 AdmissionReview types.
|
||||
type unversionedAdmissionReview struct {
|
||||
v1.AdmissionReview
|
||||
}
|
||||
|
||||
var _ runtime.Object = &unversionedAdmissionReview{}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright 2024 The Kubernetes 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 metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"sigs.k8s.io/controller-runtime/pkg/metrics"
|
||||
)
|
||||
|
||||
var (
|
||||
// WebhookPanics is a prometheus counter metrics which holds the total
|
||||
// number of panics from webhooks.
|
||||
WebhookPanics = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "controller_runtime_webhook_panics_total",
|
||||
Help: "Total number of webhook panics",
|
||||
}, []string{})
|
||||
)
|
||||
|
||||
func init() {
|
||||
metrics.Registry.MustRegister(
|
||||
WebhookPanics,
|
||||
)
|
||||
// Init metric.
|
||||
WebhookPanics.WithLabelValues().Add(0)
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 2018 The Kubernetes 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 admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
jsonpatch "gomodules.xyz/jsonpatch/v2"
|
||||
admissionv1 "k8s.io/api/admission/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
type multiMutating []Handler
|
||||
|
||||
func (hs multiMutating) Handle(ctx context.Context, req Request) Response {
|
||||
patches := []jsonpatch.JsonPatchOperation{}
|
||||
warnings := []string{}
|
||||
for _, handler := range hs {
|
||||
resp := handler.Handle(ctx, req)
|
||||
if !resp.Allowed {
|
||||
return resp
|
||||
}
|
||||
if resp.PatchType != nil && *resp.PatchType != admissionv1.PatchTypeJSONPatch {
|
||||
return Errored(http.StatusInternalServerError,
|
||||
fmt.Errorf("unexpected patch type returned by the handler: %v, only allow: %v",
|
||||
resp.PatchType, admissionv1.PatchTypeJSONPatch))
|
||||
}
|
||||
patches = append(patches, resp.Patches...)
|
||||
warnings = append(warnings, resp.Warnings...)
|
||||
}
|
||||
var err error
|
||||
marshaledPatch, err := json.Marshal(patches)
|
||||
if err != nil {
|
||||
return Errored(http.StatusBadRequest, fmt.Errorf("error when marshaling the patch: %w", err))
|
||||
}
|
||||
return Response{
|
||||
AdmissionResponse: admissionv1.AdmissionResponse{
|
||||
Allowed: true,
|
||||
Result: &metav1.Status{
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
Patch: marshaledPatch,
|
||||
Warnings: warnings,
|
||||
PatchType: func() *admissionv1.PatchType { pt := admissionv1.PatchTypeJSONPatch; return &pt }(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MultiMutatingHandler combines multiple mutating webhook handlers into a single
|
||||
// mutating webhook handler. Handlers are called in sequential order, and the first
|
||||
// `allowed: false` response may short-circuit the rest. Users must take care to
|
||||
// ensure patches are disjoint.
|
||||
func MultiMutatingHandler(handlers ...Handler) Handler {
|
||||
return multiMutating(handlers)
|
||||
}
|
||||
|
||||
type multiValidating []Handler
|
||||
|
||||
func (hs multiValidating) Handle(ctx context.Context, req Request) Response {
|
||||
warnings := []string{}
|
||||
for _, handler := range hs {
|
||||
resp := handler.Handle(ctx, req)
|
||||
if !resp.Allowed {
|
||||
return resp
|
||||
}
|
||||
warnings = append(warnings, resp.Warnings...)
|
||||
}
|
||||
return Response{
|
||||
AdmissionResponse: admissionv1.AdmissionResponse{
|
||||
Allowed: true,
|
||||
Result: &metav1.Status{
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
Warnings: warnings,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MultiValidatingHandler combines multiple validating webhook handlers into a single
|
||||
// validating webhook handler. Handlers are called in sequential order, and the first
|
||||
// `allowed: false` response may short-circuit the rest.
|
||||
func MultiValidatingHandler(handlers ...Handler) Handler {
|
||||
return multiValidating(handlers)
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Copyright 2018 The Kubernetes 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 admission
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
jsonpatch "gomodules.xyz/jsonpatch/v2"
|
||||
admissionv1 "k8s.io/api/admission/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// Allowed constructs a response indicating that the given operation
|
||||
// is allowed (without any patches).
|
||||
func Allowed(message string) Response {
|
||||
return ValidationResponse(true, message)
|
||||
}
|
||||
|
||||
// Denied constructs a response indicating that the given operation
|
||||
// is not allowed.
|
||||
func Denied(message string) Response {
|
||||
return ValidationResponse(false, message)
|
||||
}
|
||||
|
||||
// Patched constructs a response indicating that the given operation is
|
||||
// allowed, and that the target object should be modified by the given
|
||||
// JSONPatch operations.
|
||||
func Patched(message string, patches ...jsonpatch.JsonPatchOperation) Response {
|
||||
resp := Allowed(message)
|
||||
resp.Patches = patches
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// Errored creates a new Response for error-handling a request.
|
||||
func Errored(code int32, err error) Response {
|
||||
return Response{
|
||||
AdmissionResponse: admissionv1.AdmissionResponse{
|
||||
Allowed: false,
|
||||
Result: &metav1.Status{
|
||||
Code: code,
|
||||
Message: err.Error(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidationResponse returns a response for admitting a request.
|
||||
func ValidationResponse(allowed bool, message string) Response {
|
||||
code := http.StatusForbidden
|
||||
reason := metav1.StatusReasonForbidden
|
||||
if allowed {
|
||||
code = http.StatusOK
|
||||
reason = ""
|
||||
}
|
||||
resp := Response{
|
||||
AdmissionResponse: admissionv1.AdmissionResponse{
|
||||
Allowed: allowed,
|
||||
Result: &metav1.Status{
|
||||
Code: int32(code),
|
||||
Reason: reason,
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(message) > 0 {
|
||||
resp.Result.Message = message
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// PatchResponseFromRaw takes 2 byte arrays and returns a new response with json patch.
|
||||
// The original object should be passed in as raw bytes to avoid the roundtripping problem
|
||||
// described in https://github.com/kubernetes-sigs/kubebuilder/issues/510.
|
||||
func PatchResponseFromRaw(original, current []byte) Response {
|
||||
patches, err := jsonpatch.CreatePatch(original, current)
|
||||
if err != nil {
|
||||
return Errored(http.StatusInternalServerError, err)
|
||||
}
|
||||
return Response{
|
||||
Patches: patches,
|
||||
AdmissionResponse: admissionv1.AdmissionResponse{
|
||||
Allowed: true,
|
||||
PatchType: func() *admissionv1.PatchType {
|
||||
if len(patches) == 0 {
|
||||
return nil
|
||||
}
|
||||
pt := admissionv1.PatchTypeJSONPatch
|
||||
return &pt
|
||||
}(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// validationResponseFromStatus returns a response for admitting a request with provided Status object.
|
||||
func validationResponseFromStatus(allowed bool, status metav1.Status) Response {
|
||||
resp := Response{
|
||||
AdmissionResponse: admissionv1.AdmissionResponse{
|
||||
Allowed: allowed,
|
||||
Result: &status,
|
||||
},
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// WithWarnings adds the given warnings to the Response.
|
||||
// If any warnings were already given, they will not be overwritten.
|
||||
func (r Response) WithWarnings(warnings ...string) Response {
|
||||
r.AdmissionResponse.Warnings = append(r.AdmissionResponse.Warnings, warnings...)
|
||||
return r
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
Copyright 2021 The Kubernetes 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 admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
v1 "k8s.io/api/admission/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// Warnings represents warning messages.
|
||||
type Warnings []string
|
||||
|
||||
// Validator defines functions for validating an operation.
|
||||
// The object to be validated is passed into methods as a parameter.
|
||||
type Validator[T runtime.Object] interface {
|
||||
// ValidateCreate validates the object on creation.
|
||||
// The optional warnings will be added to the response as warning messages.
|
||||
// Return an error if the object is invalid.
|
||||
ValidateCreate(ctx context.Context, obj T) (warnings Warnings, err error)
|
||||
|
||||
// ValidateUpdate validates the object on update.
|
||||
// The optional warnings will be added to the response as warning messages.
|
||||
// Return an error if the object is invalid.
|
||||
ValidateUpdate(ctx context.Context, oldObj, newObj T) (warnings Warnings, err error)
|
||||
|
||||
// ValidateDelete validates the object on deletion.
|
||||
// The optional warnings will be added to the response as warning messages.
|
||||
// Return an error if the object is invalid.
|
||||
ValidateDelete(ctx context.Context, obj T) (warnings Warnings, err error)
|
||||
}
|
||||
|
||||
// CustomValidator defines functions for validating an operation.
|
||||
//
|
||||
// Deprecated: CustomValidator is deprecated, use Validator instead
|
||||
type CustomValidator = Validator[runtime.Object]
|
||||
|
||||
// WithValidator creates a new Webhook for validating the provided type.
|
||||
func WithValidator[T runtime.Object](scheme *runtime.Scheme, validator Validator[T]) *Webhook {
|
||||
return &Webhook{
|
||||
Handler: &validatorForType[T]{
|
||||
validator: validator,
|
||||
decoder: NewDecoder(scheme),
|
||||
new: func() T {
|
||||
var zero T
|
||||
typ := reflect.TypeOf(zero)
|
||||
if typ.Kind() == reflect.Ptr {
|
||||
return reflect.New(typ.Elem()).Interface().(T)
|
||||
}
|
||||
return zero
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// WithCustomValidator creates a new Webhook for a CustomValidator.
|
||||
//
|
||||
// Deprecated: WithCustomValidator is deprecated, use WithValidator instead
|
||||
func WithCustomValidator(scheme *runtime.Scheme, obj runtime.Object, validator CustomValidator) *Webhook {
|
||||
return &Webhook{
|
||||
Handler: &validatorForType[runtime.Object]{
|
||||
validator: validator,
|
||||
decoder: NewDecoder(scheme),
|
||||
new: func() runtime.Object { return obj.DeepCopyObject() },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type validatorForType[T runtime.Object] struct {
|
||||
validator Validator[T]
|
||||
decoder Decoder
|
||||
new func() T
|
||||
}
|
||||
|
||||
// Handle handles admission requests.
|
||||
func (h *validatorForType[T]) Handle(ctx context.Context, req Request) Response {
|
||||
if h.decoder == nil {
|
||||
panic("decoder should never be nil")
|
||||
}
|
||||
if h.validator == nil {
|
||||
panic("validator should never be nil")
|
||||
}
|
||||
|
||||
ctx = NewContextWithRequest(ctx, req)
|
||||
|
||||
obj := h.new()
|
||||
|
||||
var err error
|
||||
var warnings []string
|
||||
|
||||
switch req.Operation {
|
||||
case v1.Connect:
|
||||
// No validation for connect requests.
|
||||
// TODO(vincepri): Should we validate CONNECT requests? In what cases?
|
||||
case v1.Create:
|
||||
if err := h.decoder.Decode(req, obj); err != nil {
|
||||
return Errored(http.StatusBadRequest, err)
|
||||
}
|
||||
|
||||
warnings, err = h.validator.ValidateCreate(ctx, obj)
|
||||
case v1.Update:
|
||||
oldObj := h.new()
|
||||
if err := h.decoder.DecodeRaw(req.Object, obj); err != nil {
|
||||
return Errored(http.StatusBadRequest, err)
|
||||
}
|
||||
if err := h.decoder.DecodeRaw(req.OldObject, oldObj); err != nil {
|
||||
return Errored(http.StatusBadRequest, err)
|
||||
}
|
||||
|
||||
warnings, err = h.validator.ValidateUpdate(ctx, oldObj, obj)
|
||||
case v1.Delete:
|
||||
// In reference to PR: https://github.com/kubernetes/kubernetes/pull/76346
|
||||
// OldObject contains the object being deleted
|
||||
if err := h.decoder.DecodeRaw(req.OldObject, obj); err != nil {
|
||||
return Errored(http.StatusBadRequest, err)
|
||||
}
|
||||
|
||||
warnings, err = h.validator.ValidateDelete(ctx, obj)
|
||||
default:
|
||||
return Errored(http.StatusBadRequest, fmt.Errorf("unknown operation %q", req.Operation))
|
||||
}
|
||||
|
||||
// Check the error message first.
|
||||
if err != nil {
|
||||
var apiStatus apierrors.APIStatus
|
||||
if errors.As(err, &apiStatus) {
|
||||
return validationResponseFromStatus(false, apiStatus.Status()).WithWarnings(warnings...)
|
||||
}
|
||||
return Denied(err.Error()).WithWarnings(warnings...)
|
||||
}
|
||||
|
||||
// Return allowed if everything succeeded.
|
||||
return Allowed("").WithWarnings(warnings...)
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
Copyright 2018 The Kubernetes 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 admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
"gomodules.xyz/jsonpatch/v2"
|
||||
admissionv1 "k8s.io/api/admission/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/json"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
"k8s.io/klog/v2"
|
||||
admissionmetrics "sigs.k8s.io/controller-runtime/pkg/webhook/admission/metrics"
|
||||
|
||||
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
||||
"sigs.k8s.io/controller-runtime/pkg/webhook/internal/metrics"
|
||||
)
|
||||
|
||||
var (
|
||||
errUnableToEncodeResponse = errors.New("unable to encode response")
|
||||
)
|
||||
|
||||
// Request defines the input for an admission handler.
|
||||
// It contains information to identify the object in
|
||||
// question (group, version, kind, resource, subresource,
|
||||
// name, namespace), as well as the operation in question
|
||||
// (e.g. Get, Create, etc), and the object itself.
|
||||
type Request struct {
|
||||
admissionv1.AdmissionRequest
|
||||
}
|
||||
|
||||
// Response is the output of an admission handler.
|
||||
// It contains a response indicating if a given
|
||||
// operation is allowed, as well as a set of patches
|
||||
// to mutate the object in the case of a mutating admission handler.
|
||||
type Response struct {
|
||||
// Patches are the JSON patches for mutating webhooks.
|
||||
// Using this instead of setting Response.Patch to minimize
|
||||
// overhead of serialization and deserialization.
|
||||
// Patches set here will override any patches in the response,
|
||||
// so leave this empty if you want to set the patch response directly.
|
||||
Patches []jsonpatch.JsonPatchOperation
|
||||
// AdmissionResponse is the raw admission response.
|
||||
// The Patch field in it will be overwritten by the listed patches.
|
||||
admissionv1.AdmissionResponse
|
||||
}
|
||||
|
||||
// Complete populates any fields that are yet to be set in
|
||||
// the underlying AdmissionResponse, It mutates the response.
|
||||
func (r *Response) Complete(req Request) error {
|
||||
r.UID = req.UID
|
||||
|
||||
// ensure that we have a valid status code
|
||||
if r.Result == nil {
|
||||
r.Result = &metav1.Status{}
|
||||
}
|
||||
if r.Result.Code == 0 {
|
||||
r.Result.Code = http.StatusOK
|
||||
}
|
||||
// TODO(directxman12): do we need to populate this further, and/or
|
||||
// is code actually necessary (the same webhook doesn't use it)
|
||||
|
||||
if len(r.Patches) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
r.Patch, err = json.Marshal(r.Patches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patchType := admissionv1.PatchTypeJSONPatch
|
||||
r.PatchType = &patchType
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handler can handle an AdmissionRequest.
|
||||
type Handler interface {
|
||||
// Handle yields a response to an AdmissionRequest.
|
||||
//
|
||||
// The supplied context is extracted from the received http.Request, allowing wrapping
|
||||
// http.Handlers to inject values into and control cancelation of downstream request processing.
|
||||
Handle(context.Context, Request) Response
|
||||
}
|
||||
|
||||
// HandlerFunc implements Handler interface using a single function.
|
||||
type HandlerFunc func(context.Context, Request) Response
|
||||
|
||||
var _ Handler = HandlerFunc(nil)
|
||||
|
||||
// Handle process the AdmissionRequest by invoking the underlying function.
|
||||
func (f HandlerFunc) Handle(ctx context.Context, req Request) Response {
|
||||
return f(ctx, req)
|
||||
}
|
||||
|
||||
// Webhook represents each individual webhook.
|
||||
//
|
||||
// It must be registered with a webhook.Server or
|
||||
// populated by StandaloneWebhook to be ran on an arbitrary HTTP server.
|
||||
type Webhook struct {
|
||||
// Handler actually processes an admission request returning whether it was allowed or denied,
|
||||
// and potentially patches to apply to the handler.
|
||||
Handler Handler
|
||||
|
||||
// RecoverPanic indicates whether the panic caused by webhook should be recovered.
|
||||
// Defaults to true.
|
||||
RecoverPanic *bool
|
||||
|
||||
// WithContextFunc will allow you to take the http.Request.Context() and
|
||||
// add any additional information such as passing the request path or
|
||||
// headers thus allowing you to read them from within the handler
|
||||
WithContextFunc func(context.Context, *http.Request) context.Context
|
||||
|
||||
// LogConstructor is used to construct a logger for logging messages during webhook calls
|
||||
// based on the given base logger (which might carry more values like the webhook's path).
|
||||
// Note: LogConstructor has to be able to handle nil requests as we are also using it
|
||||
// outside the context of requests.
|
||||
LogConstructor func(base logr.Logger, req *Request) logr.Logger
|
||||
|
||||
setupLogOnce sync.Once
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
// WithRecoverPanic takes a bool flag which indicates whether the panic caused by webhook should be recovered.
|
||||
// Defaults to true.
|
||||
func (wh *Webhook) WithRecoverPanic(recoverPanic bool) *Webhook {
|
||||
wh.RecoverPanic = &recoverPanic
|
||||
return wh
|
||||
}
|
||||
|
||||
// Handle processes AdmissionRequest.
|
||||
// If the webhook is mutating type, it delegates the AdmissionRequest to each handler and merge the patches.
|
||||
// If the webhook is validating type, it delegates the AdmissionRequest to each handler and
|
||||
// deny the request if anyone denies.
|
||||
func (wh *Webhook) Handle(ctx context.Context, req Request) (response Response) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
admissionmetrics.WebhookPanics.WithLabelValues().Inc()
|
||||
|
||||
if wh.RecoverPanic == nil || *wh.RecoverPanic {
|
||||
for _, fn := range utilruntime.PanicHandlers {
|
||||
fn(ctx, r)
|
||||
}
|
||||
response = Errored(http.StatusInternalServerError, fmt.Errorf("panic: %v [recovered]", r))
|
||||
// Note: We explicitly have to set the response UID. Usually that is done via resp.Complete below,
|
||||
// but if we encounter a panic in wh.Handler.Handle we are never going to reach resp.Complete.
|
||||
response.UID = req.UID
|
||||
return
|
||||
}
|
||||
|
||||
log := logf.FromContext(ctx)
|
||||
log.Info(fmt.Sprintf("Observed a panic in webhook: %v", r))
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
|
||||
reqLog := wh.getLogger(&req)
|
||||
ctx = logf.IntoContext(ctx, reqLog)
|
||||
|
||||
resp := wh.Handler.Handle(ctx, req)
|
||||
if err := resp.Complete(req); err != nil {
|
||||
reqLog.Error(err, "unable to encode response")
|
||||
resp := Errored(http.StatusInternalServerError, errUnableToEncodeResponse)
|
||||
// Note: We explicitly have to set the response UID. Usually that is done via resp.Complete.
|
||||
resp.UID = req.UID
|
||||
return resp
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// getLogger constructs a logger from the injected log and LogConstructor.
|
||||
func (wh *Webhook) getLogger(req *Request) logr.Logger {
|
||||
wh.setupLogOnce.Do(func() {
|
||||
if wh.log.GetSink() == nil {
|
||||
wh.log = logf.Log.WithName("admission")
|
||||
}
|
||||
})
|
||||
|
||||
logConstructor := wh.LogConstructor
|
||||
if logConstructor == nil {
|
||||
logConstructor = DefaultLogConstructor
|
||||
}
|
||||
return logConstructor(wh.log, req)
|
||||
}
|
||||
|
||||
// DefaultLogConstructor adds some commonly interesting fields to the given logger.
|
||||
func DefaultLogConstructor(base logr.Logger, req *Request) logr.Logger {
|
||||
if req != nil {
|
||||
return base.WithValues("object", klog.KRef(req.Namespace, req.Name),
|
||||
"namespace", req.Namespace, "name", req.Name,
|
||||
"resource", req.Resource, "user", req.UserInfo.Username,
|
||||
"requestID", req.UID,
|
||||
)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// StandaloneOptions let you configure a StandaloneWebhook.
|
||||
type StandaloneOptions struct {
|
||||
// Logger to be used by the webhook.
|
||||
// If none is set, it defaults to log.Log global logger.
|
||||
Logger logr.Logger
|
||||
// MetricsPath is used for labelling prometheus metrics
|
||||
// by the path is served on.
|
||||
// If none is set, prometheus metrics will not be generated.
|
||||
MetricsPath string
|
||||
}
|
||||
|
||||
// StandaloneWebhook prepares a webhook for use without a webhook.Server,
|
||||
// passing in the information normally populated by webhook.Server
|
||||
// and instrumenting the webhook with metrics.
|
||||
//
|
||||
// Use this to attach your webhook to an arbitrary HTTP server or mux.
|
||||
//
|
||||
// Note that you are responsible for terminating TLS if you use StandaloneWebhook
|
||||
// in your own server/mux. In order to be accessed by a kubernetes cluster,
|
||||
// all webhook servers require TLS.
|
||||
func StandaloneWebhook(hook *Webhook, opts StandaloneOptions) (http.Handler, error) {
|
||||
if opts.Logger.GetSink() != nil {
|
||||
hook.log = opts.Logger
|
||||
}
|
||||
if opts.MetricsPath == "" {
|
||||
return hook, nil
|
||||
}
|
||||
return metrics.InstrumentedHook(opts.MetricsPath, hook), nil
|
||||
}
|
||||
|
||||
// requestContextKey is how we find the admission.Request in a context.Context.
|
||||
type requestContextKey struct{}
|
||||
|
||||
// RequestFromContext returns an admission.Request from ctx.
|
||||
func RequestFromContext(ctx context.Context) (Request, error) {
|
||||
if v, ok := ctx.Value(requestContextKey{}).(Request); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
return Request{}, errors.New("admission.Request not found in context")
|
||||
}
|
||||
|
||||
// NewContextWithRequest returns a new Context, derived from ctx, which carries the
|
||||
// provided admission.Request.
|
||||
func NewContextWithRequest(ctx context.Context, req Request) context.Context {
|
||||
return context.WithValue(ctx, requestContextKey{}, req)
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
Copyright 2019 The Kubernetes 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 (
|
||||
"gomodules.xyz/jsonpatch/v2"
|
||||
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
|
||||
)
|
||||
|
||||
// define some aliases for common bits of the webhook functionality
|
||||
|
||||
// CustomDefaulter defines functions for setting defaults on resources.
|
||||
//
|
||||
// Deprecated: Use admission.Defaulter instead.
|
||||
type CustomDefaulter = admission.CustomDefaulter //nolint:staticcheck
|
||||
|
||||
// CustomValidator defines functions for validating an operation.
|
||||
//
|
||||
// Deprecated: Use admission.Validator instead.
|
||||
type CustomValidator = admission.CustomValidator //nolint:staticcheck
|
||||
|
||||
// AdmissionRequest defines the input for an admission handler.
|
||||
// It contains information to identify the object in
|
||||
// question (group, version, kind, resource, subresource,
|
||||
// name, namespace), as well as the operation in question
|
||||
// (e.g. Get, Create, etc), and the object itself.
|
||||
type AdmissionRequest = admission.Request
|
||||
|
||||
// AdmissionResponse is the output of an admission handler.
|
||||
// It contains a response indicating if a given
|
||||
// operation is allowed, as well as a set of patches
|
||||
// to mutate the object in the case of a mutating admission handler.
|
||||
type AdmissionResponse = admission.Response
|
||||
|
||||
// Admission is webhook suitable for registration with the server
|
||||
// an admission webhook that validates API operations and potentially
|
||||
// mutates their contents.
|
||||
type Admission = admission.Webhook
|
||||
|
||||
// AdmissionHandler knows how to process admission requests, validating them,
|
||||
// and potentially mutating the objects they contain.
|
||||
type AdmissionHandler = admission.Handler
|
||||
|
||||
// AdmissionDecoder knows how to decode objects from admission requests.
|
||||
type AdmissionDecoder = admission.Decoder
|
||||
|
||||
// JSONPatchOp represents a single JSONPatch patch operation.
|
||||
type JSONPatchOp = jsonpatch.Operation
|
||||
|
||||
var (
|
||||
// Allowed indicates that the admission request should be allowed for the given reason.
|
||||
Allowed = admission.Allowed
|
||||
|
||||
// Denied indicates that the admission request should be denied for the given reason.
|
||||
Denied = admission.Denied
|
||||
|
||||
// Patched indicates that the admission request should be allowed for the given reason,
|
||||
// and that the contained object should be mutated using the given patches.
|
||||
Patched = admission.Patched
|
||||
|
||||
// Errored indicates that an error occurred in the admission request.
|
||||
Errored = admission.Errored
|
||||
)
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
/*
|
||||
Copyright 2019 The Kubernetes 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 conversion provides implementation for CRD conversion webhook that implements handler for version conversion requests for types that are convertible.
|
||||
|
||||
See pkg/conversion for interface definitions required to ensure an API Type is convertible.
|
||||
*/
|
||||
package conversion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
apix "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
"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"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/conversion"
|
||||
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
||||
conversionmetrics "sigs.k8s.io/controller-runtime/pkg/webhook/conversion/metrics"
|
||||
)
|
||||
|
||||
var (
|
||||
log = logf.Log.WithName("conversion-webhook")
|
||||
)
|
||||
|
||||
func NewWebhookHandler(scheme *runtime.Scheme, registry Registry) http.Handler {
|
||||
return &webhook{scheme: scheme, decoder: NewDecoder(scheme), registry: registry}
|
||||
}
|
||||
|
||||
// webhook implements a CRD conversion webhook HTTP handler.
|
||||
type webhook struct {
|
||||
scheme *runtime.Scheme
|
||||
decoder *Decoder
|
||||
registry Registry
|
||||
}
|
||||
|
||||
// ensure Webhook implements http.Handler
|
||||
var _ http.Handler = &webhook{}
|
||||
|
||||
func (wh *webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
convertReview := &apix.ConversionReview{}
|
||||
err := json.NewDecoder(r.Body).Decode(convertReview)
|
||||
if err != nil {
|
||||
log.Error(err, "failed to read conversion request")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if convertReview.Request == nil {
|
||||
log.Error(nil, "conversion request is nil")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(droot): may be move the conversion logic to a separate module to
|
||||
// decouple it from the http layer ?
|
||||
resp, err := wh.handleConvertRequest(ctx, convertReview.Request)
|
||||
if err != nil {
|
||||
log.Error(err, "failed to convert", "request", convertReview.Request.UID)
|
||||
convertReview.Response = errored(err)
|
||||
} else {
|
||||
convertReview.Response = resp
|
||||
}
|
||||
convertReview.Response.UID = convertReview.Request.UID
|
||||
convertReview.Request = nil
|
||||
|
||||
err = json.NewEncoder(w).Encode(convertReview)
|
||||
if err != nil {
|
||||
log.Error(err, "failed to write response")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handles a version conversion request.
|
||||
func (wh *webhook) handleConvertRequest(ctx context.Context, req *apix.ConversionRequest) (_ *apix.ConversionResponse, retErr error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
conversionmetrics.WebhookPanics.WithLabelValues().Inc()
|
||||
|
||||
for _, fn := range utilruntime.PanicHandlers {
|
||||
fn(ctx, r)
|
||||
}
|
||||
retErr = errors.New("internal error occurred during conversion")
|
||||
return
|
||||
}
|
||||
}()
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("conversion request is nil")
|
||||
}
|
||||
var objects []runtime.RawExtension
|
||||
|
||||
for _, obj := range req.Objects {
|
||||
src, gvk, err := wh.decoder.Decode(obj.Raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dst, err := wh.allocateDstObject(req.DesiredAPIVersion, gvk.Kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = wh.convertObject(ctx, src, dst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objects = append(objects, runtime.RawExtension{Object: dst})
|
||||
}
|
||||
return &apix.ConversionResponse{
|
||||
UID: req.UID,
|
||||
ConvertedObjects: objects,
|
||||
Result: metav1.Status{
|
||||
Status: metav1.StatusSuccess,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// convertObject will convert given a src object to dst object.
|
||||
// Note(droot): couldn't find a way to reduce the cyclomatic complexity under 10
|
||||
// without compromising readability, so disabling gocyclo linter
|
||||
func (wh *webhook) convertObject(ctx context.Context, src, dst runtime.Object) error {
|
||||
srcGVK := src.GetObjectKind().GroupVersionKind()
|
||||
dstGVK := dst.GetObjectKind().GroupVersionKind()
|
||||
|
||||
if srcGVK.GroupKind() != dstGVK.GroupKind() {
|
||||
return fmt.Errorf("src %T and dst %T does not belong to same API Group", src, dst)
|
||||
}
|
||||
|
||||
if srcGVK == dstGVK {
|
||||
return fmt.Errorf("conversion is not allowed between same type %T", src)
|
||||
}
|
||||
|
||||
if converter, ok := wh.registry.GetConverter(srcGVK.GroupKind()); ok {
|
||||
return converter.ConvertObject(ctx, src, dst)
|
||||
}
|
||||
|
||||
srcIsHub, dstIsHub := isHub(src), isHub(dst)
|
||||
srcIsConvertible, dstIsConvertible := isConvertible(src), isConvertible(dst)
|
||||
|
||||
switch {
|
||||
case srcIsHub && dstIsConvertible:
|
||||
return dst.(conversion.Convertible).ConvertFrom(src.(conversion.Hub))
|
||||
case dstIsHub && srcIsConvertible:
|
||||
return src.(conversion.Convertible).ConvertTo(dst.(conversion.Hub))
|
||||
case srcIsConvertible && dstIsConvertible:
|
||||
return wh.convertViaHub(src.(conversion.Convertible), dst.(conversion.Convertible))
|
||||
default:
|
||||
return fmt.Errorf("%T is not convertible to %T", src, dst)
|
||||
}
|
||||
}
|
||||
|
||||
func (wh *webhook) convertViaHub(src, dst conversion.Convertible) error {
|
||||
hub, err := wh.getHub(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hub == nil {
|
||||
return fmt.Errorf("%s does not have any Hub defined", src)
|
||||
}
|
||||
|
||||
err = src.ConvertTo(hub)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%T failed to convert to hub version %T : %w", src, hub, err)
|
||||
}
|
||||
|
||||
err = dst.ConvertFrom(hub)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%T failed to convert from hub version %T : %w", dst, hub, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getHub returns an instance of the Hub for passed-in object's group/kind.
|
||||
func (wh *webhook) getHub(obj runtime.Object) (conversion.Hub, error) {
|
||||
gvks, err := objectGVKs(wh.scheme, obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(gvks) == 0 {
|
||||
return nil, fmt.Errorf("error retrieving gvks for object : %v", obj)
|
||||
}
|
||||
|
||||
var hub conversion.Hub
|
||||
var hubFoundAlready bool
|
||||
for _, gvk := range gvks {
|
||||
instance, err := wh.scheme.New(gvk)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to allocate an instance for gvk %v: %w", gvk, err)
|
||||
}
|
||||
if val, isHub := instance.(conversion.Hub); isHub {
|
||||
if hubFoundAlready {
|
||||
return nil, fmt.Errorf("multiple hub version defined for %T", obj)
|
||||
}
|
||||
hubFoundAlready = true
|
||||
hub = val
|
||||
}
|
||||
}
|
||||
return hub, nil
|
||||
}
|
||||
|
||||
// allocateDstObject returns an instance for a given GVK.
|
||||
func (wh *webhook) allocateDstObject(apiVersion, kind string) (runtime.Object, error) {
|
||||
gvk := schema.FromAPIVersionAndKind(apiVersion, kind)
|
||||
|
||||
obj, err := wh.scheme.New(gvk)
|
||||
if err != nil {
|
||||
return obj, err
|
||||
}
|
||||
|
||||
t, err := meta.TypeAccessor(obj)
|
||||
if err != nil {
|
||||
return obj, err
|
||||
}
|
||||
|
||||
t.SetAPIVersion(apiVersion)
|
||||
t.SetKind(kind)
|
||||
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// IsConvertible determines if given type is convertible or not. For a type
|
||||
// to be convertible, the group-kind needs to have a Hub type defined and all
|
||||
// non-hub types must be able to convert to/from Hub.
|
||||
func IsConvertible(scheme *runtime.Scheme, obj runtime.Object) (bool, error) {
|
||||
var hubs, spokes, nonSpokes []runtime.Object
|
||||
|
||||
gvks, err := objectGVKs(scheme, obj)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(gvks) == 0 {
|
||||
return false, fmt.Errorf("error retrieving gvks for object : %v", obj)
|
||||
}
|
||||
|
||||
for _, gvk := range gvks {
|
||||
instance, err := scheme.New(gvk)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to allocate an instance for gvk %v: %w", gvk, err)
|
||||
}
|
||||
|
||||
if isHub(instance) {
|
||||
hubs = append(hubs, instance)
|
||||
continue
|
||||
}
|
||||
|
||||
if !isConvertible(instance) {
|
||||
nonSpokes = append(nonSpokes, instance)
|
||||
continue
|
||||
}
|
||||
|
||||
spokes = append(spokes, instance)
|
||||
}
|
||||
|
||||
if len(gvks) == 1 {
|
||||
return false, nil // single version
|
||||
}
|
||||
|
||||
if len(hubs) == 0 && len(spokes) == 0 {
|
||||
// multiple version detected with no conversion implementation. This is
|
||||
// true for multi-version built-in types.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if len(hubs) == 1 && len(nonSpokes) == 0 { // convertible
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, PartialImplementationError{
|
||||
hubs: hubs,
|
||||
nonSpokes: nonSpokes,
|
||||
spokes: spokes,
|
||||
}
|
||||
}
|
||||
|
||||
// objectGVKs returns all (Group,Version,Kind) for the Group/Kind of given object.
|
||||
func objectGVKs(scheme *runtime.Scheme, obj runtime.Object) ([]schema.GroupVersionKind, error) {
|
||||
// NB: we should not use `obj.GetObjectKind().GroupVersionKind()` to get the
|
||||
// GVK here, since it is parsed from apiVersion and kind fields and it may
|
||||
// return empty GVK if obj is an uninitialized object.
|
||||
objGVKs, _, err := scheme.ObjectKinds(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(objGVKs) != 1 {
|
||||
return nil, fmt.Errorf("expect to get only one GVK for %v", obj)
|
||||
}
|
||||
objGVK := objGVKs[0]
|
||||
knownTypes := scheme.AllKnownTypes()
|
||||
|
||||
var gvks []schema.GroupVersionKind
|
||||
for gvk := range knownTypes {
|
||||
if objGVK.GroupKind() == gvk.GroupKind() {
|
||||
gvks = append(gvks, gvk)
|
||||
}
|
||||
}
|
||||
return gvks, nil
|
||||
}
|
||||
|
||||
// PartialImplementationError represents an error due to partial conversion
|
||||
// implementation such as hub without spokes, multiple hubs or spokes without hub.
|
||||
type PartialImplementationError struct {
|
||||
gvk schema.GroupVersionKind
|
||||
hubs []runtime.Object
|
||||
nonSpokes []runtime.Object
|
||||
spokes []runtime.Object
|
||||
}
|
||||
|
||||
func (e PartialImplementationError) Error() string {
|
||||
if len(e.hubs) == 0 {
|
||||
return fmt.Sprintf("no hub defined for gvk %s", e.gvk)
|
||||
}
|
||||
if len(e.hubs) > 1 {
|
||||
return fmt.Sprintf("multiple(%d) hubs defined for group-kind '%s' ",
|
||||
len(e.hubs), e.gvk.GroupKind())
|
||||
}
|
||||
if len(e.nonSpokes) > 0 {
|
||||
return fmt.Sprintf("%d inconvertible types detected for group-kind '%s'",
|
||||
len(e.nonSpokes), e.gvk.GroupKind())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isHub determines if passed-in object is a Hub or not.
|
||||
func isHub(obj runtime.Object) bool {
|
||||
_, yes := obj.(conversion.Hub)
|
||||
return yes
|
||||
}
|
||||
|
||||
// isConvertible determines if passed-in object is a convertible.
|
||||
func isConvertible(obj runtime.Object) bool {
|
||||
_, yes := obj.(conversion.Convertible)
|
||||
return yes
|
||||
}
|
||||
|
||||
// helper to construct error response.
|
||||
func errored(err error) *apix.ConversionResponse {
|
||||
return &apix.ConversionResponse{
|
||||
Result: metav1.Status{
|
||||
Status: metav1.StatusFailure,
|
||||
Message: err.Error(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+173
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
Copyright 2025 The Kubernetes 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 conversion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
|
||||
)
|
||||
|
||||
func NewHubSpokeConverter[hubObject runtime.Object](hub hubObject, spokeConverter ...SpokeConverter[hubObject]) func(scheme *runtime.Scheme) (Converter, error) {
|
||||
return func(scheme *runtime.Scheme) (Converter, error) {
|
||||
hubGVK, err := apiutil.GVKForObject(hub, scheme)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create hub spoke converter: failed to get GroupVersionKind for hub: %w", err)
|
||||
}
|
||||
allGVKs, err := objectGVKs(scheme, hub)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create hub spoke converter for %s: %w", hubGVK.Kind, err)
|
||||
}
|
||||
spokeVersions := sets.New[string]()
|
||||
for _, gvk := range allGVKs {
|
||||
if gvk != hubGVK {
|
||||
spokeVersions.Insert(gvk.Version)
|
||||
}
|
||||
}
|
||||
|
||||
c := &hubSpokeConverter[hubObject]{
|
||||
scheme: scheme,
|
||||
hubGVK: hubGVK,
|
||||
spokeConverterByGVK: map[schema.GroupVersionKind]SpokeConverter[hubObject]{},
|
||||
}
|
||||
|
||||
spokeConverterVersions := sets.New[string]()
|
||||
for _, sc := range spokeConverter {
|
||||
spokeGVK, err := apiutil.GVKForObject(sc.GetSpoke(), scheme)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+
|
||||
"failed to get GroupVersionKind for spoke converter: %w",
|
||||
hubGVK.Kind, err)
|
||||
}
|
||||
if hubGVK.GroupKind() != spokeGVK.GroupKind() {
|
||||
return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+
|
||||
"spoke converter GroupKind %s does not match hub GroupKind %s",
|
||||
hubGVK.Kind, spokeGVK.GroupKind(), hubGVK.GroupKind())
|
||||
}
|
||||
|
||||
if _, ok := c.spokeConverterByGVK[spokeGVK]; ok {
|
||||
return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+
|
||||
"duplicate spoke converter for version %s",
|
||||
hubGVK.Kind, spokeGVK.Version)
|
||||
}
|
||||
c.spokeConverterByGVK[spokeGVK] = sc
|
||||
spokeConverterVersions.Insert(spokeGVK.Version)
|
||||
}
|
||||
|
||||
if !spokeConverterVersions.Equal(spokeVersions) {
|
||||
return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+
|
||||
"expected spoke converter for %s got spoke converter for %s",
|
||||
hubGVK.Kind, sortAndJoin(spokeVersions), sortAndJoin(spokeConverterVersions))
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
func sortAndJoin(set sets.Set[string]) string {
|
||||
list := set.UnsortedList()
|
||||
slices.Sort(list)
|
||||
return strings.Join(list, ",")
|
||||
}
|
||||
|
||||
type hubSpokeConverter[hubObject runtime.Object] struct {
|
||||
scheme *runtime.Scheme
|
||||
hubGVK schema.GroupVersionKind
|
||||
spokeConverterByGVK map[schema.GroupVersionKind]SpokeConverter[hubObject]
|
||||
}
|
||||
|
||||
func (c hubSpokeConverter[hubObject]) ConvertObject(ctx context.Context, src, dst runtime.Object) error {
|
||||
srcGVK := src.GetObjectKind().GroupVersionKind()
|
||||
dstGVK := dst.GetObjectKind().GroupVersionKind()
|
||||
|
||||
if srcGVK.GroupKind() != dstGVK.GroupKind() {
|
||||
return fmt.Errorf("src %T and dst %T does not belong to same API Group", src, dst)
|
||||
}
|
||||
|
||||
if srcGVK == dstGVK {
|
||||
return fmt.Errorf("conversion is not allowed between same type %T", src)
|
||||
}
|
||||
|
||||
srcIsHub := c.hubGVK == srcGVK
|
||||
dstIsHub := c.hubGVK == dstGVK
|
||||
_, srcIsConvertible := c.spokeConverterByGVK[srcGVK]
|
||||
_, dstIsConvertible := c.spokeConverterByGVK[dstGVK]
|
||||
|
||||
switch {
|
||||
case srcIsHub && dstIsConvertible:
|
||||
return c.spokeConverterByGVK[dstGVK].ConvertHubToSpoke(ctx, src.(hubObject), dst)
|
||||
case dstIsHub && srcIsConvertible:
|
||||
return c.spokeConverterByGVK[srcGVK].ConvertSpokeToHub(ctx, src, dst.(hubObject))
|
||||
case srcIsConvertible && dstIsConvertible:
|
||||
hub, err := c.scheme.New(c.hubGVK)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to allocate an instance for GroupVersionKind %s: %w", c.hubGVK, err)
|
||||
}
|
||||
if err := c.spokeConverterByGVK[srcGVK].ConvertSpokeToHub(ctx, src, hub.(hubObject)); err != nil {
|
||||
return fmt.Errorf("failed to convert spoke %s to hub %s : %w", srcGVK, c.hubGVK, err)
|
||||
}
|
||||
if err := c.spokeConverterByGVK[dstGVK].ConvertHubToSpoke(ctx, hub.(hubObject), dst); err != nil {
|
||||
return fmt.Errorf("failed to convert hub %s to spoke %s : %w", c.hubGVK, dstGVK, err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("failed to convert %s to %s: not convertible", srcGVK, dstGVK)
|
||||
}
|
||||
}
|
||||
|
||||
type SpokeConverter[hubObject runtime.Object] interface {
|
||||
GetSpoke() runtime.Object
|
||||
ConvertHubToSpoke(ctx context.Context, hub hubObject, spoke runtime.Object) error
|
||||
ConvertSpokeToHub(ctx context.Context, spoke runtime.Object, hub hubObject) error
|
||||
}
|
||||
|
||||
func NewSpokeConverter[hubObject, spokeObject client.Object](
|
||||
spoke spokeObject,
|
||||
convertHubToSpokeFunc func(ctx context.Context, src hubObject, dst spokeObject) error,
|
||||
convertSpokeToHubFunc func(ctx context.Context, src spokeObject, dst hubObject) error,
|
||||
) SpokeConverter[hubObject] {
|
||||
return &spokeConverter[hubObject, spokeObject]{
|
||||
spoke: spoke,
|
||||
convertSpokeToHubFunc: convertSpokeToHubFunc,
|
||||
convertHubToSpokeFunc: convertHubToSpokeFunc,
|
||||
}
|
||||
}
|
||||
|
||||
type spokeConverter[hubObject, spokeObject runtime.Object] struct {
|
||||
spoke spokeObject
|
||||
convertHubToSpokeFunc func(ctx context.Context, src hubObject, dst spokeObject) error
|
||||
convertSpokeToHubFunc func(ctx context.Context, src spokeObject, dst hubObject) error
|
||||
}
|
||||
|
||||
func (c spokeConverter[hubObject, spokeObject]) GetSpoke() runtime.Object {
|
||||
return c.spoke
|
||||
}
|
||||
|
||||
func (c spokeConverter[hubObject, spokeObject]) ConvertHubToSpoke(ctx context.Context, hub hubObject, spoke runtime.Object) error {
|
||||
return c.convertHubToSpokeFunc(ctx, hub, spoke.(spokeObject))
|
||||
}
|
||||
|
||||
func (c spokeConverter[hubObject, spokeObject]) ConvertSpokeToHub(ctx context.Context, spoke runtime.Object, hub hubObject) error {
|
||||
return c.convertSpokeToHubFunc(ctx, spoke.(spokeObject), hub)
|
||||
}
|
||||
Generated
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
Copyright 2025 The Kubernetes 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 conversion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
type Converter interface {
|
||||
ConvertObject(ctx context.Context, src, dst runtime.Object) error
|
||||
}
|
||||
|
||||
type Registry interface {
|
||||
RegisterConverter(gk schema.GroupKind, converter Converter) error
|
||||
GetConverter(gk schema.GroupKind) (Converter, bool)
|
||||
}
|
||||
|
||||
type registry struct {
|
||||
converterByGK map[schema.GroupKind]Converter
|
||||
}
|
||||
|
||||
func NewRegistry() Registry {
|
||||
return registry{
|
||||
converterByGK: map[schema.GroupKind]Converter{},
|
||||
}
|
||||
}
|
||||
func (r registry) RegisterConverter(gk schema.GroupKind, converter Converter) error {
|
||||
if _, ok := r.converterByGK[gk]; ok {
|
||||
return fmt.Errorf("failed to register Converter for GroupKind %s: converter already registered", gk)
|
||||
}
|
||||
|
||||
r.converterByGK[gk] = converter
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r registry) GetConverter(gk schema.GroupKind) (Converter, bool) {
|
||||
c, ok := r.converterByGK[gk]
|
||||
return c, ok
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Copyright 2021 The Kubernetes 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 conversion
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
)
|
||||
|
||||
// Decoder knows how to decode the contents of a CRD version conversion
|
||||
// request into a concrete object.
|
||||
// TODO(droot): consider reusing decoder from admission pkg for this.
|
||||
type Decoder struct {
|
||||
codecs serializer.CodecFactory
|
||||
}
|
||||
|
||||
// NewDecoder creates a Decoder given the runtime.Scheme
|
||||
func NewDecoder(scheme *runtime.Scheme) *Decoder {
|
||||
if scheme == nil {
|
||||
panic("scheme should never be nil")
|
||||
}
|
||||
return &Decoder{codecs: serializer.NewCodecFactory(scheme)}
|
||||
}
|
||||
|
||||
// Decode decodes the inlined object.
|
||||
func (d *Decoder) Decode(content []byte) (runtime.Object, *schema.GroupVersionKind, error) {
|
||||
deserializer := d.codecs.UniversalDeserializer()
|
||||
return deserializer.Decode(content, nil, nil)
|
||||
}
|
||||
|
||||
// DecodeInto decodes the inlined object in the into the passed-in runtime.Object.
|
||||
func (d *Decoder) DecodeInto(content []byte, into runtime.Object) error {
|
||||
deserializer := d.codecs.UniversalDeserializer()
|
||||
return runtime.DecodeInto(deserializer, content, into)
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright 2025 The Kubernetes 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 metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"sigs.k8s.io/controller-runtime/pkg/metrics"
|
||||
)
|
||||
|
||||
var (
|
||||
// WebhookPanics is a prometheus counter metrics which holds the total
|
||||
// number of panics from conversion webhooks.
|
||||
WebhookPanics = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "controller_runtime_conversion_webhook_panics_total",
|
||||
Help: "Total number of conversion webhook panics",
|
||||
}, []string{})
|
||||
)
|
||||
|
||||
func init() {
|
||||
metrics.Registry.MustRegister(
|
||||
WebhookPanics,
|
||||
)
|
||||
// Init metric.
|
||||
WebhookPanics.WithLabelValues().Add(0)
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright 2018 The Kubernetes 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 provides methods to build and bootstrap a webhook server.
|
||||
|
||||
Currently, it only supports admission webhooks. It will support CRD conversion webhooks in the near future.
|
||||
*/
|
||||
package webhook
|
||||
|
||||
import (
|
||||
logf "sigs.k8s.io/controller-runtime/pkg/internal/log"
|
||||
)
|
||||
|
||||
var log = logf.RuntimeLog.WithName("webhook")
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
Copyright 2018 The Kubernetes 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 metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"sigs.k8s.io/controller-runtime/pkg/metrics"
|
||||
)
|
||||
|
||||
var (
|
||||
// RequestLatency is a prometheus metric which is a histogram of the latency
|
||||
// of processing admission requests.
|
||||
RequestLatency = prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "controller_runtime_webhook_latency_seconds",
|
||||
Help: "Histogram of the latency of processing admission requests",
|
||||
Buckets: prometheus.ExponentialBuckets(10e-9, 10, 12),
|
||||
NativeHistogramBucketFactor: 1.1,
|
||||
NativeHistogramMaxBucketNumber: 100,
|
||||
NativeHistogramMinResetDuration: 1 * time.Hour,
|
||||
},
|
||||
[]string{"webhook"},
|
||||
)
|
||||
|
||||
// RequestTotal is a prometheus metric which is a counter of the total processed admission requests.
|
||||
RequestTotal = func() *prometheus.CounterVec {
|
||||
return prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "controller_runtime_webhook_requests_total",
|
||||
Help: "Total number of admission requests by HTTP status code.",
|
||||
},
|
||||
[]string{"webhook", "code"},
|
||||
)
|
||||
}()
|
||||
|
||||
// RequestInFlight is a prometheus metric which is a gauge of the in-flight admission requests.
|
||||
RequestInFlight = func() *prometheus.GaugeVec {
|
||||
return prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "controller_runtime_webhook_requests_in_flight",
|
||||
Help: "Current number of admission requests being served.",
|
||||
},
|
||||
[]string{"webhook"},
|
||||
)
|
||||
}()
|
||||
)
|
||||
|
||||
func init() {
|
||||
metrics.Registry.MustRegister(RequestLatency, RequestTotal, RequestInFlight)
|
||||
}
|
||||
|
||||
// InstrumentedHook adds some instrumentation on top of the given webhook.
|
||||
func InstrumentedHook(path string, hookRaw http.Handler) http.Handler {
|
||||
lbl := prometheus.Labels{"webhook": path}
|
||||
|
||||
lat := RequestLatency.MustCurryWith(lbl)
|
||||
cnt := RequestTotal.MustCurryWith(lbl)
|
||||
gge := RequestInFlight.With(lbl)
|
||||
|
||||
// Initialize the most likely HTTP status codes.
|
||||
cnt.WithLabelValues("200")
|
||||
cnt.WithLabelValues("500")
|
||||
|
||||
return promhttp.InstrumentHandlerDuration(
|
||||
lat,
|
||||
promhttp.InstrumentHandlerCounter(
|
||||
cnt,
|
||||
promhttp.InstrumentHandlerInFlight(gge, hookRaw),
|
||||
),
|
||||
)
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
Copyright 2018 The Kubernetes 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"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"sigs.k8s.io/controller-runtime/pkg/certwatcher"
|
||||
"sigs.k8s.io/controller-runtime/pkg/healthz"
|
||||
"sigs.k8s.io/controller-runtime/pkg/internal/httpserver"
|
||||
"sigs.k8s.io/controller-runtime/pkg/webhook/internal/metrics"
|
||||
)
|
||||
|
||||
// DefaultPort is the default port that the webhook server serves.
|
||||
var DefaultPort = 9443
|
||||
|
||||
// Server is an admission webhook server that can serve traffic and
|
||||
// generates related k8s resources for deploying.
|
||||
//
|
||||
// TLS is required for a webhook to be accessed by kubernetes, so
|
||||
// you must provide a CertName and KeyName or have valid cert/key
|
||||
// at the default locations (tls.crt and tls.key). If you do not
|
||||
// want to configure TLS (i.e for testing purposes) run an
|
||||
// admission.StandaloneWebhook in your own server.
|
||||
type Server interface {
|
||||
// NeedLeaderElection implements the LeaderElectionRunnable interface, which indicates
|
||||
// the webhook server doesn't need leader election.
|
||||
NeedLeaderElection() bool
|
||||
|
||||
// Register marks the given webhook as being served at the given path.
|
||||
// It panics if two hooks are registered on the same path.
|
||||
Register(path string, hook http.Handler)
|
||||
|
||||
// Start runs the server.
|
||||
// It will install the webhook related resources depend on the server configuration.
|
||||
Start(ctx context.Context) error
|
||||
|
||||
// StartedChecker returns an healthz.Checker which is healthy after the
|
||||
// server has been started.
|
||||
StartedChecker() healthz.Checker
|
||||
|
||||
// WebhookMux returns the servers WebhookMux
|
||||
WebhookMux() *http.ServeMux
|
||||
}
|
||||
|
||||
// Options are all the available options for a webhook.Server
|
||||
type Options struct {
|
||||
// Host is the address that the server will listen on.
|
||||
// Defaults to "" - all addresses.
|
||||
Host string
|
||||
|
||||
// Port is the port number that the server will serve.
|
||||
// It will be defaulted to 9443 if unspecified.
|
||||
Port int
|
||||
|
||||
// CertDir is the directory that contains the server key and certificate. Defaults to
|
||||
// <temp-dir>/k8s-webhook-server/serving-certs.
|
||||
CertDir string
|
||||
|
||||
// CertName is the server certificate name. Defaults to tls.crt.
|
||||
//
|
||||
// Note: This option is only used when TLSOpts does not set GetCertificate.
|
||||
CertName string
|
||||
|
||||
// KeyName is the server key name. Defaults to tls.key.
|
||||
//
|
||||
// Note: This option is only used when TLSOpts does not set GetCertificate.
|
||||
KeyName string
|
||||
|
||||
// ClientCAName is the CA certificate name which server used to verify remote(client)'s certificate.
|
||||
// Defaults to "", which means server does not verify client's certificate.
|
||||
ClientCAName string
|
||||
|
||||
// TLSOpts is used to allow configuring the TLS config used for the server.
|
||||
// This also allows providing a certificate via GetCertificate.
|
||||
TLSOpts []func(*tls.Config)
|
||||
|
||||
// WebhookMux is the multiplexer that handles different webhooks.
|
||||
WebhookMux *http.ServeMux
|
||||
}
|
||||
|
||||
// NewServer constructs a new webhook.Server from the provided options.
|
||||
func NewServer(o Options) Server {
|
||||
return &DefaultServer{
|
||||
Options: o,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultServer is the default implementation used for Server.
|
||||
type DefaultServer struct {
|
||||
Options Options
|
||||
|
||||
// webhooks keep track of all registered webhooks
|
||||
webhooks map[string]http.Handler
|
||||
|
||||
// defaultingOnce ensures that the default fields are only ever set once.
|
||||
defaultingOnce sync.Once
|
||||
|
||||
// started is set to true immediately before the server is started
|
||||
// and thus can be used to check if the server has been started
|
||||
started bool
|
||||
|
||||
// mu protects access to the webhook map & setFields for Start, Register, etc
|
||||
mu sync.Mutex
|
||||
|
||||
webhookMux *http.ServeMux
|
||||
}
|
||||
|
||||
// setDefaults does defaulting for the Server.
|
||||
func (o *Options) setDefaults() {
|
||||
if o.WebhookMux == nil {
|
||||
o.WebhookMux = http.NewServeMux()
|
||||
}
|
||||
|
||||
if o.Port <= 0 {
|
||||
o.Port = DefaultPort
|
||||
}
|
||||
|
||||
if len(o.CertDir) == 0 {
|
||||
o.CertDir = filepath.Join(os.TempDir(), "k8s-webhook-server", "serving-certs")
|
||||
}
|
||||
|
||||
if len(o.CertName) == 0 {
|
||||
o.CertName = "tls.crt"
|
||||
}
|
||||
|
||||
if len(o.KeyName) == 0 {
|
||||
o.KeyName = "tls.key"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DefaultServer) setDefaults() {
|
||||
s.webhooks = map[string]http.Handler{}
|
||||
s.Options.setDefaults()
|
||||
|
||||
s.webhookMux = s.Options.WebhookMux
|
||||
}
|
||||
|
||||
// NeedLeaderElection implements the LeaderElectionRunnable interface, which indicates
|
||||
// the webhook server doesn't need leader election.
|
||||
func (*DefaultServer) NeedLeaderElection() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Register marks the given webhook as being served at the given path.
|
||||
// It panics if two hooks are registered on the same path.
|
||||
func (s *DefaultServer) Register(path string, hook http.Handler) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.defaultingOnce.Do(s.setDefaults)
|
||||
if _, found := s.webhooks[path]; found {
|
||||
panic(fmt.Errorf("can't register duplicate path: %v", path))
|
||||
}
|
||||
s.webhooks[path] = hook
|
||||
s.webhookMux.Handle(path, metrics.InstrumentedHook(path, hook))
|
||||
|
||||
regLog := log.WithValues("path", path)
|
||||
regLog.Info("Registering webhook")
|
||||
}
|
||||
|
||||
// Start runs the server.
|
||||
// It will install the webhook related resources depend on the server configuration.
|
||||
func (s *DefaultServer) Start(ctx context.Context) error {
|
||||
s.defaultingOnce.Do(s.setDefaults)
|
||||
|
||||
log.Info("Starting webhook server")
|
||||
|
||||
cfg := &tls.Config{
|
||||
NextProtos: []string{"h2"},
|
||||
}
|
||||
// fallback TLS config ready, will now mutate if passer wants full control over it
|
||||
for _, op := range s.Options.TLSOpts {
|
||||
op(cfg)
|
||||
}
|
||||
|
||||
if cfg.GetCertificate == nil {
|
||||
certPath := filepath.Join(s.Options.CertDir, s.Options.CertName)
|
||||
keyPath := filepath.Join(s.Options.CertDir, s.Options.KeyName)
|
||||
|
||||
// Create the certificate watcher and
|
||||
// set the config's GetCertificate on the TLSConfig
|
||||
certWatcher, err := certwatcher.New(certPath, keyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.GetCertificate = certWatcher.GetCertificate
|
||||
|
||||
go func() {
|
||||
if err := certWatcher.Start(ctx); err != nil {
|
||||
log.Error(err, "certificate watcher error")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Load CA to verify client certificate, if configured.
|
||||
if s.Options.ClientCAName != "" {
|
||||
certPool := x509.NewCertPool()
|
||||
clientCABytes, err := os.ReadFile(filepath.Join(s.Options.CertDir, s.Options.ClientCAName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read client CA cert: %w", err)
|
||||
}
|
||||
|
||||
ok := certPool.AppendCertsFromPEM(clientCABytes)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to append client CA cert to CA pool")
|
||||
}
|
||||
|
||||
cfg.ClientCAs = certPool
|
||||
cfg.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
}
|
||||
|
||||
listener, err := tls.Listen("tcp", net.JoinHostPort(s.Options.Host, strconv.Itoa(s.Options.Port)), cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info("Serving webhook server", "host", s.Options.Host, "port", s.Options.Port)
|
||||
|
||||
srv := httpserver.New(s.webhookMux)
|
||||
|
||||
idleConnsClosed := make(chan struct{})
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
log.Info("Shutting down webhook server with timeout of 1 minute")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
// Error from closing listeners, or context timeout
|
||||
log.Error(err, "error shutting down the HTTP server")
|
||||
}
|
||||
close(idleConnsClosed)
|
||||
}()
|
||||
|
||||
s.mu.Lock()
|
||||
s.started = true
|
||||
s.mu.Unlock()
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
|
||||
<-idleConnsClosed
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartedChecker returns an healthz.Checker which is healthy after the
|
||||
// server has been started.
|
||||
func (s *DefaultServer) StartedChecker() healthz.Checker {
|
||||
config := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
return func(req *http.Request) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.started {
|
||||
return fmt.Errorf("webhook server has not been started yet")
|
||||
}
|
||||
|
||||
d := &net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := tls.DialWithDialer(d, "tcp", net.JoinHostPort(s.Options.Host, strconv.Itoa(s.Options.Port)), config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook server is not reachable: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.Close(); err != nil {
|
||||
return fmt.Errorf("webhook server is not reachable: closing connection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WebhookMux returns the servers WebhookMux
|
||||
func (s *DefaultServer) WebhookMux() *http.ServeMux {
|
||||
return s.webhookMux
|
||||
}
|
||||
Reference in New Issue
Block a user