From f562169abd0bd6cde3645fea67e0f794fe3a365e Mon Sep 17 00:00:00 2001 From: Alex Masi Date: Tue, 2 Apr 2024 17:08:26 -0700 Subject: [PATCH] Add `x` directory and mutating webhook (#524) * Add mutating webhook to x * fix build * fix * update * fix cfg * linter * fix * add info to readme * linter * linter * fix lint --- .gitignore | 1 + go.mod | 5 + go.sum | 11 + x/README.md | 15 + x/webhook/Dockerfile.webhook | 9 + x/webhook/README.md | 203 +++++++++++ x/webhook/admission/admission.go | 101 ++++++ x/webhook/admission/admission_test.go | 158 ++++++++ x/webhook/containerize.sh | 12 + .../examples/addcontainer/addcontainer.go | 55 +++ .../addcontainer/addcontainer_test.go | 73 ++++ .../examples/addcontainer/testdata/in.json | 322 +++++++++++++++++ .../addcontainer/testdata/in_nomod.json | 321 +++++++++++++++++ .../examples/addcontainer/testdata/out.json | 341 ++++++++++++++++++ .../addcontainer/testdata/out_nomod.json | 321 +++++++++++++++++ x/webhook/examples/topology.textproto | 19 + x/webhook/main.go | 111 ++++++ x/webhook/manifests/deploy.yaml | 29 ++ x/webhook/manifests/mutating.config.yaml | 46 +++ x/webhook/manifests/namespace.yaml | 6 + x/webhook/manifests/svc.yaml | 17 + x/webhook/manifests/tls.secret.yaml | 9 + x/webhook/mutate/mutate.go | 66 ++++ x/webhook/mutate/mutate_test.go | 96 +++++ x/webhook/secure/genCerts.sh | 36 ++ 25 files changed, 2383 insertions(+) create mode 100644 x/README.md create mode 100644 x/webhook/Dockerfile.webhook create mode 100644 x/webhook/README.md create mode 100644 x/webhook/admission/admission.go create mode 100644 x/webhook/admission/admission_test.go create mode 100755 x/webhook/containerize.sh create mode 100644 x/webhook/examples/addcontainer/addcontainer.go create mode 100644 x/webhook/examples/addcontainer/addcontainer_test.go create mode 100644 x/webhook/examples/addcontainer/testdata/in.json create mode 100644 x/webhook/examples/addcontainer/testdata/in_nomod.json create mode 100644 x/webhook/examples/addcontainer/testdata/out.json create mode 100644 x/webhook/examples/addcontainer/testdata/out_nomod.json create mode 100644 x/webhook/examples/topology.textproto create mode 100644 x/webhook/main.go create mode 100644 x/webhook/manifests/deploy.yaml create mode 100644 x/webhook/manifests/mutating.config.yaml create mode 100644 x/webhook/manifests/namespace.yaml create mode 100644 x/webhook/manifests/svc.yaml create mode 100644 x/webhook/manifests/tls.secret.yaml create mode 100644 x/webhook/mutate/mutate.go create mode 100644 x/webhook/mutate/mutate_test.go create mode 100755 x/webhook/secure/genCerts.sh diff --git a/.gitignore b/.gitignore index f15f0f93..2297926c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ **/super-linter.log kne_cli/kne_cli controller/server/server +x/webhook/webhook diff --git a/go.mod b/go.mod index fcd3c5f8..c1377bd5 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/spf13/viper v1.17.0 github.com/srl-labs/srl-controller v0.6.1 github.com/srl-labs/srlinux-scrapli v0.6.0 + github.com/wI2L/jsondiff v0.5.1 go.universe.tf/metallb v0.13.5 golang.org/x/oauth2 v0.13.0 google.golang.org/api v0.149.0 @@ -121,6 +122,10 @@ require ( github.com/spf13/afero v1.10.0 // indirect github.com/spf13/cast v1.5.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tidwall/gjson v1.17.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/sjson v1.2.5 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/otel v1.19.0 // indirect go.opentelemetry.io/otel/metric v1.19.0 // indirect diff --git a/go.sum b/go.sum index def6784d..35a171f7 100644 --- a/go.sum +++ b/go.sum @@ -1318,6 +1318,17 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= +github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/wI2L/jsondiff v0.5.1 h1:xS4zYUspH4U3IB0Lwo9+jv+MSRJSWMF87Y4BpDbFMHo= +github.com/wI2L/jsondiff v0.5.1/go.mod h1:qqG6hnK0Lsrz2BpIVCxWiK9ItsBCpIZQiv0izJjOZ9s= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= diff --git a/x/README.md b/x/README.md new file mode 100644 index 00000000..fc1a5954 --- /dev/null +++ b/x/README.md @@ -0,0 +1,15 @@ +# `x` directory + +This directory contains code that is compatible with KNE but is outside of the +main tree. Code is separated here to indicate that it is less thoroughly tested +than the rest of the code in the repository. There is no SLO for `x` code. Code +under `x` may contain backwards incompatible changes (although this will try to +be avoided). + +We will try to maintain this tree and continue to contribute to and enhance it. +However this will largely be "best effort" and held to a lesser standard than +the main tree. + +This is similar to the concept of core Golang X-repositories (with even looser +requirements). See [Golang docs](https://go.dev/wiki/X-Repositories) for more +info. diff --git a/x/webhook/Dockerfile.webhook b/x/webhook/Dockerfile.webhook new file mode 100644 index 00000000..aab6a8aa --- /dev/null +++ b/x/webhook/Dockerfile.webhook @@ -0,0 +1,9 @@ +FROM golang:latest + +RUN mkdir /launcher +COPY webhook /launcher +WORKDIR /launcher + +EXPOSE 8080 + +CMD ["./webhook"] diff --git a/x/webhook/README.md b/x/webhook/README.md new file mode 100644 index 00000000..2b82d2bd --- /dev/null +++ b/x/webhook/README.md @@ -0,0 +1,203 @@ +# KNE Mutating Webhook + +This directory contains the code and configurations (in the form of manifests) +for the mutating webhook. The webhook should be deployed onto a KNE +cluster. + +This webhook can be used to mutate any K8 resources. This directory contains +the generic webhook along with an example mutator that simply adds an alpine +linux container to created pods. + +To develop custom a custom mutation simply change the mutate function in the +examples subdirectory. + +The following guide assumes your working directory is `kne/x/webhook`. + +## Build + +Run: + +```bash +./containerize.sh +``` + +to build the webhook container from the binary `main.go`. + +## Deployment + +For the webhook to be effective it must be deployed when the k8s cluster is up +but before the KNE topology is created. + +Start by deploying the kubernetes cluster + +```bash +kne deploy ../../deploy/kne/kind-bridge.yaml +``` + +You should be in this state: + +```bash +$ kubectl get pods -A +NAMESPACE NAME READY STATUS RESTARTS AGE +arista-ceoslab-operator-system arista-ceoslab-operator-controller-manager-5cb5fb9db4-7jqp9 2/2 Running 0 45h +ixiatg-op-system ixiatg-op-controller-manager-5947cd6f59-jq5pw 2/2 Running 0 45h +kube-system coredns-787d4945fb-8x8wf 1/1 Running 0 45h +kube-system coredns-787d4945fb-ng7hf 1/1 Running 0 45h +kube-system etcd-kne-control-plane 1/1 Running 0 45h +kube-system kindnet-zlwzz 1/1 Running 0 45h +kube-system kube-apiserver-kne-control-plane 1/1 Running 0 45h +kube-system kube-controller-manager-kne-control-plane 1/1 Running 0 45h +kube-system kube-proxy-kwsqm 1/1 Running 0 45h +kube-system kube-scheduler-kne-control-plane 1/1 Running 0 45h +lemming-operator lemming-controller-manager-6fc9d47f7d-vnshj 2/2 Running 0 45h +local-path-storage local-path-provisioner-c8855d4bb-8m9bp 1/1 Running 0 45h +meshnet meshnet-ddm8q 1/1 Running 0 45h +metallb-system controller-8bb68977b-vx99n 1/1 Running 0 45h +metallb-system speaker-hj8jf 1/1 Running 0 45h +srlinux-controller srlinux-controller-controller-manager-57f8c48bf-6kqlg 2/2 Running 0 45h +``` + +At this point the k8s cluster is up and operational. We can now load the webhook +manifests. + +```bash +kind load docker-image webhook:latest --name kne +``` + +```bash +kubectl apply -f manifests/ +``` + +This should result in the webhook pod to be present. + +```bash +$ kubectl get pods -A +... +default kne-assembly-webhook-f5b8cf987-lpxjt 1/1 Running 0 5s +... +``` + +We can now create the KNE topology. + +*Note* The KNE topology must have the label `webhook:enabled` for each node, as in +[this example](examples/topology.textproto), +otherwise the webhook will ignore the pod upon create. + +```bash +labels { + key: "webhook" + value: "enabled" +} +``` + +Use the normal KNE command to create the topology. + +```bash +kne create examples/topology.textproto +``` + +You should now see r1 with 2 containers instead of the one, this is +because the webhook has injected the alpine linux container. + +```bash +$ kubectl get pods -n webhook-example +r1 3/3 Running 0 24s +r2 2/2 Running 0 22s +``` + +```bash +$ kubectl describe pod r1 -n webhook-example +... +Containers: + r1: + Container ID: containerd://0dd84381ac5970d796c866adf73022c1ed5610ceb40546e443a35b7eff6a3f39 + Image: alpine:latest + Image ID: docker.io/library/alpine@sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b + Port: + Host Port: + Command: + /bin/sh + -c + sleep 2000000000000 + State: Running + Started: Tue, 02 Apr 2024 23:24:40 +0000 + Ready: True + Restart Count: 0 + Environment: + Mounts: + /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-kgrn6 (ro) + alpine: + Container ID: containerd://be7db4c4704b415d0dda1d5ed64b70c7f271086670aa803f105399dc95e35ad8 + Image: alpine:latest + Image ID: docker.io/library/alpine@sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b + Port: + Host Port: + Command: + /bin/sh + -c + sleep 2000000000000 + State: Running + Started: Tue, 02 Apr 2024 23:24:40 +0000 + Ready: True + Restart Count: 0 + Requests: + cpu: 500m + memory: 1Gi + Environment: + Mounts: + /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-kgrn6 (ro) +... +``` + +## Removing the webhook + +Removing the webhook can be achieved by deleting the loaded manifests from the +k8s cluster. + +```bash +kubectl delete -f manifests/ +``` + +## Debugging + +In order to obtain the logs of the webhook you can use the following command. + +```bash +kubectl logs -l app=kne-assembly-webhook -f +``` + +In the logs you should see output similar to this: + +```bash +I1215 11:54:46.729680 1 main.go:25] Listening on port 443... +I0402 23:24:36.383536 1 mutate.go:45] Mutating &TypeMeta{Kind:Pod,APIVersion:v1,} +I0402 23:24:36.394188 1 mutate.go:45] Mutating &TypeMeta{Kind:Pod,APIVersion:v1,} +I0402 23:24:36.394227 1 addcontainer.go:34] Ignoring pod "r2", mutation not requested +``` + +This output shows that it mutated the pod r1 but not r2 since +the label was not added to that KNE node. + +### TLS + +Run: + +```bash +./secure/genCerts.sh +``` + +to optionally update the TLS certs in the manifest files. It handles updating +`manifests/tls.secret.yaml` however the `caBundle` in +`manifests/mutating.config.yaml` will need to be updated manually based on the +output of the script. This is not required but may be useful. + +## Customize the webhook + +Edit `main.go` to specify any mutation functions as desired. The example uses +the mutation function found in `examples/addcontainer/addcontainer.go` but any +mutation function is supported. This includes mutating services and other +resources besides just pods. However you may also have to change +`manifests/mutating.config.yaml` to select other resources types than just +pods. + +After customization is done, rebuild the container and reapply the manifests. diff --git a/x/webhook/admission/admission.go b/x/webhook/admission/admission.go new file mode 100644 index 00000000..f685e40f --- /dev/null +++ b/x/webhook/admission/admission.go @@ -0,0 +1,101 @@ +// Copyright 2024 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 admission handles kubernetes admissions. +package admission + +import ( + "net/http" + + "github.com/openconfig/kne/x/webhook/mutate" + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" +) + +// mutator is an entity capable of mutating pods. A mutation is any addition or removal from a +// Pod configuration. This could be adding containers or simply added envVars. +type mutator interface { + MutateObject(runtime.Object) ([]byte, error) +} + +// Admitter admits a pod into the review process. +type Admitter struct { + request *admissionv1.AdmissionRequest + mutator mutator +} + +// New builds a new Admitter. +func New(request *admissionv1.AdmissionRequest, mutations []mutate.MutationFunc) *Admitter { + return &Admitter{ + request: request, + mutator: mutate.New(mutations), + } +} + +// Review filters for resources that should be mutated by this mutating webhook. Specifically, any resource who +// has the label `"webhook":"enabled"`, will be mutated by this webhook. +func (a Admitter) Review() (*admissionv1.AdmissionReview, error) { + obj, err := runtime.Decode(scheme.Codecs.UniversalDeserializer(), a.request.Object.Raw) + if err != nil { + return reviewResponse(a.request.UID, false, http.StatusBadRequest, err.Error()), err + } + patch, err := a.mutator.MutateObject(obj) + if err != nil { + return reviewResponse(a.request.UID, false, http.StatusBadRequest, err.Error()), err + } + return patchReviewResponse(a.request.UID, patch) +} + +// reviewResponse constructs a valid response for the k8s server. +func reviewResponse(uid types.UID, allowed bool, httpCode int32, reason string) *admissionv1.AdmissionReview { + return &admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + Kind: "AdmissionReview", + APIVersion: "admission.k8s.io/v1", + }, + Response: &admissionv1.AdmissionResponse{ + UID: uid, + Allowed: allowed, + Result: &metav1.Status{ + Code: httpCode, + Message: reason, + }, + }, + } +} + +// patchReviewResponse builds an admission review with given json patch +func patchReviewResponse(uid types.UID, patch []byte) (*admissionv1.AdmissionReview, error) { + patchType := admissionv1.PatchTypeJSONPatch + resp := &admissionv1.AdmissionResponse{ + UID: uid, + Allowed: true, + } + + if patch != nil { + resp.PatchType = &patchType + resp.Patch = patch + } + + return &admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + Kind: "AdmissionReview", + APIVersion: "admission.k8s.io/v1", + }, + Response: resp, + }, nil +} diff --git a/x/webhook/admission/admission_test.go b/x/webhook/admission/admission_test.go new file mode 100644 index 00000000..5a9669b6 --- /dev/null +++ b/x/webhook/admission/admission_test.go @@ -0,0 +1,158 @@ +// Copyright 2024 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 admission + +import ( + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +const ( + basicPod = ` + { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "r0", + "namespace": "b2b" + } + } + ` +) + +type fakeMutator struct { + ret []byte +} + +func (f *fakeMutator) MutateObject(_ runtime.Object) ([]byte, error) { + return f.ret, nil +} + +func TestPatchReviewResponse(t *testing.T) { + uid := types.UID("test") + patchType := admissionv1.PatchTypeJSONPatch + patch := []byte(`not quite a real patch`) + + want := &admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + Kind: "AdmissionReview", + APIVersion: "admission.k8s.io/v1", + }, + Response: &admissionv1.AdmissionResponse{ + UID: uid, + Allowed: true, + PatchType: &patchType, + Patch: patch, + }, + } + + got, err := patchReviewResponse(uid, patch) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(got, want); diff != "" { + t.Errorf("patchReviewResponse(%q, %q) returned diff (-got, +want):\n%s", uid, patch, diff) + } +} + +func TestReviewResponse(t *testing.T) { + uid := types.UID("test") + reason := "fail!" + + want := &admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + Kind: "AdmissionReview", + APIVersion: "admission.k8s.io/v1", + }, + Response: &admissionv1.AdmissionResponse{ + UID: uid, + Allowed: false, + Result: &metav1.Status{ + Code: 418, + Message: reason, + }, + }, + } + + got := reviewResponse(uid, false, http.StatusTeapot, reason) + if diff := cmp.Diff(got, want); diff != "" { + t.Errorf("reviewResponse(%q, false, ...) returned diff (-got, +want):\n%s", uid, diff) + } +} + +func TestReview(t *testing.T) { + patchType := admissionv1.PatchTypeJSONPatch + tests := []struct { + name string + inReq *admissionv1.AdmissionRequest + wantResp *admissionv1.AdmissionReview + inMutor mutator + wantErr bool + }{ + { + name: "webhook label", + inReq: &admissionv1.AdmissionRequest{ + UID: types.UID("mutated"), + Kind: metav1.GroupVersionKind{ + Kind: "Pod", + }, + Object: runtime.RawExtension{ + Raw: []byte(basicPod), + }, + }, + wantResp: &admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + Kind: "AdmissionReview", + APIVersion: "admission.k8s.io/v1", + }, + Response: &admissionv1.AdmissionResponse{ + UID: types.UID("mutated"), + Allowed: true, + PatchType: &patchType, + Patch: []byte("notquiteapatch"), + }, + }, + inMutor: &fakeMutator{ + ret: []byte("notquiteapatch"), + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &Admitter{ + request: tt.inReq, + mutator: tt.inMutor, + } + + got, err := a.Review() + if tt.wantErr != (err != nil) { + t.Fatalf("Review() returned unexpected error. want: %t, got %t, error: %v", tt.wantErr, err != nil, err) + } + + if diff := cmp.Diff(got, tt.wantResp); diff != "" { + t.Errorf("Review() returned diff (-got, +want):\n%s", diff) + } + }) + } +} diff --git a/x/webhook/containerize.sh b/x/webhook/containerize.sh new file mode 100755 index 00000000..08f9280d --- /dev/null +++ b/x/webhook/containerize.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# exit when a command fails +set -e + +echo "Beginning webhook build" +go build +echo "webhook build complete." + +docker build -t webhook -f Dockerfile.webhook . + +echo "docker build complete. Have a nice day." diff --git a/x/webhook/examples/addcontainer/addcontainer.go b/x/webhook/examples/addcontainer/addcontainer.go new file mode 100644 index 00000000..4d753c61 --- /dev/null +++ b/x/webhook/examples/addcontainer/addcontainer.go @@ -0,0 +1,55 @@ +// Copyright 2024 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 addcontainer + +import ( + "google.golang.org/protobuf/proto" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/runtime" + log "k8s.io/klog/v2" +) + +// AddContainer adds an alpine container to a pod. +func AddContainer(obj runtime.Object) (runtime.Object, error) { + p, ok := obj.(*corev1.Pod) + if !ok { + log.Infof("Ignoring object of type %v, not a pod", obj.GetObjectKind()) + return obj, nil + } + + if p.GetLabels()["webhook"] != "enabled" { + log.Infof("Ignoring pod %q, mutation not requested", p.GetName()) + return obj, nil + } + + mp := p.DeepCopy() + mp.Spec.Containers = append(mp.Spec.Containers, corev1.Container{ + Name: "alpine", + Image: "alpine:latest", + Command: []string{"/bin/sh", "-c", "sleep 2000000000000"}, + ImagePullPolicy: corev1.PullIfNotPresent, + SecurityContext: &corev1.SecurityContext{ + Privileged: proto.Bool(true), + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("1Gi"), + }, + }, + }) + return mp, nil +} diff --git a/x/webhook/examples/addcontainer/addcontainer_test.go b/x/webhook/examples/addcontainer/addcontainer_test.go new file mode 100644 index 00000000..e119fb1e --- /dev/null +++ b/x/webhook/examples/addcontainer/addcontainer_test.go @@ -0,0 +1,73 @@ +// Copyright 2024 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 addcontainer + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" +) + +func parsePod(t *testing.T, name string) *corev1.Pod { + t.Helper() + buf, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatal(err) + } + + p := &corev1.Pod{} + if err := json.Unmarshal(buf, p); err != nil { + t.Fatal(err) + } + + return p +} + +func TestAddContainer(t *testing.T) { + tests := []struct { + name string + inPod *corev1.Pod + wantPod *corev1.Pod + }{ + { + name: "valid pod", + inPod: parsePod(t, "in.json"), + wantPod: parsePod(t, "out.json"), + }, + { + name: "valid pod - nomod", + inPod: parsePod(t, "in_nomod.json"), + wantPod: parsePod(t, "out_nomod.json"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPod, err := AddContainer(tt.inPod) + if err != nil { + t.Fatalf("AddContainer() returned unexpected error: %v", err) + } + + if diff := cmp.Diff(gotPod, tt.wantPod); diff != "" { + t.Errorf("AddContainer() returned diff (-got, +want):\n%s", diff) + } + }) + } + +} diff --git a/x/webhook/examples/addcontainer/testdata/in.json b/x/webhook/examples/addcontainer/testdata/in.json new file mode 100644 index 00000000..1b573ce2 --- /dev/null +++ b/x/webhook/examples/addcontainer/testdata/in.json @@ -0,0 +1,322 @@ +{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "creationTimestamp": "2023-11-07T14:19:54Z", + "labels": { + "webhook": "enabled", + "app": "r0", + "model": "ceos", + "ondatra-role": "DUT", + "os": "eos", + "topo": "b2b", + "vendor": "ARISTA", + "version": "" + }, + "name": "r0", + "namespace": "b2b", + "ownerReferences": [ + { + "apiVersion": "ceoslab.arista.com/v1alpha1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "CEosLabDevice", + "name": "r0", + "uid": "7faf439d-befe-4df4-b9f4-42dd35659927" + } + ], + "resourceVersion": "21744", + "uid": "4cc873e1-4166-415e-89a1-9692270e260d" + }, + "spec": { + "containers": [ + { + "args": [ + "systemd.setenv=CEOS=1", + "systemd.setenv=EOS_PLATFORM=ceoslab", + "systemd.setenv=ETBA=1", + "systemd.setenv=INTFTYPE=eth", + "systemd.setenv=SKIP_ZEROTOUCH_BARRIER_IN_SYSDBINIT=1", + "systemd.setenv=container=docker" + ], + "command": [ + "/sbin/init" + ], + "env": [ + { + "name": "CEOS", + "value": "1" + }, + { + "name": "EOS_PLATFORM", + "value": "ceoslab" + }, + { + "name": "ETBA", + "value": "1" + }, + { + "name": "INTFTYPE", + "value": "eth" + }, + { + "name": "SKIP_ZEROTOUCH_BARRIER_IN_SYSDBINIT", + "value": "1" + }, + { + "name": "container", + "value": "docker" + } + ], + "image": "us-west1-docker.pkg.dev/gep-kne/ceos:ga", + "imagePullPolicy": "IfNotPresent", + "name": "ceos", + "resources": { + "requests": { + "cpu": "500m", + "memory": "1Gi" + } + }, + "securityContext": { + "privileged": true + }, + "startupProbe": { + "exec": { + "command": [ + "wfw", + "-t", + "5" + ] + }, + "failureThreshold": 24, + "periodSeconds": 5, + "successThreshold": 1, + "timeoutSeconds": 5 + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/mnt/flash/EosIntfMapping.json", + "name": "volume-configmap-intfmapping-r0", + "subPath": "EosIntfMapping.json" + }, + { + "mountPath": "/mnt/flash/rc.eos", + "name": "volume-configmap-rceos-r0", + "subPath": "rc.eos" + }, + { + "mountPath": "/mnt/flash/startup-config", + "name": "volume-r0-config", + "subPath": "startup-config" + }, + { + "mountPath": "/mnt/flash/gnmiCert.pem", + "name": "volume-secret-selfsigned-r0-0", + "subPath": "gnmiCert.pem" + }, + { + "mountPath": "/mnt/flash/gnmiCertKey.pem", + "name": "volume-secret-selfsigned-r0-0", + "subPath": "gnmiCertKey.pem" + }, + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "kube-api-access-4txwv", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "initContainers": [ + { + "args": [ + "4", + "0" + ], + "image": "us-west1-docker.pkg.dev/gep-kne/kne/networkop/init-wait:ga", + "imagePullPolicy": "IfNotPresent", + "name": "init-r0", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "kube-api-access-4txwv", + "readOnly": true + } + ] + } + ], + "nodeName": "kne-control-plane", + "preemptionPolicy": "PreemptLowerPriority", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 0, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "configMap": { + "defaultMode": 420, + "name": "configmap-intfmapping-r0" + }, + "name": "volume-configmap-intfmapping-r0" + }, + { + "configMap": { + "defaultMode": 509, + "name": "configmap-rceos-r0" + }, + "name": "volume-configmap-rceos-r0" + }, + { + "configMap": { + "defaultMode": 420, + "name": "r0-config" + }, + "name": "volume-r0-config" + }, + { + "name": "volume-secret-selfsigned-r0-0", + "secret": { + "defaultMode": 420, + "secretName": "secret-selfsigned-r0-0" + } + }, + { + "name": "kube-api-access-4txwv", + "projected": { + "defaultMode": 420, + "sources": [ + { + "serviceAccountToken": { + "expirationSeconds": 3607, + "path": "token" + } + }, + { + "configMap": { + "items": [ + { + "key": "ca.crt", + "path": "ca.crt" + } + ], + "name": "kube-root-ca.crt" + } + }, + { + "downwardAPI": { + "items": [ + { + "fieldRef": { + "apiVersion": "v1", + "fieldPath": "metadata.namespace" + }, + "path": "namespace" + } + ] + } + } + ] + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:20:03Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:20:10Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:20:10Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:19:54Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "containerd://0905e616570eb080cf572a5f632f90abd6fc115234e2059551e8c9338cedbe45", + "image": "us-west1-docker.pkg.dev/gep-kne/ceos:ga", + "imageID": "docker.io/library/import-2023-11-07@sha256:c778a7bcd90456754797022daf173e157e92b7cc30310638f2c00953db61ad08", + "lastState": {}, + "name": "ceos", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2023-11-07T14:20:03Z" + } + } + } + ], + "hostIP": "192.168.16.2", + "initContainerStatuses": [ + { + "containerID": "containerd://68119d6b9c97273d7a5f4b9ddc8d9bc2a09f447685ccfec616f5220c95fce7d9", + "image": "ghcr.io/srl-labs/init-wait:latest", + "imageID": "docker.io/library/import-2023-11-07@sha256:6826c6c65984870f2c56cda40ff2adf5830b36d8c45738129807e1fd523059a5", + "lastState": {}, + "name": "init-r0", + "ready": true, + "restartCount": 0, + "state": { + "terminated": { + "containerID": "containerd://68119d6b9c97273d7a5f4b9ddc8d9bc2a09f447685ccfec616f5220c95fce7d9", + "exitCode": 0, + "finishedAt": "2023-11-07T14:20:02Z", + "reason": "Completed", + "startedAt": "2023-11-07T14:19:55Z" + } + } + } + ], + "phase": "Running", + "podIP": "10.244.0.31", + "podIPs": [ + { + "ip": "10.244.0.31" + } + ], + "qosClass": "Burstable", + "startTime": "2023-11-07T14:19:54Z" + } +} diff --git a/x/webhook/examples/addcontainer/testdata/in_nomod.json b/x/webhook/examples/addcontainer/testdata/in_nomod.json new file mode 100644 index 00000000..fc8c2cb6 --- /dev/null +++ b/x/webhook/examples/addcontainer/testdata/in_nomod.json @@ -0,0 +1,321 @@ +{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "creationTimestamp": "2023-11-07T14:19:54Z", + "labels": { + "app": "r0", + "model": "ceos", + "ondatra-role": "DUT", + "os": "eos", + "topo": "b2b", + "vendor": "ARISTA", + "version": "" + }, + "name": "r0", + "namespace": "b2b", + "ownerReferences": [ + { + "apiVersion": "ceoslab.arista.com/v1alpha1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "CEosLabDevice", + "name": "r0", + "uid": "7faf439d-befe-4df4-b9f4-42dd35659927" + } + ], + "resourceVersion": "21744", + "uid": "4cc873e1-4166-415e-89a1-9692270e260d" + }, + "spec": { + "containers": [ + { + "args": [ + "systemd.setenv=CEOS=1", + "systemd.setenv=EOS_PLATFORM=ceoslab", + "systemd.setenv=ETBA=1", + "systemd.setenv=INTFTYPE=eth", + "systemd.setenv=SKIP_ZEROTOUCH_BARRIER_IN_SYSDBINIT=1", + "systemd.setenv=container=docker" + ], + "command": [ + "/sbin/init" + ], + "env": [ + { + "name": "CEOS", + "value": "1" + }, + { + "name": "EOS_PLATFORM", + "value": "ceoslab" + }, + { + "name": "ETBA", + "value": "1" + }, + { + "name": "INTFTYPE", + "value": "eth" + }, + { + "name": "SKIP_ZEROTOUCH_BARRIER_IN_SYSDBINIT", + "value": "1" + }, + { + "name": "container", + "value": "docker" + } + ], + "image": "us-west1-docker.pkg.dev/gep-kne/ceos:ga", + "imagePullPolicy": "IfNotPresent", + "name": "ceos", + "resources": { + "requests": { + "cpu": "500m", + "memory": "1Gi" + } + }, + "securityContext": { + "privileged": true + }, + "startupProbe": { + "exec": { + "command": [ + "wfw", + "-t", + "5" + ] + }, + "failureThreshold": 24, + "periodSeconds": 5, + "successThreshold": 1, + "timeoutSeconds": 5 + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/mnt/flash/EosIntfMapping.json", + "name": "volume-configmap-intfmapping-r0", + "subPath": "EosIntfMapping.json" + }, + { + "mountPath": "/mnt/flash/rc.eos", + "name": "volume-configmap-rceos-r0", + "subPath": "rc.eos" + }, + { + "mountPath": "/mnt/flash/startup-config", + "name": "volume-r0-config", + "subPath": "startup-config" + }, + { + "mountPath": "/mnt/flash/gnmiCert.pem", + "name": "volume-secret-selfsigned-r0-0", + "subPath": "gnmiCert.pem" + }, + { + "mountPath": "/mnt/flash/gnmiCertKey.pem", + "name": "volume-secret-selfsigned-r0-0", + "subPath": "gnmiCertKey.pem" + }, + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "kube-api-access-4txwv", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "initContainers": [ + { + "args": [ + "4", + "0" + ], + "image": "us-west1-docker.pkg.dev/gep-kne/kne/networkop/init-wait:ga", + "imagePullPolicy": "IfNotPresent", + "name": "init-r0", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "kube-api-access-4txwv", + "readOnly": true + } + ] + } + ], + "nodeName": "kne-control-plane", + "preemptionPolicy": "PreemptLowerPriority", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 0, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "configMap": { + "defaultMode": 420, + "name": "configmap-intfmapping-r0" + }, + "name": "volume-configmap-intfmapping-r0" + }, + { + "configMap": { + "defaultMode": 509, + "name": "configmap-rceos-r0" + }, + "name": "volume-configmap-rceos-r0" + }, + { + "configMap": { + "defaultMode": 420, + "name": "r0-config" + }, + "name": "volume-r0-config" + }, + { + "name": "volume-secret-selfsigned-r0-0", + "secret": { + "defaultMode": 420, + "secretName": "secret-selfsigned-r0-0" + } + }, + { + "name": "kube-api-access-4txwv", + "projected": { + "defaultMode": 420, + "sources": [ + { + "serviceAccountToken": { + "expirationSeconds": 3607, + "path": "token" + } + }, + { + "configMap": { + "items": [ + { + "key": "ca.crt", + "path": "ca.crt" + } + ], + "name": "kube-root-ca.crt" + } + }, + { + "downwardAPI": { + "items": [ + { + "fieldRef": { + "apiVersion": "v1", + "fieldPath": "metadata.namespace" + }, + "path": "namespace" + } + ] + } + } + ] + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:20:03Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:20:10Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:20:10Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:19:54Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "containerd://0905e616570eb080cf572a5f632f90abd6fc115234e2059551e8c9338cedbe45", + "image": "us-west1-docker.pkg.dev/gep-kne/ceos:ga", + "imageID": "docker.io/library/import-2023-11-07@sha256:c778a7bcd90456754797022daf173e157e92b7cc30310638f2c00953db61ad08", + "lastState": {}, + "name": "ceos", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2023-11-07T14:20:03Z" + } + } + } + ], + "hostIP": "192.168.16.2", + "initContainerStatuses": [ + { + "containerID": "containerd://68119d6b9c97273d7a5f4b9ddc8d9bc2a09f447685ccfec616f5220c95fce7d9", + "image": "ghcr.io/srl-labs/init-wait:latest", + "imageID": "docker.io/library/import-2023-11-07@sha256:6826c6c65984870f2c56cda40ff2adf5830b36d8c45738129807e1fd523059a5", + "lastState": {}, + "name": "init-r0", + "ready": true, + "restartCount": 0, + "state": { + "terminated": { + "containerID": "containerd://68119d6b9c97273d7a5f4b9ddc8d9bc2a09f447685ccfec616f5220c95fce7d9", + "exitCode": 0, + "finishedAt": "2023-11-07T14:20:02Z", + "reason": "Completed", + "startedAt": "2023-11-07T14:19:55Z" + } + } + } + ], + "phase": "Running", + "podIP": "10.244.0.31", + "podIPs": [ + { + "ip": "10.244.0.31" + } + ], + "qosClass": "Burstable", + "startTime": "2023-11-07T14:19:54Z" + } +} diff --git a/x/webhook/examples/addcontainer/testdata/out.json b/x/webhook/examples/addcontainer/testdata/out.json new file mode 100644 index 00000000..de7956e4 --- /dev/null +++ b/x/webhook/examples/addcontainer/testdata/out.json @@ -0,0 +1,341 @@ +{ + "kind": "Pod", + "apiVersion": "v1", + "metadata": { + "name": "r0", + "namespace": "b2b", + "uid": "4cc873e1-4166-415e-89a1-9692270e260d", + "resourceVersion": "21744", + "creationTimestamp": "2023-11-07T14:19:54Z", + "labels": { + "webhook": "enabled", + "app": "r0", + "model": "ceos", + "ondatra-role": "DUT", + "os": "eos", + "topo": "b2b", + "vendor": "ARISTA", + "version": "" + }, + "ownerReferences": [ + { + "apiVersion": "ceoslab.arista.com/v1alpha1", + "kind": "CEosLabDevice", + "name": "r0", + "uid": "7faf439d-befe-4df4-b9f4-42dd35659927", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "volumes": [ + { + "name": "volume-configmap-intfmapping-r0", + "configMap": { + "name": "configmap-intfmapping-r0", + "defaultMode": 420 + } + }, + { + "name": "volume-configmap-rceos-r0", + "configMap": { + "name": "configmap-rceos-r0", + "defaultMode": 509 + } + }, + { + "name": "volume-r0-config", + "configMap": { + "name": "r0-config", + "defaultMode": 420 + } + }, + { + "name": "volume-secret-selfsigned-r0-0", + "secret": { + "secretName": "secret-selfsigned-r0-0", + "defaultMode": 420 + } + }, + { + "name": "kube-api-access-4txwv", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "expirationSeconds": 3607, + "path": "token" + } + }, + { + "configMap": { + "name": "kube-root-ca.crt", + "items": [ + { + "key": "ca.crt", + "path": "ca.crt" + } + ] + } + }, + { + "downwardAPI": { + "items": [ + { + "path": "namespace", + "fieldRef": { + "apiVersion": "v1", + "fieldPath": "metadata.namespace" + } + } + ] + } + } + ], + "defaultMode": 420 + } + } + ], + "initContainers": [ + { + "name": "init-r0", + "image": "us-west1-docker.pkg.dev/gep-kne/kne/networkop/init-wait:ga", + "args": [ + "4", + "0" + ], + "resources": {}, + "volumeMounts": [ + { + "name": "kube-api-access-4txwv", + "readOnly": true, + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount" + } + ], + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "IfNotPresent" + } + ], + "containers": [ + { + "name": "ceos", + "image": "us-west1-docker.pkg.dev/gep-kne/ceos:ga", + "command": [ + "/sbin/init" + ], + "args": [ + "systemd.setenv=CEOS=1", + "systemd.setenv=EOS_PLATFORM=ceoslab", + "systemd.setenv=ETBA=1", + "systemd.setenv=INTFTYPE=eth", + "systemd.setenv=SKIP_ZEROTOUCH_BARRIER_IN_SYSDBINIT=1", + "systemd.setenv=container=docker" + ], + "env": [ + { + "name": "CEOS", + "value": "1" + }, + { + "name": "EOS_PLATFORM", + "value": "ceoslab" + }, + { + "name": "ETBA", + "value": "1" + }, + { + "name": "INTFTYPE", + "value": "eth" + }, + { + "name": "SKIP_ZEROTOUCH_BARRIER_IN_SYSDBINIT", + "value": "1" + }, + { + "name": "container", + "value": "docker" + } + ], + "resources": { + "requests": { + "cpu": "500m", + "memory": "1Gi" + } + }, + "volumeMounts": [ + { + "name": "volume-configmap-intfmapping-r0", + "mountPath": "/mnt/flash/EosIntfMapping.json", + "subPath": "EosIntfMapping.json" + }, + { + "name": "volume-configmap-rceos-r0", + "mountPath": "/mnt/flash/rc.eos", + "subPath": "rc.eos" + }, + { + "name": "volume-r0-config", + "mountPath": "/mnt/flash/startup-config", + "subPath": "startup-config" + }, + { + "name": "volume-secret-selfsigned-r0-0", + "mountPath": "/mnt/flash/gnmiCert.pem", + "subPath": "gnmiCert.pem" + }, + { + "name": "volume-secret-selfsigned-r0-0", + "mountPath": "/mnt/flash/gnmiCertKey.pem", + "subPath": "gnmiCertKey.pem" + }, + { + "name": "kube-api-access-4txwv", + "readOnly": true, + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount" + } + ], + "startupProbe": { + "exec": { + "command": [ + "wfw", + "-t", + "5" + ] + }, + "timeoutSeconds": 5, + "periodSeconds": 5, + "successThreshold": 1, + "failureThreshold": 24 + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "IfNotPresent", + "securityContext": { + "privileged": true + } + }, + { + "name": "alpine", + "image": "alpine:latest", + "command": [ + "/bin/sh", + "-c", + "sleep 2000000000000" + ], + "resources": { + "requests": { + "cpu": "500m", + "memory": "1Gi" + } + }, + "imagePullPolicy": "IfNotPresent", + "securityContext": { + "privileged": true + } + } + ], + "restartPolicy": "Always", + "terminationGracePeriodSeconds": 0, + "dnsPolicy": "ClusterFirst", + "serviceAccountName": "default", + "serviceAccount": "default", + "nodeName": "kne-control-plane", + "securityContext": {}, + "schedulerName": "default-scheduler", + "tolerations": [ + { + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + }, + { + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + } + ], + "priority": 0, + "enableServiceLinks": true, + "preemptionPolicy": "PreemptLowerPriority" + }, + "status": { + "phase": "Running", + "conditions": [ + { + "type": "Initialized", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:20:03Z" + }, + { + "type": "Ready", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:20:10Z" + }, + { + "type": "ContainersReady", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:20:10Z" + }, + { + "type": "PodScheduled", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:19:54Z" + } + ], + "hostIP": "192.168.16.2", + "podIP": "10.244.0.31", + "podIPs": [ + { + "ip": "10.244.0.31" + } + ], + "startTime": "2023-11-07T14:19:54Z", + "initContainerStatuses": [ + { + "name": "init-r0", + "state": { + "terminated": { + "exitCode": 0, + "reason": "Completed", + "startedAt": "2023-11-07T14:19:55Z", + "finishedAt": "2023-11-07T14:20:02Z", + "containerID": "containerd://68119d6b9c97273d7a5f4b9ddc8d9bc2a09f447685ccfec616f5220c95fce7d9" + } + }, + "lastState": {}, + "ready": true, + "restartCount": 0, + "image": "ghcr.io/srl-labs/init-wait:latest", + "imageID": "docker.io/library/import-2023-11-07@sha256:6826c6c65984870f2c56cda40ff2adf5830b36d8c45738129807e1fd523059a5", + "containerID": "containerd://68119d6b9c97273d7a5f4b9ddc8d9bc2a09f447685ccfec616f5220c95fce7d9" + } + ], + "containerStatuses": [ + { + "name": "ceos", + "state": { + "running": { + "startedAt": "2023-11-07T14:20:03Z" + } + }, + "lastState": {}, + "ready": true, + "restartCount": 0, + "image": "us-west1-docker.pkg.dev/gep-kne/ceos:ga", + "imageID": "docker.io/library/import-2023-11-07@sha256:c778a7bcd90456754797022daf173e157e92b7cc30310638f2c00953db61ad08", + "containerID": "containerd://0905e616570eb080cf572a5f632f90abd6fc115234e2059551e8c9338cedbe45", + "started": true + } + ], + "qosClass": "Burstable" + } +} diff --git a/x/webhook/examples/addcontainer/testdata/out_nomod.json b/x/webhook/examples/addcontainer/testdata/out_nomod.json new file mode 100644 index 00000000..e6d84cfa --- /dev/null +++ b/x/webhook/examples/addcontainer/testdata/out_nomod.json @@ -0,0 +1,321 @@ +{ + "kind": "Pod", + "apiVersion": "v1", + "metadata": { + "name": "r0", + "namespace": "b2b", + "uid": "4cc873e1-4166-415e-89a1-9692270e260d", + "resourceVersion": "21744", + "creationTimestamp": "2023-11-07T14:19:54Z", + "labels": { + "app": "r0", + "model": "ceos", + "ondatra-role": "DUT", + "os": "eos", + "topo": "b2b", + "vendor": "ARISTA", + "version": "" + }, + "ownerReferences": [ + { + "apiVersion": "ceoslab.arista.com/v1alpha1", + "kind": "CEosLabDevice", + "name": "r0", + "uid": "7faf439d-befe-4df4-b9f4-42dd35659927", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "volumes": [ + { + "name": "volume-configmap-intfmapping-r0", + "configMap": { + "name": "configmap-intfmapping-r0", + "defaultMode": 420 + } + }, + { + "name": "volume-configmap-rceos-r0", + "configMap": { + "name": "configmap-rceos-r0", + "defaultMode": 509 + } + }, + { + "name": "volume-r0-config", + "configMap": { + "name": "r0-config", + "defaultMode": 420 + } + }, + { + "name": "volume-secret-selfsigned-r0-0", + "secret": { + "secretName": "secret-selfsigned-r0-0", + "defaultMode": 420 + } + }, + { + "name": "kube-api-access-4txwv", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "expirationSeconds": 3607, + "path": "token" + } + }, + { + "configMap": { + "name": "kube-root-ca.crt", + "items": [ + { + "key": "ca.crt", + "path": "ca.crt" + } + ] + } + }, + { + "downwardAPI": { + "items": [ + { + "path": "namespace", + "fieldRef": { + "apiVersion": "v1", + "fieldPath": "metadata.namespace" + } + } + ] + } + } + ], + "defaultMode": 420 + } + } + ], + "initContainers": [ + { + "name": "init-r0", + "image": "us-west1-docker.pkg.dev/gep-kne/kne/networkop/init-wait:ga", + "args": [ + "4", + "0" + ], + "resources": {}, + "volumeMounts": [ + { + "name": "kube-api-access-4txwv", + "readOnly": true, + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount" + } + ], + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "IfNotPresent" + } + ], + "containers": [ + { + "name": "ceos", + "image": "us-west1-docker.pkg.dev/gep-kne/ceos:ga", + "command": [ + "/sbin/init" + ], + "args": [ + "systemd.setenv=CEOS=1", + "systemd.setenv=EOS_PLATFORM=ceoslab", + "systemd.setenv=ETBA=1", + "systemd.setenv=INTFTYPE=eth", + "systemd.setenv=SKIP_ZEROTOUCH_BARRIER_IN_SYSDBINIT=1", + "systemd.setenv=container=docker" + ], + "env": [ + { + "name": "CEOS", + "value": "1" + }, + { + "name": "EOS_PLATFORM", + "value": "ceoslab" + }, + { + "name": "ETBA", + "value": "1" + }, + { + "name": "INTFTYPE", + "value": "eth" + }, + { + "name": "SKIP_ZEROTOUCH_BARRIER_IN_SYSDBINIT", + "value": "1" + }, + { + "name": "container", + "value": "docker" + } + ], + "resources": { + "requests": { + "cpu": "500m", + "memory": "1Gi" + } + }, + "volumeMounts": [ + { + "name": "volume-configmap-intfmapping-r0", + "mountPath": "/mnt/flash/EosIntfMapping.json", + "subPath": "EosIntfMapping.json" + }, + { + "name": "volume-configmap-rceos-r0", + "mountPath": "/mnt/flash/rc.eos", + "subPath": "rc.eos" + }, + { + "name": "volume-r0-config", + "mountPath": "/mnt/flash/startup-config", + "subPath": "startup-config" + }, + { + "name": "volume-secret-selfsigned-r0-0", + "mountPath": "/mnt/flash/gnmiCert.pem", + "subPath": "gnmiCert.pem" + }, + { + "name": "volume-secret-selfsigned-r0-0", + "mountPath": "/mnt/flash/gnmiCertKey.pem", + "subPath": "gnmiCertKey.pem" + }, + { + "name": "kube-api-access-4txwv", + "readOnly": true, + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount" + } + ], + "startupProbe": { + "exec": { + "command": [ + "wfw", + "-t", + "5" + ] + }, + "timeoutSeconds": 5, + "periodSeconds": 5, + "successThreshold": 1, + "failureThreshold": 24 + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "IfNotPresent", + "securityContext": { + "privileged": true + } + } + ], + "restartPolicy": "Always", + "terminationGracePeriodSeconds": 0, + "dnsPolicy": "ClusterFirst", + "serviceAccountName": "default", + "serviceAccount": "default", + "nodeName": "kne-control-plane", + "securityContext": {}, + "schedulerName": "default-scheduler", + "tolerations": [ + { + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + }, + { + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + } + ], + "priority": 0, + "enableServiceLinks": true, + "preemptionPolicy": "PreemptLowerPriority" + }, + "status": { + "phase": "Running", + "conditions": [ + { + "type": "Initialized", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:20:03Z" + }, + { + "type": "Ready", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:20:10Z" + }, + { + "type": "ContainersReady", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:20:10Z" + }, + { + "type": "PodScheduled", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2023-11-07T14:19:54Z" + } + ], + "hostIP": "192.168.16.2", + "podIP": "10.244.0.31", + "podIPs": [ + { + "ip": "10.244.0.31" + } + ], + "startTime": "2023-11-07T14:19:54Z", + "initContainerStatuses": [ + { + "name": "init-r0", + "state": { + "terminated": { + "exitCode": 0, + "reason": "Completed", + "startedAt": "2023-11-07T14:19:55Z", + "finishedAt": "2023-11-07T14:20:02Z", + "containerID": "containerd://68119d6b9c97273d7a5f4b9ddc8d9bc2a09f447685ccfec616f5220c95fce7d9" + } + }, + "lastState": {}, + "ready": true, + "restartCount": 0, + "image": "ghcr.io/srl-labs/init-wait:latest", + "imageID": "docker.io/library/import-2023-11-07@sha256:6826c6c65984870f2c56cda40ff2adf5830b36d8c45738129807e1fd523059a5", + "containerID": "containerd://68119d6b9c97273d7a5f4b9ddc8d9bc2a09f447685ccfec616f5220c95fce7d9" + } + ], + "containerStatuses": [ + { + "name": "ceos", + "state": { + "running": { + "startedAt": "2023-11-07T14:20:03Z" + } + }, + "lastState": {}, + "ready": true, + "restartCount": 0, + "image": "us-west1-docker.pkg.dev/gep-kne/ceos:ga", + "imageID": "docker.io/library/import-2023-11-07@sha256:c778a7bcd90456754797022daf173e157e92b7cc30310638f2c00953db61ad08", + "containerID": "containerd://0905e616570eb080cf572a5f632f90abd6fc115234e2059551e8c9338cedbe45", + "started": true + } + ], + "qosClass": "Burstable" + } +} diff --git a/x/webhook/examples/topology.textproto b/x/webhook/examples/topology.textproto new file mode 100644 index 00000000..26ea2218 --- /dev/null +++ b/x/webhook/examples/topology.textproto @@ -0,0 +1,19 @@ +name: "webhook-example" +nodes: { + name: "r1" + vendor: HOST + labels { + key: "webhook" + value: "enabled" + } +} +nodes: { + name: "r2" + vendor: HOST +} +links: { + a_node: "r1" + a_int: "eth1" + z_node: "r2" + z_int: "eth1" +} diff --git a/x/webhook/main.go b/x/webhook/main.go new file mode 100644 index 00000000..0df6bf62 --- /dev/null +++ b/x/webhook/main.go @@ -0,0 +1,111 @@ +// Copyright 2024 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. + +// Main is an mutating k8s webhook +// (https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/) +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/openconfig/kne/x/webhook/admission" + "github.com/openconfig/kne/x/webhook/examples/addcontainer" + "github.com/openconfig/kne/x/webhook/mutate" + admissionv1 "k8s.io/api/admission/v1" + log "k8s.io/klog/v2" +) + +const ( + cert = "/etc/kne-assembly-webhook/tls/tls.crt" + key = "/etc/kne-assembly-webhook/tls/tls.key" +) + +func main() { + http.HandleFunc("/mutate-objects", ServeMutateObjects) + http.HandleFunc("/health", ServeHealth) + + log.Info("Listening on port 443...") + s := http.Server{ + Addr: ":443", + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + log.Fatal(s.ListenAndServeTLS(cert, key)) +} + +// ServeHealth returns 200 when things are good +func ServeHealth(w http.ResponseWriter, r *http.Request) { + log.Infof("uri %s - healthy", r.RequestURI) + fmt.Fprint(w, "OK") +} + +// ServeMutateObjects returns an admission review with mutations as a json patch +// in the review response +func ServeMutateObjects(w http.ResponseWriter, r *http.Request) { + in, err := parseRequest(*r) + if err != nil { + log.Error(err) + return + } + + admitter := admission.New(in.Request, []mutate.MutationFunc{addcontainer.AddContainer}) + + res, err := admitter.Review() + if err != nil { + log.Errorf("admitter.Review() error: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + jout, err := json.Marshal(res) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + fmt.Fprintf(w, "%s", jout) +} + +// parseRequest extracts an AdmissionReview from an http.Request if possible +func parseRequest(r http.Request) (*admissionv1.AdmissionReview, error) { + log.Infof("parsing request: %v", r) + if r.Header.Get("Content-Type") != "application/json" { + return nil, fmt.Errorf("Content-Type: %q should be %q", r.Header.Get("Content-Type"), "application/json") + } + + bodybuf := new(bytes.Buffer) + bodybuf.ReadFrom(r.Body) + body := bodybuf.Bytes() + + if len(body) == 0 { + return nil, fmt.Errorf("admission request body is empty") + } + + var a admissionv1.AdmissionReview + + if err := json.Unmarshal(body, &a); err != nil { + return nil, fmt.Errorf("could not parse admission review request: %v", err) + } + + if a.Request == nil { + return nil, fmt.Errorf("admission review can't be used: Request field is nil") + } + + return &a, nil +} diff --git a/x/webhook/manifests/deploy.yaml b/x/webhook/manifests/deploy.yaml new file mode 100644 index 00000000..fddb16a5 --- /dev/null +++ b/x/webhook/manifests/deploy.yaml @@ -0,0 +1,29 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: kne-assembly-webhook + name: kne-assembly-webhook + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: kne-assembly-webhook + template: + metadata: + labels: + app: kne-assembly-webhook + spec: + containers: + - image: webhook:latest + imagePullPolicy: IfNotPresent + name: kne-assembly-webhook + volumeMounts: + - name: tls + mountPath: "/etc/kne-assembly-webhook/tls" + readOnly: true + volumes: + - name: tls + secret: + secretName: kne-assembly-webhook-tls diff --git a/x/webhook/manifests/mutating.config.yaml b/x/webhook/manifests/mutating.config.yaml new file mode 100644 index 00000000..8c0bdbd8 --- /dev/null +++ b/x/webhook/manifests/mutating.config.yaml @@ -0,0 +1,46 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + name: "kne-assembly-webhook.kne.org" +webhooks: + - name: "kne-assembly-webhook.kne.org" + namespaceSelector: + matchLabels: + kne-topology: "true" + rules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE"] + resources: ["pods"] + scope: "*" + clientConfig: + service: + namespace: default + name: kne-assembly-webhook + path: /mutate-objects + port: 443 + caBundle: | + LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURPVENDQWlHZ0F3SUJBZ0lVRFBMb2FqV1NL + Vzd2R2k0blQ3bkxQbkJSZW5Nd0RRWUpLb1pJaHZjTkFRRUwKQlFBd0xERUxNQWtHQTFVRUJoTUNR + VlV4SFRBYkJnTlZCQU1NRkd0dVpTMWhjM05sYldKc2VTMTNaV0pvYjI5cgpNQjRYRFRJME1EUXdN + akl4TVRnME1Gb1hEVEkxTURRd01qSXhNVGcwTUZvd0xERUxNQWtHQTFVRUJoTUNRVlV4CkhUQWJC + Z05WQkFNTUZHdHVaUzFoYzNObGJXSnNlUzEzWldKb2IyOXJNSUlCSWpBTkJna3Foa2lHOXcwQkFR + RUYKQUFPQ0FROEFNSUlCQ2dLQ0FRRUFuL0lQb2xuZEx2b3FVejRjMUt6a3RLVFBPZzc5eG5IYTc0 + ZGFJZWU4enFaUwpmMjlMRmk0R1FRT0EwaStmNDJvOVdPK2UwTFY5VTV1eHhOWVpkL2Q4WnJrU295 + Q2RtYnhqeFduZFduOUY1cVE5ClZZQ05jaGNVcnJQamJPK1c0dVcwZlRCUmo3SGd0RktTOFRGZXBo + a0x2Yjd5VWJZUldlZ3dXUStyQTdjQ3JGd1UKemI4ZFBXT2xTRzQyVXhaekM5aDZGbzkxM09IVzdX + a0E5MTg2S0IycE1pQlNDcHhwb1d1cW5NVk9ndWR3bWpqZgpEaC80K2xhWkNDTThLbk5Ca3NhNGQw + czJiQXhCaVFtR2grWk5vUHV2c2xiYWhMakVreDYzR2hDc1lhWUZMWUhxCk00UWg1T0ZLZWgvMVYz + SUNOV2dHa1BHQVlRalRIOU5VTVB5eTlONlIwUUlEQVFBQm8xTXdVVEFkQmdOVkhRNEUKRmdRVWpM + OUxtZHp0bzNhcEQ5Tm41UFMxRUs2TThLWXdId1lEVlIwakJCZ3dGb0FVakw5TG1kenRvM2FwRDlO + bgo1UFMxRUs2TThLWXdEd1lEVlIwVEFRSC9CQVV3QXdFQi96QU5CZ2txaGtpRzl3MEJBUXNGQUFP + Q0FRRUFjMFNyCnNuZ29ka2I3WUx1QnBZMHZ6L2VybkFQM08xK1F2VmdZUGtRU1NjbkhyVXBrQUo1 + UnR4Mk4xemxQQm1kdXJqNUwKSEx5N3hmR002R0hDR1FBNGtGZG13dDdaME9qVUVBQ0hvSzNPWHRX + cGY4bWdzb243WmVDOG1XUDlWbS9SdElmNworKzJVdVgvSkk5TkN0dlhDYVQzS21CTWtna3ZxWUMw + M0RKUlVtWUo1OVNscVA2TmNGcTZBcGRyZCtBazNvcy9pCkhjT2ZyU0Y3dEpZdFQxZzBiT2hXbDNQ + SmpMSTIvWWxTcm0yTXRrQkpuWTJYNldUV2Y2Y0RhcVNjc3hOaWl5N08KYmdCMFpmcDdvVnp5dWhv + ZVVjV2lXRUJIY1BkZGI5Q0VEeW0yamZ4V2NUWWhiRitGaHM0LzhndHBLY3lRWVZCRgo0Z2o3SCsv + bFlnOCtsZHNJa1E9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== + admissionReviewVersions: ["v1"] + sideEffects: None + timeoutSeconds: 2 diff --git a/x/webhook/manifests/namespace.yaml b/x/webhook/manifests/namespace.yaml new file mode 100644 index 00000000..2adf86a8 --- /dev/null +++ b/x/webhook/manifests/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: webhook + labels: + admission-webhook: enabled diff --git a/x/webhook/manifests/svc.yaml b/x/webhook/manifests/svc.yaml new file mode 100644 index 00000000..b5330084 --- /dev/null +++ b/x/webhook/manifests/svc.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: kne-assembly-webhook + name: kne-assembly-webhook + namespace: default +spec: + type: NodePort + ports: + - port: 443 + protocol: TCP + targetPort: 443 + nodePort: 30100 + selector: + app: kne-assembly-webhook diff --git a/x/webhook/manifests/tls.secret.yaml b/x/webhook/manifests/tls.secret.yaml new file mode 100644 index 00000000..4e5bd8bb --- /dev/null +++ b/x/webhook/manifests/tls.secret.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +data: + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURWVENDQWoyZ0F3SUJBZ0lVVEJRMWJtWFlOZFNCRC9iUllkakpsajJtNVlVd0RRWUpLb1pJaHZjTkFRRUwKQlFBd0xERUxNQWtHQTFVRUJoTUNRVlV4SFRBYkJnTlZCQU1NRkd0dVpTMWhjM05sYldKc2VTMTNaV0pvYjI5cgpNQjRYRFRJME1EUXdNakl4TVRnME1Wb1hEVEkxTURRd01qSXhNVGcwTVZvd0xERUxNQWtHQTFVRUJoTUNRVlV4CkhUQWJCZ05WQkFNTUZHdHVaUzFoYzNObGJXSnNlUzEzWldKb2IyOXJNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUYKQUFPQ0FROEFNSUlCQ2dLQ0FRRUE0M0F2L2lLRzRLNFE0N1luZlc1bGVtTUFLcWRTWjQ3YXhDRDBzM0hoSVI2dgpiTlRldmVmamEvRTN5S08vZ282ZTNVT3d6T01qanVEVHRveVJWZ05kQnBkUklXc2pKSXd2S1ZBR0tUV0ZRVmppCmoyNzIyTG9nNkJ2OVBOVmJSRFRiSUErNnRWbW9IUWFIei9KY3lOR0ZtQmxwZWxhWWY4SXZwQjNkWkY5T3lNSWoKWDVydXlxeTFyUi9uNUliZytZK0M1KzZDWW1OVDVNN2Nqajd3MzNwQ2dWVkRXaGk3NkU3QjJ0TVVTM01aczlMNwpvaHI0T3phK04wczl6ZlgreFY3bnVCTlQ5d1hmNGRET0g3REdUUVhvYjVKYVF0Uk45NlZuRFhtd3Z0VGlSZTZvClExMW1IRkNyQWprbGYyeXZwOXplM2tQekZ1djJkTHg5NzhjSk9vZWlzUUlEQVFBQm8yOHdiVEFyQmdOVkhSRUUKSkRBaWdpQnJibVV0WVhOelpXMWliSGt0ZDJWaWFHOXZheTVrWldaaGRXeDBMbk4yWXpBZEJnTlZIUTRFRmdRVQpkS2lmUkNadHc0MnVieENBVzlOSmd4ZWhubkl3SHdZRFZSMGpCQmd3Rm9BVWpMOUxtZHp0bzNhcEQ5Tm41UFMxCkVLNk04S1l3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUIyOTg1VGUzZzA2c2dMRzdKYTNpZmF1SXFEbDBEVk4KMHlqbXFQQnNNL2syU3JDSE53UW53Rk01MVRxVlhoek8yM2o3bUhhR1FVdlNpTUZzRG1kK1JCS09MVUNFSWwzLwphTlkrNVpaS2thTUZzM1d0bjIzVWtzaEpKR3VJR1N1dXkreTRqdTJ1WXJ4RTVuZWpvRzBvRTk4TXo5ZmZ4bDVFClh4TnRiejZPR1JFNXloQXNBcE1jbnVsY0ZPektFaS9hNHN3MW4vam5KbEwwV0ptRHN1T2FOOWNZMndFS0pCa0cKOEF4d0ZYMUxlOHFhRkl2RGxqWVlnc2tnTjFVZ1hzZkdxdm05eGYzejQ4bDJURGF5T1NxWUhTRHN1T3d1WXRHNwoxV3JWazVjb09FbzljZVBTQlpzYnpKQlpGeFZnbERvdzFVTVUyVWp4TEt4MDQza0hvMEpzSjNzPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== + tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRRGpjQy8rSW9iZ3JoRGoKdGlkOWJtVjZZd0FxcDFKbmp0ckVJUFN6Y2VFaEhxOXMxTjY5NStOcjhUZklvNytDanA3ZFE3RE00eU9PNE5PMgpqSkZXQTEwR2wxRWhheU1rakM4cFVBWXBOWVZCV09LUGJ2Yll1aURvRy8wODFWdEVOTnNnRDdxMVdhZ2RCb2ZQCjhsekkwWVdZR1dsNlZwaC93aStrSGQxa1gwN0l3aU5mbXU3S3JMV3RIK2ZraHVENWo0TG43b0ppWTFQa3p0eU8KUHZEZmVrS0JWVU5hR0x2b1RzSGEweFJMY3htejB2dWlHdmc3TnI0M1N6M045ZjdGWHVlNEUxUDNCZC9oME00ZgpzTVpOQmVodmtscEMxRTMzcFdjTmViQysxT0pGN3FoRFhXWWNVS3NDT1NWL2JLK24zTjdlUS9NVzYvWjB2SDN2Cnh3azZoNkt4QWdNQkFBRUNnZ0VBQyt4Zys3bjhKN0hBYld1cWdOTWxpMGUrNWIyTlhxUDQySkRndXpxVVlwdmMKOXYvbTZINU1hQ2VHZE9TT3dPMndxdWxtVHF0MnZRSVEzMUJadXpCa2MxakI5bFpMU3BwMXkzem9RY2RPUTltQQowU093V2JiU1RGRFJVeHZkVThOUW5JcnVqeHpTNlNpalBKWFlZdWZIRU96K0lGMkRVNmxPdlpRYU8rMmFNQUNTCk5GYXNWNlQzSVRSajZwUUt0UVV4VXFSelpIeThpOVFQcGx3bURlSk4zWXJNcnVtdFkreWdsWi9BQ0JPY1AzTlYKK3g5cVloNWFwRnZ5UCtQeVphOWJBZ28xNnd0UmtUVllRMHlGRGJJZzZacFFQQTUxUXB1UHQ1TThFLzJUL2VLVQpmNXN2MUJLQjZIOWJMRU42N0FudDJUcERNbzFTY2o3YTFYYU9FSUdqOFFLQmdRRDBTZ1BMTGVBWjkwUVEycDlFCk5pN2JqT1IzYkhUTEFaSTJYVWRtZUlDOENTcVVtSW9abXluVVExbEttTTJQb09VWlRrTkpVdlUxK0xTWnVCcjUKYVkyOWpSSHNzUHl5ano3cE9XbmNxUXRPL0NJN2ROU05oS0ZGTFJQWnd2YlRHU0ZiNWd1bDlXSW55Z3N4VmwwUgoxcmlPcm1WMnpORUptSXRWRFYrNk0wRnZ1UUtCZ1FEdVYxK3drQ0ROc2NKWkxJU2ZUNENYM3FSVlNpZ044TEpiCjZQZW9wckVlbk96cVJqY1VlenBQM3dTdXl6UzVnNW9MeTdnZ3BTMi9zNmdmemNvQzFZZXV6Z1M5cXhlSDJkbSsKUVVvU1hQbXR1QjYxL3hzWG1tMU1ianVCblhncTBqcVljZFZOMVZyUXRjMnNYRWFTcUZiNFloUzhIeVg0dUkyeQpMeENPdjhRV3VRS0JnRmYySnJPTVN6dE9TNVgrQW9jZk0zUWVvVTFYSWg3TzdBVGpSWWhpTDRpRmpHMkJGNGpzCjAvejRXemgvR05WMHk4bDI0c2VPTlhrL21sZ1hjSzhLRU4yRjVFUmozam0xVnFQSDVwUnIwZ1NZeVFLN3FLVmsKY21Wa085ZVhXaVRjMGFRemkxSXdyeTFBbFJNbzA4NU9rSm5mdGUwM0JyWDcxWC9FbHdtRzF6TVJBb0dCQUl0cgpFTTUzZ0xqU0FwMm5MTzBEMUhVQ0I1N2NnaEdsZXEvSTF4WVFiQXM4UUZuS09PNENKMW9SV3V2a2NqTVNpRW5lCklSYjNpSXRhekQzT1l4ekZTMWsxcWhCSXhMcnk5Q3dXaFAyNDVWUjVIMzNXZkVLU1V0MGluaXh6c0pkYjRtcksKSzd3YjBjUEVsVXI5cjBxYXJrVWRHb1B3dElXSmIxbUxybVBTU1NJQkFvR0FZTU5CS3UwZUU1YWJjYzlUK2gySwpIQXY4aEZFRlNhWkIwYVVnR1lhZ21ZTmd3Q0EzWjRSQU1YWWNKVC9KSm1EODdtTWVhcmpidC9GeW1LZyswQ21FCjVsZ2VPeTg1MGpJMkdsbkY4SkxjYjM2bXc4VWdNbkdvMW1TMUU3Y0pvWEV3UlYwOS95UzFZR3crNDFNeTNxYzYKRzRsZ1dHM09xMUV6VnhDMkRiQkRvVW89Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K +kind: Secret +metadata: + creationTimestamp: null + name: kne-assembly-webhook-tls +type: kubernetes.io/tls diff --git a/x/webhook/mutate/mutate.go b/x/webhook/mutate/mutate.go new file mode 100644 index 00000000..e4185511 --- /dev/null +++ b/x/webhook/mutate/mutate.go @@ -0,0 +1,66 @@ +// Copyright 2024 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 mutate contains a struct for performing mutations to K8s objects. +package mutate + +import ( + "encoding/json" + "fmt" + + "github.com/wI2L/jsondiff" + "k8s.io/apimachinery/pkg/runtime" + log "k8s.io/klog/v2" +) + +type MutationFunc func(runtime.Object) (runtime.Object, error) + +// Mutor contains a set of mutation functions. +type Mutor struct { + mutationFuncs []MutationFunc +} + +// New build a new Mutor +func New(m []MutationFunc) *Mutor { + return &Mutor{mutationFuncs: m} +} + +// MutatePod applies all mutations to a copy of the provided object. +// It returns a json patch (rfc6902). +func (m *Mutor) MutateObject(obj runtime.Object) ([]byte, error) { + if obj == nil { + return nil, fmt.Errorf("object cannot be nil") + } + log.Infof("Mutating %s", obj.GetObjectKind()) + + cObj := obj.DeepCopyObject() + + // apply all mutations + for _, mutation := range m.mutationFuncs { + var err error + cObj, err = mutation(cObj) + if err != nil { + return nil, err + } + } + + // mpod.Spec.AutomountServiceAccountToken = proto.Bool(false) + + // generate json patch + patch, err := jsondiff.Compare(obj, cObj) + if err != nil { + return nil, err + } + return json.Marshal(patch) +} diff --git a/x/webhook/mutate/mutate_test.go b/x/webhook/mutate/mutate_test.go new file mode 100644 index 00000000..648be07c --- /dev/null +++ b/x/webhook/mutate/mutate_test.go @@ -0,0 +1,96 @@ +// Copyright 2024 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 mutate + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/openconfig/gnmi/errdiff" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + log "k8s.io/klog/v2" +) + +func fakeMutation(obj runtime.Object) (runtime.Object, error) { + p, ok := obj.(*corev1.Pod) + if !ok { + log.Infof("Ignoring object of type %v, not a pod", obj.GetObjectKind()) + return obj, nil + } + if p.Name == "bad-pod" { + return nil, fmt.Errorf("cannot mutate bad-pod") + } + if p.Labels == nil { + p.Labels = make(map[string]string) + } + p.Labels["fake"] = "fake" + + return p, nil +} + +func TestMutatePod(t *testing.T) { + tests := []struct { + name string + in runtime.Object + wantErr string + wantPatch []byte + }{ + { + name: "basic-pod", + in: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic-pod", + }, + }, + wantPatch: []byte(`[{"value":{"fake":"fake"},"op":"add","path":"/metadata/labels"}]`), + }, + { + name: "bad-pod", + in: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bad-pod", + }, + }, + wantErr: "cannot mutate", + }, + { + name: "not a pod", + in: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic-ns", + }, + }, + wantPatch: []byte("null"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New([]MutationFunc{fakeMutation}) + + patch, err := m.MutateObject(tt.in) + if s := errdiff.Check(err, tt.wantErr); s != "" { + t.Errorf("MutateObject() failed: %s", s) + } + + if diff := cmp.Diff(patch, tt.wantPatch); diff != "" { + t.Errorf("MutateObject returned diff (-got, +want):\n%s", diff) + } + }) + } +} diff --git a/x/webhook/secure/genCerts.sh b/x/webhook/secure/genCerts.sh new file mode 100755 index 00000000..85ab9dad --- /dev/null +++ b/x/webhook/secure/genCerts.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# +# Generate the server certificate and key as well as the certificate authority +# to be used by the kube cluster to establish rpc connection to the mutating +# webhook. + +openssl genrsa -out ca.key 2048 + +openssl req -new -x509 -days 365 -key ca.key \ + -subj "/C=AU/CN=kne-assembly-webhook"\ + -out ca.crt + +openssl req -newkey rsa:2048 -nodes -keyout server.key \ + -subj "/C=AU/CN=kne-assembly-webhook" \ + -out server.csr + +openssl x509 -req \ + -extfile <(printf "subjectAltName=DNS:kne-assembly-webhook.default.svc") \ + -days 365 \ + -in server.csr \ + -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out server.crt + +echo +echo ">> Generating kube secrets..." +kubectl create secret tls kne-assembly-webhook-tls \ + --cert=server.crt \ + --key=server.key \ + --dry-run=client -o yaml \ + > manifests/tls.secret.yaml + +echo +echo ">> MutatingWebhookConfiguration caBundle:" +< ca.crt base64 | fold + +rm ca.crt ca.key ca.srl server.crt server.csr server.key