mirror of https://github.com/knative/pkg.git
515 lines
15 KiB
Go
515 lines
15 KiB
Go
/*
|
|
Copyright 2017 The Knative Authors
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package webhook
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"github.com/mattbaird/jsonpatch"
|
|
"go.uber.org/zap"
|
|
admissionv1beta1 "k8s.io/api/admission/v1beta1"
|
|
admissionregistrationv1beta1 "k8s.io/api/admissionregistration/v1beta1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
"k8s.io/client-go/kubernetes"
|
|
// corev1 "k8s.io/api/core/v1"
|
|
v1beta1 "k8s.io/api/extensions/v1beta1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
fakekubeclientset "k8s.io/client-go/kubernetes/fake"
|
|
|
|
. "github.com/knative/pkg/logging/testing"
|
|
. "github.com/knative/pkg/webhook/testing"
|
|
)
|
|
|
|
func newDefaultOptions() ControllerOptions {
|
|
return ControllerOptions{
|
|
Namespace: "knative-something",
|
|
ServiceName: "webhook",
|
|
Port: 443,
|
|
SecretName: "webhook-certs",
|
|
WebhookName: "webhook.knative.dev",
|
|
}
|
|
}
|
|
|
|
const (
|
|
testNamespace = "test-namespace"
|
|
testResourceName = "test-resource"
|
|
)
|
|
|
|
func newRunningTestAdmissionController(t *testing.T, options ControllerOptions) (
|
|
kubeClient *fakekubeclientset.Clientset,
|
|
ac *AdmissionController,
|
|
stopCh chan struct{}) {
|
|
// Create fake clients
|
|
kubeClient = fakekubeclientset.NewSimpleClientset()
|
|
|
|
ac, err := NewAdmissionController(kubeClient, options, TestLogger(t))
|
|
if err != nil {
|
|
t.Fatalf("Failed to create new admission controller: %s", err)
|
|
}
|
|
stopCh = make(chan struct{})
|
|
go func() {
|
|
if err := ac.Run(stopCh); err != nil {
|
|
t.Fatalf("Error running controller: %v", err)
|
|
}
|
|
}()
|
|
ac.Run(stopCh)
|
|
return
|
|
}
|
|
|
|
func newNonRunningTestAdmissionController(t *testing.T, options ControllerOptions) (
|
|
kubeClient *fakekubeclientset.Clientset,
|
|
ac *AdmissionController) {
|
|
// Create fake clients
|
|
kubeClient = fakekubeclientset.NewSimpleClientset()
|
|
|
|
ac, err := NewAdmissionController(kubeClient, options, TestLogger(t))
|
|
if err != nil {
|
|
t.Fatalf("Failed to create new admission controller: %s", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
func TestDeleteAllowed(t *testing.T) {
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
|
|
req := admissionv1beta1.AdmissionRequest{
|
|
Operation: admissionv1beta1.Delete,
|
|
}
|
|
|
|
resp := ac.admit(TestContextWithLogger(t), &req)
|
|
if !resp.Allowed {
|
|
t.Fatalf("unexpected denial of delete")
|
|
}
|
|
}
|
|
|
|
func TestConnectAllowed(t *testing.T) {
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
|
|
req := admissionv1beta1.AdmissionRequest{
|
|
Operation: admissionv1beta1.Connect,
|
|
}
|
|
|
|
resp := ac.admit(TestContextWithLogger(t), &req)
|
|
if !resp.Allowed {
|
|
t.Fatalf("unexpected denial of connect")
|
|
}
|
|
}
|
|
|
|
func TestUnknownKindFails(t *testing.T) {
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
|
|
req := admissionv1beta1.AdmissionRequest{
|
|
Operation: admissionv1beta1.Create,
|
|
Kind: metav1.GroupVersionKind{Kind: "Garbage"},
|
|
}
|
|
|
|
expectFailsWith(t, ac.admit(TestContextWithLogger(t), &req), "unhandled kind")
|
|
}
|
|
|
|
func TestInvalidNameFails(t *testing.T) {
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
req := &admissionv1beta1.AdmissionRequest{
|
|
Operation: admissionv1beta1.Create,
|
|
Kind: metav1.GroupVersionKind{Kind: "Resource"},
|
|
}
|
|
invalidName := "an.example"
|
|
config := createResource(0, invalidName)
|
|
marshaled, err := json.Marshal(config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal resource: %s", err)
|
|
}
|
|
req.Object.Raw = marshaled
|
|
expectFailsWith(t, ac.admit(TestContextWithLogger(t), req), "Invalid resource name")
|
|
|
|
invalidName = strings.Repeat("a", 64)
|
|
config = createResource(0, invalidName)
|
|
marshaled, err = json.Marshal(config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal resource: %s", err)
|
|
}
|
|
req.Object.Raw = marshaled
|
|
expectFailsWith(t, ac.admit(TestContextWithLogger(t), req), "Invalid resource name")
|
|
}
|
|
|
|
func TestValidCreateResourceSucceeds(t *testing.T) {
|
|
r := createResource(1234, "a name")
|
|
r.SetDefaults() // Fill in defaults to check that there are no patches.
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
resp := ac.admit(TestContextWithLogger(t), createCreateResource(&r))
|
|
expectAllowed(t, resp)
|
|
expectPatches(t, resp.Patch, []jsonpatch.JsonPatchOperation{
|
|
incrementGenerationPatch(0),
|
|
})
|
|
}
|
|
|
|
func TestValidCreateResourceSucceedsWithDefaultPatch(t *testing.T) {
|
|
r := createResource(1234, "a name")
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
resp := ac.admit(TestContextWithLogger(t), createCreateResource(&r))
|
|
expectAllowed(t, resp)
|
|
expectPatches(t, resp.Patch, []jsonpatch.JsonPatchOperation{
|
|
incrementGenerationPatch(0),
|
|
{
|
|
Operation: "add",
|
|
Path: "/spec/fieldWithDefault",
|
|
Value: "I'm a default.",
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestInvalidCreateResourceFails(t *testing.T) {
|
|
r := createResource(1234, "a name")
|
|
|
|
// Put a bad value in.
|
|
r.Spec.FieldWithValidation = "not what's expected"
|
|
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
resp := ac.admit(TestContextWithLogger(t), createCreateResource(&r))
|
|
expectFailsWith(t, resp, "invalid value")
|
|
}
|
|
|
|
func TestNopUpdateResourceSucceeds(t *testing.T) {
|
|
r := createResource(1234, "a name")
|
|
r.SetDefaults() // Fill in defaults to check that there are no patches.
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
resp := ac.admit(TestContextWithLogger(t), createUpdateResource(&r, &r))
|
|
expectAllowed(t, resp)
|
|
expectPatches(t, resp.Patch, []jsonpatch.JsonPatchOperation{})
|
|
}
|
|
|
|
func TestValidUpdateResourceSucceeds(t *testing.T) {
|
|
old := createResource(1234, "a name")
|
|
old.SetDefaults() // Fill in defaults to check that there are no patches.
|
|
new := createResource(1234, "a name")
|
|
// We clear the field that has a default.
|
|
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
resp := ac.admit(TestContextWithLogger(t), createUpdateResource(&old, &new))
|
|
expectAllowed(t, resp)
|
|
expectPatches(t, resp.Patch, []jsonpatch.JsonPatchOperation{{
|
|
Operation: "replace",
|
|
Path: "/spec/generation",
|
|
Value: 1235.0,
|
|
}, {
|
|
Operation: "add",
|
|
Path: "/spec/fieldWithDefault",
|
|
Value: "I'm a default.",
|
|
}})
|
|
}
|
|
|
|
func TestInvalidUpdateResourceFailsValidation(t *testing.T) {
|
|
old := createResource(1234, "a name")
|
|
new := createResource(1234, "a name")
|
|
|
|
// Try to update to a bad value.
|
|
new.Spec.FieldWithValidation = "not what's expected"
|
|
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
resp := ac.admit(TestContextWithLogger(t), createUpdateResource(&old, &new))
|
|
expectFailsWith(t, resp, "invalid value")
|
|
}
|
|
|
|
func TestInvalidUpdateResourceFailsImmutability(t *testing.T) {
|
|
old := createResource(1234, "a name")
|
|
new := createResource(1234, "a name")
|
|
|
|
// Try to change the value
|
|
new.Spec.FieldThatsImmutable = "a different value"
|
|
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
resp := ac.admit(TestContextWithLogger(t), createUpdateResource(&old, &new))
|
|
expectFailsWith(t, resp, "Immutable field changed")
|
|
}
|
|
|
|
func TestValidWebhook(t *testing.T) {
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
createDeployment(ac)
|
|
ac.register(TestContextWithLogger(t), ac.Client.AdmissionregistrationV1beta1().MutatingWebhookConfigurations(), []byte{})
|
|
_, err := ac.Client.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Get(ac.Options.WebhookName, metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create webhook: %s", err)
|
|
}
|
|
}
|
|
|
|
func TestUpdatingWebhook(t *testing.T) {
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
webhook := &admissionregistrationv1beta1.MutatingWebhookConfiguration{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: ac.Options.WebhookName,
|
|
},
|
|
Webhooks: []admissionregistrationv1beta1.Webhook{{
|
|
Name: ac.Options.WebhookName,
|
|
Rules: []admissionregistrationv1beta1.RuleWithOperations{{}},
|
|
ClientConfig: admissionregistrationv1beta1.WebhookClientConfig{},
|
|
}},
|
|
}
|
|
|
|
createDeployment(ac)
|
|
createWebhook(ac, webhook)
|
|
ac.register(TestContextWithLogger(t), ac.Client.AdmissionregistrationV1beta1().MutatingWebhookConfigurations(), []byte{})
|
|
currentWebhook, _ := ac.Client.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Get(ac.Options.WebhookName, metav1.GetOptions{})
|
|
if reflect.DeepEqual(currentWebhook.Webhooks, webhook.Webhooks) {
|
|
t.Fatalf("Expected webhook to be updated")
|
|
}
|
|
}
|
|
|
|
func TestRegistrationForAlreadyExistingWebhook(t *testing.T) {
|
|
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
|
|
webhook := &admissionregistrationv1beta1.MutatingWebhookConfiguration{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: ac.Options.WebhookName,
|
|
},
|
|
Webhooks: []admissionregistrationv1beta1.Webhook{
|
|
{
|
|
Name: ac.Options.WebhookName,
|
|
Rules: []admissionregistrationv1beta1.RuleWithOperations{{}},
|
|
ClientConfig: admissionregistrationv1beta1.WebhookClientConfig{},
|
|
},
|
|
},
|
|
}
|
|
createWebhook(ac, webhook)
|
|
|
|
ac.Options.RegistrationDelay = 1 * time.Millisecond
|
|
stopCh := make(chan struct{})
|
|
errCh := make(chan error)
|
|
|
|
go func() {
|
|
errCh <- ac.Run(stopCh)
|
|
}()
|
|
|
|
err := <-errCh
|
|
if err == nil {
|
|
t.Fatal("Expected webhook controller to fail")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "configmaps") {
|
|
t.Fatal("Expected error msg to contain configmap key missing error")
|
|
}
|
|
}
|
|
|
|
func TestCertConfigurationForAlreadyGeneratedSecret(t *testing.T) {
|
|
secretName := "test-secret"
|
|
ns := "test-namespace"
|
|
opts := newDefaultOptions()
|
|
opts.SecretName = secretName
|
|
opts.Namespace = ns
|
|
kubeClient, ac := newNonRunningTestAdmissionController(t, opts)
|
|
|
|
ctx := context.TODO()
|
|
newSecret, err := generateSecret(ctx, &opts)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate secret: %v", err)
|
|
}
|
|
_, err = kubeClient.CoreV1().Secrets(ns).Create(newSecret)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create secret: %v", err)
|
|
}
|
|
|
|
createNamespace(t, ac.Client, metav1.NamespaceSystem)
|
|
createTestConfigMap(t, ac.Client)
|
|
|
|
tlsConfig, caCert, err := configureCerts(ctx, kubeClient, &ac.Options)
|
|
if err != nil {
|
|
t.Fatalf("Failed to configure secret: %v", err)
|
|
}
|
|
expectedCert, err := tls.X509KeyPair(newSecret.Data[secretServerCert], newSecret.Data[secretServerKey])
|
|
if err != nil {
|
|
t.Fatalf("Failed to create cert from x509 key pair: %v", err)
|
|
}
|
|
|
|
if tlsConfig == nil {
|
|
t.Fatal("Expected TLS config not to be nil")
|
|
}
|
|
if len(tlsConfig.Certificates) < 1 {
|
|
t.Fatalf("Expected TLS Config Cert to be set")
|
|
}
|
|
|
|
if diff := cmp.Diff(expectedCert.Certificate, tlsConfig.Certificates[0].Certificate, cmp.AllowUnexported()); diff != "" {
|
|
t.Fatalf("Unexpected cert diff (-want, +got) %v", diff)
|
|
}
|
|
if diff := cmp.Diff(newSecret.Data[secretCACert], caCert, cmp.AllowUnexported()); diff != "" {
|
|
t.Fatalf("Unexpected CA cert diff (-want, +got) %v", diff)
|
|
}
|
|
}
|
|
|
|
func TestCertConfigurationForGeneratedSecret(t *testing.T) {
|
|
secretName := "test-secret"
|
|
ns := "test-namespace"
|
|
opts := newDefaultOptions()
|
|
opts.SecretName = secretName
|
|
opts.Namespace = ns
|
|
kubeClient, ac := newNonRunningTestAdmissionController(t, opts)
|
|
|
|
ctx := context.TODO()
|
|
createNamespace(t, ac.Client, metav1.NamespaceSystem)
|
|
createTestConfigMap(t, ac.Client)
|
|
|
|
tlsConfig, caCert, err := configureCerts(ctx, kubeClient, &ac.Options)
|
|
if err != nil {
|
|
t.Fatalf("Failed to configure certificates: %v", err)
|
|
}
|
|
|
|
if tlsConfig == nil {
|
|
t.Fatal("Expected TLS config not to be nil")
|
|
}
|
|
if len(tlsConfig.Certificates) < 1 {
|
|
t.Fatalf("Expected TLS Certfificate to be set on webhook server")
|
|
}
|
|
|
|
p, _ := pem.Decode(caCert)
|
|
if p == nil {
|
|
t.Fatalf("Expected PEM encoded CA cert ")
|
|
}
|
|
if p.Type != "CERTIFICATE" {
|
|
t.Fatalf("Expectet type to be CERTIFICATE but got %s", string(p.Type))
|
|
}
|
|
}
|
|
|
|
func createDeployment(ac *AdmissionController) {
|
|
deployment := &v1beta1.Deployment{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "whatever",
|
|
Namespace: "knative-something",
|
|
},
|
|
}
|
|
ac.Client.ExtensionsV1beta1().Deployments("knative-something").Create(deployment)
|
|
}
|
|
|
|
func createResource(generation int64, name string) Resource {
|
|
return Resource{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Namespace: testNamespace,
|
|
Name: name,
|
|
},
|
|
Spec: ResourceSpec{
|
|
Generation: generation,
|
|
FieldWithValidation: "magic value",
|
|
},
|
|
}
|
|
}
|
|
|
|
func createBaseUpdateResource() *admissionv1beta1.AdmissionRequest {
|
|
return &admissionv1beta1.AdmissionRequest{
|
|
Operation: admissionv1beta1.Update,
|
|
Kind: metav1.GroupVersionKind{Kind: "Resource"},
|
|
}
|
|
}
|
|
|
|
func createUpdateResource(old, new *Resource) *admissionv1beta1.AdmissionRequest {
|
|
req := createBaseUpdateResource()
|
|
marshaled, err := json.Marshal(new)
|
|
if err != nil {
|
|
panic("failed to marshal resource")
|
|
}
|
|
req.Object.Raw = marshaled
|
|
marshaledOld, err := json.Marshal(old)
|
|
if err != nil {
|
|
panic("failed to marshal resource")
|
|
}
|
|
req.OldObject.Raw = marshaledOld
|
|
return req
|
|
}
|
|
|
|
func createCreateResource(r *Resource) *admissionv1beta1.AdmissionRequest {
|
|
req := &admissionv1beta1.AdmissionRequest{
|
|
Operation: admissionv1beta1.Create,
|
|
Kind: metav1.GroupVersionKind{Kind: "Resource"},
|
|
}
|
|
marshaled, err := json.Marshal(r)
|
|
if err != nil {
|
|
panic("failed to marshal resource")
|
|
}
|
|
req.Object.Raw = marshaled
|
|
return req
|
|
}
|
|
|
|
func createWebhook(ac *AdmissionController, webhook *admissionregistrationv1beta1.MutatingWebhookConfiguration) {
|
|
client := ac.Client.AdmissionregistrationV1beta1().MutatingWebhookConfigurations()
|
|
_, err := client.Create(webhook)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("failed to create test webhook: %s", err))
|
|
}
|
|
}
|
|
|
|
func expectAllowed(t *testing.T, resp *admissionv1beta1.AdmissionResponse) {
|
|
t.Helper()
|
|
if !resp.Allowed {
|
|
t.Errorf("Expected allowed, but failed with %+v", resp.Result)
|
|
}
|
|
}
|
|
|
|
func expectFailsWith(t *testing.T, resp *admissionv1beta1.AdmissionResponse, contains string) {
|
|
t.Helper()
|
|
if resp.Allowed {
|
|
t.Errorf("expected denial, got allowed")
|
|
return
|
|
}
|
|
if !strings.Contains(resp.Result.Message, contains) {
|
|
t.Errorf("expected failure containing %q got %q", contains, resp.Result.Message)
|
|
}
|
|
}
|
|
|
|
func expectPatches(t *testing.T, a []byte, e []jsonpatch.JsonPatchOperation) {
|
|
t.Helper()
|
|
var got []jsonpatch.JsonPatchOperation
|
|
|
|
err := json.Unmarshal(a, &got)
|
|
if err != nil {
|
|
t.Errorf("failed to unmarshal patches: %s", err)
|
|
return
|
|
}
|
|
|
|
if diff := cmp.Diff(e, got, cmpopts.EquateEmpty()); diff != "" {
|
|
t.Errorf("expectPatches (-want, +got) = %v", diff)
|
|
}
|
|
}
|
|
|
|
func incrementGenerationPatch(old float64) jsonpatch.JsonPatchOperation {
|
|
return jsonpatch.JsonPatchOperation{
|
|
Operation: "add",
|
|
Path: "/spec/generation",
|
|
Value: old + 1.0,
|
|
}
|
|
}
|
|
|
|
func NewAdmissionController(client kubernetes.Interface, options ControllerOptions,
|
|
logger *zap.SugaredLogger) (*AdmissionController, error) {
|
|
return &AdmissionController{
|
|
Client: client,
|
|
Options: options,
|
|
GroupVersion: schema.GroupVersion{
|
|
Group: "pkg.knative.dev",
|
|
Version: "v1alpha1",
|
|
},
|
|
Handlers: map[string]runtime.Object{
|
|
"Resource": &Resource{},
|
|
},
|
|
Logger: logger,
|
|
}, nil
|
|
}
|