add generic webhook admission controller

As part of https://github.com/kubernetes/community/pull/132, thsi commit
adds a generic webhook admission controller.  This plugin allows for a
completely declarative approach for filtering/matching admission requests
and for matching admission requests, calls out to an external webhook for
handling admission requests.
This commit is contained in:
Jeremy Whitlock 2017-05-23 16:20:20 -06:00 committed by Daniel Smith
parent 5375bc0cc8
commit b26c19bc61
11 changed files with 1694 additions and 0 deletions

View File

@ -47,6 +47,7 @@ import (
"k8s.io/kubernetes/plugin/pkg/admission/securitycontext/scdeny"
"k8s.io/kubernetes/plugin/pkg/admission/serviceaccount"
storagedefault "k8s.io/kubernetes/plugin/pkg/admission/storageclass/default"
"k8s.io/kubernetes/plugin/pkg/admission/webhook"
)
// registerAllAdmissionPlugins registers all admission plugins
@ -73,4 +74,5 @@ func registerAllAdmissionPlugins(plugins *admission.Plugins) {
scdeny.Register(plugins)
serviceaccount.Register(plugins)
storagedefault.Register(plugins)
webhook.Register(plugins)
}

View File

@ -0,0 +1,164 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package webhook checks a webhook for configured operation admission
package webhook
import (
"errors"
"fmt"
"io"
"github.com/golang/glog"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/util/webhook"
"k8s.io/kubernetes/pkg/api"
admissionv1alpha1 "k8s.io/kubernetes/pkg/apis/admission/v1alpha1"
// install the clientgo admissiony API for use with api registry
_ "k8s.io/kubernetes/pkg/apis/admission/install"
)
var (
groupVersions = []schema.GroupVersion{
admissionv1alpha1.SchemeGroupVersion,
}
)
// Register registers a plugin
func Register(plugins *admission.Plugins) {
plugins.Register("GenericAdmissionWebhook", func(configFile io.Reader) (admission.Interface, error) {
var gwhConfig struct {
WebhookConfig GenericAdmissionWebhookConfig `json:"webhook"`
}
d := yaml.NewYAMLOrJSONDecoder(configFile, 4096)
err := d.Decode(&gwhConfig)
if err != nil {
return nil, err
}
plugin, err := NewGenericAdmissionWebhook(&gwhConfig.WebhookConfig)
if err != nil {
return nil, err
}
return plugin, nil
})
}
// NewGenericAdmissionWebhook returns a generic admission webhook plugin.
func NewGenericAdmissionWebhook(config *GenericAdmissionWebhookConfig) (admission.Interface, error) {
err := normalizeConfig(config)
if err != nil {
return nil, err
}
gw, err := webhook.NewGenericWebhook(api.Registry, api.Codecs, config.KubeConfigFile, groupVersions, config.RetryBackoff)
if err != nil {
return nil, err
}
return &GenericAdmissionWebhook{
Handler: admission.NewHandler(admission.Connect, admission.Create, admission.Delete, admission.Update),
webhook: gw,
rules: config.Rules,
}, nil
}
// GenericAdmissionWebhook is an implementation of admission.Interface.
type GenericAdmissionWebhook struct {
*admission.Handler
webhook *webhook.GenericWebhook
rules []Rule
}
// Admit makes an admission decision based on the request attributes.
func (a *GenericAdmissionWebhook) Admit(attr admission.Attributes) (err error) {
var matched *Rule
// Process all declared rules to attempt to find a match
for i, rule := range a.rules {
if Matches(rule, attr) {
glog.V(2).Infof("rule at index %d matched request", i)
matched = &a.rules[i]
break
}
}
if matched == nil {
glog.V(2).Infof("rule explicitly allowed the request: no rule matched the admission request")
return nil
}
// The matched rule skips processing this request
if matched.Type == Skip {
glog.V(2).Infof("rule explicitly allowed the request")
return nil
}
// Make the webhook request
request := admissionv1alpha1.NewAdmissionReview(attr)
response := a.webhook.RestClient.Post().Body(&request).Do()
// Handle webhook response
if err := response.Error(); err != nil {
return a.handleError(attr, matched.FailAction, err)
}
var statusCode int
if response.StatusCode(&statusCode); statusCode < 200 || statusCode >= 300 {
return a.handleError(attr, matched.FailAction, fmt.Errorf("error contacting webhook: %d", statusCode))
}
if err := response.Into(&request); err != nil {
return a.handleError(attr, matched.FailAction, err)
}
if !request.Status.Allowed {
if request.Status.Result != nil && len(request.Status.Result.Reason) > 0 {
return a.handleError(attr, Deny, fmt.Errorf("webhook backend denied the request: %s", request.Status.Result.Reason))
}
return a.handleError(attr, Deny, errors.New("webhook backend denied the request"))
}
// The webhook admission controller DOES NOT allow mutation of the admission request so nothing else is required
return nil
}
func (a *GenericAdmissionWebhook) handleError(attr admission.Attributes, allowIfErr FailAction, err error) error {
if err != nil {
glog.V(2).Infof("error contacting webhook backend: %s", err)
if allowIfErr != Allow {
glog.V(2).Infof("resource not allowed due to webhook backend failure: %s", err)
return admission.NewForbidden(attr, err)
}
glog.V(2).Infof("resource allowed in spite of webhook backend failure")
}
return nil
}
// TODO: Allow configuring the serialization strategy

View File

