Commit b26c19bc authored by Jeremy Whitlock's avatar Jeremy Whitlock Committed by Daniel Smith

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.
parent 5375bc0c
...@@ -47,6 +47,7 @@ import ( ...@@ -47,6 +47,7 @@ import (
"k8s.io/kubernetes/plugin/pkg/admission/securitycontext/scdeny" "k8s.io/kubernetes/plugin/pkg/admission/securitycontext/scdeny"
"k8s.io/kubernetes/plugin/pkg/admission/serviceaccount" "k8s.io/kubernetes/plugin/pkg/admission/serviceaccount"
storagedefault "k8s.io/kubernetes/plugin/pkg/admission/storageclass/default" storagedefault "k8s.io/kubernetes/plugin/pkg/admission/storageclass/default"
"k8s.io/kubernetes/plugin/pkg/admission/webhook"
) )
// registerAllAdmissionPlugins registers all admission plugins // registerAllAdmissionPlugins registers all admission plugins
...@@ -73,4 +74,5 @@ func registerAllAdmissionPlugins(plugins *admission.Plugins) { ...@@ -73,4 +74,5 @@ func registerAllAdmissionPlugins(plugins *admission.Plugins) {
scdeny.Register(plugins) scdeny.Register(plugins)
serviceaccount.Register(plugins) serviceaccount.Register(plugins)
storagedefault.Register(plugins) storagedefault.Register(plugins)
webhook.Register(plugins)
} }
/*
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
/*
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,
},
})
}
}
/*
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
}
/*
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)
}
}
}
}
/*
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"
#!/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
\ No newline at end of file
/*
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
}
/*
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)
}
/*
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"
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment