Commit a0cb2ce6 authored by Daniel Smith's avatar Daniel Smith

Add URL beside service

parent dbcab6d7
...@@ -266,26 +266,60 @@ const ( ...@@ -266,26 +266,60 @@ const (
// WebhookClientConfig contains the information to make a TLS // WebhookClientConfig contains the information to make a TLS
// connection with the webhook // connection with the webhook
type WebhookClientConfig struct { type WebhookClientConfig struct {
// Service is a reference to the service for this webhook. If there is only // `url` gives the location of the webhook, in standard URL form
// one port open for the service, that port will be used. If there are multiple // (`[scheme://]host:port/path`). Exactly one of `url` or `service`
// ports open, port 443 will be used if it is open, otherwise it is an error. // must be specified.
// Required //
Service ServiceReference // The `host` should not refer to a service running in the cluster; use
// the `service` field instead. The host might be resolved via external
// DNS in some apiservers (e.g., `kube-apiserver` cannot resolve
// in-cluster DNS as that would be a layering violation). `host` may
// also be an IP address.
//
// Please note that using `localhost` or `127.0.0.1` as a `host` is
// risky unless you take great care to run this webhook on all hosts
// which run an apiserver which might need to make calls to this
// webhook. Such installs are likely to be non-portable, i.e., not easy
// to turn up in a new cluster.
//
// If the scheme is present, it must be "https://".
//
// A path is optional, and if present may be any string permissible in
// a URL. You may use the path to pass an arbitrary string to the
// webhook, for example, a cluster identifier.
//
// +optional
URL *string
// URLPath is an optional field that specifies the URL path to use when posting the AdmissionReview object. // `service` is a reference to the service for this webhook. Either
URLPath string // `service` or `url` must be specified.
//
// If the webhook is running within the cluster, then you should use `service`.
//
// If there is only one port open for the service, that port will be
// used. If there are multiple ports open, port 443 will be used if it
// is open, otherwise it is an error.
//
// +optional
Service *ServiceReference
// CABundle is a PEM encoded CA bundle which will be used to validate webhook's server certificate. // `caBundle` is a PEM encoded CA bundle which will be used to validate
// Required // the webhook's server certificate.
// Required.
CABundle []byte CABundle []byte
} }
// ServiceReference holds a reference to Service.legacy.k8s.io // ServiceReference holds a reference to Service.legacy.k8s.io
type ServiceReference struct { type ServiceReference struct {
// Namespace is the namespace of the service // `namespace` is the namespace of the service.
// Required // Required
Namespace string Namespace string
// Name is the name of the service // `name` is the name of the service.
// Required // Required
Name string Name string
// `path` is an optional URL path which will be sent in any request to
// this service.
// +optional
Path *string
} }
...@@ -18,6 +18,7 @@ package validation ...@@ -18,6 +18,7 @@ package validation
import ( import (
"fmt" "fmt"
"net/url"
"strings" "strings"
genericvalidation "k8s.io/apimachinery/pkg/api/validation" genericvalidation "k8s.io/apimachinery/pkg/api/validation"
...@@ -192,29 +193,69 @@ func validateWebhook(hook *admissionregistration.Webhook, fldPath *field.Path) f ...@@ -192,29 +193,69 @@ func validateWebhook(hook *admissionregistration.Webhook, fldPath *field.Path) f
allErrors = append(allErrors, field.NotSupported(fldPath.Child("failurePolicy"), *hook.FailurePolicy, supportedFailurePolicies.List())) allErrors = append(allErrors, field.NotSupported(fldPath.Child("failurePolicy"), *hook.FailurePolicy, supportedFailurePolicies.List()))
} }
if len(hook.ClientConfig.URLPath) != 0 {
allErrors = append(allErrors, validateURLPath(fldPath.Child("clientConfig", "urlPath"), hook.ClientConfig.URLPath)...)
}
if hook.NamespaceSelector != nil { if hook.NamespaceSelector != nil {
allErrors = append(allErrors, metav1validation.ValidateLabelSelector(hook.NamespaceSelector, fldPath.Child("namespaceSelector"))...) allErrors = append(allErrors, metav1validation.ValidateLabelSelector(hook.NamespaceSelector, fldPath.Child("namespaceSelector"))...)
} }
allErrors = append(allErrors, validateWebhookClientConfig(fldPath.Child("clientConfig"), &hook.ClientConfig)...)
return allErrors
}
func validateWebhookClientConfig(fldPath *field.Path, cc *admissionregistration.WebhookClientConfig) field.ErrorList {
var allErrors field.ErrorList
if (cc.URL == nil) == (cc.Service == nil) {
allErrors = append(allErrors, field.Required(fldPath.Child("url"), "exactly one of url or service is required"))
}
if cc.URL != nil {
const form = "; desired format: https://host[/path]"
if u, err := url.Parse(*cc.URL); err != nil {
allErrors = append(allErrors, field.Required(fldPath.Child("url"), "url must be a valid URL: "+err.Error()+form))
} else {
if u.Scheme != "" && u.Scheme != "https" {
allErrors = append(allErrors, field.Required(fldPath.Child("url"), "'https' is the only allowed URL scheme"+form))
}
if len(u.Host) == 0 {
allErrors = append(allErrors, field.Required(fldPath.Child("url"), "host must be provided"+form))
}
}
}
if cc.Service != nil {
allErrors = append(allErrors, validateWebhookService(fldPath.Child("service"), cc.Service)...)
}
return allErrors return allErrors
} }
func validateURLPath(fldPath *field.Path, urlPath string) field.ErrorList { func validateWebhookService(fldPath *field.Path, svc *admissionregistration.ServiceReference) field.ErrorList {
var allErrors field.ErrorList var allErrors field.ErrorList
if len(svc.Name) == 0 {
allErrors = append(allErrors, field.Required(fldPath.Child("name"), "service name is required"))
}
if len(svc.Namespace) == 0 {
allErrors = append(allErrors, field.Required(fldPath.Child("namespace"), "service namespace is required"))
}
if svc.Path == nil {
return allErrors
}
// TODO: replace below with url.Parse + verifying that host is empty?
urlPath := *svc.Path
if urlPath == "/" || len(urlPath) == 0 { if urlPath == "/" || len(urlPath) == 0 {
return allErrors return allErrors
} }
if urlPath == "//" { if urlPath == "//" {
allErrors = append(allErrors, field.Invalid(fldPath, urlPath, "segment[0] may not be empty")) allErrors = append(allErrors, field.Invalid(fldPath.Child("path"), urlPath, "segment[0] may not be empty"))
return allErrors return allErrors
} }
if !strings.HasPrefix(urlPath, "/") { if !strings.HasPrefix(urlPath, "/") {
allErrors = append(allErrors, field.Invalid(fldPath, urlPath, "must start with a '/'")) allErrors = append(allErrors, field.Invalid(fldPath.Child("path"), urlPath, "must start with a '/'"))
} }
urlPathToCheck := urlPath[1:] urlPathToCheck := urlPath[1:]
...@@ -224,12 +265,12 @@ func validateURLPath(fldPath *field.Path, urlPath string) field.ErrorList { ...@@ -224,12 +265,12 @@ func validateURLPath(fldPath *field.Path, urlPath string) field.ErrorList {
steps := strings.Split(urlPathToCheck, "/") steps := strings.Split(urlPathToCheck, "/")
for i, step := range steps { for i, step := range steps {
if len(step) == 0 { if len(step) == 0 {
allErrors = append(allErrors, field.Invalid(fldPath, urlPath, fmt.Sprintf("segment[%d] may not be empty", i))) allErrors = append(allErrors, field.Invalid(fldPath.Child("path"), urlPath, fmt.Sprintf("segment[%d] may not be empty", i)))
continue continue
} }
failures := validation.IsDNS1123Subdomain(step) failures := validation.IsDNS1123Subdomain(step)
for _, failure := range failures { for _, failure := range failures {
allErrors = append(allErrors, field.Invalid(fldPath, urlPath, fmt.Sprintf("segment[%d]: %v", i, failure))) allErrors = append(allErrors, field.Invalid(fldPath.Child("path"), urlPath, fmt.Sprintf("segment[%d]: %v", i, failure)))
} }
} }
......
...@@ -272,26 +272,60 @@ const ( ...@@ -272,26 +272,60 @@ const (
// WebhookClientConfig contains the information to make a TLS // WebhookClientConfig contains the information to make a TLS
// connection with the webhook // connection with the webhook
type WebhookClientConfig struct { type WebhookClientConfig struct {
// Service is a reference to the service for this webhook. If there is only // `url` gives the location of the webhook, in standard URL form
// one port open for the service, that port will be used. If there are multiple // (`[scheme://]host:port/path`). Exactly one of `url` or `service`
// ports open, port 443 will be used if it is open, otherwise it is an error. // must be specified.
// Required //
Service ServiceReference `json:"service" protobuf:"bytes,1,opt,name=service"` // The `host` should not refer to a service running in the cluster; use
// the `service` field instead. The host might be resolved via external
// DNS in some apiservers (e.g., `kube-apiserver` cannot resolve
// in-cluster DNS as that would be a layering violation). `host` may
// also be an IP address.
//
// Please note that using `localhost` or `127.0.0.1` as a `host` is
// risky unless you take great care to run this webhook on all hosts
// which run an apiserver which might need to make calls to this
// webhook. Such installs are likely to be non-portable, i.e., not easy
// to turn up in a new cluster.
//
// If the scheme is present, it must be "https://".
//
// A path is optional, and if present may be any string permissible in
// a URL. You may use the path to pass an arbitrary string to the
// webhook, for example, a cluster identifier.
//
// +optional
URL *string `json:"url,omitempty"`
// URLPath is an optional field that specifies the URL path to use when posting the AdmissionReview object. // `service` is a reference to the service for this webhook. Either
URLPath string `json:"urlPath" protobuf:"bytes,3,opt,name=urlPath"` // `service` or `url` must be specified.
//
// If the webhook is running within the cluster, then you should use `service`.
//
// If there is only one port open for the service, that port will be
// used. If there are multiple ports open, port 443 will be used if it
// is open, otherwise it is an error.
//
// +optional
Service *ServiceReference `json:"service" protobuf:"bytes,1,opt,name=service"`
// CABundle is a PEM encoded CA bundle which will be used to validate webhook's server certificate. // `caBundle` is a PEM encoded CA bundle which will be used to validate
// Required // the webhook's server certificate.
// Required.
CABundle []byte `json:"caBundle" protobuf:"bytes,2,opt,name=caBundle"` CABundle []byte `json:"caBundle" protobuf:"bytes,2,opt,name=caBundle"`
} }
// ServiceReference holds a reference to Service.legacy.k8s.io // ServiceReference holds a reference to Service.legacy.k8s.io
type ServiceReference struct { type ServiceReference struct {
// Namespace is the namespace of the service // `namespace` is the namespace of the service.
// Required // Required
Namespace string `json:"namespace" protobuf:"bytes,1,opt,name=namespace"` Namespace string `json:"namespace" protobuf:"bytes,1,opt,name=namespace"`
// Name is the name of the service // `name` is the name of the service.
// Required // Required
Name string `json:"name" protobuf:"bytes,2,opt,name=name"` Name string `json:"name" protobuf:"bytes,2,opt,name=name"`
// `path` is an optional URL path which will be sent in any request to
// this service.
// +optional
Path *string `json:"path,omitempty"`
} }
...@@ -20,6 +20,7 @@ package webhook ...@@ -20,6 +20,7 @@ package webhook
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net" "net"
...@@ -55,6 +56,10 @@ const ( ...@@ -55,6 +56,10 @@ const (
defaultCacheSize = 200 defaultCacheSize = 200
) )
var (
ErrNeedServiceOrURL = errors.New("webhook configuration must have either service or URL")
)
type ErrCallingWebhook struct { type ErrCallingWebhook struct {
WebhookName string WebhookName string
Reason error Reason error
...@@ -395,47 +400,71 @@ func toStatusErr(name string, result *metav1.Status) *apierrors.StatusError { ...@@ -395,47 +400,71 @@ func toStatusErr(name string, result *metav1.Status) *apierrors.StatusError {
func (a *GenericAdmissionWebhook) hookClient(h *v1alpha1.Webhook) (*rest.RESTClient, error) { func (a *GenericAdmissionWebhook) hookClient(h *v1alpha1.Webhook) (*rest.RESTClient, error) {
cacheKey, err := json.Marshal(h.ClientConfig) cacheKey, err := json.Marshal(h.ClientConfig)
if err != nil {
return nil, err
}
if client, ok := a.cache.Get(string(cacheKey)); ok { if client, ok := a.cache.Get(string(cacheKey)); ok {
return client.(*rest.RESTClient), nil return client.(*rest.RESTClient), nil
} }
serverName := h.ClientConfig.Service.Name + "." + h.ClientConfig.Service.Namespace + ".svc" complete := func(cfg *rest.Config) (*rest.RESTClient, error) {
restConfig, err := a.authInfoResolver.ClientConfigFor(serverName) cfg.TLSClientConfig.CAData = h.ClientConfig.CABundle
if err != nil { cfg.ContentConfig.NegotiatedSerializer = a.negotiatedSerializer
return nil, err cfg.ContentConfig.ContentType = runtime.ContentTypeJSON
client, err := rest.UnversionedRESTClientFor(cfg)
if err == nil {
a.cache.Add(string(cacheKey), client)
}
return client, err
} }
cfg := rest.CopyConfig(restConfig) if svc := h.ClientConfig.Service; svc != nil {
host := serverName + ":443" serverName := svc.Name + "." + svc.Namespace + ".svc"
cfg.Host = "https://" + host restConfig, err := a.authInfoResolver.ClientConfigFor(serverName)
cfg.APIPath = h.ClientConfig.URLPath if err != nil {
cfg.TLSClientConfig.ServerName = serverName return nil, err
cfg.TLSClientConfig.CAData = h.ClientConfig.CABundle }
cfg.ContentConfig.NegotiatedSerializer = a.negotiatedSerializer cfg := rest.CopyConfig(restConfig)
cfg.ContentConfig.ContentType = runtime.ContentTypeJSON host := serverName + ":443"
cfg.Host = "https://" + host
delegateDialer := cfg.Dial if svc.Path != nil {
if delegateDialer == nil { cfg.APIPath = *svc.Path
delegateDialer = net.Dial }
} cfg.TLSClientConfig.ServerName = serverName
cfg.Dial = func(network, addr string) (net.Conn, error) { delegateDialer := cfg.Dial
if addr == host { if delegateDialer == nil {
u, err := a.serviceResolver.ResolveEndpoint(h.ClientConfig.Service.Namespace, h.ClientConfig.Service.Name) delegateDialer = net.Dial
if err != nil { }
return nil, err cfg.Dial = func(network, addr string) (net.Conn, error) {
if addr == host {
u, err := a.serviceResolver.ResolveEndpoint(svc.Namespace, svc.Name)
if err != nil {
return nil, err
}
addr = u.Host
} }
addr = u.Host return delegateDialer(network, addr)
} }
return delegateDialer(network, addr)
return complete(cfg)
}
if h.ClientConfig.URL == nil {
return nil, &ErrCallingWebhook{WebhookName: h.Name, Reason: ErrNeedServiceOrURL}
}
u, err := url.Parse(*h.ClientConfig.URL)
if err != nil {
return nil, &ErrCallingWebhook{WebhookName: h.Name, Reason: fmt.Errorf("Unparsable URL: %v", err)}
} }
client, err := rest.UnversionedRESTClientFor(cfg) restConfig, err := a.authInfoResolver.ClientConfigFor(u.Host)
if err == nil { if err != nil {
a.cache.Add(string(cacheKey), client) return nil, err
} }
return client, err
cfg := rest.CopyConfig(restConfig)
cfg.Host = u.Host
cfg.APIPath = u.Path
// TODO: test if this is needed: cfg.TLSClientConfig.ServerName = u.Host
return complete(cfg)
} }
...@@ -218,6 +218,8 @@ func deployWebhookAndService(f *framework.Framework, image string, context *cert ...@@ -218,6 +218,8 @@ func deployWebhookAndService(f *framework.Framework, image string, context *cert
framework.ExpectNoError(err, "waiting for service %s/%s have %d endpoint", namespace, serviceName, 1) framework.ExpectNoError(err, "waiting for service %s/%s have %d endpoint", namespace, serviceName, 1)
} }
func strPtr(s string) *string { return &s }
func registerWebhook(f *framework.Framework, context *certContext) { func registerWebhook(f *framework.Framework, context *certContext) {
client := f.ClientSet client := f.ClientSet
By("Registering the webhook via the AdmissionRegistration API") By("Registering the webhook via the AdmissionRegistration API")
...@@ -239,11 +241,11 @@ func registerWebhook(f *framework.Framework, context *certContext) { ...@@ -239,11 +241,11 @@ func registerWebhook(f *framework.Framework, context *certContext) {
}, },
}}, }},
ClientConfig: v1alpha1.WebhookClientConfig{ ClientConfig: v1alpha1.WebhookClientConfig{
Service: v1alpha1.ServiceReference{ Service: &v1alpha1.ServiceReference{
Namespace: namespace, Namespace: namespace,
Name: serviceName, Name: serviceName,
Path: strPtr("/pods"),
}, },
URLPath: "/pods",
CABundle: context.signingCert, CABundle: context.signingCert,
}, },
}, },
...@@ -268,11 +270,11 @@ func registerWebhook(f *framework.Framework, context *certContext) { ...@@ -268,11 +270,11 @@ func registerWebhook(f *framework.Framework, context *certContext) {
}, },
}, },
ClientConfig: v1alpha1.WebhookClientConfig{ ClientConfig: v1alpha1.WebhookClientConfig{
Service: v1alpha1.ServiceReference{ Service: &v1alpha1.ServiceReference{
Namespace: namespace, Namespace: namespace,
Name: serviceName, Name: serviceName,
Path: strPtr("/configmaps"),
}, },
URLPath: "/configmaps",
CABundle: context.signingCert, CABundle: context.signingCert,
}, },
}, },
......
...@@ -382,11 +382,11 @@ var etcdStorageData = map[schema.GroupVersionResource]struct { ...@@ -382,11 +382,11 @@ var etcdStorageData = map[schema.GroupVersionResource]struct {
expectedEtcdPath: "/registry/initializerconfigurations/ic1", expectedEtcdPath: "/registry/initializerconfigurations/ic1",
}, },
gvr("admissionregistration.k8s.io", "v1alpha1", "validatingwebhookconfigurations"): { gvr("admissionregistration.k8s.io", "v1alpha1", "validatingwebhookconfigurations"): {
stub: `{"metadata":{"name":"hook1","creationTimestamp":null},"webhooks":[{"name":"externaladmissionhook.k8s.io","clientConfig":{"service":{"namespace":"","name":""},"caBundle":null},"rules":[{"operations":["CREATE"],"apiGroups":["group"],"apiVersions":["version"],"resources":["resource"]}],"failurePolicy":"Ignore"}]}`, stub: `{"metadata":{"name":"hook1","creationTimestamp":null},"webhooks":[{"name":"externaladmissionhook.k8s.io","clientConfig":{"service":{"namespace":"ns","name":"n"},"caBundle":null},"rules":[{"operations":["CREATE"],"apiGroups":["group"],"apiVersions":["version"],"resources":["resource"]}],"failurePolicy":"Ignore"}]}`,
expectedEtcdPath: "/registry/validatingwebhookconfigurations/hook1", expectedEtcdPath: "/registry/validatingwebhookconfigurations/hook1",
}, },
gvr("admissionregistration.k8s.io", "v1alpha1", "mutatingwebhookconfigurations"): { gvr("admissionregistration.k8s.io", "v1alpha1", "mutatingwebhookconfigurations"): {
stub: `{"metadata":{"name":"hook1","creationTimestamp":null},"webhooks":[{"name":"externaladmissionhook.k8s.io","clientConfig":{"service":{"namespace":"","name":""},"caBundle":null},"rules":[{"operations":["CREATE"],"apiGroups":["group"],"apiVersions":["version"],"resources":["resource"]}],"failurePolicy":"Ignore"}]}`, stub: `{"metadata":{"name":"hook1","creationTimestamp":null},"webhooks":[{"name":"externaladmissionhook.k8s.io","clientConfig":{"service":{"namespace":"ns","name":"n"},"caBundle":null},"rules":[{"operations":["CREATE"],"apiGroups":["group"],"apiVersions":["version"],"resources":["resource"]}],"failurePolicy":"Ignore"}]}`,
expectedEtcdPath: "/registry/mutatingwebhookconfigurations/hook1", expectedEtcdPath: "/registry/mutatingwebhookconfigurations/hook1",
}, },
// -- // --
......
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