@ -0,0 +1,369 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package webhook
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/client-go/pkg/api"
"k8s.io/client-go/tools/clientcmd/api/v1"
"k8s.io/kubernetes/pkg/apis/admission/v1alpha1"
_ "k8s.io/kubernetes/pkg/apis/admission/install"
)
const (
errAdmitPrefix = `pods "my-pod" is forbidden: `
)
var (
requestCount int
testRoot string
testServer *httptest.Server
)
type genericAdmissionWebhookTest struct {
test string
config *GenericAdmissionWebhookConfig
value interface{}
}
func TestMain(m *testing.M) {
tmpRoot, err := ioutil.TempDir("", "")
if err != nil {
killTests(err)
}
testRoot = tmpRoot
// Cleanup
defer os.RemoveAll(testRoot)
// Create the test webhook server
cert, err := tls.X509KeyPair(clientCert, clientKey)
if err != nil {
killTests(err)
}
rootCAs := x509.NewCertPool()
rootCAs.AppendCertsFromPEM(caCert)
testServer = httptest.NewUnstartedServer(http.HandlerFunc(webhookHandler))
testServer.TLS = &tls.Config{
Certificates: []tls.Certificate{cert},
ClientCAs: rootCAs,
ClientAuth: tls.RequireAndVerifyClientCert,
}
// Create the test webhook server
testServer.StartTLS()
// Cleanup
defer testServer.Close()
// Create an invalid and valid Kubernetes configuration file
var kubeConfig bytes.Buffer
err = json.NewEncoder(&kubeConfig).Encode(v1.Config{
Clusters: []v1.NamedCluster{
{
Cluster: v1.Cluster{
Server: testServer.URL,
CertificateAuthorityData: caCert,
},
},
},
AuthInfos: []v1.NamedAuthInfo{
{
AuthInfo: v1.AuthInfo{
ClientCertificateData: clientCert,
ClientKeyData: clientKey,
},
},
},
})
if err != nil {
killTests(err)
}
// The files needed on disk for the webhook tests
files := map[string][]byte{
"ca.pem": caCert,
"client.pem": clientCert,
"client-key.pem": clientKey,
"kube-config": kubeConfig.Bytes(),
}
// Write the certificate files to disk or fail
for fileName, fileData := range files {
if err := ioutil.WriteFile(filepath.Join(testRoot, fileName), fileData, 0400); err != nil {
killTests(err)
}
}
// Run the tests
m.Run()
}
// TestNewGenericAdmissionWebhook tests that NewGenericAdmissionWebhook works as expected
func TestNewGenericAdmissionWebhook(t *testing.T) {
tests := []genericAdmissionWebhookTest{
genericAdmissionWebhookTest{
test: "Empty webhook config",
config: &GenericAdmissionWebhookConfig{},
value: fmt.Errorf("kubeConfigFile is required"),
},
genericAdmissionWebhookTest{
test: "Broken webhook config",
config: &GenericAdmissionWebhookConfig{
KubeConfigFile: filepath.Join(testRoot, "kube-config.missing"),
Rules: []Rule{
Rule{
Type: Skip,
},
},
},
value: fmt.Errorf("stat .*kube-config.missing.*"),
},
genericAdmissionWebhookTest{
test: "Valid webhook config",
config: &GenericAdmissionWebhookConfig{
KubeConfigFile: filepath.Join(testRoot, "kube-config"),
Rules: []Rule{
Rule{
Type: Skip,
},
},
},
value: nil,
},
}
for _, tt := range tests {
_, err := NewGenericAdmissionWebhook(tt.config)
checkForError(t, tt.test, tt.value, err)
}
}
// TestAdmit tests that GenericAdmissionWebhook#Admit works as expected
func TestAdmit(t *testing.T) {
configWithFailAction := makeGoodConfig()
kind := api.Kind("Pod").WithVersion("v1")
name := "my-pod"
namespace := "webhook-test"
object := api.Pod{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"pod.name": name,
},
Name: name,
Namespace: namespace,
},
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Pod",
},
}
oldObject := api.Pod{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
}
operation := admission.Update
resource := api.Resource("pods").WithVersion("v1")
subResource := ""
userInfo := user.DefaultInfo{
Name: "webhook-test",
UID: "webhook-test",
}
configWithFailAction.Rules[0].FailAction = Allow
tests := []genericAdmissionWebhookTest{
genericAdmissionWebhookTest{
test: "No matching rule",
config: &GenericAdmissionWebhookConfig{
KubeConfigFile: filepath.Join(testRoot, "kube-config"),
Rules: []Rule{
Rule{
Operations: []admission.Operation{
admission.Create,
},
Type: Send,
},
},
},
value: nil,
},
genericAdmissionWebhookTest{
test: "Matching rule skips",
config: &GenericAdmissionWebhookConfig{
KubeConfigFile: filepath.Join(testRoot, "kube-config"),
Rules: []Rule{
Rule{
Operations: []admission.Operation{
admission.Update,
},
Type: Skip,
},
},
},
value: nil,
},
genericAdmissionWebhookTest{
test: "Matching rule sends (webhook internal server error)",
config: makeGoodConfig(),
value: fmt.Errorf(`%san error on the server ("webhook internal server error") has prevented the request from succeeding`, errAdmitPrefix),
},
genericAdmissionWebhookTest{
test: "Matching rule sends (webhook unsuccessful response)",
config: makeGoodConfig(),
value: fmt.Errorf("%serror contacting webhook: 101", errAdmitPrefix),
},
genericAdmissionWebhookTest{
test: "Matching rule sends (webhook unmarshallable response)",
config: makeGoodConfig(),
value: fmt.Errorf("%scouldn't get version/kind; json parse error: json: cannot unmarshal string into Go value of type struct.*", errAdmitPrefix),
},
genericAdmissionWebhookTest{
test: "Matching rule sends (webhook error but allowed via fail action)",
config: configWithFailAction,
value: nil,
},
genericAdmissionWebhookTest{
test: "Matching rule sends (webhook request denied without reason)",
config: makeGoodConfig(),
value: fmt.Errorf("%swebhook backend denied the request", errAdmitPrefix),
},
genericAdmissionWebhookTest{
test: "Matching rule sends (webhook request denied with reason)",
config: makeGoodConfig(),
value: fmt.Errorf("%swebhook backend denied the request: you shall not pass", errAdmitPrefix),
},
genericAdmissionWebhookTest{
test: "Matching rule sends (webhook request allowed)",
config: makeGoodConfig(),
value: nil,
},
}
for _, tt := range tests {
wh, err := NewGenericAdmissionWebhook(tt.config)
if err != nil {
t.Errorf("%s: unexpected error: %v", tt.test, err)
} else {
err = wh.Admit(admission.NewAttributesRecord(&object, &oldObject, kind, namespace, name, resource, subResource, operation, &userInfo))
checkForError(t, tt.test, tt.value, err)
}
}
}
func checkForError(t *testing.T, test string, expected, actual interface{}) {
aErr, _ := actual.(error)
eErr, _ := expected.(error)
if eErr != nil {
if aErr == nil {
t.Errorf("%s: expected an error", test)
} else if eErr.Error() != aErr.Error() && !regexp.MustCompile(eErr.Error()).MatchString(aErr.Error()) {
t.Errorf("%s: unexpected error message to match:\n Expected: %s\n Actual: %s", test, eErr.Error(), aErr.Error())
}
} else {
if aErr != nil {
t.Errorf("%s: unexpected error: %v", test, aErr)
}
}
}
func killTests(err error) {
panic(fmt.Sprintf("Unable to bootstrap tests: %v", err))
}
func makeGoodConfig() *GenericAdmissionWebhookConfig {
return &GenericAdmissionWebhookConfig{
KubeConfigFile: filepath.Join(testRoot, "kube-config"),
Rules: []Rule{
Rule{
Operations: []admission.Operation{
admission.Update,
},
Type: Send,
},
},
}
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
requestCount++
switch requestCount {
case 1, 4:
http.Error(w, "webhook internal server error", http.StatusInternalServerError)
return
case 2:
w.WriteHeader(http.StatusSwitchingProtocols)
w.Write([]byte("webhook invalid request"))
return
case 3:
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("webhook invalid response"))
case 5:
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(&v1alpha1.AdmissionReview{
Status: v1alpha1.AdmissionReviewStatus{
Allowed: false,
},
})
case 6:
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(&v1alpha1.AdmissionReview{
Status: v1alpha1.AdmissionReviewStatus{
Allowed: false,
Result: &metav1.Status{
Reason: "you shall not pass",
},
},
})
case 7:
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(&v1alpha1.AdmissionReview{
Status: v1alpha1.AdmissionReviewStatus{
Allowed: true,
},
})
}
}

View File

