From 2da0e28a9e39d46a1bddc1f0802a57348a99a891 Mon Sep 17 00:00:00 2001 From: Matt Silverlock Date: Sat, 3 Aug 2019 13:26:57 -0700 Subject: [PATCH] feature: EnforcePodAnnotations (#13) * logging: log listener type (HTTP vs HTTPS) * wip: EnforcePodAnnotations * wip: TestEnforcePodAnnotations * deps: update dependencies * wip: EnforcePodAnnotations now handles all core Pod-related Kinds * Add a sample manifest for EnforcePodAnnotaions & fix docker image for AC * Add EnforcePodAnnotations to example server; update README * ci: set GOPROXY * build: use GOPROXY in Dockerfile + ARGs * Handle panics in the logging middleware * wip: update sample YAML manifests for EnforcePodAnnotations * wip: fix panic on AdmitFunc errors when nil resp returned * deps: update k8s API dependencies * build: update deps * tests: add handler tests; update AdmitFunc tests for EnforcePodAnnotations * build: remove GOPROXY due to 410 errors * tests: add test comments * build: update .gitignore * tests: Improve server tests for cancellation * tests: handle nil AdmissionResponses * docs: DenyIngress - godoc clarity * build: update deps * build: fix gofmt issue * test: fix namespace/annotation access; add DaemonSet tests * build: remove refs from CI config (unused) * deps: update deps * deps: update k8s deps * tests: use podDeniedError; first draft of EnforcePodAnnotations tests * build: build the container concurrently * tests: standardize test error messages * docs: improve AdmitFunc example in README * docs: update unannotated deployment sample --- .circleci/config.yml | 24 +- .gitignore | 97 +----- Dockerfile | 5 + README.md | 67 +++- admit_funcs.go | 170 +++++++--- admit_funcs_test.go | 316 ++++++++++++++++++- examples/admissiond/main.go | 8 + go.mod | 19 +- go.sum | 50 +++ handler.go | 10 +- handler_test.go | 120 +++++++ request_logger.go | 11 + samples/admission-control-service.yaml | 6 +- samples/enforce-pod-annotations-example.yaml | 52 +++ samples/unannotated-deployment.yaml | 26 ++ server.go | 23 +- server_test.go | 49 ++- 17 files changed, 853 insertions(+), 200 deletions(-) create mode 100644 handler_test.go create mode 100644 samples/enforce-pod-annotations-example.yaml create mode 100644 samples/unannotated-deployment.yaml diff --git a/.circleci/config.yml b/.circleci/config.yml index ff30130..2bb29d6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,14 +1,7 @@ version: 2.1 jobs: - # Base test configuration for Go library tests Each distinct version should - # inherit this base, and override (at least) the container image used. - "test": &test - docker: - - image: "circleci/golang:<< parameters.v >>" - working_directory: /go/src/github.com/elithrar/admission-control - environment: - GO111MODULE: "on" + "test": parameters: v: type: string @@ -22,6 +15,15 @@ jobs: modules: type: boolean default: true + goproxy: + type: string + default: "" + docker: + - image: "circleci/golang:<< parameters.v >>" + working_directory: /go/src/github.com/elithrar/admission-control + environment: + GO111MODULE: "on" + GOPROXY: "<< parameters.goproxy >>" steps: - checkout - run: @@ -62,7 +64,7 @@ jobs: command: > go test -v -race ./... - "build-container": &build-container + "build-container": docker: - image: docker:18 working_directory: /go/src/github.com/elithrar/admission-control @@ -88,6 +90,4 @@ workflows: - test: name: "v1.12" v: "1.12" - - "build-container": - requires: - - "latest" + - "build-container" diff --git a/.gitignore b/.gitignore index ed0d3c8..66fd13c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,88 +1,15 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +# Test binary, built with `go test -c` +*.test -# Runtime data -pids -*.pid -*.seed -*.pid.lock +# Output of the go coverage tool, specifically when used with LiteIDE +*.out -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# TypeScript v1 declaration files -typings/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env -.env.test - -# parcel-bundler cache (https://parceljs.org/) -.cache - -# next.js build output -.next - -# nuxt.js build output -.nuxt - -# vuepress build output -.vuepress/dist - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ +# Dependency directories (remove the comment below to include it) +# vendor/ diff --git a/Dockerfile b/Dockerfile index 50aa686..f40f015 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,15 @@ FROM golang:1.12 as build +ARG GIT_COMMIT="" +LABEL commit=$GIT_COMMIT +ENV GIT_COMMIT=$GIT_COMMIT + WORKDIR /go/src/app COPY go.mod . COPY go.sum . ENV GO111MODULE=on +#ENV GOPROXY="https://proxy.golang.org" RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go install -v ./... diff --git a/README.md b/README.md index f6b61a0..513a974 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,27 @@ A micro-framework for building Kubernetes [Admission Controllers](https://kubern ### Built-In AdmitFuncs -Admission Control provides a number of useful built-in _AdmitFuncs_, including: - -- `DenyPublicLoadBalancers` - prevents exposing `Services` of `type: LoadBalancer` outside of the cluster; instead requiring the LB to be annotated as internal-only. -- `DenyIngresses` - similar to the above, it prevents creating Ingresses (except in the namespaces you allow) - -More built-ins are coming soon! ⏳ +Admission Control provides a number of useful built-in [**AdmitFuncs**](https://godoc.org/github.com/elithrar/admission-control#AdmitFunc), including: + +- `EnforcePodAnnotations` - ensures that admitted Pods have (at least) the + required set of annotations. Annotation _values_ are matched using a + `matchFunc` (a `func(string) bool`) that allows flexible matching. For + example, a matchFunc could wrap the + [`IsDomainName`](https://godoc.org/github.com/miekg/dns#IsDomainName) + function from `miekg/dns`, or reference a `[]string` of accepted values. It + is strongly suggested you use a + [`namespaceSelector`](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#webhook-v1beta1-admissionregistration) + as part of your webhook configuration to only apply this to specific + namespaces, and/or set the `ignoreNamespaces` argument to include + `kube-system`, as annotation validation will otherwise include system Pods. +- `DenyPublicLoadBalancers` - prevents exposing `Services` of `type: LoadBalancer` outside of the cluster, instead requiring the LB to be + annotated as internal-only, by looking for the well-known annotations for + major cloud providers. +- `DenyIngresses` - similar to the above, it prevents creating Ingresses + (except in the namespaces you allow). This can be useful for limiting which + namespaces can expose services via common Ingress types. + +More built-ins are coming soon, and suggestions are welcome! ⏳ ### Creating Your Own AdmitFunc @@ -48,17 +63,43 @@ An example `AdmitFunc` looks like this: func DenyDefaultLoadBalancerSourceRanges() AdmitFunc { // Return a function of type AdmitFunc return func(admissionReview *admission.AdmissionReview) (*admission.AdmissionResponse, error) { - // do work - - // returning a non-nil AdmissionResponse and a nil error will allow admission. - - // returning an error will deny Admission; the error string will be - // provided to the client and should be clear about why we rejected - // them. + kind := admissionReview.Request.Kind.Kind + // Create an *admission.AdmissionResponse that denies by default. + resp := newDefaultDenyResponse() + + // Create an object to deserialize our requests' object into + service := core.Service{} + deserializer := serializer.NewCodecFactory(runtime.NewScheme()).UniversalDeserializer() + if _, _, err := deserializer.Decode(admissionReview.Request.Object.Raw, nil, &service); err != nil { + return nil, err + } + + // Allow non-LoadBalancer Services to pass through. + if service.Spec.Type != "LoadBalancer" { + resp.Allowed = true + resp.Result.Message = fmt.Sprintf( + "received a non-LoadBalancer type (%s)", + service.Spec.Type, + ) + return resp, nil + } + + // Inspect the service.Spec.LoadBalancerSourceRanges field + // If unset, reject it. + // Returning an error from an AdmitFunc will automatically deny admission of that requests' object. + if service.Spec.LoadBalancerSourceRanges == nil { + return resp, fmt.Errorf("LoadBalancers without explicitly configured LoadBalancerSourceRanges are not allowed.") + } + + // Set resp.Allowed to true before returning your AdmissionResponse + resp.Allowed = true + return resp, nil } } ``` +You can see that we deserialize the raw object in our `AdmissionReview` into an object (based on its Kind), inspect and validate the fields we're interested in, and either return an error (rejecting admission) or set `resp.Allowed = true` and allow admission. + Tips: - Having your `AdmitFunc`s focus on "one" thing is best practice: it allows you to be more granular in how you apply constraints to your cluster diff --git a/admit_funcs.go b/admit_funcs.go index c7ae2cc..26f7347 100644 --- a/admit_funcs.go +++ b/admit_funcs.go @@ -4,6 +4,8 @@ import ( "fmt" admission "k8s.io/api/admission/v1beta1" + apps "k8s.io/api/apps/v1" + batch "k8s.io/api/batch/v1" core "k8s.io/api/core/v1" extensionsv1beta1 "k8s.io/api/extensions/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -12,6 +14,11 @@ import ( "k8s.io/apimachinery/pkg/runtime/serializer" ) +var ( + podDeniedError = "the submitted Pods are missing required annotations:" + unsupportedKindError = "the submitted Kind is not supported by this admission handler:" +) + // CloudProvider represents supported cloud platforms for provider-specific // configuration. type CloudProvider int @@ -51,7 +58,9 @@ func newDefaultDenyResponse() *admission.AdmissionResponse { // except for any explicitly allowed namespaces (e.g. istio-system). // // Providing an empty/nil list of ignoredNamespaces will reject Ingress objects -// across all namespaces. Kinds other than Ingress will be allowed. +// across all namespaces. +// +// Kinds other than Ingress will be allowed. func DenyIngresses(ignoredNamespaces []string) AdmitFunc { return func(admissionReview *admission.AdmissionReview) (*admission.AdmissionResponse, error) { kind := admissionReview.Request.Kind.Kind // Base Kind - e.g. "Service" as opposed to "v1/Service" @@ -65,7 +74,7 @@ func DenyIngresses(ignoredNamespaces []string) AdmitFunc { return nil, err } - // Allow Ingresses in whitelisted namespaces. + // Ignore objects in whitelisted namespaces. for _, ns := range ignoredNamespaces { if ingress.Namespace == ns { resp.Allowed = true @@ -106,7 +115,7 @@ func DenyPublicLoadBalancers(ignoredNamespaces []string, provider CloudProvider) return nil, err } - if service.Spec.Type != "LoadBalancer" { + if kind != "Service" || service.Spec.Type != "LoadBalancer" { resp.Allowed = true resp.Result.Message = fmt.Sprintf( "DenyPublicLoadBalancers received a non-LoadBalancer type (%s)", @@ -115,7 +124,7 @@ func DenyPublicLoadBalancers(ignoredNamespaces []string, provider CloudProvider) return resp, nil } - // Don't deny Services in whitelisted namespaces + // Ignore objects in whitelisted namespaces. for _, ns := range ignoredNamespaces { if service.Namespace == ns { resp.Allowed = true @@ -126,18 +135,128 @@ func DenyPublicLoadBalancers(ignoredNamespaces []string, provider CloudProvider) expectedAnnotations, ok := ilbAnnotations[provider] if !ok { - return nil, fmt.Errorf("internal load balancer annotations for the given provider (%q) are not supported", provider) + return resp, fmt.Errorf("internal load balancer annotations for the given provider (%q) are not supported", provider) } - // If we're missing any annotations, provide them in the AdmissionResponse so + // TODO(matt): If we're missing any annotations, provide them in the AdmissionResponse so // the user can correct them. - if missing, ok := ensureHasAnnotations(expectedAnnotations, service.ObjectMeta.Annotations); !ok { - resp.Result.Message = fmt.Sprintf("%s object is missing the required annotations: %v", kind, missing) - return nil, fmt.Errorf("%s objects of type: LoadBalancer without an internal-only annotation cannot be deployed to this cluster", kind) + if _, ok := ensureHasAnnotations(expectedAnnotations, service.ObjectMeta.Annotations); !ok { + return resp, fmt.Errorf("%s objects of type: LoadBalancer without an internal-only annotation cannot be deployed to this cluster", kind) } + // No missing or invalid annotations; allow admission resp.Allowed = true + return resp, nil + } +} + +// EnforcePodAnnotations ensures that Pods have the required annotations by +// looking for a strict (case-sensitive) key-match, and then running the +// matchFunc (a func(string) bool) over the value. +// +// This allows the caller to perform flexible matching - checking for valid DNS +// names or a list of accepted values - rather than having to iterate over all +// possible values, which may not be possible. +// +// EnforcePodAnnotations can inspect Pods, Deployments, StatefulSets, DaemonSets & +// Jobs. +// +// Unknown object kinds are rejected. You can create multiple versions of +// this AdmitFunc for a given ValidatingAdmissionWebhook configuration if you +// wish to apply different configurations per kind or namespace. +func EnforcePodAnnotations(ignoredNamespaces []string, requiredAnnotations map[string]func(string) bool) AdmitFunc { + return func(admissionReview *admission.AdmissionReview) (*admission.AdmissionResponse, error) { + kind := admissionReview.Request.Kind.Kind + resp := newDefaultDenyResponse() + + deserializer := serializer.NewCodecFactory(runtime.NewScheme()).UniversalDeserializer() + + // We handle all built-in Kinds that include a PodTemplateSpec, as described here: + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.15/#pod-v1-core + var namespace string + annotations := make(map[string]string) + // Extract the necessary metadata from our known Kinds + switch kind { + case "Pod": + pod := core.Pod{} + if _, _, err := deserializer.Decode(admissionReview.Request.Object.Raw, nil, &pod); err != nil { + return nil, err + } + + namespace = pod.GetNamespace() + annotations = pod.GetAnnotations() + case "Deployment": + deployment := apps.Deployment{} + if _, _, err := deserializer.Decode(admissionReview.Request.Object.Raw, nil, &deployment); err != nil { + return nil, err + } + + deployment.GetNamespace() + annotations = deployment.Spec.Template.GetAnnotations() + case "StatefulSet": + statefulset := apps.StatefulSet{} + if _, _, err := deserializer.Decode(admissionReview.Request.Object.Raw, nil, &statefulset); err != nil { + return nil, err + } + + namespace = statefulset.GetNamespace() + annotations = statefulset.Spec.Template.GetAnnotations() + case "DaemonSet": + daemonset := apps.DaemonSet{} + if _, _, err := deserializer.Decode(admissionReview.Request.Object.Raw, nil, &daemonset); err != nil { + return nil, err + } + namespace = daemonset.GetNamespace() + annotations = daemonset.Spec.Template.GetAnnotations() + case "Job": + job := batch.Job{} + if _, _, err := deserializer.Decode(admissionReview.Request.Object.Raw, nil, &job); err != nil { + return nil, err + } + + namespace = job.Spec.Template.GetNamespace() + annotations = job.Spec.Template.GetAnnotations() + default: + // TODO(matt): except for whitelisted namespaces + return nil, fmt.Errorf("the submitted Kind is not supported by this admission handler: %s", kind) + } + + // Ignore objects in whitelisted namespaces. + for _, ns := range ignoredNamespaces { + if namespace == ns { + resp.Allowed = true + resp.Result.Message = fmt.Sprintf("allowing admission: %s namespace is whitelisted", namespace) + return resp, nil + } + } + + missing := make(map[string]string) + // We check whether the (strictly matched) annotation key exists, and then run + // our user-provided matchFunc against it. If we're missing any keys, or the + // value for a key does not match, admission is rejected. + for requiredKey, matchFunc := range requiredAnnotations { + if matchFunc == nil { + return resp, fmt.Errorf("cannot validate annotations (%s) with a nil matchFunc", requiredKey) + } + + if existingVal, ok := annotations[requiredKey]; !ok { + // Key does not exist; add it to the missing annotations list + missing[requiredKey] = "key was not found" + } else { + if matched := matchFunc(existingVal); !matched { + missing[requiredKey] = "value did not match" + } + // Key exists & matchFunc returned OK. + } + } + + if len(missing) > 0 { + return resp, fmt.Errorf("%s %v", podDeniedError, missing) + } + + // No missing or invalid annotations; allow admission + resp.Allowed = true return resp, nil } } @@ -170,36 +289,3 @@ func ensureHasAnnotations(required map[string]string, annotations map[string]str return nil, true } - -// func EnforcePodAnnotations(ignoredNamespaces []string, matchFunc func(string, string) bool) AdmitFunc { -// return func(admissionReview *admission.AdmissionReview) (*admission.AdmissionResponse, error) { -// kind := admissionReview.Request.Kind.Kind -// resp := newDefaultDenyResponse() -// -// TODO(matt): enforce annotations on a Pod -// -// return resp, nil -// } -// } - -// func DenyContainersWithMutableTags(ignoredNamespaces []string, allowedTags []string) AdmitFunc { -// return func(admissionReview *admission.AdmissionReview) (*admission.AdmissionResponse, error) { -// kind := admissionReview.Request.Kind.Kind -// resp := newDefaultDenyResponse() -// -// TODO(matt): Range over Containers in a Pod spec, parse image URL and inspect tags. -// -// return resp, nil -// } -// } - -// func AddAnnotationsToPod(ignoredNamespaces []string, newAnnotations map[string]string) AdmitFunc { -// return func(admissionReview *admission.AdmissionReview) (*admission.AdmissionResponse, error) { -// kind := admissionReview.Request.Kind.Kind -// resp := newDefaultDenyResponse() -// -// TODO(matt): Add annotations to the object's ObjectMeta. -// -// return resp, nil -// } -// } diff --git a/admit_funcs_test.go b/admit_funcs_test.go index e49dfd4..1d7b987 100644 --- a/admit_funcs_test.go +++ b/admit_funcs_test.go @@ -1,20 +1,49 @@ package admissioncontrol import ( + "encoding/json" + "fmt" + "strings" "testing" admission "k8s.io/api/admission/v1beta1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" meta "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +var ( + testErrAdmissionMismatch = "admission mismatch (kind: %v): got allowed=%t - wanted allowed=%t)" + testErrMessageMismatch = "error message does not match: got %q - expected %q" ) type objectTest struct { - testName string - cloudProvider CloudProvider - kind meta.GroupVersionKind - rawObject []byte - ignoredNamespaces []string - expectedMessage string - shouldAllow bool + testName string + admitFunc AdmitFunc + cloudProvider CloudProvider + requiredAnnotations map[string]func(string) bool + kind meta.GroupVersionKind + object interface{} + rawObject []byte + ignoredNamespaces []string + expectedMessage string + shouldAllow bool +} + +func newTestAdmissionRequest(kind meta.GroupVersionKind, object []byte, expected bool) *admission.AdmissionReview { + ar := &admission.AdmissionReview{ + Request: &admission.AdmissionRequest{ + Kind: kind, + Object: runtime.RawExtension{ + Raw: object, + }, + }, + Response: &admission.AdmissionResponse{}, + } + + return ar } // TestDenyIngress validates that the DenyIngress AdmitFunc correctly rejects @@ -114,19 +143,19 @@ func TestDenyIngress(t *testing.T) { resp, err := DenyIngresses(tt.ignoredNamespaces)(&incomingReview) if err != nil { if tt.expectedMessage != err.Error() { - t.Fatalf("error message does not match: got %q - expected %q", err.Error(), tt.expectedMessage) + t.Fatalf(testErrMessageMismatch, err.Error(), tt.expectedMessage) } if tt.shouldAllow { - t.Fatalf("incorrectly rejected admission for %s (kind: %v): %s", tt.testName, tt.kind, err.Error()) + t.Fatalf("incorrectly rejected admission for Kind: %v: %s", tt.kind, err.Error()) } - t.Logf("correctly rejected admission for %s (kind: %v): %s", tt.testName, tt.kind, err.Error()) + t.Logf("correctly rejected admission for Kind: %v: %s", tt.kind, err.Error()) return } if resp.Allowed != tt.shouldAllow { - t.Fatalf("admission mismatch for (kind: %v): got Allowed: %t, wanted %t", tt.kind, resp.Allowed, tt.shouldAllow) + t.Fatalf(testErrAdmissionMismatch, tt.kind, resp.Allowed, tt.shouldAllow) } }) } @@ -307,20 +336,277 @@ func TestDenyPublicLoadBalancers(t *testing.T) { resp, err := DenyPublicLoadBalancers(tt.ignoredNamespaces, tt.cloudProvider)(&incomingReview) if err != nil { if tt.expectedMessage != err.Error() { - t.Fatalf("error message does not match: got %q - expected %q", err.Error(), tt.expectedMessage) + t.Fatalf(testErrMessageMismatch, err.Error(), tt.expectedMessage) } if tt.shouldAllow { - t.Fatalf("incorrectly rejected admission for %s (kind: %v): %s", tt.testName, tt.kind, err.Error()) + t.Fatalf("incorrectly rejected admission for Kind: %v: %s", tt.kind, err.Error()) } - t.Logf("correctly rejected admission for %s (kind: %v): %s", tt.testName, tt.kind, err.Error()) + t.Logf("correctly rejected admission for Kind: %v: %s", tt.kind, err.Error()) return } if resp.Allowed != tt.shouldAllow { - t.Fatalf("admission mismatch for (kind: %v): got Allowed: %t, wanted %t", tt.kind, resp.Allowed, tt.shouldAllow) + t.Fatalf(testErrAdmissionMismatch, tt.kind, resp.Allowed, tt.shouldAllow) } }) } } + +func TestEnforcePodAnnotations(t *testing.T) { + var denyTests = []objectTest{ + { + testName: "Allow Pod with required annotations", + requiredAnnotations: map[string]func(string) bool{ + "questionable.services/hostname": func(s string) bool { return true }, + "buildVersion": func(s string) bool { return strings.HasPrefix(s, "v") }, + }, + kind: meta.GroupVersionKind{ + Group: "", + Kind: "Pod", + Version: "v1", + }, + rawObject: []byte(`{"kind":"Pod","apiVersion":"v1","group":"","metadata":{"name":"hello-app","namespace":"default","annotations":{"questionable.services/hostname":"hello-app.questionable.services","buildVersion":"v1.0.2"}},"spec":{"containers":[{"name":"nginx","image":"nginx:latest"}]}}`), + expectedMessage: "", + shouldAllow: true, + }, + { + testName: "Reject Pod with missing annotations", + requiredAnnotations: map[string]func(string) bool{ + "questionable.services/hostname": func(s string) bool { return true }, + }, + kind: meta.GroupVersionKind{ + Group: "", + Kind: "Pod", + Version: "v1", + }, + // missing the "hostname" annotation + rawObject: []byte(`{"kind":"Pod","apiVersion":"v1","group":"","metadata":{"name":"hello-app","namespace":"default","annotations":{"buildVersion":"v1.0.2"}},"spec":{"containers":[{"name":"nginx","image":"nginx:latest"}]}}`), + expectedMessage: fmt.Sprintf("%s %s", podDeniedError, "map[questionable.services/hostname:key was not found]"), + shouldAllow: false, + }, + { + testName: "Reject Pod with invalid annotation value", + requiredAnnotations: map[string]func(string) bool{ + "buildVersion": func(s string) bool { return strings.HasPrefix(s, "v") }}, + kind: meta.GroupVersionKind{ + Group: "", + Kind: "Pod", + Version: "v1", + }, + // buildVersion is missing the "v" in the version number + rawObject: []byte(`{"kind":"Pod","apiVersion":"v1","group":"","metadata":{"name":"hello-app","namespace":"default","annotations":{"buildVersion":"1.0.2"}},"spec":{"containers":[{"name":"nginx","image":"nginx:latest"}]}}`), + expectedMessage: fmt.Sprintf("%s %s", podDeniedError, "map[buildVersion:value did not match]"), + shouldAllow: false, + }, + { + testName: "Allow admission to a whitelisted namespace", + requiredAnnotations: map[string]func(string) bool{ + "buildVersion": func(s string) bool { return strings.HasPrefix(s, "v") }}, + kind: meta.GroupVersionKind{ + Group: "", + Kind: "Pod", + Version: "v1", + }, + ignoredNamespaces: []string{"istio-system"}, + object: &corev1.Pod{ + TypeMeta: meta.TypeMeta{Kind: "Pod", APIVersion: "v1"}, + ObjectMeta: meta.ObjectMeta{Namespace: "istio-system"}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx:latest"}}}, + }, + expectedMessage: "", + shouldAllow: true, + }, + { + testName: "Unhandled Kinds (Service) are correctly rejected", + kind: meta.GroupVersionKind{ + Group: "", + Kind: "Service", + Version: "v1", + }, + rawObject: []byte(`{"kind":"Service","apiVersion":"v1","metadata":{"name":"hello-service","namespace":"default","annotations":{}},"spec":{"ports":[{"protocol":"TCP","port":8000,"targetPort":8080,"nodePort":31433}],"selector":{"app":"hello-app"},"type":"LoadBalancer","externalTrafficPolicy":"Cluster"}}`), + expectedMessage: fmt.Sprintf("%s %s", unsupportedKindError, "Service"), + shouldAllow: false, + }, + { + testName: "Allow correctly annotated Pods in a Deployment", + requiredAnnotations: map[string]func(string) bool{ + "buildVersion": func(s string) bool { return strings.HasPrefix(s, "v") }}, + kind: meta.GroupVersionKind{ + Group: "apps", + Kind: "Deployment", + Version: "v1", + }, + object: &appsv1.Deployment{ + TypeMeta: meta.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}, + ObjectMeta: meta.ObjectMeta{Namespace: "default"}, + Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{ObjectMeta: meta.ObjectMeta{Annotations: map[string]string{"buildVersion": "v1.0.0"}}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx:latest"}}}}}, + }, + expectedMessage: "", + shouldAllow: true, + }, + { + testName: "Reject unannotated Pods in a Deployment", + requiredAnnotations: map[string]func(string) bool{ + "buildVersion": func(s string) bool { return strings.HasPrefix(s, "v") }}, + kind: meta.GroupVersionKind{ + Group: "apps", + Kind: "Deployment", + Version: "v1", + }, + object: &appsv1.Deployment{ + TypeMeta: meta.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}, + ObjectMeta: meta.ObjectMeta{Namespace: "default"}, + Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{ObjectMeta: meta.ObjectMeta{}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx:latest"}}}}}, + }, + expectedMessage: fmt.Sprintf("%s %s", podDeniedError, "map[buildVersion:key was not found]"), + shouldAllow: false, + }, + { + testName: "Allow correctly annotated Pods in a DaemonSet", + requiredAnnotations: map[string]func(string) bool{ + "buildVersion": func(s string) bool { return strings.HasPrefix(s, "v") }}, + kind: meta.GroupVersionKind{ + Group: "apps", + Kind: "DaemonSet", + Version: "v1", + }, + rawObject: []byte(`{"kind":"DaemonSet","apiVersion":"v1","group":"apps","metadata":{"name":"hello-daemonset","namespace":"default","annotations":{}},"spec":{"template":{"metadata":{"annotations":{"buildVersion":"v1.0.0"}},"spec":{"containers":[{"name":"nginx","image":"nginx:latest"}]}}}}`), + expectedMessage: "", + shouldAllow: true, + }, + { + testName: "Reject unannotated Pods in a DaemonSet", + requiredAnnotations: map[string]func(string) bool{ + "buildVersion": func(s string) bool { return strings.HasPrefix(s, "v") }}, + kind: meta.GroupVersionKind{ + Group: "apps", + Kind: "DaemonSet", + Version: "v1", + }, + rawObject: []byte(`{"kind":"DaemonSet","apiVersion":"v1","group":"apps","metadata":{"name":"hello-daemonset","namespace":"default","annotations":{}},"spec":{"template":{"metadata":{"annotations":{}},"spec":{"containers":[{"name":"nginx","image":"nginx:latest"}]}}}}`), + expectedMessage: fmt.Sprintf("%s %s", podDeniedError, "map[buildVersion:key was not found]"), + shouldAllow: false, + }, + { + testName: "Allow correctly annotated Pods in a StatefulSet", + requiredAnnotations: map[string]func(string) bool{ + "buildVersion": func(s string) bool { return strings.HasPrefix(s, "v") }}, + kind: meta.GroupVersionKind{ + Group: "apps", + Kind: "StatefulSet", + Version: "v1", + }, + rawObject: []byte(`{"kind":"StatefulSet","apiVersion":"v1","group":"apps","metadata":{"name":"hello-statefulset","namespace":"default","annotations":{}},"spec":{"template":{"metadata":{"annotations":{"buildVersion":"v1.0.0"}},"spec":{"containers":[{"name":"nginx","image":"nginx:latest"}]}}}}`), + expectedMessage: "", + shouldAllow: true, + }, + { + testName: "Reject unannotated Pods in a StatefulSet", + requiredAnnotations: map[string]func(string) bool{ + "buildVersion": func(s string) bool { return strings.HasPrefix(s, "v") }}, + kind: meta.GroupVersionKind{ + Group: "apps", + Kind: "StatefulSet", + Version: "v1", + }, + rawObject: []byte(`{"kind":"StatefulSet","apiVersion":"v1","group":"apps","metadata":{"name":"hello-statefulset","namespace":"default","annotations":{}},"spec":{"template":{"metadata":{"annotations":{}},"spec":{"containers":[{"name":"nginx","image":"nginx:latest"}]}}}}`), + expectedMessage: fmt.Sprintf("%s %s", podDeniedError, "map[buildVersion:key was not found]"), + shouldAllow: false, + }, + { + testName: "Allow correctly annotated Pods in a Job", + requiredAnnotations: map[string]func(string) bool{ + "buildVersion": func(s string) bool { return strings.HasPrefix(s, "v") }}, + kind: meta.GroupVersionKind{ + Group: "batch", + Kind: "Job", + Version: "v1", + }, + object: &batchv1.Job{ + TypeMeta: meta.TypeMeta{Kind: "Job", APIVersion: "batch/v1"}, + ObjectMeta: meta.ObjectMeta{Name: "", Namespace: "default"}, + Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: meta.ObjectMeta{Annotations: map[string]string{"buildVersion": "v1.0.0"}}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx:latest"}}}}}, + }, + expectedMessage: "", + shouldAllow: true, + }, + { + testName: "Reject unannotated Pods in a Job", + requiredAnnotations: map[string]func(string) bool{ + "buildVersion": func(s string) bool { return strings.HasPrefix(s, "v") }}, + kind: meta.GroupVersionKind{ + Group: "batch", + Kind: "Job", + Version: "v1", + }, + object: &batchv1.Job{ + TypeMeta: meta.TypeMeta{Kind: "Job", APIVersion: "batch/v1"}, + ObjectMeta: meta.ObjectMeta{Name: "", Namespace: "default"}, + Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: meta.ObjectMeta{Name: ""}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx:latest"}}}}}, + }, + expectedMessage: fmt.Sprintf("%s %s", podDeniedError, "map[buildVersion:key was not found]"), + shouldAllow: false, + }, + { + testName: "Reject cases where the outer object is annotated, but the PodTemplateSpec is not", + requiredAnnotations: map[string]func(string) bool{ + "buildVersion": func(s string) bool { return strings.HasPrefix(s, "v") }}, + kind: meta.GroupVersionKind{ + Group: "apps", + Kind: "Deployment", + Version: "v1", + }, + object: &appsv1.Deployment{ + TypeMeta: meta.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}, + ObjectMeta: meta.ObjectMeta{Namespace: "default", Annotations: map[string]string{ + "buildVersion": "v1.0.0", + }}, + Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{ObjectMeta: meta.ObjectMeta{Name: ""}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx:latest"}}}}}, + }, + expectedMessage: fmt.Sprintf("%s %s", podDeniedError, "map[buildVersion:key was not found]"), + shouldAllow: false, + }, + } + + for _, tt := range denyTests { + t.Run(tt.testName, func(t *testing.T) { + incomingReview := admission.AdmissionReview{ + Request: &admission.AdmissionRequest{}, + } + + incomingReview.Request.Kind = tt.kind + + if tt.rawObject == nil { + serialized, err := json.Marshal(tt.object) + if err != nil { + t.Fatalf("could not marshal k8s API object: %v", err) + } + + incomingReview.Request.Object.Raw = serialized + } else { + incomingReview.Request.Object.Raw = tt.rawObject + } + + resp, err := EnforcePodAnnotations(tt.ignoredNamespaces, tt.requiredAnnotations)(&incomingReview) + if err != nil { + if tt.expectedMessage != err.Error() { + t.Fatalf(testErrMessageMismatch, err.Error(), tt.expectedMessage) + } + + if tt.shouldAllow { + t.Fatalf("incorrectly rejected admission for Kind: %v: %s", tt.kind, err.Error()) + } + + t.Logf("correctly rejected admission for Kind: %v: %s", tt.kind, err.Error()) + return + } + + if resp.Allowed != tt.shouldAllow { + t.Fatalf(testErrAdmissionMismatch, tt.kind, resp.Allowed, tt.shouldAllow) + } + }) + } + +} diff --git a/examples/admissiond/main.go b/examples/admissiond/main.go index 67fd414..88f1516 100644 --- a/examples/admissiond/main.go +++ b/examples/admissiond/main.go @@ -72,6 +72,14 @@ func main() { AdmitFunc: admissioncontrol.DenyPublicLoadBalancers(nil, admissioncontrol.AWS), Logger: logger, }).Methods(http.MethodPost) + admissions.Handle("/enforce-pod-annotations", &admissioncontrol.AdmissionHandler{ + AdmitFunc: admissioncontrol.EnforcePodAnnotations( + []string{"kube-system"}, + map[string]func(string) bool{ + "k8s.questionable.services/hostname": func(string) bool { return true }, + }), + Logger: logger, + }).Methods(http.MethodPost) // HTTP server timeout := time.Second * 15 diff --git a/go.mod b/go.mod index 5d18f1c..21c9b0d 100644 --- a/go.mod +++ b/go.mod @@ -14,25 +14,24 @@ require ( github.com/go-openapi/spec v0.19.2 // indirect github.com/go-openapi/swag v0.19.4 // indirect github.com/go-stack/stack v1.8.0 // indirect - github.com/gogo/protobuf v1.2.1 // indirect github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 // indirect github.com/golang/protobuf v1.3.2 // indirect github.com/googleapis/gnostic v0.3.0 // indirect github.com/gorilla/mux v1.7.3 - github.com/hashicorp/golang-lru v0.5.1 // indirect - github.com/kisielk/errcheck v1.2.0 // indirect + github.com/hashicorp/golang-lru v0.5.3 // indirect + github.com/json-iterator/go v1.1.7 // indirect github.com/kr/pty v1.1.8 // indirect github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e // indirect github.com/munnerz/goautoneg v0.0.0-20190414153302-2ae31c8b6b30 // indirect golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 // indirect - golang.org/x/net v0.0.0-20190628185345-da137c7871d7 // indirect - golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7 // indirect - golang.org/x/tools v0.0.0-20190719005602-e377ae9d6386 // indirect + golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 // indirect + golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3 // indirect + golang.org/x/tools v0.0.0-20190802220118-1d1727260058 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/api v0.0.0-20190720062849-3043179095b6 - k8s.io/apimachinery v0.0.0-20190719140911-bfcf53abc9f8 + k8s.io/api v0.0.0-20190803060717-3ce214556aa9 + k8s.io/apimachinery v0.0.0-20190802060556-6fa4771c83b3 k8s.io/gengo v0.0.0-20190327210449-e17681d19d3a // indirect k8s.io/klog v0.3.3 // indirect - k8s.io/kube-openapi v0.0.0-20190718094010-3cf2ea392886 // indirect - sigs.k8s.io/structured-merge-diff v0.0.0-20190719182312-e94e05bfbbe3 // indirect + k8s.io/kube-openapi v0.0.0-20190722073852-5e22f3d471e6 // indirect + sigs.k8s.io/structured-merge-diff v0.0.0-20190724202554-0c1d754dd648 // indirect ) diff --git a/go.sum b/go.sum index e94ac2f..a87f704 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,7 @@ github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkg github.com/elazarl/goproxy v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/emicklei/go-restful v2.9.6+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/evanphx/json-patch v0.0.0-20190203023257-5858425f7550/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -39,9 +40,12 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v0.0.0-20171007142547-342cbe0a0415 h1:WSBJMqJbLxsn+bTCPyPYZfqHdJmc8MK4wrBjMft6BAM= github.com/gogo/protobuf v0.0.0-20171007142547-342cbe0a0415/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v0.0.0-20190410021324-65acae22fc9/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d h1:3PaI8p3seN09VjbTYC/QWlUZdZ1qS1zGjy7LH2Wt07I= +github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -50,8 +54,10 @@ github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gnostic v0.0.0-20170426233943-68f4ded48ba9/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= @@ -62,10 +68,14 @@ github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v0.0.0-20180701071628-ab8a2e0c74be/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7 h1:KfgG9LzI+pYjr4xvmz/5H4FXjokeP+rlHLhv3iH62Fo= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -81,9 +91,11 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= @@ -93,16 +105,19 @@ github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v0.0.0-20190113212917-5533ce8a0da3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -110,6 +125,7 @@ golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190206173232-65e2d4e15006/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190328230028-74de082e2cca h1:hyA6yiAgbUwuWqtscNvWAI7U1CtlaD1KilQ6iudt1aI= golang.org/x/net v0.0.0-20190328230028-74de082e2cca/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -118,6 +134,8 @@ golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7 h1:rTIdg5QFRR7XCaK4LCjBiPbx8j4DQRpdYMnGn/bJUEU= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -128,6 +146,8 @@ golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db h1:6/JqlYfC1CCaLnGceQTI+sDGhC9UBSPAsBqI0Gun6kU= @@ -140,6 +160,11 @@ golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190719005602-e377ae9d6386/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +golang.org/x/tools v0.0.0-20190723021737-8bb11ff117ca/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +golang.org/x/tools v0.0.0-20190724185037-8aa4eac1a7c1/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +golang.org/x/tools v0.0.0-20190731214159-1e85ed8060aa/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +golang.org/x/tools v0.0.0-20190802220118-1d1727260058/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= @@ -155,12 +180,35 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= k8s.io/api v0.0.0-20190620073856-dcce3486da33 h1:aC/EvF9PT1h8NeMEOVwTel8xxbZwq0SZnxXNThEROnE= k8s.io/api v0.0.0-20190620073856-dcce3486da33/go.mod h1:ldk709UQo/iedNLOW7J06V9QSSGY5heETKeWqnPoqF8= +k8s.io/api v0.0.0-20190718183219-b59d8169aab5 h1:X3LHYU4fwu75lvvWypbppCKuhqg1KrvcZ1lLaAgmE/g= +k8s.io/api v0.0.0-20190718183219-b59d8169aab5/go.mod h1:TBhBqb1AWbBQbW3XRusr7n7E4v2+5ZY8r8sAMnyFC5A= k8s.io/api v0.0.0-20190720062849-3043179095b6 h1:3C9EiZRqH+JKCzZqxuthj5C3q3cw61SlRr2z8XeSfmQ= k8s.io/api v0.0.0-20190720062849-3043179095b6/go.mod h1:1O0xzX/RAtnm7l+5VEUxZ1ysO2ghatfq/OZED4zM9kA= +k8s.io/api v0.0.0-20190722141453-b90922c02518 h1:mShu41WQl4VJGAd7fbhhH0tsy+KMjZRnC/OcFYF8RVc= +k8s.io/api v0.0.0-20190722141453-b90922c02518/go.mod h1:1O0xzX/RAtnm7l+5VEUxZ1ysO2ghatfq/OZED4zM9kA= +k8s.io/api v0.0.0-20190725062911-6607c48751ae h1:La/F8nlqpe1mOXWX22I+Ce8wfQOfXcymYZofbDgmjyo= +k8s.io/api v0.0.0-20190726022912-69e1bce1dad5 h1:vSfC/FjyeuqXC/fjdNqZixNpeec4mEHJ68K3kzetm/M= +k8s.io/api v0.0.0-20190726022912-69e1bce1dad5/go.mod h1:V6cpJ9D7WqSy0wqcE096gcbj+W//rshgQgmj1Shdwi8= +k8s.io/api v0.0.0-20190731142925-739c7f7721ed h1:Y78gEND681Vka9RAFa6WIr2rBionjSpb17i4myQUwIs= +k8s.io/api v0.0.0-20190731142925-739c7f7721ed/go.mod h1:1KBIEXI7qpsZM6s0w+RthBLGH1IYg7JQlerAl+wN1lw= +k8s.io/api v0.0.0-20190802060718-d0d4f3afa3ab h1:rIcOGLTF52ktddf7KTMbbCJQLnipzgL1S9zGY4YZZh0= +k8s.io/api v0.0.0-20190802060718-d0d4f3afa3ab/go.mod h1:SgXHCRh94q+5GrRf9Dty2ZG8+wCVmqvQbZJXXcAswkw= +k8s.io/api v0.0.0-20190803060717-3ce214556aa9 h1:Gaj41zd82Oo4j8f8QGlOV6eDOKCk71D39H2+9ER5QIc= +k8s.io/api v0.0.0-20190803060717-3ce214556aa9/go.mod h1:SgXHCRh94q+5GrRf9Dty2ZG8+wCVmqvQbZJXXcAswkw= +k8s.io/apimachinery v0.0.0-20190612205821-1799e75a0719 h1:uV4S5IB5g4Nvi+TBVNf3e9L4wrirlwYJ6w88jUQxTUw= +k8s.io/apimachinery v0.0.0-20190612205821-1799e75a0719/go.mod h1:I4A+glKBHiTgiEjQiCCQfCAIcIMFGt291SmsvcrFzJA= k8s.io/apimachinery v0.0.0-20190620073744-d16981aedf33 h1:Lkd+QNFOB3DqrDyWo796aodJgFJautn/M+t9IGearPc= k8s.io/apimachinery v0.0.0-20190620073744-d16981aedf33/go.mod h1:9q5NW/mMno/nwbRZd/Ks2TECgi2PTZ9cwarf4q+ze6Q= k8s.io/apimachinery v0.0.0-20190719140911-bfcf53abc9f8 h1:fVMoqaOPZ6KTeszBSBO8buFmXaR2JlnMn53eEBeganU= k8s.io/apimachinery v0.0.0-20190719140911-bfcf53abc9f8/go.mod h1:sBJWIJZfxLhp7mRsRyuAE/NfKTr3kXGR1iaqg8O0gJo= +k8s.io/apimachinery v0.0.0-20190726022757-641a75999153 h1:Mg7TTs6b/jjTI6dMHGZM9/dbvHDAPCxnKyqK2ag9pAs= +k8s.io/apimachinery v0.0.0-20190726022757-641a75999153/go.mod h1:eXR4ljjmbwK6Ng0PKsXRySPXnTUy/qBUa6kPDeckhQ0= +k8s.io/apimachinery v0.0.0-20190727130956-f97a4e5b4abc h1:fi1vG9UrnqoGU/H2HP2rr7GH6vaQeFdLxfocg5uMQmA= +k8s.io/apimachinery v0.0.0-20190727130956-f97a4e5b4abc/go.mod h1:eXR4ljjmbwK6Ng0PKsXRySPXnTUy/qBUa6kPDeckhQ0= +k8s.io/apimachinery v0.0.0-20190731142807-035e418f1ad9 h1:RedGIevS9N0IS0QVL+kWS8ZNBQVXh7nioDippOKAuak= +k8s.io/apimachinery v0.0.0-20190731142807-035e418f1ad9/go.mod h1:+ntn62igV2hyNj7/0brOvXSMONE2KxcePkSxK7/9FFQ= +k8s.io/apimachinery v0.0.0-20190802060556-6fa4771c83b3 h1:ov3gR/oGSdOkfEetREkvyrTMbEUDAADeF9WMoihPv0w= +k8s.io/apimachinery v0.0.0-20190802060556-6fa4771c83b3/go.mod h1:+ntn62igV2hyNj7/0brOvXSMONE2KxcePkSxK7/9FFQ= k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= k8s.io/gengo v0.0.0-20190327210449-e17681d19d3a/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= @@ -171,7 +219,9 @@ k8s.io/klog v0.3.3/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= k8s.io/kube-openapi v0.0.0-20190228160746-b3a7cee44a30/go.mod h1:BXM9ceUBTj2QnfH2MK1odQs778ajze1RxcmP6S8RVVc= k8s.io/kube-openapi v0.0.0-20190709113604-33be087ad058/go.mod h1:nfDlWeOsu3pUf4yWGL+ERqohP4YsZcBJXWMK+gkzOA4= k8s.io/kube-openapi v0.0.0-20190718094010-3cf2ea392886/go.mod h1:RZvgC8MSN6DjiMV6oIfEE9pDL9CYXokkfaCKZeHm3nc= +k8s.io/kube-openapi v0.0.0-20190722073852-5e22f3d471e6/go.mod h1:RZvgC8MSN6DjiMV6oIfEE9pDL9CYXokkfaCKZeHm3nc= sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= sigs.k8s.io/structured-merge-diff v0.0.0-20190719182312-e94e05bfbbe3/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= +sigs.k8s.io/structured-merge-diff v0.0.0-20190724202554-0c1d754dd648/go.mod h1:IIgPezJWb76P0hotTxzDbWsMYB8APh18qZnxkomBpxA= sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= diff --git a/handler.go b/handler.go index 871ee33..473ff6e 100644 --- a/handler.go +++ b/handler.go @@ -16,7 +16,8 @@ import ( log "github.com/go-kit/kit/log" ) -// AdmitFunc checks whether an admission request is valid, and should return an +// AdmitFunc is a type for building Kubernetes admission webhooks. An AdmitFunc +// should check whether an admission request is valid, and shall return an // admission response that sets AdmissionResponse.Allowed to true or false as // needed. // @@ -123,13 +124,18 @@ func (ah *AdmissionHandler) handleAdmissionRequest(w http.ResponseWriter, r *htt } if incomingReview.Request == nil { - return errors.New("received invalid AdmissionReview") + return errors.New("received invalid request: no AdmissionReview was found") } + reviewResponse, err := ah.AdmitFunc(&incomingReview) if err != nil { return AdmissionError{false, err.Error(), "the AdmitFunc returned an error"} } + if reviewResponse == nil { + return AdmissionError{false, "the AdmitFunc returned an empty AdmissionReview", ""} + } + reviewResponse.UID = incomingReview.Request.UID review := admission.AdmissionReview{ Response: reviewResponse, diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..19fa66e --- /dev/null +++ b/handler_test.go @@ -0,0 +1,120 @@ +package admissioncontrol + +import ( + "bytes" + "encoding/json" + "errors" + admission "k8s.io/api/admission/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "net/http" + "net/http/httptest" + "testing" +) + +func newTestAdmitFunc(allowed bool, returnError bool) AdmitFunc { + return func(admissionReview *admission.AdmissionReview) (*admission.AdmissionResponse, error) { + ar := &admission.AdmissionResponse{ + Allowed: allowed, + Result: &metav1.Status{}, + } + + if !allowed { + return ar, errors.New("admission not allowed") + } + + return ar, nil + } +} + +func TestAdmissionHandler(t *testing.T) { + var handlerTests = []struct { + testName string + admitFunc AdmitFunc + incomingReview *admission.AdmissionReview + shouldPass bool + }{ + { + testName: "Pass-through AdmitFunc returns HTTP 200 & allows admission", + admitFunc: newTestAdmitFunc(true, false), + incomingReview: &admission.AdmissionReview{ + Request: &admission.AdmissionRequest{}, + }, + shouldPass: true, + }, + { + testName: "AdmitFunc returns HTTP 200 & denies admission", + admitFunc: newTestAdmitFunc(false, true), + incomingReview: &admission.AdmissionReview{ + Request: &admission.AdmissionRequest{}, + }, + shouldPass: false, + }, + { + testName: "Reject a nil/empty AdmissionReview", + admitFunc: newTestAdmitFunc(false, true), + incomingReview: nil, + shouldPass: false, + }, + { + testName: "Reject a malformed AdmissionReview (no Kind)", + admitFunc: newTestAdmitFunc(false, true), + incomingReview: &admission.AdmissionReview{ + Request: &admission.AdmissionRequest{}, + }, + shouldPass: false, + }, + { + testName: "Return an error for a malformed outgoing AdmissionReview", + admitFunc: func(_ *admission.AdmissionReview) (*admission.AdmissionResponse, error) { + return nil, nil + }, + incomingReview: &admission.AdmissionReview{ + Request: &admission.AdmissionRequest{}, + }, + shouldPass: false, + }, + } + + for _, tt := range handlerTests { + t.Run(tt.testName, func(t *testing.T) { + handler := &AdmissionHandler{ + AdmitFunc: tt.admitFunc, + Logger: &noopLogger{}, + } + + buf := &bytes.Buffer{} + err := json.NewEncoder(buf).Encode(&tt.incomingReview) + if err != nil { + t.Fatalf("error marshalling incomingReview: %v", err) + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/", + buf, + ) + + handler.ServeHTTP(rr, req) + + // Testing for: + // 1. Did we get a non-nil response body? + // 2. Did it return a valid AdmissionReview object? + // 3. Was the status code as expected? + // 4. Did the AdmissionReview object set Allowed to the expected value? + if rr.Body.Len() == 0 { + t.Fatalf("received an empty response body") + } + + review := &admission.AdmissionReview{} + if err := json.Unmarshal(rr.Body.Bytes(), review); err != nil { + t.Fatalf("couldn't marshal the review response: %v", err) + } + + if allowed := review.Response.Allowed; allowed != tt.shouldPass { + t.Fatalf("invalid review response: got allowed: %t (want %t)", allowed, tt.shouldPass) + } + }) + } + +} diff --git a/request_logger.go b/request_logger.go index 4edff2c..89a5a2d 100644 --- a/request_logger.go +++ b/request_logger.go @@ -2,6 +2,7 @@ package admissioncontrol import ( "net/http" + "runtime/debug" "time" log "github.com/go-kit/kit/log" @@ -39,6 +40,16 @@ func (rw *responseWriter) WriteHeader(code int) { func LoggingMiddleware(logger log.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + w.WriteHeader(http.StatusInternalServerError) + logger.Log( + "err", err, + "trace", debug.Stack(), + ) + } + }() + start := time.Now() wrapped := wrapResponseWriter(w) next.ServeHTTP(wrapped, r) diff --git a/samples/admission-control-service.yaml b/samples/admission-control-service.yaml index 274662b..4a6a8c7 100644 --- a/samples/admission-control-service.yaml +++ b/samples/admission-control-service.yaml @@ -15,12 +15,12 @@ spec: app: admission-control spec: containers: - - name: admission-controld - image: gcr.io/optimum-rock-145719/admissiond-example:latest + - name: admission-control-example + image: gcr.io/optimum-rock-145719/admission-control-example:latest command: ["/admissiond"] env: - name: HOSTNAME - value: "admissiond.questionable.services" + value: "admission-c0ntrol-example.questionable.services" args: [ "-host", diff --git a/samples/enforce-pod-annotations-example.yaml b/samples/enforce-pod-annotations-example.yaml new file mode 100644 index 0000000..61044e7 --- /dev/null +++ b/samples/enforce-pod-annotations-example.yaml @@ -0,0 +1,52 @@ +apiVersion: v1 +kind: Namespace +metadata: + # Create a namespace that we'll match on + name: enforce-annotations + labels: + enforce-annotations: "true" +--- +apiVersion: admissionregistration.k8s.io/v1beta1 +kind: ValidatingWebhookConfiguration +metadata: + name: enforce-pod-annotations +webhooks: + - name: enforce-pod-annotations.questionable.services + sideEffects: None + # "Equivalent" provides insurance against API version upgrades/changes - e.g. + # extensions/v1beta1 Ingress -> networking.k8s.io/v1beta1 Ingress + # matchPolicy: Equivalent + rules: + - apiGroups: + - "*" + apiVersions: + - "*" + operations: + - "CREATE" + - "UPDATE" + resources: + - "pods" + - "deployments" + namespaceSelector: + matchExpressions: + # Any Namespace with a label matching the below will have its + # annotations validated by this admission controller + - key: "enforce-annotations" + operator: In + values: ["true"] + failurePolicy: Fail + clientConfig: + service: + # This is the hostname our certificate needs in its Subject Alternative + # Name array - name.namespace.svc + # If the certificate does NOT have this name, TLS validation will fail. + name: admission-control-service + namespace: default + path: "/admission-control/enforce-pod-annotations" + # This should be the CA certificate from your Kubernetes cluster + # Use the below to generate the certificate in a valid format: + # $ kubectl config view --raw --minify --flatten \ + # -o jsonpath='{.clusters[].cluster.certificate-authority-data}' + caBundle: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURERENDQWZTZ0F3SUJBZ0lSQVB3S1JwRkJZdlZEQlJpRXVUNExrcDh3RFFZSktvWklodmNOQVFFTEJRQXcKTHpFdE1Dc0dBMVVFQXhNa05qUXhNMk14T0RndE9UUTVOUzAwT0dJNUxUZzRNVGt0WXpKaFpUTXlPREkzTkRjeQpNQjRYRFRFNU1EWXlNekl3TXpBek9Gb1hEVEkwTURZeU1USXhNekF6T0Zvd0x6RXRNQ3NHQTFVRUF4TWtOalF4Ck0yTXhPRGd0T1RRNU5TMDBPR0k1TFRnNE1Ua3RZekpoWlRNeU9ESTNORGN5TUlJQklqQU5CZ2txaGtpRzl3MEIKQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBdTdCa1JlM1VRWjNkaWQzaEl3a3ZmaXJNTUVNcCs3eEw4Uytrc3RkVgpVZEwrY0NQb01KeGluS25CZTA0Qmdzckp6bS96TWpMbmIrcTZBV0VFQTUxbk1jSnp2eWFSSFZkQmNsbU1zTlc2Cm95OC9vci9pWDhSNjYxcllUTlVibHFsa3JqSWNxRE9naGJNSzYwbUgzZnlmZlBZcmJUVUs3b3JiQlAvaitPdk8KcEFZYnNQTjh3R2hoZ2Q3K2pGL3JPWlN2amVHZHY0eG9UeUpMZU5QSzJJb3FrWWQ5eENLR2lMNktpaWhXbi9HYgozM3kzWkpJVVZucGFjdTNQODVNanJEVDJSam1vaklIRjgvWHh6VHQ0c2tXQUllZXV1MWhocDJ5WkFuRXRjUVR0CjMvSUIzYm84blJMaUxMRWJmZDJVcnE0ZzlEdkVFK1pOSEZuUFI3dE1POE5GVFFJREFRQUJveU13SVRBT0JnTlYKSFE4QkFmOEVCQU1DQWdRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQVFFQQpsL2N5UDROVGVIdkUvT25DcTM3WlBqL2pMbWpsZVlUck1wZ0NrTk1DNG5oQ0c4R3pxTkZtQ3V6TmZWUGliMmYvCi9wSUhRSmZBTUxzWVg3NlpGQzFoMGE1Mm9Db0kzeFpScTVJTGNIWnRZOXAwYm5HQ2EzYUFEcnQ0OUZOVUN3N2QKYTdsOHgrQ3NGQk1lRlZ0dm96RnVaUE1uWnlxNkw1Y2swRnBNd2tQT1VwYjE5bjdQYW1QSGJSbkRVNThVQTlGbApHbkRZdGlBMGZJdFhnbDJwTjVCd1orNlRiOS9FdW1GOU5VQUMvV1ZkUGJ1VTBqK2RPcWwvelQyTHlsRndSVXUzCjVkUWxSRE5LVTgwY2pVSzlqd1Z2U0txVUtReHdFdTRHMWFwM3E2MDdFZFlSTVNuT2NOdzRSeDU2Qm5rdVo2V2oKZmNwQXF4Q2FTYkRqMVEyZk01eERadz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K" + # You can alternatively supply a URL to the service, as long as its reachable by the cluster. + # url: "" diff --git a/samples/unannotated-deployment.yaml b/samples/unannotated-deployment.yaml new file mode 100644 index 0000000..4232a27 --- /dev/null +++ b/samples/unannotated-deployment.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hello-app + namespace: enforce-annotations + labels: + app: hello-app +spec: + selector: + matchLabels: + app: hello-app + template: + metadata: + annotations: + # invalid annotation + "k8s.questionable.services/id": "abc" + # valid annotation + # "k8s.questionable.services/hostname": "abc.example.com" + labels: + app: hello-app + spec: + containers: + - name: hello-app + image: gcr.io/google-samples/hello-app:1.0 + ports: + - containerPort: 8080 diff --git a/server.go b/server.go index fc09436..10956fe 100644 --- a/server.go +++ b/server.go @@ -48,7 +48,10 @@ func NewServer(srv *http.Server, logger log.Logger) (*AdmissionServer, error) { return nil, errors.New("a non-nil *http.Server must be provided") } - // TODO(matt): Should warn here & support plaintext HTTP for proxied environments + if logger == nil { + return nil, errors.New("a non-nil log.Logger must be provided") + } + if srv.TLSConfig == nil { // Warn that TLS termination is required logger.Log( @@ -56,10 +59,6 @@ func NewServer(srv *http.Server, logger log.Logger) (*AdmissionServer, error) { ) } - if logger == nil { - return nil, errors.New("a non-nil log.Logger must be provided") - } - as := &AdmissionServer{ srv: srv, logger: logger, @@ -77,8 +76,10 @@ func NewServer(srv *http.Server, logger log.Logger) (*AdmissionServer, error) { // 1. An interrupt (SIGINT; "Ctrl+C") or termination (SIGTERM) signal, such as // the SIGTERM most process managers send: e.g. as Kubernetes sends to a Pod: // https://kubernetes.io/docs/concepts/workloads/pods/pod/#termination-of-pods +// // 2. When an error is returned from the listener on our server (fails to bind // to a port, terminal network issue, etc.) +// // 3. When we receive a cancellation signal from the parent context; e.g. by // calling the returned CancelFunc from calling context.WithCancel(ctx) // @@ -94,13 +95,13 @@ func (as *AdmissionServer) Run(ctx context.Context) error { errs := make(chan error) defer close(errs) go func() { - as.logger.Log( - "msg", fmt.Sprintf("admission control listening on '%s'", as.srv.Addr), - ) - // Start a plaintext listener if no TLSConfig is provided switch as.srv.TLSConfig { case nil: + as.logger.Log( + "msg", fmt.Sprintf("admission control listening on '%s' (plaintext HTTP)", as.srv.Addr), + ) + if err := as.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { errs <- err as.logger.Log( @@ -110,6 +111,10 @@ func (as *AdmissionServer) Run(ctx context.Context) error { return } default: + as.logger.Log( + "msg", fmt.Sprintf("admission control listening on '%s' (TLS)", as.srv.Addr), + ) + if err := as.srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { errs <- err as.logger.Log( diff --git a/server_test.go b/server_test.go index cfb5675..0985947 100644 --- a/server_test.go +++ b/server_test.go @@ -31,14 +31,12 @@ func newTestServer(ctx context.Context, t *testing.T) *testServer { }) testSrv := httptest.NewUnstartedServer(testHandler) + testSrv.Start() // We start the test server, copy its config out, and close it down so we can - // start our own server. This is because httptest.Server only generates a - // self-signed TLS config after starting it. - testSrv.StartTLS() + // start our own server. srv := &http.Server{ - Addr: testSrv.Listener.Addr().String(), - Handler: testHandler, - TLSConfig: testSrv.TLS.Clone(), + Addr: testSrv.Listener.Addr().String(), + Handler: testHandler, } admissionServer, err := NewServer(srv, &noopLogger{}) @@ -87,8 +85,24 @@ func newTestServer(ctx context.Context, t *testing.T) *testServer { } // Test that we can start a minimal AdmissionServer and handle a request. -func TestRun(t *testing.T) { - t.Run("Server accepts HTTP requests", func(t *testing.T) { +func TestAdmissionServer(t *testing.T) { + t.Run("AdmissionServer should return an error w/o a *http.Server", func(t *testing.T) { + _, err := NewServer(nil, &noopLogger{}) + if err == nil { + t.Fatalf("nil *http.Server did not return an error") + } + + }) + + t.Run("AdmissionServer should return an error w/o a log.Logger", func(t *testing.T) { + _, err := NewServer(&http.Server{}, nil) + if err == nil { + t.Fatalf("nil log.Logger did not return an error") + } + + }) + + t.Run("AdmissionServer starts & accepts HTTP requests", func(t *testing.T) { testSrv := newTestServer(context.TODO(), t) defer testSrv.srv.Stop() client := testSrv.client @@ -107,7 +121,7 @@ func TestRun(t *testing.T) { } }) - t.Run("Stop stops the server", func(t *testing.T) { + t.Run("AdmissionServer.Stop() stops the server", func(t *testing.T) { testSrv := newTestServer(context.TODO(), t) testSrv.srv.GracePeriod = time.Microsecond * 1 @@ -121,6 +135,23 @@ func TestRun(t *testing.T) { http.ErrServerClosed, ) } + }) + t.Run("AdmissionServer handles a cancellation context and shuts down.", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + testSrv := newTestServer(ctx, t) + testSrv.srv.GracePeriod = time.Microsecond * 1 + + // Cancel the context + cancel() + time.Sleep(testSrv.srv.GracePeriod + time.Second) + if err := testSrv.srv.srv.ListenAndServeTLS("", ""); err != http.ErrServerClosed { + t.Fatalf( + "server did not shutdown after a cancellation signal was received: got %v (want %v)", + err, + http.ErrServerClosed, + ) + } }) + }