@ -0,0 +1,218 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// This file was generated using openssl by the gencerts.sh script
// and holds raw certificates for the webhook tests.
package webhook
var caKey = []byte(`-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA5qdHpJR3sAg1afEWLlnxdbDV1LtAvCd9WWE/4g71+BBwvwLL
C/piPRi+m/7AYHJ9zoVwvwAUYBJM2ppQV2Gwsq4XA5dLvr8fXt4+YA/sLTAo0PAm
/QQmZziHU9v7OJ04ypjTTuA86D/EkkpEP7lRkN/NFpV3PhU0F5TTrkrzSpePBIR1
aPSzh/6Um6TtFk9oiqat05leWcMzonizrgeQU9EW6bSYY5w+gq1X2eZb1s9eR98H
0xcZfoh3qHmJ1Iq9Cc2Nr+MUohm2m45ozT+1L+g46ZMJxyPB6xLr7uOhkoJsK30q
67AZKPo58tDTmEKSXfBotIvFI5N9P3sAWxcTiQIDAQABAoIBAAaJHOWT82RAh0ru
MuOzVr0v+o8hky8Bq3KZ59Z++AdEZ/1xldFMEfaLOfNvn4HcHKZ6b3xqAynJuvXC
w54GPZyChFJsug+4mKn2gCv2p4mMQMvS0jf/IxtvpZ4BsLek9NQAypQElJU8IVTH
1/E6Tg5d2RDXwV43+Zbld64Ln6MwZGwv8UFPEHylDMjwkex5u3tzVBD5NaegI0MD
AHAf3fiCsANmAeGWjTvUXQsOes6wjaHw6kbih5QrXM6iThHfU/YHXYmgfdfSSIFC
4puLaehp3/U8HcI97xN238B0khnkOVHzUmRJpmf17SWFSkOAZyUMiFfTSFSOedvu
lFO7v0UCgYEA/FczxADDZXq4SSTvrU61XzolgI//OKblsqe4RW8JGtk8pwjZdYOQ
v4UAYEcv5rUWA3wWohcjgNWzI9EzdhOCYpC9YFqbHJalmwhGgOjRCvDy581pcXz7
xsfkm2loWm3g+PcrCIset2tGQw/5gqSkBW42E/U0ba25wCS6RlYWCFsCgYEA6f+Q
vENXPmBUBW8TsyjWj7MZKVzKuV2yyKT37Mf7RmrpSNpft0PLJDqGqRMfhe1J7Adm
Np1fv/18RngjGjV3fSnjbvQ51748gabwzGZKWKiWJPsRDqjEriiQcFInhaqVJs7F
D0TaWalBHWyjPHCQQx7rWqA8tr3Wpga0AhNUuOsCgYEAnbrQU7L6cEM+UBIzcswh
GO4apPrdWIcSSxMFXvlh4pNpkys36nmbj+tN6eB1c6s7oF//McBu48gwWrIYjbTy
KjQ4+7KHBF6yE28fytI8YK9t1jESuOqb4ovuPKqtnODT4Ct3jbaQM6xtVdv1ZZEO
KYrTaLQ72lbeJdmPSgnjacMCgYASip6kXE2ocqeVuqR7+MtvnYhr359sqsEE5xWC
HKKLhOMxU6Rr+CI7n6uV8B76VMAbxMZTo4q3wtU7HD/jzsLGFzCfVRjUQI241EqW
V7Cib9Fd4ssKN1NGXY58Z/YbwFWLOq0gtZr7qc6wDzCsFFtKBkQt7S6CaG5+v186
HuACuwKBgEd0JaREFj6AG6boytQAx+Npj+wGG7K22O8v9YslJjaS5t2i8XrvIr0F
5ltR8Ijegp9a2pCgjshaEzUqMHhxX3EGvUxVM4R430EaQ933WRPmiLlVLyhthYt0
9oPxMoN783J7UP/IkBc6AGi3a4uTn/h6Vi884wOLop4bmsK37uIt
-----END RSA PRIVATE KEY-----`)
var caCert = []byte(`-----BEGIN CERTIFICATE-----
MIIDhDCCAmygAwIBAgIJAIf67NAEFfGmMA0GCSqGSIb3DQEBBQUAMDQxMjAwBgNV
BAMUKWdlbmVyaWNfd2ViaG9va19hZG1pc3Npb25fcGx1Z2luX3Rlc3RzX2NhMCAX
DTE3MDUwNDIyMzMyOFoYDzIyOTEwMjE3MjIzMzI4WjA0MTIwMAYDVQQDFClnZW5l
cmljX3dlYmhvb2tfYWRtaXNzaW9uX3BsdWdpbl90ZXN0c19jYTCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBAOanR6SUd7AINWnxFi5Z8XWw1dS7QLwnfVlh
P+IO9fgQcL8Cywv6Yj0Yvpv+wGByfc6FcL8AFGASTNqaUFdhsLKuFwOXS76/H17e
PmAP7C0wKNDwJv0EJmc4h1Pb+zidOMqY007gPOg/xJJKRD+5UZDfzRaVdz4VNBeU
065K80qXjwSEdWj0s4f+lJuk7RZPaIqmrdOZXlnDM6J4s64HkFPRFum0mGOcPoKt
V9nmW9bPXkffB9MXGX6Id6h5idSKvQnNja/jFKIZtpuOaM0/tS/oOOmTCccjwesS
6+7joZKCbCt9KuuwGSj6OfLQ05hCkl3waLSLxSOTfT97AFsXE4kCAwEAAaOBljCB
kzAdBgNVHQ4EFgQU55hOE1Dsydy16+6wgOxwsPKZ8JEwZAYDVR0jBF0wW4AU55hO
E1Dsydy16+6wgOxwsPKZ8JGhOKQ2MDQxMjAwBgNVBAMUKWdlbmVyaWNfd2ViaG9v
a19hZG1pc3Npb25fcGx1Z2luX3Rlc3RzX2NhggkAh/rs0AQV8aYwDAYDVR0TBAUw
AwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAtEa7AMnFdc/GmVrtY0aOmA3h1WJ/rhhd
SnVOp7LmA+1jnA6FMMvVCOFflYLfBwLhIPjPj4arQadWHyd5Quok6GIqgzL4RkK7
67hPMc8inNH8w+9K0kgsCls06Jy08NIt7QTtIckh8skvxQsfJ6An/ROiCNI5QiYj
oQOK3Tp8jGf/2wcsJLeO9y09ZPcOUbLkDe2YlnT+OqNMx9VXrPSvRwq2qtYphgYW
YWHPMEBnB/9XrVkEtnKeWPjtjarVd+rNnfVpCW0ImnZRFtKQQeI4rtf4Gr83Gdju
Z0gPepfIFptDMl5wKWyw4o2XVSZ69Ur8tQQynoNyX/FTIx6tQ//hzg==
-----END CERTIFICATE-----`)
var badCAKey = []byte(`-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA2Mbqfl5nW+rYCSw/+G00ymtBElvTwzEI88sg3m5PXU0XMv5y
Tyjt+S3mhbzLY460ty6fXlEiveNNP0bzbo0k+tvUj7HbOG4q4Cs0JWiLvibchRXQ
LimqtndsVsT2xj56m7oTETWMYhjd8KFz1s6w7yK9dChe2pBCeeGjIbQs0def8Bf0
5h7BbsmuXQb44nnpUdWgFI/Frqcz6jO6jySYV5U3ajqCjcWOBQmXRtMi1we48Hdj
2t4jW/evbRIo/tX88kluXFSVBmTitKA3nn23AzA+/qa6kTDwzQ8OntjC09JT7ykS
764WLRiBOo+wC3a6M3eZNMkW615+DNP7wAjYEwIDAQABAoIBAC9lZnXUvDKLqUpw
I1h0wBsV0jdqXmWJ/hQXsIsRgUa8CTt8CJAoOcfGcmWBPtL4q6h1iCC+CqOL5CLW
p3jfYVt73wC/+VdgNv2mVJNtRUiBBKwQdeDx+UJF4CkkjXQQywvrZinYFGaKW1Q2
aLZpoKPYa6XPAdY1vmMZo2pGE5qZbGbIXKcl3kl5N2X0qyJKvTR6RA1Q8JiTmATh
H1U3S9K/MIGU+9OwP8zCDVKFdDI+xgJvDo05W8bt6XLfmE4bLRYHRfe0iE0uBNSC
zx5OGBNqkiHq/vMk5mLCYM0w/uzUkhB5uXqe5gSqa7DioLrHztqAe+sbtBvfL+Yd
hP3asWECgYEA9NQXa2B6AVXVf2GII67H8diPFrILKLEw4CUZjFmVbvrKIRB2ypqA
IsohpYl97s0nstetZt/75uEvkv3RPu5ad5LJaVAPHE3ESXKdAz6u4fSmi33qqPi6
PvhHLYDDXvMgC7j2yCYyANVKNg2T+EJvpWMISlXM4h7CVm21CSDVZjECgYEA4qsl
zDA3sHoClfC4nAOVrRghYlHU2bT6HPLxjLtBkcUTfj6nO6nOLGC7EFVkqYw5mUWq
uSNntk1D1+MYVnZBeKqw6y21FFsclmzzXTtAJg0vuAg1jxnMHqiDbNqpUUO8ZWLG
iz2tdAWiBAKwZv0Psv44Dy++4v/BMd8FBb/iHYMCgYEAttKmRmW91b9t9Xg0fEjp
QBzyBQWhNZrTn520rUy8PSqDxBsSSgsDgncklwPMCYYjjfZmo3rBFdC0gPSOy4qb
/cycIMtK7VzZJeuzehfV6h+SOnolwFY0Zg9qv3z257FwDbDqf92d22dqymBrTaj2
zC7eovvdSkGj53x3AsEE+hECgYAd7JJU3pi7h6AHw3vbvO1pqKHfpQYAp8/NOpWB
CsehQu9L32GcktJRMYQAqAVeDNEd1wCu6Gmsu46VVbnE0F/cWkx4/9PEGDMx+Lg4
OrZBT8RY+1x2w+UatwyCtmtb+yFIET4866uWgZfeB6zaK9aCvuUPvDHrLfCHcPXs
yGRFmQKBgQDYvcjkUqKvnZwwpV6p6BHi6PuHWyjL+GLTk/IzsxOJykHzX4SV46Sd
9IxyE+OWZhkISBMSMe8wQC/S+QYs8hDbEE3WH1DXp33joyNgt2aWI0Splu7S1f6P
Pe08fYlLgz1xrbJuJAOgvkbskAUcmfmRwFeGaZSkkCtkzVxgN7LS3Q==
-----END RSA PRIVATE KEY-----`)
var badCACert = []byte(`-----BEGIN CERTIFICATE-----
MIIDhDCCAmygAwIBAgIJAIabsJi6ILN7MA0GCSqGSIb3DQEBBQUAMDQxMjAwBgNV
BAMUKWdlbmVyaWNfd2ViaG9va19hZG1pc3Npb25fcGx1Z2luX3Rlc3RzX2NhMCAX
DTE3MDUwNDIyMzMyOFoYDzIyOTEwMjE3MjIzMzI4WjA0MTIwMAYDVQQDFClnZW5l
cmljX3dlYmhvb2tfYWRtaXNzaW9uX3BsdWdpbl90ZXN0c19jYTCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBANjG6n5eZ1vq2AksP/htNMprQRJb08MxCPPL
IN5uT11NFzL+ck8o7fkt5oW8y2OOtLcun15RIr3jTT9G826NJPrb1I+x2zhuKuAr
NCVoi74m3IUV0C4pqrZ3bFbE9sY+epu6ExE1jGIY3fChc9bOsO8ivXQoXtqQQnnh
oyG0LNHXn/AX9OYewW7Jrl0G+OJ56VHVoBSPxa6nM+ozuo8kmFeVN2o6go3FjgUJ
l0bTItcHuPB3Y9reI1v3r20SKP7V/PJJblxUlQZk4rSgN559twMwPv6mupEw8M0P
Dp7YwtPSU+8pEu+uFi0YgTqPsAt2ujN3mTTJFutefgzT+8AI2BMCAwEAAaOBljCB
kzAdBgNVHQ4EFgQU9SUu1vDxGeyGEgy7pzZjLm+WSH0wZAYDVR0jBF0wW4AU9SUu
1vDxGeyGEgy7pzZjLm+WSH2hOKQ2MDQxMjAwBgNVBAMUKWdlbmVyaWNfd2ViaG9v
a19hZG1pc3Npb25fcGx1Z2luX3Rlc3RzX2NhggkAhpuwmLogs3swDAYDVR0TBAUw
AwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAViib1ZcwLUi5YE279NRypsGLa8pfT/A8
10u41L4xw+32mD2HojMmAlAF4jnC62exaXFsAYEsze4TF+0zqwDkHyGqViw/hKAv
SrGgPUX3C7wLyiMa3pjZfcQQy+80SKiJLeClxxjkdhO0mGNo1LdJThYU5IADHVtF
u2oOKLTjWBVzkMRkTXp5RReeEoUPvFgJhPKIVLggdXdJT8oQjgIVlx6IuzjU0AeM
tJ5AIWYrsqv9FlpfUXWjdiy8uF3iLWTOpd6pICnjzfj02wQouTEkxQ2iFinl7Das
iK+7d34q6Ww1/1nu4EBBDYB1VlWdhDLJVT4F+mF8wZFBu863Ba+U5Q==
-----END CERTIFICATE-----`)
var serverKey = []byte(`-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAquioChbTG+iyTg3+SwGww/7yN84jtj55v8Xld8TgkyavSZm7
Q6WURevvXtJHMug/NAz6qoCFy/zOiozUrMB36GFOA8MtwaOddCMdHMruX7q2+CiF
amhFg58uPfVr9qit20JiyBhaPH97WmQzwsfRQt4E1mCbEHZYK6r1RlF3i00nDCxc
741wuYp4Uc5oYM0dYoVCv11DYdf62v1grLFO02a5qjAULS74KKNaJ4YDOmqnTtWt
I75ZkEJSC2TT90o8eFIRuImY17venqIbp30A+XtvMhITg55648Zdci+CGHDcqJA1
C+y0rxXZkcuFFFrK0tiN7K/cPK1LDtS08gp61QIDAQABAoIBAFhGVxTu+Rc/N2lt
fNzNALobIoyEYpms50GQO5eDDuOyZXNEfh7QlScQV9DIF5JJtutxkL8kJvdXmm6h
ku+vcb+LErqKw0Vy9s6XnF/UyQ6U6BCBDXgKZ202eLHz41HBihrnzRHA0krRJato
efuvLXy2JBV+TFlSZvQXFxy801wVIxlI7Jh94YR7CT3HYmD5qjAtiTVkSS+wmx++
cfh0rrMulkXDEUlRPm/fXqt0do+neH9eNYee4h0mbZ84f3ecz/ql3HfkfoK9ZBq+
M85VWnEvRetRF0AQlYK3IUPQH2XHIEZkUab8s+EZoIsgVBY9xvl6VTEQWFtbzRWC
7ozg2PkCgYEA3X8Pv6a5wzWBwX17RkTXcPqS44uzJ/K0adv1QSrVvXrZJQ0FEGqr
gR74aQXiaVl06X/N50y/soQIHWpvqGjoEbkA+jS5y4GkGxAviFFAuRnW9DyRePxH
nQiYFzgxBj55iDsdvdqJ2e0vM1EWNcaVmghNI25D+cBc4Qh1Zz4qFSsCgYEAxYg9
vYGepcTMJ0/dk4SVMaITi/8nAnZHnptILpSjgPNp8udsovQPkymwo+EDnjQqVPO4
OIIICEopk6Zup0Rq2iW3zRGbRJtp+uJ7mfFS+nT6sAe0tPWbGejwrudDDcx0fkx4
C+1//rJ95H0c9L4nd51azCJD0k2yKtIFyj91r/8CgYBwMkWa8exU+oyQo2xHSuXK
n9K6GnCUwrcqjDWuXfFI+qp1vyOajj3zuOlh4Y4viRXUlV2KVXEhDwpBREHtD77G
A22AUCbw8+lZoBhDt8zONk2RCAE0RK5N2CWaVWdX31uWa0OEgOelESUAnIlgkggD
r0LLuLYME6m4f51gv7d3YwKBgCFp8He0C3AjIB2uRt8DWHFy5zeRS7oA5BCSV915
S0cu5ccvGpNeEZxlOvodwAzs6hRAvfLhHBa65NmTF7i3vBN2uea4iblLSNwln57k
0ZKIYzePtiO+QCRb4QrVF+SnpzUOHmh2HmapLt6Nw24rFGYJeih5y1sxxWe060HR
BkllAoGBAMkT1a3BhhEwoyyKiwC05+gzlKfAWz7t6J//6/yx+82lXDk4/J455qcw
ny2y6P6r964EUoqMrAU0bdTs3sKDtOLdNMIt5RfoDBsdQDt2ktbv0pvii3E8SQFi
JuJWSenrfFaI+AgwE9jDo1Hy6dhF6/hnV3+QoznwEPRAO6wmPyVA
-----END RSA PRIVATE KEY-----`)
var serverCert = []byte(`-----BEGIN CERTIFICATE-----
MIIDOzCCAiOgAwIBAgIJAN0PSMLOjTVAMA0GCSqGSIb3DQEBBQUAMDQxMjAwBgNV
BAMUKWdlbmVyaWNfd2ViaG9va19hZG1pc3Npb25fcGx1Z2luX3Rlc3RzX2NhMCAX
DTE3MDUwNDIyMzMyOFoYDzIyOTEwMjE3MjIzMzI4WjA4MTYwNAYDVQQDDC1nZW5l
cmljX3dlYmhvb2tfYWRtaXNzaW9uX3BsdWdpbl90ZXN0c19zZXJ2ZXIwggEiMA0G
CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq6KgKFtMb6LJODf5LAbDD/vI3ziO2
Pnm/xeV3xOCTJq9JmbtDpZRF6+9e0kcy6D80DPqqgIXL/M6KjNSswHfoYU4Dwy3B
o510Ix0cyu5furb4KIVqaEWDny499Wv2qK3bQmLIGFo8f3taZDPCx9FC3gTWYJsQ
dlgrqvVGUXeLTScMLFzvjXC5inhRzmhgzR1ihUK/XUNh1/ra/WCssU7TZrmqMBQt
Lvgoo1onhgM6aqdO1a0jvlmQQlILZNP3Sjx4UhG4iZjXu96eohunfQD5e28yEhOD
nnrjxl1yL4IYcNyokDUL7LSvFdmRy4UUWsrS2I3sr9w8rUsO1LTyCnrVAgMBAAGj
SjBIMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMC
BggrBgEFBQcDATAPBgNVHREECDAGhwR/AAABMA0GCSqGSIb3DQEBBQUAA4IBAQCA
/IFclqY/dsadGY0e9U3xq0XliIjZIHaI6OLXmO8xBdLDVf2+d6Bt3dl9gQMtGQI3
zj9vqJrM+znXR30yAERFefZItk8hTzAotk15HYExVJkIn5JQBaXRbeO2DUZFgAnu
6OU6KnuVC6i+7xDlbMl8wtRPmeZ6FS1wW4wnxLWZtKYAuLVDs0ISy6qbznGhCkWc
b0uPbxnMmZHQLVL+yF1LYWpX9+Xa9QnWXOSY7KHtcuYXZB/XV4Pt6aDncg76bFdl
MG3bocbJ9MsoS/LdlAiYzLNmKlFa243QPOo/zN170NhEZaF1lM80YBaLIE4rRtga
nrkNOnPHx1evvuleH0Yv
-----END CERTIFICATE-----`)
var clientKey = []byte(`-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEA1Cf8Tch5IieN9zAI0b5FFJoMzPsN3RAYD5GAk3Xo9XNbKnvJ
38o2TKdxFu1/Q8hqG3668vWuL6TYDYVcYWtC6rwNpU/moAdJZyyEtmZIam1Va1VW
HDCpylsk4b8jbUxxm6OzF1XxZpBUJg9o/1OvL1XA0rmcDiddXN5VdL2adJZsS54t
gI57hcH0jkQocHTEv5gIl2tcTDStd4GeWxBmCotYdCQWcNk+96QMRxIai88aPPYw
QxpOQeaABrYrjPO1fJA0jimM9bhkjGXK6cnrRQrg3jkeF3vuPw9BFbE4VeENLY+y
v2OWBPYOD8ov0Tn2inge4QjE2DZyK95l46wQbwIDAQABAoIBAGZiAY1cALEt24H9
yVPG+bluelz1jwQuvx3MPvtqvIivKcC/ynVYNYoaiCXjaTZB4orwRrH3RB8z8xvb
TvCofbugEwnDHG3/9jl3L3iCtdG+f6lznkGublH8WDklL6iQaocMoeHSFNRFNIbF
iwskzHcQcCSBdEEUWCb4GM9krMQz7pBR9BloyV40ZAGilMXI9F9FZ0YBWWee09gi
jid6sEzYQveZ5RQgEEDrE/i+jzXkU8sBKsSm1GuKH62+YrtelBqP83DwVIKthMOJ
79tw6i98v5JHV+1ikqC1Na/c7OxBBF3xrgwCN47ok0cHaIXh7SX+EIN6jcwKTmRH
VZQBz2ECgYEA6tTpJEKNAzEY3KOO578zY2hWhPUNqzUTm8yLxZedhDt2rwyWjd4J
HhS7CiNqFpMT/kxFyOupA8SFJPZDhJBXttnyzO9Sb61fLWgSP739K9OQfK1FGTA6
khjc3vHtBeGrWm9+1jSxQtrwly7Rs+EmdvseDCN7yie/mgrBLS7p1l8CgYEA50fN
6BnbeAgCTK8GDBWSJPaYUlo/lebUDCn5QIp1LK93vPQWjJR8xRBwL1TbiVKGd054
dRZVuJYMJx+2mbrt5ca9UArisZp5OZgR4xz29n9u69P5XiuG8Fq/JBJXp1GXONVx
JNOsUHOW/b3w2tNUWZcMQAH601BHOtO+EtaEX/ECgYBHygz4A8xeFG1YTjwKxt3r
3uLMRKoIE/LJp093eXEzEoam3v9LoXxCEO5ZHBh7jD0JecG/uaNyvmpBsXNUnFfk
U16xndwiveqh0/X4PJmgA05hfwrnt2HAdg9XrLfcG3Ap9nnc/EDQgmQYo7yB9Cux
JfW6mkJmu54Mdos1x+i+mwKBgHmewcGe71E0bPkkRLrQERUM89bCjJNoWfO3ktIE
vU9tSjr75GuyndYHKedJ6VRSKFHO2vs/bn5tsSBVxfEbYoSlOOJBhyo8AClwNV/H
2HqRUqQCySxjGUeFgOQYHS3ocuw5GZFzGjcIQctXObPo0391NcTnBZ5fpcVimZ5Q
XjYRAoGAN2O3HQjPyThoOUOn5MJEIx0L5bMvGNDzJO+oFngAntX/zv0zu/zsD9tc
kk4EbMLluiw2/XJvYjCStieaYxbSWioKwlThy39C+iUut+IbpP2eI46SOkhvPczt
4u1/sslqjs4ZSntR4Z9UOk3vY3oxRKbiXX2/vl9cqzB/cGYu01c=
-----END RSA PRIVATE KEY-----`)
var clientCert = []byte(`-----BEGIN CERTIFICATE-----
MIIDOzCCAiOgAwIBAgIJAN0PSMLOjTVBMA0GCSqGSIb3DQEBBQUAMDQxMjAwBgNV
BAMUKWdlbmVyaWNfd2ViaG9va19hZG1pc3Npb25fcGx1Z2luX3Rlc3RzX2NhMCAX
DTE3MDUwNDIyMzMyOFoYDzIyOTEwMjE3MjIzMzI4WjA4MTYwNAYDVQQDDC1nZW5l
cmljX3dlYmhvb2tfYWRtaXNzaW9uX3BsdWdpbl90ZXN0c19jbGllbnQwggEiMA0G
CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDUJ/xNyHkiJ433MAjRvkUUmgzM+w3d
EBgPkYCTdej1c1sqe8nfyjZMp3EW7X9DyGobfrry9a4vpNgNhVxha0LqvA2lT+ag
B0lnLIS2ZkhqbVVrVVYcMKnKWyThvyNtTHGbo7MXVfFmkFQmD2j/U68vVcDSuZwO
J11c3lV0vZp0lmxLni2AjnuFwfSORChwdMS/mAiXa1xMNK13gZ5bEGYKi1h0JBZw
2T73pAxHEhqLzxo89jBDGk5B5oAGtiuM87V8kDSOKYz1uGSMZcrpyetFCuDeOR4X
e+4/D0EVsThV4Q0tj7K/Y5YE9g4Pyi/ROfaKeB7hCMTYNnIr3mXjrBBvAgMBAAGj
SjBIMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMC
BggrBgEFBQcDATAPBgNVHREECDAGhwR/AAABMA0GCSqGSIb3DQEBBQUAA4IBAQC/
TBXk511JBKLosKVqrjluo8bzbgnREUrPcKclatiAOiIFKbMBy4nE4BlGZZW34t1u
sStB1dDHBHIuEkZxs93xwjqXN03yNNfve+FkRcb+guaZJEIBRlNocNxhd+lVDo8J
axRTdoOxyEOHGCjg+gyb0i9f/rqEqLnDwnYLZbH9Qbh/yv6OgISUTYOCzH35H0/6
unY5JaBhRvmJHI0Z3KtmvMShbUyzoYD+oNLaS31fvoYIekcHsnOjZGBukaIx1bE1
4SFjCUSPGDdzJdaYxQb0UXNI7oXKr6e6YeOrglIrVbboa0X3jtqGF1U7rop8ts3v
24SeXsvxqJht40itVvGK
-----END CERTIFICATE-----`)

View File

@ -0,0 +1,120 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package webhook checks a webhook for configured operation admission
package webhook
import (
"fmt"
"strings"
"time"
"k8s.io/apiserver/pkg/admission"
)
const (
defaultFailAction = Deny
defaultRetryBackoff = time.Duration(500) * time.Millisecond
errInvalidFailAction = "webhook rule (%d) has an invalid fail action: %s should be either %v or %v"
errInvalidRuleOperation = "webhook rule (%d) has an invalid operation (%d): %s"
errInvalidRuleType = "webhook rule (%d) has an invalid type: %s should be either %v or %v"
errMissingKubeConfigFile = "kubeConfigFile is required"
errOneRuleRequired = "webhook requires at least one rule defined"
errRetryBackoffOutOfRange = "webhook retry backoff is invalid: %v should be between %v and %v"
minRetryBackoff = time.Duration(1) * time.Millisecond
maxRetryBackoff = time.Duration(5) * time.Minute
)
func normalizeConfig(config *GenericAdmissionWebhookConfig) error {
allowFailAction := string(Allow)
denyFailAction := string(Deny)
connectOperation := string(admission.Connect)
createOperation := string(admission.Create)
deleteOperation := string(admission.Delete)
updateOperation := string(admission.Update)
sendRuleType := string(Send)
skipRuleType := string(Skip)
// Validate the kubeConfigFile property is present
if config.KubeConfigFile == "" {
return fmt.Errorf(errMissingKubeConfigFile)
}
// Normalize and validate the retry backoff
if config.RetryBackoff == 0 {
config.RetryBackoff = defaultRetryBackoff
} else {
// Unmarshalling gives nanoseconds so convert to milliseconds
config.RetryBackoff *= time.Millisecond
}
if config.RetryBackoff < minRetryBackoff || config.RetryBackoff > maxRetryBackoff {
return fmt.Errorf(errRetryBackoffOutOfRange, config.RetryBackoff, minRetryBackoff, maxRetryBackoff)
}
// Validate that there is at least one rule
if len(config.Rules) == 0 {
return fmt.Errorf(errOneRuleRequired)
}
for i, rule := range config.Rules {
// Normalize and validate the fail action
failAction := strings.ToUpper(string(rule.FailAction))
switch failAction {
case "":
config.Rules[i].FailAction = defaultFailAction
case allowFailAction:
config.Rules[i].FailAction = Allow
case denyFailAction:
config.Rules[i].FailAction = Deny
default:
return fmt.Errorf(errInvalidFailAction, i, rule.FailAction, Allow, Deny)
}
// Normalize and validate the rule operation(s)
for j, operation := range rule.Operations {
operation := strings.ToUpper(string(operation))
switch operation {
case connectOperation:
config.Rules[i].Operations[j] = admission.Connect
case createOperation:
config.Rules[i].Operations[j] = admission.Create
case deleteOperation:
config.Rules[i].Operations[j] = admission.Delete
case updateOperation:
config.Rules[i].Operations[j] = admission.Update
default:
return fmt.Errorf(errInvalidRuleOperation, i, j, rule.Operations[j])
}
}
// Normalize and validate the rule type
ruleType := strings.ToUpper(string(rule.Type))
switch ruleType {
case sendRuleType:
config.Rules[i].Type = Send
case skipRuleType:
config.Rules[i].Type = Skip
default:
return fmt.Errorf(errInvalidRuleType, i, rule.Type, Send, Skip)
}
}
return nil
}

View File

@ -0,0 +1,220 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package webhook
import (
"fmt"
"reflect"
"testing"
"time"
"k8s.io/apiserver/pkg/admission"
)
func TestConfigNormalization(t *testing.T) {
defaultRules := []Rule{
Rule{
Type: Skip,
},
}
highRetryBackoff := (maxRetryBackoff / time.Millisecond) + (time.Duration(1) * time.Millisecond)
kubeConfigFile := "/tmp/kube/config"
lowRetryBackoff := time.Duration(-1)
normalizedValidRules := []Rule{
Rule{
APIGroups: []string{""},
FailAction: Allow,
Namespaces: []string{"my-ns"},
Operations: []admission.Operation{
admission.Connect,
admission.Create,
admission.Delete,
admission.Update,
},
Resources: []string{"pods"},
ResourceNames: []string{"my-name"},
Type: Send,
},
Rule{
FailAction: Deny,
Type: Skip,
},
}
rawValidRules := []Rule{
Rule{
APIGroups: []string{""},
FailAction: FailAction("AlLoW"),
Namespaces: []string{"my-ns"},
Operations: []admission.Operation{
admission.Operation("connect"),
admission.Operation("CREATE"),
admission.Operation("DeLeTe"),
admission.Operation("UPdaTE"),
},
Resources: []string{"pods"},
ResourceNames: []string{"my-name"},
Type: RuleType("SenD"),
},
Rule{
FailAction: Deny,
Type: Skip,
},
}
unknownFailAction := FailAction("Unknown")
unknownOperation := admission.Operation("Unknown")
unknownRuleType := RuleType("Allow")
tests := []struct {
test string
rawConfig GenericAdmissionWebhookConfig
normalizedConfig GenericAdmissionWebhookConfig
err error
}{
{
test: "kubeConfigFile was not provided (error)",
rawConfig: GenericAdmissionWebhookConfig{},
err: fmt.Errorf(errMissingKubeConfigFile),
},
{
test: "retryBackoff was not provided (use default)",
rawConfig: GenericAdmissionWebhookConfig{
KubeConfigFile: kubeConfigFile,
Rules: defaultRules,
},
normalizedConfig: GenericAdmissionWebhookConfig{
KubeConfigFile: kubeConfigFile,
RetryBackoff: defaultRetryBackoff,
Rules: defaultRules,
},
},
{
test: "retryBackoff was below minimum value (error)",
rawConfig: GenericAdmissionWebhookConfig{
KubeConfigFile: kubeConfigFile,
RetryBackoff: lowRetryBackoff,
Rules: defaultRules,
},
err: fmt.Errorf(errRetryBackoffOutOfRange, lowRetryBackoff*time.Millisecond, minRetryBackoff, maxRetryBackoff),
},
{
test: "retryBackoff was above maximum value (error)",
rawConfig: GenericAdmissionWebhookConfig{
KubeConfigFile: kubeConfigFile,
RetryBackoff: highRetryBackoff,
Rules: defaultRules,
},
err: fmt.Errorf(errRetryBackoffOutOfRange, highRetryBackoff*time.Millisecond, minRetryBackoff, maxRetryBackoff),
},
{
test: "rules should have at least one rule (error)",
rawConfig: GenericAdmissionWebhookConfig{
KubeConfigFile: kubeConfigFile,
},
err: fmt.Errorf(errOneRuleRequired),
},
{
test: "fail action was not provided (use default)",
rawConfig: GenericAdmissionWebhookConfig{
KubeConfigFile: kubeConfigFile,
Rules: []Rule{
Rule{
Type: Skip,
},
},
},
normalizedConfig: GenericAdmissionWebhookConfig{
KubeConfigFile: kubeConfigFile,
RetryBackoff: defaultRetryBackoff,
Rules: []Rule{
Rule{
FailAction: defaultFailAction,
Type: Skip,
},
},
},
},
{
test: "rule has invalid fail action (error)",
rawConfig: GenericAdmissionWebhookConfig{
KubeConfigFile: kubeConfigFile,
Rules: []Rule{
Rule{
FailAction: unknownFailAction,
},
},
},
err: fmt.Errorf(errInvalidFailAction, 0, unknownFailAction, Allow, Deny),
},
{
test: "rule has invalid operation (error)",
rawConfig: GenericAdmissionWebhookConfig{
KubeConfigFile: kubeConfigFile,
Rules: []Rule{
Rule{
Operations: []admission.Operation{unknownOperation},
},
},
},
err: fmt.Errorf(errInvalidRuleOperation, 0, 0, unknownOperation),
},
{
test: "rule has invalid type (error)",
rawConfig: GenericAdmissionWebhookConfig{
KubeConfigFile: kubeConfigFile,
Rules: []Rule{
Rule{
Type: unknownRuleType,
},
},
},
err: fmt.Errorf(errInvalidRuleType, 0, unknownRuleType, Send, Skip),
},
{
test: "valid configuration",
rawConfig: GenericAdmissionWebhookConfig{
KubeConfigFile: kubeConfigFile,
Rules: rawValidRules,
},
normalizedConfig: GenericAdmissionWebhookConfig{
KubeConfigFile: kubeConfigFile,
RetryBackoff: defaultRetryBackoff,
Rules: normalizedValidRules,
},
err: nil,
},
}
for _, tt := range tests {
err := normalizeConfig(&tt.rawConfig)
if err == nil {
if tt.err != nil {
// Ensure that expected errors are produced
t.Errorf("%s: expected error but did not produce one", tt.test)
} else if !reflect.DeepEqual(tt.rawConfig, tt.normalizedConfig) {
// Ensure that valid configurations are structured properly
t.Errorf("%s: normalized config mismtach. got: %v expected: %v", tt.test, tt.rawConfig, tt.normalizedConfig)
}
} else {
if tt.err == nil {
// Ensure that unexpected errors are not produced
t.Errorf("%s: unexpected error: %v", tt.test, err)
} else if err != nil && tt.err != nil && err.Error() != tt.err.Error() {
// Ensure that expected errors are formated properly
t.Errorf("%s: error message mismatch. got: '%v' expected: '%v'", tt.test, err, tt.err)
}
}
}
}

View File

@ -0,0 +1,18 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package webhook checks a webhook for configured operation admission
package webhook // import "k8s.io/kubernetes/plugin/pkg/admission/webhook"

View File

@ -0,0 +1,107 @@
#!/bin/bash
# Copyright 2017 The Kubernetes Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
set -e
# gencerts.sh generates the certificates for the generic webhook admission plugin tests.
#
# It is not expected to be run often (there is no go generate rule), and mainly
# exists for documentation purposes.
CN_BASE="generic_webhook_admission_plugin_tests"
cat > server.conf << EOF
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
[req_distinguished_name]
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth, serverAuth
subjectAltName = @alt_names
[alt_names]
IP.1 = 127.0.0.1
EOF
cat > client.conf << EOF
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
[req_distinguished_name]
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth, serverAuth
subjectAltName = @alt_names
[alt_names]
IP.1 = 127.0.0.1
EOF
# Create a certificate authority
openssl genrsa -out caKey.pem 2048
openssl req -x509 -new -nodes -key caKey.pem -days 100000 -out caCert.pem -subj "/CN=${CN_BASE}_ca"
# Create a second certificate authority
openssl genrsa -out badCAKey.pem 2048
openssl req -x509 -new -nodes -key badCAKey.pem -days 100000 -out badCACert.pem -subj "/CN=${CN_BASE}_ca"
# Create a server certiticate
openssl genrsa -out serverKey.pem 2048
openssl req -new -key serverKey.pem -out server.csr -subj "/CN=${CN_BASE}_server" -config server.conf
openssl x509 -req -in server.csr -CA caCert.pem -CAkey caKey.pem -CAcreateserial -out serverCert.pem -days 100000 -extensions v3_req -extfile server.conf
# Create a client certiticate
openssl genrsa -out clientKey.pem 2048
openssl req -new -key clientKey.pem -out client.csr -subj "/CN=${CN_BASE}_client" -config client.conf
openssl x509 -req -in client.csr -CA caCert.pem -CAkey caKey.pem -CAcreateserial -out clientCert.pem -days 100000 -extensions v3_req -extfile client.conf
outfile=certs_test.go
cat > $outfile << EOF
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
EOF
echo "// This file was generated using openssl by the gencerts.sh script" >> $outfile
echo "// and holds raw certificates for the webhook tests." >> $outfile
echo "" >> $outfile
echo "package webhook" >> $outfile
for file in caKey caCert badCAKey badCACert serverKey serverCert clientKey clientCert; do
data=$(cat ${file}.pem)
echo "" >> $outfile
echo "var $file = []byte(\`$data\`)" >> $outfile
done
# Clean up after we're done.
rm *.pem
rm *.csr
rm *.srl
rm *.conf

View File

@ -0,0 +1,98 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package webhook checks a webhook for configured operation admission
package webhook
import "k8s.io/apiserver/pkg/admission"
func indexOf(items []string, requested string) int {
for i, item := range items {
if item == requested {
return i
}
}
return -1
}
// APIGroupMatches returns if the admission.Attributes matches the rule's API groups
func APIGroupMatches(rule Rule, attr admission.Attributes) bool {
if len(rule.APIGroups) == 0 {
return true
}
return indexOf(rule.APIGroups, attr.GetResource().Group) > -1
}
// Matches returns if the admission.Attributes matches the rule
func Matches(rule Rule, attr admission.Attributes) bool {
return APIGroupMatches(rule, attr) &&
NamespaceMatches(rule, attr) &&
OperationMatches(rule, attr) &&
ResourceMatches(rule, attr) &&
ResourceNamesMatches(rule, attr)
}
// OperationMatches returns if the admission.Attributes matches the rule's operation
func OperationMatches(rule Rule, attr admission.Attributes) bool {
if len(rule.Operations) == 0 {
return true
}
aOp := attr.GetOperation()
for _, rOp := range rule.Operations {
if aOp == rOp {
return true
}
}
return false
}
// NamespaceMatches returns if the admission.Attributes matches the rule's namespaces
func NamespaceMatches(rule Rule, attr admission.Attributes) bool {
if len(rule.Namespaces) == 0 {
return true
}
return indexOf(rule.Namespaces, attr.GetNamespace()) > -1
}
// ResourceMatches returns if the admission.Attributes matches the rule's resource (and optional subresource)
func ResourceMatches(rule Rule, attr admission.Attributes) bool {
if len(rule.Resources) == 0 {
return true
}
resource := attr.GetResource().Resource
if len(attr.GetSubresource()) > 0 {
resource = attr.GetResource().Resource + "/" + attr.GetSubresource()
}
return indexOf(rule.Resources, resource) > -1
}
// ResourceNamesMatches returns if the admission.Attributes matches the rule's resource names
func ResourceNamesMatches(rule Rule, attr admission.Attributes) bool {
if len(rule.ResourceNames) == 0 {
return true
}
return indexOf(rule.ResourceNames, attr.GetName()) > -1
}

View File

@ -0,0 +1,309 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package webhook
import (
"fmt"
"strings"
"testing"
"k8s.io/apiserver/pkg/admission"
"k8s.io/client-go/pkg/api"
)
type ruleTest struct {
test string
rule Rule
attrAPIGroup string
attrNamespace string
attrOperation admission.Operation
attrResource string
attrResourceName string
attrSubResource string
shouldMatch bool
}
func TestAPIGroupMatches(t *testing.T) {
tests := []ruleTest{
ruleTest{
test: "apiGroups empty match",
rule: Rule{},
shouldMatch: true,
},
ruleTest{
test: "apiGroup match",
rule: Rule{
APIGroups: []string{"my-group"},
},
attrAPIGroup: "my-group",
shouldMatch: true,
},
ruleTest{
test: "apiGroup mismatch",
rule: Rule{
APIGroups: []string{"my-group"},
},
attrAPIGroup: "your-group",
shouldMatch: false,
},
}
runTests(t, "apiGroups", tests)
}
func TestMatches(t *testing.T) {
tests := []ruleTest{
ruleTest{
test: "empty rule matches",
rule: Rule{},
shouldMatch: true,
},
ruleTest{
test: "all properties match",
rule: Rule{
APIGroups: []string{"my-group"},
Namespaces: []string{"my-ns"},
Operations: []admission.Operation{admission.Create},
ResourceNames: []string{"my-name"},
Resources: []string{"pods/status"},
},
shouldMatch: true,
attrAPIGroup: "my-group",
attrNamespace: "my-ns",
attrOperation: admission.Create,
attrResource: "pods",
attrResourceName: "my-name",
attrSubResource: "status",
},
ruleTest{
test: "no properties match",
rule: Rule{
APIGroups: []string{"my-group"},
Namespaces: []string{"my-ns"},
Operations: []admission.Operation{admission.Create},
ResourceNames: []string{"my-name"},
Resources: []string{"pods/status"},
},
shouldMatch: false,
attrAPIGroup: "your-group",
attrNamespace: "your-ns",
attrOperation: admission.Delete,
attrResource: "secrets",
attrResourceName: "your-name",
},
}
runTests(t, "", tests)
}
func TestNamespaceMatches(t *testing.T) {
tests := []ruleTest{
ruleTest{
test: "namespaces empty match",
rule: Rule{},
shouldMatch: true,
},
ruleTest{
test: "namespace match",
rule: Rule{
Namespaces: []string{"my-ns"},
},
attrNamespace: "my-ns",
shouldMatch: true,
},
ruleTest{
test: "namespace mismatch",
rule: Rule{
Namespaces: []string{"my-ns"},
},
attrNamespace: "your-ns",
shouldMatch: false,
},
}
runTests(t, "namespaces", tests)
}
func TestOperationMatches(t *testing.T) {
tests := []ruleTest{
ruleTest{
test: "operations empty match",
rule: Rule{},
shouldMatch: true,
},
ruleTest{
test: "operation match",
rule: Rule{
Operations: []admission.Operation{admission.Create},
},
attrOperation: admission.Create,
shouldMatch: true,
},
ruleTest{
test: "operation mismatch",
rule: Rule{
Operations: []admission.Operation{admission.Create},
},
attrOperation: admission.Delete,
shouldMatch: false,
},
}
runTests(t, "operations", tests)
}
func TestResourceMatches(t *testing.T) {
tests := []ruleTest{
ruleTest{
test: "resources empty match",
rule: Rule{},
shouldMatch: true,
},
ruleTest{
test: "resource match",
rule: Rule{
Resources: []string{"pods"},
},
attrResource: "pods",
shouldMatch: true,
},
ruleTest{
test: "resource mismatch",
rule: Rule{
Resources: []string{"pods"},
},
attrResource: "secrets",
shouldMatch: false,
},
ruleTest{
test: "resource with subresource match",
rule: Rule{
Resources: []string{"pods/status"},
},
attrResource: "pods",
attrSubResource: "status",
shouldMatch: true,
},
ruleTest{
test: "resource with subresource mismatch",
rule: Rule{
Resources: []string{"pods"},
},
attrResource: "pods",
attrSubResource: "status",
shouldMatch: false,
},
}
runTests(t, "resources", tests)
}
func TestResourceNameMatches(t *testing.T) {
tests := []ruleTest{
ruleTest{
test: "resourceNames empty match",
rule: Rule{},
shouldMatch: true,
},
ruleTest{
test: "resourceName match",
rule: Rule{
ResourceNames: []string{"my-name"},
},
attrResourceName: "my-name",
shouldMatch: true,
},
ruleTest{
test: "resourceName mismatch",
rule: Rule{
ResourceNames: []string{"my-name"},
},
attrResourceName: "your-name",
shouldMatch: false,
},
}
runTests(t, "resourceNames", tests)
}
func runTests(t *testing.T, prop string, tests []ruleTest) {
for _, tt := range tests {
if tt.attrResource == "" {
tt.attrResource = "pods"
}
res := api.Resource(tt.attrResource).WithVersion("version")
if tt.attrAPIGroup != "" {
res.Group = tt.attrAPIGroup
}
attr := admission.NewAttributesRecord(nil, nil, api.Kind("Pod").WithVersion("version"), tt.attrNamespace, tt.attrResourceName, res, tt.attrSubResource, tt.attrOperation, nil)
var attrVal string
var ruleVal []string
var matches bool
switch prop {
case "":
matches = Matches(tt.rule, attr)
case "apiGroups":
attrVal = tt.attrAPIGroup
matches = APIGroupMatches(tt.rule, attr)
ruleVal = tt.rule.APIGroups
case "namespaces":
attrVal = tt.attrNamespace
matches = NamespaceMatches(tt.rule, attr)
ruleVal = tt.rule.Namespaces
case "operations":
attrVal = string(tt.attrOperation)
matches = OperationMatches(tt.rule, attr)
ruleVal = make([]string, len(tt.rule.Operations))
for _, rOp := range tt.rule.Operations {
ruleVal = append(ruleVal, string(rOp))
}
case "resources":
attrVal = tt.attrResource
matches = ResourceMatches(tt.rule, attr)
ruleVal = tt.rule.Resources
case "resourceNames":
attrVal = tt.attrResourceName
matches = ResourceNamesMatches(tt.rule, attr)
ruleVal = tt.rule.ResourceNames
default:
t.Errorf("Unexpected test property: %s", prop)
}
if matches && !tt.shouldMatch {
if prop == "" {
testError(t, tt.test, "Expected match")
} else {
testError(t, tt.test, fmt.Sprintf("Expected %s rule property not to match %s against one of the following: %s", prop, attrVal, strings.Join(ruleVal, ", ")))
}
} else if !matches && tt.shouldMatch {
if prop == "" {
testError(t, tt.test, "Unexpected match")
} else {
testError(t, tt.test, fmt.Sprintf("Expected %s rule property to match %s against one of the following: %s", prop, attrVal, strings.Join(ruleVal, ", ")))
}
}
}
}
func testError(t *testing.T, name, msg string) {
t.Errorf("test failed (%s): %s", name, msg)
}

View File

@ -0,0 +1,69 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package webhook checks a webhook for configured operation admission
package webhook
import (
"time"
"k8s.io/apiserver/pkg/admission"
)
// FailAction is the action to take whenever a webhook fails and there are no more retries available
type FailAction string
const (
// Allow is the fail action taken whenever the webhook call fails and there are no more retries
Allow FailAction = "ALLOW"
// Deny is the fail action taken whenever the webhook call fails and there are no more retries
Deny FailAction = "DENY"
)
// GenericAdmissionWebhookConfig holds configuration for an admission webhook
type GenericAdmissionWebhookConfig struct {
KubeConfigFile string `json:"kubeConfigFile"`
RetryBackoff time.Duration `json:"retryBackoff"`
Rules []Rule `json:"rules"`
}
// Rule is the type defining an admission rule in the admission controller configuration file
type Rule struct {
// APIGroups is a list of API groups that contain the resource this rule applies to.
APIGroups []string `json:"apiGroups"`
// FailAction is the action to take whenever the webhook fails and there are no more retries (Default: DENY)
FailAction FailAction `json:"failAction"`
// Namespaces is a list of namespaces this rule applies to.
Namespaces []string `json:"namespaces"`
// Operations is a list of admission operations this rule applies to.
Operations []admission.Operation `json:"operations"`
// Resources is a list of resources this rule applies to.
Resources []string `json:"resources"`
// ResourceNames is a list of resource names this rule applies to.
ResourceNames []string `json:"resourceNames"`
// Type is the admission rule type
Type RuleType `json:"type"`
}
// RuleType is the type of admission rule
type RuleType string
const (
// Send is the rule type for when a matching admission.Attributes should be sent to the webhook
Send RuleType = "SEND"
// Skip is the rule type for when a matching admission.Attributes should not be sent to the webhook
Skip RuleType = "SKIP"
)