diff --git a/contrib/migration/unqualified-images.sh b/contrib/migration/unqualified-images.sh new file mode 100755 index 000000000000..16f5d16e0da0 --- /dev/null +++ b/contrib/migration/unqualified-images.sh @@ -0,0 +1,77 @@ +#!/bin/bash + +# List all unqualified images in all pods in all namespaces. + +strindex() { + local x="${1%%$2*}" # find occurrence of $2 in $1 + [[ "$x" == "$1" ]] && echo -1 || echo "${#x}" +} + +# Finds any-of chars in string. Returns 0 on success, otherwise 1. +strchr() { + local str=$1 + local chars=$2 + for (( i=0; i<${#chars}; i++ )); do + [[ $(strindex "$str" "${chars:$i:1}") -ge 0 ]] && return 0 + done + return 1 +} + +split_image_at_domain() { + local image=$1 + local index=$(strindex "$image" "/") + + if [[ "$index" == -1 ]] || (! strchr "${image:0:$index}" ".:" && [[ "${image:0:$index}" != "localhost" ]]); then + echo "" + else + echo "${image:0:$index}" + fi +} + +has_domain() { + local image=$1 + [[ -n $(split_image_at_domain "$image") ]] +} + +die() { + echo "$*" 1>&2 + exit 1 +} + +self_test() { + strchr "foo/busybox" "Z" && die "self-test 1 failed" + + strchr "foo/busybox" "/" || die "self-test 2 failed" + strchr "foo/busybox" "Zx" || die "self-test 3 failed" + + has_domain "foo" && die "self-test 4 failed" + has_domain "foo/busybox" && die "self-test 5 failed" + has_domain "repo/foo/busybox" && die "self-test 6 failed" + has_domain "a/b/c/busybox" && die "self-test 7 failed" + + has_domain "localhost/busybox" || die "self-test 8 failed" + has_domain "localhost:5000/busybox" || die "self-test 9 failed" + has_domain "foo.com:5000/busybox" || die "self-test 10 failed" + has_domain "docker.io/busybox" || die "self-test 11 failed" + has_domain "a.b.c.io/busybox" || die "self-test 12 failed" +} + +[[ -n ${SELF_TEST:-} ]] && self_test + +template=' +{{- range .items -}} + {{- $metadata := .metadata -}} + {{- $containers := .spec.containers -}} + {{- $container_statuses := .status.containerStatuses -}} + {{- if and $containers $container_statuses -}} + {{- if eq (len $containers) (len $container_statuses) -}} + {{- range $n, $container := $containers -}} + {{- printf "%s %s %s %s\n" $metadata.namespace $metadata.name $container.image (index $container_statuses $n).imageID -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}}' + +kubectl get pods --all-namespaces -o go-template="$template" | while read -r namespace pod image image_id; do + has_domain "$image" || echo "$namespace $pod $image $image_id" +done diff --git a/pkg/cmd/server/api/install/install.go b/pkg/cmd/server/api/install/install.go index 207ae35e89f2..c1da3cbbde90 100644 --- a/pkg/cmd/server/api/install/install.go +++ b/pkg/cmd/server/api/install/install.go @@ -17,6 +17,7 @@ import ( _ "github.com/openshift/origin/pkg/build/controller/build/defaults/api/install" _ "github.com/openshift/origin/pkg/build/controller/build/overrides/api/install" _ "github.com/openshift/origin/pkg/image/admission/imagepolicy/api/install" + _ "github.com/openshift/origin/pkg/image/admission/imagequalify/api/install" _ "github.com/openshift/origin/pkg/ingress/admission/api/install" _ "github.com/openshift/origin/pkg/project/admission/requestlimit/api/install" _ "github.com/openshift/origin/pkg/quota/admission/clusterresourceoverride/api/install" diff --git a/pkg/cmd/server/api/latest/latest.go b/pkg/cmd/server/api/latest/latest.go index bf3b6cb77a22..a7ab0364114b 100644 --- a/pkg/cmd/server/api/latest/latest.go +++ b/pkg/cmd/server/api/latest/latest.go @@ -26,4 +26,5 @@ var Codec = serializer.NewCodecFactory(configapi.Scheme).LegacyCodec( schema.GroupVersion{Group: "", Version: "v1"}, schema.GroupVersion{Group: "apiserver.k8s.io", Version: "v1alpha1"}, schema.GroupVersion{Group: "audit.k8s.io", Version: "v1alpha1"}, + schema.GroupVersion{Group: "admission.config.openshift.io", Version: "v1"}, ) diff --git a/pkg/cmd/server/origin/admission/chain_builder.go b/pkg/cmd/server/origin/admission/chain_builder.go index 3718dfa36190..6239d00a5619 100644 --- a/pkg/cmd/server/origin/admission/chain_builder.go +++ b/pkg/cmd/server/origin/admission/chain_builder.go @@ -21,6 +21,7 @@ import ( configapilatest "github.com/openshift/origin/pkg/cmd/server/api/latest" imageadmission "github.com/openshift/origin/pkg/image/admission" imagepolicy "github.com/openshift/origin/pkg/image/admission/imagepolicy/api" + imagequalify "github.com/openshift/origin/pkg/image/admission/imagequalify/api" ingressadmission "github.com/openshift/origin/pkg/ingress/admission" overrideapi "github.com/openshift/origin/pkg/quota/admission/clusterresourceoverride/api" sccadmission "github.com/openshift/origin/pkg/security/admission" @@ -56,6 +57,7 @@ var ( serviceadmit.ExternalIPPluginName, serviceadmit.RestrictedEndpointsPluginName, imagepolicy.PluginName, + imagequalify.PluginName, "ImagePolicyWebhook", "PodPreset", "LimitRanger", @@ -102,6 +104,7 @@ var ( serviceadmit.ExternalIPPluginName, serviceadmit.RestrictedEndpointsPluginName, imagepolicy.PluginName, + imagequalify.PluginName, "ImagePolicyWebhook", "PodPreset", "LimitRanger", diff --git a/pkg/cmd/server/origin/admission/register.go b/pkg/cmd/server/origin/admission/register.go index 1337baa134cd..e9644464bb21 100644 --- a/pkg/cmd/server/origin/admission/register.go +++ b/pkg/cmd/server/origin/admission/register.go @@ -17,6 +17,7 @@ import ( buildstrategyrestrictions "github.com/openshift/origin/pkg/build/admission/strategyrestrictions" imageadmission "github.com/openshift/origin/pkg/image/admission" imagepolicy "github.com/openshift/origin/pkg/image/admission/imagepolicy" + imagequalify "github.com/openshift/origin/pkg/image/admission/imagequalify" ingressadmission "github.com/openshift/origin/pkg/ingress/admission" projectlifecycle "github.com/openshift/origin/pkg/project/admission/lifecycle" projectnodeenv "github.com/openshift/origin/pkg/project/admission/nodeenv" @@ -32,6 +33,7 @@ import ( storageclassdefaultadmission "k8s.io/kubernetes/plugin/pkg/admission/storageclass/setdefault" imagepolicyapi "github.com/openshift/origin/pkg/image/admission/imagepolicy/api" + imagequalifyapi "github.com/openshift/origin/pkg/image/admission/imagequalify/api" overrideapi "github.com/openshift/origin/pkg/quota/admission/clusterresourceoverride/api" "k8s.io/apiserver/pkg/admission/plugin/namespace/lifecycle" @@ -55,6 +57,7 @@ func RegisterAllAdmissionPlugins(plugins *admission.Plugins) { buildstrategyrestrictions.Register(plugins) imageadmission.Register(plugins) imagepolicy.Register(plugins) + imagequalify.Register(plugins) ingressadmission.Register(plugins) projectlifecycle.Register(plugins) projectnodeenv.Register(plugins) @@ -103,6 +106,7 @@ var ( "PodNodeConstraints", overrideapi.PluginName, imagepolicyapi.PluginName, + imagequalifyapi.PluginName, "AlwaysPullImages", "ImagePolicyWebhook", "openshift.io/RestrictSubjectBindings", diff --git a/pkg/image/admission/imagequalify/admission.go b/pkg/image/admission/imagequalify/admission.go new file mode 100644 index 000000000000..a0a076c8b897 --- /dev/null +++ b/pkg/image/admission/imagequalify/admission.go @@ -0,0 +1,186 @@ +package imagequalify + +import ( + "fmt" + "io" + + apierrs "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apiserver/pkg/admission" + kapi "k8s.io/kubernetes/pkg/apis/core" + + "github.com/golang/glog" + "github.com/openshift/origin/pkg/image/admission/imagequalify/api" +) + +var _ admission.MutationInterface = &Plugin{} +var _ admission.ValidationInterface = &Plugin{} + +// Plugin is an implementation of admission.Interface. +type Plugin struct { + *admission.Handler + + rules []api.ImageQualifyRule +} + +// Register creates and registers the new plugin but only if there is +// non-empty and a valid configuration. +func Register(plugins *admission.Plugins) { + plugins.Register(api.PluginName, func(config io.Reader) (admission.Interface, error) { + pluginConfig, err := readConfig(config) + if err != nil { + return nil, err + } + if pluginConfig == nil { + glog.Infof("Admission plugin %q is not configured so it will be disabled.", api.PluginName) + return nil, nil + } + return NewPlugin(pluginConfig.Rules), nil + }) +} + +func isSubresourceRequest(attributes admission.Attributes) bool { + return len(attributes.GetSubresource()) > 0 +} + +func isPodsRequest(attributes admission.Attributes) bool { + return attributes.GetResource().GroupResource() == kapi.Resource("pods") +} + +func shouldIgnore(attributes admission.Attributes) bool { + switch { + case isSubresourceRequest(attributes): + return true + case !isPodsRequest(attributes): + return true + default: + return false + } +} + +func qualifyImages(images []string, rules []api.ImageQualifyRule) ([]string, error) { + qnames := make([]string, len(images)) + + for i := range images { + qname, err := qualifyImage(images[i], rules) + if err != nil { + return nil, apierrs.NewBadRequest(fmt.Sprintf("invalid image %q: %s", images[i], err)) + } + qnames[i] = qname + } + + return qnames, nil +} + +func containerImages(containers []kapi.Container) []string { + names := make([]string, len(containers)) + + for i := range containers { + names[i] = containers[i].Image + } + + return names +} + +func qualifyContainers(containers []kapi.Container, rules []api.ImageQualifyRule, action func(index int, qname string) error) error { + qnames, err := qualifyImages(containerImages(containers), rules) + + if err != nil { + return err + } + + for i := range containers { + if err := action(i, qnames[i]); err != nil { + return err + } + } + + return nil +} + +// Admit makes an admission decision based on the request attributes. +// If the attributes are valid then any container image names that are +// unqualified (i.e., have no domain component) will be qualified with +// domain according to the set of rules. If no rule matches then the +// name can still remain unqualified. +func (p *Plugin) Admit(attributes admission.Attributes) error { + // Ignore all calls to subresources or resources other than pods. + if shouldIgnore(attributes) { + return nil + } + + pod, ok := attributes.GetObject().(*kapi.Pod) + if !ok { + return apierrs.NewBadRequest("Resource was marked with kind Pod but was unable to be converted") + } + + if err := qualifyContainers(pod.Spec.InitContainers, p.rules, func(i int, qname string) error { + if pod.Spec.InitContainers[i].Image != qname { + glog.V(4).Infof("qualifying image %q as %q", pod.Spec.InitContainers[i].Image, qname) + pod.Spec.InitContainers[i].Image = qname + } + return nil + }); err != nil { + return err + } + + if err := qualifyContainers(pod.Spec.Containers, p.rules, func(i int, qname string) error { + if pod.Spec.Containers[i].Image != qname { + glog.V(4).Infof("qualifying image %q as %q", pod.Spec.Containers[i].Image, qname) + pod.Spec.Containers[i].Image = qname + } + return nil + }); err != nil { + return err + } + + return nil +} + +// Validate makes an admission decision based on the request +// attributes. It checks that image names that got qualified in +// Admit() remain qualified, returning an error if this condition no +// longer holds true. +func (p *Plugin) Validate(attributes admission.Attributes) error { + // Ignore all calls to subresources or resources other than pods. + if shouldIgnore(attributes) { + return nil + } + + pod, ok := attributes.GetObject().(*kapi.Pod) + if !ok { + return apierrs.NewBadRequest("Resource was marked with kind Pod but was unable to be converted") + } + + // Re-qualify - anything that has become unqualified has been + // changed post Admit() and is now in error. + + if err := qualifyContainers(pod.Spec.InitContainers, p.rules, func(i int, qname string) error { + if pod.Spec.InitContainers[i].Image != qname { + msg := fmt.Sprintf("image %q should be qualified as %q", pod.Spec.InitContainers[i].Image, qname) + return apierrs.NewBadRequest(msg) + } + return nil + }); err != nil { + return err + } + + if err := qualifyContainers(pod.Spec.Containers, p.rules, func(i int, qname string) error { + if pod.Spec.Containers[i].Image != qname { + msg := fmt.Sprintf("image %q should be qualified as %q", pod.Spec.Containers[i].Image, qname) + return apierrs.NewBadRequest(msg) + } + return nil + }); err != nil { + return err + } + + return nil +} + +// NewPlugin creates a new admission handler. +func NewPlugin(rules []api.ImageQualifyRule) *Plugin { + return &Plugin{ + Handler: admission.NewHandler(admission.Create, admission.Update), + rules: rules, + } +} diff --git a/pkg/image/admission/imagequalify/admission_test.go b/pkg/image/admission/imagequalify/admission_test.go new file mode 100644 index 000000000000..7767a71fa580 --- /dev/null +++ b/pkg/image/admission/imagequalify/admission_test.go @@ -0,0 +1,395 @@ +package imagequalify_test + +import ( + "bytes" + "reflect" + "testing" + + configapilatest "github.com/openshift/origin/pkg/cmd/server/api/latest" + "github.com/openshift/origin/pkg/image/admission/imagequalify" + "github.com/openshift/origin/pkg/image/admission/imagequalify/api" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/admission" + kapi "k8s.io/kubernetes/pkg/apis/core" +) + +type admissionTest struct { + config *testConfig + attributes admission.Attributes + handler *imagequalify.Plugin + pod *kapi.Pod +} + +type testConfig struct { + InitContainers []kapi.Container + Containers []kapi.Container + ExpectedInitContainers []kapi.Container + ExpectedContainers []kapi.Container + AdmissionObject runtime.Object + Resource string + Subresource string + Config *api.ImageQualifyConfig +} + +func container(image string) kapi.Container { + return kapi.Container{ + Image: image, + } +} + +func parseConfigRules(rules []api.ImageQualifyRule) (*api.ImageQualifyConfig, error) { + config, err := configapilatest.WriteYAML(&api.ImageQualifyConfig{ + Rules: rules, + }) + + if err != nil { + return nil, err + } + + return imagequalify.ReadConfig(bytes.NewReader(config)) +} + +func mustParseRules(rules []api.ImageQualifyRule) *api.ImageQualifyConfig { + config, err := parseConfigRules(rules) + if err != nil { + panic(err) + } + return config +} + +func newTest(c *testConfig) admissionTest { + pod := kapi.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admissionTest", + Namespace: "newAdmissionTest", + }, + Spec: kapi.PodSpec{ + InitContainers: c.InitContainers, + Containers: c.Containers, + }, + } + + if c.AdmissionObject == nil { + c.AdmissionObject = &pod + } + + if c.Resource == "" { + c.Resource = "pods" + } + + attributes := admission.NewAttributesRecord( + c.AdmissionObject, + nil, + kapi.Kind("Pod").WithVersion("version"), + "Namespace", + "Name", + kapi.Resource(c.Resource).WithVersion("version"), + c.Subresource, + admission.Create, // XXX and update? + nil) + + return admissionTest{ + attributes: attributes, + config: c, + handler: imagequalify.NewPlugin(c.Config.Rules), + pod: &pod, + } +} + +func imageNames(containers []kapi.Container) []string { + names := make([]string, len(containers)) + for i := range containers { + names[i] = containers[i].Image + } + return names +} + +func assertImageNamesEqual(t *testing.T, expected, actual []kapi.Container) { + a, b := imageNames(expected), imageNames(actual) + if !reflect.DeepEqual(a, b) { + t.Errorf("expected %v, got %v", a, b) + } +} + +func TestAdmissionQualifiesUnqualifiedImages(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "somerepo/*", + Domain: "somerepo.io", + }, { + Pattern: "nginx", + Domain: "nginx.com", + }, { + Pattern: "*/*", + Domain: "docker.io", + }, { + Pattern: "*", + Domain: "docker.io", + }} + + test := newTest(&testConfig{ + InitContainers: []kapi.Container{ + container("somerepo/busybox"), + container("example.com/nginx"), + container("nginx"), + container("emacs"), + }, + ExpectedInitContainers: []kapi.Container{ + container("somerepo.io/somerepo/busybox"), + container("example.com/nginx"), + container("nginx.com/nginx"), + container("docker.io/emacs"), + }, + Containers: []kapi.Container{ + container("example.com/busybox"), + container("nginx"), + container("vim"), + }, + ExpectedContainers: []kapi.Container{ + container("example.com/busybox"), + container("nginx.com/nginx"), + container("docker.io/vim"), + }, + Config: mustParseRules(rules), + }) + + if err := test.handler.Admit(test.attributes); err != nil { + t.Errorf("unexpected error returned from admission handler: %s", err) + + } + + assertImageNamesEqual(t, test.config.ExpectedInitContainers, test.config.InitContainers) + assertImageNamesEqual(t, test.config.ExpectedContainers, test.config.Containers) + + if err := test.handler.Validate(test.attributes); err != nil { + t.Errorf("unexpected error returned from admission handler: %s", err) + } + + assertImageNamesEqual(t, test.config.ExpectedInitContainers, test.config.InitContainers) + assertImageNamesEqual(t, test.config.ExpectedContainers, test.config.Containers) +} + +func TestAdmissionValidateErrors(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "somerepo/*", + Domain: "somerepo.io", + }} + + test := newTest(&testConfig{ + InitContainers: []kapi.Container{ + container("somerepo/busybox"), + }, + ExpectedInitContainers: []kapi.Container{ + container("somerepo.io/somerepo/busybox"), + }, + Config: mustParseRules(rules), + }) + + if err := test.handler.Admit(test.attributes); err != nil { + t.Errorf("unexpected error returned from admission handler: %s", err) + + } + + assertImageNamesEqual(t, test.config.ExpectedInitContainers, test.config.InitContainers) + assertImageNamesEqual(t, test.config.ExpectedContainers, test.config.Containers) + + // unqualify image post Admit() and now Validate should error. + test.config.InitContainers[0].Image = "somerepo/busybox" + + if err := test.handler.Validate(test.attributes); err == nil { + t.Errorf("expected an error from validate") + } + + // Test again, but on non-init containers. + + test = newTest(&testConfig{ + Containers: []kapi.Container{ + container("somerepo/busybox"), + }, + ExpectedContainers: []kapi.Container{ + container("somerepo.io/somerepo/busybox"), + }, + Config: mustParseRules(rules), + }) + + if err := test.handler.Admit(test.attributes); err != nil { + t.Errorf("unexpected error returned from admission handler: %s", err) + + } + + assertImageNamesEqual(t, test.config.ExpectedInitContainers, test.config.InitContainers) + assertImageNamesEqual(t, test.config.ExpectedContainers, test.config.Containers) + + // unqualify image post Admit() and now Validate should error. + test.config.Containers[0].Image = "somerepo/busybox" + + if err := test.handler.Validate(test.attributes); err == nil { + t.Errorf("expected an error from validate") + } +} + +func TestAdmissionErrorsOnNonPodObject(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "somerepo/*", + Domain: "somerepo.io", + }, { + Pattern: "nginx", + Domain: "nginx.com", + }} + + test := newTest(&testConfig{ + InitContainers: []kapi.Container{ + container("somerepo/busybox"), + }, + Containers: []kapi.Container{ + container("foo.io/busybox"), + }, + AdmissionObject: &kapi.ReplicationController{}, + Config: mustParseRules(rules), + }) + + if err := test.handler.Admit(test.attributes); err == nil { + t.Errorf("expected an error from admission handler") + } + + if err := test.handler.Validate(test.attributes); err == nil { + t.Errorf("expected an error from admission handler") + } +} + +func TestAdmissionIsIgnoredForSubresource(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "somerepo/*", + Domain: "somerepo.io", + }, { + Pattern: "nginx", + Domain: "nginx.com", + }} + + test := newTest(&testConfig{ + InitContainers: []kapi.Container{ + container("somerepo/busybox"), + container("foo.io/nginx"), + }, + ExpectedInitContainers: []kapi.Container{ + container("somerepo/busybox"), + container("foo.io/nginx"), + }, + Containers: []kapi.Container{ + container("foo.io/busybox"), + container("nginx"), + }, + ExpectedContainers: []kapi.Container{ + container("foo.io/busybox"), + container("nginx"), + }, + Subresource: "subresource", + Config: mustParseRules(rules), + }) + + // Not expecting an error for Admit() or Validate() because we + // are operating on a subresource of pod. The handler will + // ignore calls for these attributes and this means the + // container names should remain unchanged. + + if err := test.handler.Admit(test.attributes); err != nil { + t.Errorf("unexpected error from admission handler: %s", err) + } + + assertImageNamesEqual(t, test.config.ExpectedInitContainers, test.config.InitContainers) + assertImageNamesEqual(t, test.config.ExpectedContainers, test.config.Containers) + + if err := test.handler.Validate(test.attributes); err != nil { + t.Errorf("unexpected error from admission handler: %s", err) + } + + assertImageNamesEqual(t, test.config.ExpectedInitContainers, test.config.InitContainers) + assertImageNamesEqual(t, test.config.ExpectedContainers, test.config.Containers) +} + +func TestAdmissionErrorsOnNonPodsResource(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "somerepo/*", + Domain: "somerepo.io", + }, { + Pattern: "nginx", + Domain: "nginx.com", + }} + + test := newTest(&testConfig{ + InitContainers: []kapi.Container{ + container("somerepo/busybox"), + container("foo.io/nginx"), + }, + ExpectedInitContainers: []kapi.Container{ + container("somerepo/busybox"), + container("foo.io/nginx"), + }, + Containers: []kapi.Container{ + container("foo.io/busybox"), + container("nginx"), + }, + ExpectedContainers: []kapi.Container{ + container("foo.io/busybox"), + container("nginx"), + }, + Resource: "nonpods", + Config: mustParseRules(rules), + }) + + if err := test.handler.Admit(test.attributes); err != nil { + t.Errorf("expected error from admission handler") + } + + assertImageNamesEqual(t, test.config.ExpectedInitContainers, test.config.InitContainers) + assertImageNamesEqual(t, test.config.ExpectedContainers, test.config.Containers) + + if err := test.handler.Validate(test.attributes); err != nil { + t.Errorf("expected error from admission handler") + } + + assertImageNamesEqual(t, test.config.ExpectedInitContainers, test.config.InitContainers) + assertImageNamesEqual(t, test.config.ExpectedContainers, test.config.Containers) +} + +func TestAdmissionErrorsWhenImageNamesAreInvalid(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "somerepo/*", + Domain: "somerepo.io", + }, { + Pattern: "nginx", + Domain: "nginx.com", + }} + + test := newTest(&testConfig{ + InitContainers: []kapi.Container{ + container("foo.io/[]!nginx"), + }, + Config: mustParseRules(rules), + }) + + if err := test.handler.Admit(test.attributes); err == nil { + t.Errorf("expected error from admission handler") + } + + if err := test.handler.Validate(test.attributes); err == nil { + t.Errorf("expected error from admission handler") + } + + // Same test, but for non init containers. + + test = newTest(&testConfig{ + Containers: []kapi.Container{ + container("foo.io/[]!nginx"), + }, + Config: mustParseRules(rules), + }) + + if err := test.handler.Admit(test.attributes); err == nil { + t.Errorf("expected error from admission handler") + } + + if err := test.handler.Validate(test.attributes); err == nil { + t.Errorf("expected error from admission handler") + } +} diff --git a/pkg/image/admission/imagequalify/api/doc.go b/pkg/image/admission/imagequalify/api/doc.go new file mode 100644 index 000000000000..9f6c967cfa9e --- /dev/null +++ b/pkg/image/admission/imagequalify/api/doc.go @@ -0,0 +1,4 @@ +// +k8s:deepcopy-gen=package,register + +// Package api is the internal version of the API. +package api diff --git a/pkg/image/admission/imagequalify/api/install/install.go b/pkg/image/admission/imagequalify/api/install/install.go new file mode 100644 index 000000000000..304b0263d319 --- /dev/null +++ b/pkg/image/admission/imagequalify/api/install/install.go @@ -0,0 +1,41 @@ +package install + +import ( + "github.com/golang/glog" + "k8s.io/apimachinery/pkg/runtime/schema" + + configapi "github.com/openshift/origin/pkg/cmd/server/api" + "github.com/openshift/origin/pkg/image/admission/imagequalify/api" + "github.com/openshift/origin/pkg/image/admission/imagequalify/api/v1" +) + +// availableVersions lists all known external versions for this group +// from most preferred to least preferred +var availableVersions = []schema.GroupVersion{v1.SchemeGroupVersion} + +func init() { + if err := enableVersions(availableVersions); err != nil { + panic(err) + } +} + +// TODO: enableVersions should be centralized rather than spread in each API group. +func enableVersions(externalVersions []schema.GroupVersion) error { + addVersionsToScheme(externalVersions...) + return nil +} + +func addVersionsToScheme(externalVersions ...schema.GroupVersion) { + // add the internal version to Scheme + api.AddToScheme(configapi.Scheme) + // add the enabled external versions to Scheme + for _, v := range externalVersions { + switch v { + case v1.SchemeGroupVersion: + v1.AddToScheme(configapi.Scheme) + default: + glog.Errorf("Version %s is not known, so it will not be added to the Scheme.", v) + continue + } + } +} diff --git a/pkg/image/admission/imagequalify/api/name.go b/pkg/image/admission/imagequalify/api/name.go new file mode 100644 index 000000000000..9d0e9fd8c346 --- /dev/null +++ b/pkg/image/admission/imagequalify/api/name.go @@ -0,0 +1,4 @@ +package api + +const PluginName = "openshift.io/ImageQualify" +const ConfigKind = "ImageQualifyConfig" diff --git a/pkg/image/admission/imagequalify/api/register.go b/pkg/image/admission/imagequalify/api/register.go new file mode 100644 index 000000000000..64b2eac60686 --- /dev/null +++ b/pkg/image/admission/imagequalify/api/register.go @@ -0,0 +1,24 @@ +package api + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var GroupName = "admission.config.openshift.io" +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: runtime.APIVersionInternal} + +var ( + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + AddToScheme = SchemeBuilder.AddToScheme +) + +// Adds the list of known types to api.Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, &ImageQualifyConfig{}) + return nil +} + +func (obj *ImageQualifyConfig) GetObjectKind() schema.ObjectKind { + return &obj.TypeMeta +} diff --git a/pkg/image/admission/imagequalify/api/types.go b/pkg/image/admission/imagequalify/api/types.go new file mode 100644 index 000000000000..643196426491 --- /dev/null +++ b/pkg/image/admission/imagequalify/api/types.go @@ -0,0 +1,18 @@ +package api + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type ImageQualifyConfig struct { + metav1.TypeMeta + + Rules []ImageQualifyRule +} + +type ImageQualifyRule struct { + Pattern string + Domain string +} diff --git a/pkg/image/admission/imagequalify/api/v1/doc.go b/pkg/image/admission/imagequalify/api/v1/doc.go new file mode 100644 index 000000000000..124d5620f4a7 --- /dev/null +++ b/pkg/image/admission/imagequalify/api/v1/doc.go @@ -0,0 +1,4 @@ +// +k8s:deepcopy-gen=package,register + +// Package v1 is the v1 version of the API. +package v1 diff --git a/pkg/image/admission/imagequalify/api/v1/register.go b/pkg/image/admission/imagequalify/api/v1/register.go new file mode 100644 index 000000000000..50133a68457e --- /dev/null +++ b/pkg/image/admission/imagequalify/api/v1/register.go @@ -0,0 +1,25 @@ +package v1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// SchemeGroupVersion is group version used to register these objects +var GroupName = "admission.config.openshift.io" +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1"} + +var ( + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + AddToScheme = SchemeBuilder.AddToScheme +) + +// Adds the list of known types to api.Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, &ImageQualifyConfig{}) + return nil +} + +func (obj *ImageQualifyConfig) GetObjectKind() schema.ObjectKind { + return &obj.TypeMeta +} diff --git a/pkg/image/admission/imagequalify/api/v1/types.go b/pkg/image/admission/imagequalify/api/v1/types.go new file mode 100644 index 000000000000..803c5aebe78b --- /dev/null +++ b/pkg/image/admission/imagequalify/api/v1/types.go @@ -0,0 +1,18 @@ +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type ImageQualifyConfig struct { + metav1.TypeMeta `json:",inline"` + + Rules []ImageQualifyRule `json:"rules"` +} + +type ImageQualifyRule struct { + Pattern string `json:"pattern"` + Domain string `json:"domain"` +} diff --git a/pkg/image/admission/imagequalify/api/v1/zz_generated.deepcopy.go b/pkg/image/admission/imagequalify/api/v1/zz_generated.deepcopy.go new file mode 100644 index 000000000000..3c7ed7b6469a --- /dev/null +++ b/pkg/image/admission/imagequalify/api/v1/zz_generated.deepcopy.go @@ -0,0 +1,56 @@ +// +build !ignore_autogenerated_openshift + +// This file was autogenerated by deepcopy-gen. Do not edit it manually! + +package v1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageQualifyConfig) DeepCopyInto(out *ImageQualifyConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.Rules != nil { + in, out := &in.Rules, &out.Rules + *out = make([]ImageQualifyRule, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageQualifyConfig. +func (in *ImageQualifyConfig) DeepCopy() *ImageQualifyConfig { + if in == nil { + return nil + } + out := new(ImageQualifyConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ImageQualifyConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } else { + return nil + } +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageQualifyRule) DeepCopyInto(out *ImageQualifyRule) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageQualifyRule. +func (in *ImageQualifyRule) DeepCopy() *ImageQualifyRule { + if in == nil { + return nil + } + out := new(ImageQualifyRule) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/image/admission/imagequalify/api/validation/domain.go b/pkg/image/admission/imagequalify/api/validation/domain.go new file mode 100644 index 000000000000..92a3cf806638 --- /dev/null +++ b/pkg/image/admission/imagequalify/api/validation/domain.go @@ -0,0 +1,21 @@ +package validation + +import ( + "errors" +) + +const imageRefWithoutDomain = "foo/bar" + +// validateDomain validates that doamin (e.g., "myregistry.io") can be +// used as the domain component in a docker image reference. Returns +// an error if domain is invalid. +func validateDomain(domain string) error { + matchedDomain, remainder, err := ParseDomainName(domain + "/" + imageRefWithoutDomain) + if err != nil { + return err + } + if domain != matchedDomain && remainder != imageRefWithoutDomain { + return errors.New("invalid domain") + } + return nil +} diff --git a/pkg/image/admission/imagequalify/api/validation/domain_test.go b/pkg/image/admission/imagequalify/api/validation/domain_test.go new file mode 100644 index 000000000000..76af3a5921be --- /dev/null +++ b/pkg/image/admission/imagequalify/api/validation/domain_test.go @@ -0,0 +1,43 @@ +package validation + +import ( + "strings" + "testing" +) + +func TestDomainNameValid(t *testing.T) { + for i, domain := range []string{ + "test.io", + "localhost", + "localhost:5000", + "a.b.c.d.e.f", + "a.b.c.d.e.f:5000", + } { + if err := validateDomain(domain); err != nil { + t.Errorf("test #%d: unexpected error for %q, got %v", i, domain, err) + } + } +} + +func TestDomainNameErrors(t *testing.T) { + for i, test := range []struct { + description string + input string + }{{ + description: "empty input", + input: "", + }, { + description: "bad characters in domain name", + input: "!invalidname!", + }, { + description: "no '.' or : and not 'localhost'", + input: "domain", + }, { + description: "name too long", + input: strings.Repeat("x", 255) + ".io", + }} { + if err := validateDomain(test.input); err == nil { + t.Errorf("test #%v: expected error", i) + } + } +} diff --git a/pkg/image/admission/imagequalify/api/validation/image.go b/pkg/image/admission/imagequalify/api/validation/image.go new file mode 100644 index 000000000000..49828ce058cf --- /dev/null +++ b/pkg/image/admission/imagequalify/api/validation/image.go @@ -0,0 +1,42 @@ +package validation + +import ( + "strings" + + "k8s.io/kubernetes/pkg/util/parsers" +) + +// ParseDomainName parses a docker image reference into its domain +// component, if any, and everything after the domain. An empty string +// is returned if there is no domain component. This function will +// first validate that image is a valid reference, returning an error +// if it is not. +// +// Examples inputs and results for the domain component: +// +// "busybox" -> domain is "" +// "foo/busybox" -> domain is "" +// "localhost/foo/busybox" -> domain is "localhost" +// "localhost:5000/foo/busybox" -> domain is "localhost:5000" +// "gcr.io/busybox" -> domain is "gcr.io" +// "gcr.io/foo/busybox" -> domain is "gcr.io" +// "docker.io/busybox" -> domain is "docker.io" +// "docker.io/library/busybox" -> domain is "docker.io" +// "library/busybox:v1" -> domain is "" +func ParseDomainName(image string) (string, string, error) { + // Note: when we call ParseImageName() this gets normalized to + // potentially include "docker.io", and/or "library/" and or + // "latest". We are only interested in discerning the domain + // and the remainder based on the non-normalised reference. If + // the image is valid we do our own parsing of the first + // component (i.e., the repository) to see if it actually + // reflects a domain name. + if _, _, _, err := parsers.ParseImageName(image); err != nil { + return "", "", err + } + i := strings.IndexRune(image, '/') + if i == -1 || (!strings.ContainsAny(image[:i], ".:") && image[:i] != "localhost") { + return "", image, nil + } + return image[:i], image[i+1:], nil +} diff --git a/pkg/image/admission/imagequalify/api/validation/image_test.go b/pkg/image/admission/imagequalify/api/validation/image_test.go new file mode 100644 index 000000000000..c578bb1a1727 --- /dev/null +++ b/pkg/image/admission/imagequalify/api/validation/image_test.go @@ -0,0 +1,81 @@ +package validation + +import ( + "testing" +) + +func TestImageParseDomainName(t *testing.T) { + for i, test := range []struct { + input string + domain string + remainder string + expectErr bool + }{{ + expectErr: true, + }, { + input: "!baddomain!/foo", + expectErr: true, + }, { + input: "busybox", + remainder: "busybox", + }, { + input: "busybox:latest", + remainder: "busybox:latest", + }, { + input: "foo/busybox:latest", + remainder: "foo/busybox:latest", + }, { + input: "localhost:busybox", + remainder: "localhost:busybox", + }, { + input: "localhost/busybox", + domain: "localhost", + remainder: "busybox", + }, { + input: "localhost:5000/busybox", + domain: "localhost:5000", + remainder: "busybox", + }, { + input: "localhost:5000/busybox:v1.0", + domain: "localhost:5000", + remainder: "busybox:v1.0", + }, { + input: "localhost:5000/foo/busybox:v1.2.3", + domain: "localhost:5000", + remainder: "foo/busybox:v1.2.3", + }, { + input: "localhost/foo/busybox:v1.2.3", + domain: "localhost", + remainder: "foo/busybox:v1.2.3", + }, { + input: "parser.test.io/busybox", + domain: "parser.test.io", + remainder: "busybox", + }, { + input: "parser.test.io/foo/busybox", + domain: "parser.test.io", + remainder: "foo/busybox", + }, { + input: "parser.test.io/busybox:v1.2.3", + domain: "parser.test.io", + remainder: "busybox:v1.2.3", + }, { + input: "parser.test.io/foo/busybox:v1.2.3", + domain: "parser.test.io", + remainder: "foo/busybox:v1.2.3", + }} { + t.Logf("test #%v: %s", i, test.input) + domain, remainder, err := ParseDomainName(test.input) + if test.expectErr && err == nil { + t.Errorf("test %#v: expected error", i) + } else if !test.expectErr && err != nil { + t.Errorf("test %#v: expected no error, got %s", i, err) + } + if test.domain != domain { + t.Errorf("test #%v: failed; expected %q, got %q", i, test.domain, domain) + } + if test.remainder != remainder { + t.Errorf("test #%v: failed; expected %q, got %q", i, test.remainder, remainder) + } + } +} diff --git a/pkg/image/admission/imagequalify/api/validation/validation.go b/pkg/image/admission/imagequalify/api/validation/validation.go new file mode 100644 index 000000000000..a66cd8536d98 --- /dev/null +++ b/pkg/image/admission/imagequalify/api/validation/validation.go @@ -0,0 +1,43 @@ +package validation + +import ( + "fmt" + "regexp" + + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/openshift/origin/pkg/image/admission/imagequalify/api" +) + +const ( + patternCharSet = `^(?:[a-zA-Z0-9_@:/\.\-\*]+)$` +) + +var patternRegexp = regexp.MustCompile(patternCharSet) +var patternMatchError = fmt.Sprintf("pattern must match %q", patternCharSet) + +func Validate(config *api.ImageQualifyConfig) field.ErrorList { + allErrs := field.ErrorList{} + if config == nil { + return allErrs + } + for i, rule := range config.Rules { + if rule.Pattern == "" { + allErrs = append(allErrs, field.Required(field.NewPath(api.PluginName, "rules").Index(i).Child("pattern"), "")) + } + if rule.Pattern != "" { + if !patternRegexp.MatchString(rule.Pattern) { + allErrs = append(allErrs, field.Invalid(field.NewPath(api.PluginName, "rules").Index(i).Child("pattern"), rule.Pattern, patternMatchError)) + } + } + if rule.Domain == "" { + allErrs = append(allErrs, field.Required(field.NewPath(api.PluginName, "rules").Index(i).Child("domain"), "")) + } + if rule.Domain != "" { + if err := validateDomain(rule.Domain); err != nil { + allErrs = append(allErrs, field.Invalid(field.NewPath(api.PluginName, "rules").Index(i).Child("domain"), rule.Domain, err.Error())) + } + } + } + return allErrs +} diff --git a/pkg/image/admission/imagequalify/api/validation/validation_test.go b/pkg/image/admission/imagequalify/api/validation/validation_test.go new file mode 100644 index 000000000000..0e58a252ae24 --- /dev/null +++ b/pkg/image/admission/imagequalify/api/validation/validation_test.go @@ -0,0 +1,95 @@ +package validation + +import ( + "testing" + + "github.com/openshift/origin/pkg/image/admission/imagequalify/api" +) + +func TestValidation(t *testing.T) { + var testcases = []struct { + description string + config *api.ImageQualifyConfig + nErrors int + }{{ + description: "no rules", + config: &api.ImageQualifyConfig{}, + }, { + description: "missing domains", + config: &api.ImageQualifyConfig{ + Rules: []api.ImageQualifyRule{ + { + Pattern: "a/b", + }, + { + Pattern: "a/b", + }, + }, + }, + nErrors: 2, + }, { + description: "missing patterns", + config: &api.ImageQualifyConfig{ + Rules: []api.ImageQualifyRule{ + { + Domain: "foo.com", + }, { + Domain: "foo.com", + }, + }, + }, + nErrors: 2, + }, { + description: "invalid domains", + config: &api.ImageQualifyConfig{ + Rules: []api.ImageQualifyRule{ + { + Domain: "!foo!", + Pattern: "a/b", + }, + { + Domain: "[]", + Pattern: "a/b", + }, + }, + }, + nErrors: 2, + }, { + description: "invalid patterns", + config: &api.ImageQualifyConfig{ + Rules: []api.ImageQualifyRule{ + { + Domain: "foo.com", + Pattern: "!", + }, + { + Domain: "bar.com", + Pattern: "&", + }, + }, + }, + nErrors: 2, + }, { + description: "valid patterns", + config: &api.ImageQualifyConfig{ + Rules: []api.ImageQualifyRule{ + { + Domain: "foo.com", + Pattern: "a/Z/*:latest-AND_greatest.@sha256:1234567890", + }, + }, + }, + }} + + for i, tc := range testcases { + errors := Validate(tc.config) + nErrors := len(errors) + + if nErrors != tc.nErrors { + t.Errorf("test #%v: %s: expected %v errors, got %v", i, tc.description, tc.nErrors, nErrors) + for j := range errors { + t.Errorf("test #%v: error #%v: %s", i, j, errors[j]) + } + } + } +} diff --git a/pkg/image/admission/imagequalify/api/zz_generated.deepcopy.go b/pkg/image/admission/imagequalify/api/zz_generated.deepcopy.go new file mode 100644 index 000000000000..330c7b62f63b --- /dev/null +++ b/pkg/image/admission/imagequalify/api/zz_generated.deepcopy.go @@ -0,0 +1,56 @@ +// +build !ignore_autogenerated_openshift + +// This file was autogenerated by deepcopy-gen. Do not edit it manually! + +package api + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageQualifyConfig) DeepCopyInto(out *ImageQualifyConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.Rules != nil { + in, out := &in.Rules, &out.Rules + *out = make([]ImageQualifyRule, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageQualifyConfig. +func (in *ImageQualifyConfig) DeepCopy() *ImageQualifyConfig { + if in == nil { + return nil + } + out := new(ImageQualifyConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ImageQualifyConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } else { + return nil + } +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageQualifyRule) DeepCopyInto(out *ImageQualifyRule) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageQualifyRule. +func (in *ImageQualifyRule) DeepCopy() *ImageQualifyRule { + if in == nil { + return nil + } + out := new(ImageQualifyRule) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/image/admission/imagequalify/config.go b/pkg/image/admission/imagequalify/config.go new file mode 100644 index 000000000000..0258a205def1 --- /dev/null +++ b/pkg/image/admission/imagequalify/config.go @@ -0,0 +1,92 @@ +package imagequalify + +import ( + "fmt" + "io" + "strings" + + "github.com/golang/glog" + configlatest "github.com/openshift/origin/pkg/cmd/server/api/latest" + "github.com/openshift/origin/pkg/image/admission/imagequalify/api" + "github.com/openshift/origin/pkg/image/admission/imagequalify/api/validation" +) + +func filterRules(rules []api.ImageQualifyRule, test func(rule *api.ImageQualifyRule) bool) []api.ImageQualifyRule { + filtered := make([]api.ImageQualifyRule, 0, len(rules)) + + for i := range rules { + if test(&rules[i]) { + filtered = append(filtered, rules[i]) + } + } + + return filtered +} + +func compareParts(x, y *api.ImageQualifyRule, cmp func(x, y *PatternParts) bool) bool { + a, b := destructurePattern(x.Pattern), destructurePattern(y.Pattern) + return cmp(&a, &b) +} + +func sortRulesByPattern(rules []api.ImageQualifyRule) { + // Comparators for sorting rules + + depth := func(x, y *api.ImageQualifyRule) bool { + return compareParts(x, y, func(a, b *PatternParts) bool { + return a.Depth > b.Depth + }) + } + + digest := func(x, y *api.ImageQualifyRule) bool { + return compareParts(x, y, func(a, b *PatternParts) bool { + return a.Digest > b.Digest + }) + } + + tag := func(x, y *api.ImageQualifyRule) bool { + return compareParts(x, y, func(a, b *PatternParts) bool { + return a.Tag > b.Tag + }) + } + + path := func(x, y *api.ImageQualifyRule) bool { + return compareParts(x, y, func(a, b *PatternParts) bool { + return a.Path > b.Path + }) + } + + explicitRules := filterRules(rules, func(rule *api.ImageQualifyRule) bool { + return !strings.Contains(rule.Pattern, "*") + }) + + wildcardRules := filterRules(rules, func(rule *api.ImageQualifyRule) bool { + return strings.Contains(rule.Pattern, "*") + }) + + orderBy(depth, digest, tag, path).Sort(explicitRules) + orderBy(depth, digest, tag, path).Sort(wildcardRules) + copy(rules, append(explicitRules, wildcardRules...)) +} + +func readConfig(rdr io.Reader) (*api.ImageQualifyConfig, error) { + obj, err := configlatest.ReadYAML(rdr) + if err != nil { + glog.V(5).Infof("%s error reading config: %v", api.PluginName, err) + return nil, err + } + if obj == nil { + return nil, nil + } + config, ok := obj.(*api.ImageQualifyConfig) + if !ok { + return nil, fmt.Errorf("unexpected config object: %#v", obj) + } + glog.V(5).Infof("%s config is: %#v", api.PluginName, config) + if errs := validation.Validate(config); len(errs) > 0 { + return nil, errs.ToAggregate() + } + if len(config.Rules) > 0 { + sortRulesByPattern(config.Rules) + } + return config, nil +} diff --git a/pkg/image/admission/imagequalify/config_test.go b/pkg/image/admission/imagequalify/config_test.go new file mode 100644 index 000000000000..005c6fa17848 --- /dev/null +++ b/pkg/image/admission/imagequalify/config_test.go @@ -0,0 +1,162 @@ +package imagequalify_test + +import ( + "bytes" + "io" + "reflect" + "testing" + + configapilatest "github.com/openshift/origin/pkg/cmd/server/api/latest" + "github.com/openshift/origin/pkg/image/admission/imagequalify" + "github.com/openshift/origin/pkg/image/admission/imagequalify/api" + "github.com/openshift/origin/pkg/image/admission/imagequalify/api/validation" + + _ "github.com/openshift/origin/pkg/api/install" +) + +const ( + goodConfig = ` +apiVersion: admission.config.openshift.io/v1 +kind: ImageQualifyConfig +rules: +- domain: example.com + pattern: "*/*" +- domain: example.com + pattern: "*" +` + + missingPatternConfig = ` +apiVersion: admission.config.openshift.io/v1 +kind: ImageQualifyConfig +rules: +- domain: example + pattern: +` + + missingDomainConfig = ` +apiVersion: admission.config.openshift.io/v1 +kind: ImageQualifyConfig +rules: +- domain: + pattern: foo +` + + invalidDomainConfig = ` +apiVersion: admission.config.openshift.io/v1 +kind: ImageQualifyConfig +rules: +- domain: "!example!" + pattern: "*" +` + + emptyConfig = ` +apiVersion: admission.config.openshift.io/v1 +kind: ImageQualifyConfig +` +) + +var ( + deserializedYamlConfig = &api.ImageQualifyConfig{ + Rules: []api.ImageQualifyRule{{ + Pattern: "*/*", + Domain: "example.com", + }, { + Pattern: "*", + Domain: "example.com", + }}, + } +) + +func testReaderConfig(rules []api.ImageQualifyRule) *api.ImageQualifyConfig { + return &api.ImageQualifyConfig{ + Rules: rules, + } +} + +func TestConfigReader(t *testing.T) { + initialConfig := testReaderConfig([]api.ImageQualifyRule{{ + Pattern: "*/*", + Domain: "example.com", + }, { + Pattern: "*", + Domain: "example.com", + }}) + + serializedConfig, serializationErr := configapilatest.WriteYAML(initialConfig) + if serializationErr != nil { + t.Fatalf("WriteYAML: config serialize failed: %v", serializationErr) + } + + tests := []struct { + name string + config io.Reader + expectErr bool + expectNil bool + expectInvalid bool + expectedConfig *api.ImageQualifyConfig + }{{ + name: "process nil config", + config: nil, + expectNil: true, + }, { + name: "deserialize initialConfig yaml", + config: bytes.NewReader(serializedConfig), + expectedConfig: initialConfig, + }, { + name: "completely broken config", + config: bytes.NewReader([]byte("busted")), + expectErr: true, + }, { + name: "deserialize good config", + config: bytes.NewReader([]byte(goodConfig)), + expectedConfig: deserializedYamlConfig, + }, { + name: "choke on missing pattern", + config: bytes.NewReader([]byte(missingPatternConfig)), + expectInvalid: true, + expectErr: true, + }, { + name: "choke on missing domain", + config: bytes.NewReader([]byte(missingDomainConfig)), + expectInvalid: true, + expectErr: true, + }, { + name: "choke on invalid domain", + config: bytes.NewReader([]byte(invalidDomainConfig)), + expectInvalid: true, + expectErr: true, + }, { + name: "empty config", + config: bytes.NewReader([]byte(emptyConfig)), + expectedConfig: &api.ImageQualifyConfig{}, + expectInvalid: false, + expectErr: false, + }} + + for _, test := range tests { + config, err := imagequalify.ReadConfig(test.config) + if test.expectErr && err == nil { + t.Errorf("%s: expected error", test.name) + } else if !test.expectErr && err != nil { + t.Errorf("%s: expected no error, saw %v", test.name, err) + } + if err == nil { + if test.expectNil && config != nil { + t.Errorf("%s: expected nil config, but saw: %v", test.name, config) + } else if !test.expectNil && config == nil { + t.Errorf("%s: expected config, but got nil", test.name) + } + } + if config == nil { + continue + } + if test.expectedConfig != nil && !reflect.DeepEqual(*test.expectedConfig, *config) { + t.Errorf("%s: expected %v from reader, but got %v", test.name, test.expectErr, config) + } + if err := validation.Validate(config); test.expectInvalid && len(err) == 0 { + t.Errorf("%s: expected validation to fail, but it passed", test.name) + } else if !test.expectInvalid && len(err) > 0 { + t.Errorf("%s: expected validation to pass, but it failed with %v", test.name, err) + } + } +} diff --git a/pkg/image/admission/imagequalify/doc.go b/pkg/image/admission/imagequalify/doc.go new file mode 100644 index 000000000000..2268cb462663 --- /dev/null +++ b/pkg/image/admission/imagequalify/doc.go @@ -0,0 +1,91 @@ +// Package imagequalify contains the OpenShift ImageQualify admission +// control plugin. This plugin allows administrators to set a policy +// for bare image names. A "bare" image name is a docker image +// reference that contains no domain component (e.g., "repository.io", +// "docker.io", etc). +// +// The preferred domain component to use, and hence pull from, for a +// bare image name is computed from a set of path-based pattern +// matching rules in the admission configuration: +// +// admissionConfig: +// pluginConfig: +// openshift.io/ImageQualify: +// configuration: +// kind: ImageQualifyConfig +// apiVersion: admission.config.openshift.io/v1 +// rules: +// - pattern: "openshift*/*" +// domain: "access.redhat.registry.com" +// +// - pattern: "*" +// domain: "access.redhat.registry.com" +// +// - pattern: "nginx" +// domain: "nginx.com" +// +// - pattern: "repo/jenkins" +// domain: "jenkins-ci.org" +// +// Rule Ordering +// ------------- +// +// Rules are sorted into the set of all explicit patterns (i.e., those +// with no wildcards) and the set of wildcard patterns. In each set, +// the natural order is lexicographically by pattern +// {depth,digest,tag,path}. Pattern matching is first attempted +// against the explicit rules, then the wildcard rules. +// +// As we use path-based pattern matching you should be aware of what +// looks like a fallback pattern to cover any bare image reference: +// +// - pattern: "*" +// - domain: "access.redhat.registry.com" +// +// This pattern would not match "repo/jenkins" as the pattern contains +// no path segments (i.e., '/'). To match both cases you should list +// wildcard patterns that cover just image names and images in any +// repository. +// +// - pattern: "*" +// - domain: "access.redhat.registry.com" +// +// - pattern: "*/*" +// - domain: "access.redhat.registry.com" +// +// Additionally, patterns can also reference tags: +// +// - pattern: "nginx:latest" +// domain: "nginx-dev.com" +// +// - pattern: "nginx:*" +// domain: "nginx-prod.com" +// +// - pattern: "nginx:v1.2.*" +// domain: "nginx-prod.com" +// +// - pattern: "next/nginx:v2*" +// domain: "next/nginx-next.com" +// +// Additionally, patterns can also reference digests: +// +// - pattern: "nginx@sha256:abc*" +// domain: "nginx-staging.com" +// +// - pattern: "reppo/nginx:latest@sha256:abc*" +// domain: "nginx-staging.com" +// +// The plugin is configured via the ImageQualifyConfig object in the +// origin and kubernetes master configs: +// +// kubernetesMasterConfig: +// admissionConfig: +// pluginConfig: +// openshift.io/ImageQualify: +// configuration: +// kind: ImageQualifyConfig +// apiVersion: admission.config.openshift.io/v1 +// rules: +// - pattern: nginx +// domain: localhost:5000 +package imagequalify diff --git a/pkg/image/admission/imagequalify/export_test.go b/pkg/image/admission/imagequalify/export_test.go new file mode 100644 index 000000000000..d900ad00ed88 --- /dev/null +++ b/pkg/image/admission/imagequalify/export_test.go @@ -0,0 +1,7 @@ +package imagequalify + +var ( + ReadConfig = readConfig + DestructurePattern = destructurePattern + QualifyImage = qualifyImage +) diff --git a/pkg/image/admission/imagequalify/pattern.go b/pkg/image/admission/imagequalify/pattern.go new file mode 100644 index 000000000000..32386ef384e5 --- /dev/null +++ b/pkg/image/admission/imagequalify/pattern.go @@ -0,0 +1,31 @@ +package imagequalify + +import ( + "strings" +) + +// PatternParts captures the decomposed parts of an image reference. +type PatternParts struct { + Depth int + Digest string + Path string + Tag string +} + +func destructurePattern(pattern string) PatternParts { + parts := PatternParts{ + Path: pattern, + Depth: strings.Count(pattern, "/"), + } + + if i := strings.IndexRune(pattern, '@'); i != -1 { + parts.Path = pattern[:i] + parts.Digest = pattern[i+1:] + } + + if i := strings.IndexRune(parts.Path, ':'); i != -1 { + parts.Path, parts.Tag = parts.Path[:i], parts.Path[i+1:] + } + + return parts +} diff --git a/pkg/image/admission/imagequalify/pattern_test.go b/pkg/image/admission/imagequalify/pattern_test.go new file mode 100644 index 000000000000..ccc96f687744 --- /dev/null +++ b/pkg/image/admission/imagequalify/pattern_test.go @@ -0,0 +1,74 @@ +package imagequalify_test + +import ( + "reflect" + "testing" + + "github.com/openshift/origin/pkg/image/admission/imagequalify" +) + +func TestPatternDestructure(t *testing.T) { + var testcases = []struct { + pattern string + expected imagequalify.PatternParts + }{{ + pattern: "a", + expected: imagequalify.PatternParts{ + Path: "a", + }, + }, { + pattern: "a:latest", + expected: imagequalify.PatternParts{ + Path: "a", + Tag: "latest", + }, + }, { + pattern: "a/b", + expected: imagequalify.PatternParts{ + Depth: 1, + Path: "a/b", + }, + }, { + pattern: "a/b:latest", + expected: imagequalify.PatternParts{ + Depth: 1, + Path: "a/b", + Tag: "latest", + }, + }, { + pattern: "a@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + expected: imagequalify.PatternParts{ + Digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + Path: "a", + }, + }, { + pattern: "a/b@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + expected: imagequalify.PatternParts{ + Depth: 1, + Digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + Path: "a/b", + }, + }, { + pattern: "a:latest@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + expected: imagequalify.PatternParts{ + Digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + Path: "a", + Tag: "latest", + }, + }, { + pattern: "a/b:latest@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + expected: imagequalify.PatternParts{ + Depth: 1, + Digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + Path: "a/b", + Tag: "latest", + }, + }} + + for i, tc := range testcases { + actual := imagequalify.DestructurePattern(tc.pattern) + if !reflect.DeepEqual(tc.expected, actual) { + t.Errorf("test #%v: expected %#v, got %#v", i, tc.expected, actual) + } + } +} diff --git a/pkg/image/admission/imagequalify/qualify.go b/pkg/image/admission/imagequalify/qualify.go new file mode 100644 index 000000000000..2721b130edfb --- /dev/null +++ b/pkg/image/admission/imagequalify/qualify.go @@ -0,0 +1,46 @@ +package imagequalify + +import ( + "path" + + "github.com/openshift/origin/pkg/image/admission/imagequalify/api" + "github.com/openshift/origin/pkg/image/admission/imagequalify/api/validation" +) + +// matchImage attempts to match image against each pattern defined in +// rules. Returns the Rule that matched, or nil if no match was found. +func matchImage(image string, rules []api.ImageQualifyRule) *api.ImageQualifyRule { + for i := range rules { + if ok, _ := path.Match(rules[i].Pattern, image); ok { + return &rules[i] + } + } + return nil +} + +func qualifyImage(image string, rules []api.ImageQualifyRule) (string, error) { + domain, _, err := validation.ParseDomainName(image) + if err != nil { + return "", err + } + + if domain != "" { + // image is already qualified + return image, nil + } + + matched := matchImage(image, rules) + if matched == nil { + // no match, return as-is. + return image, nil + } + + qname := matched.Domain + "/" + image + + // Revalidate qualified image + if _, _, err := validation.ParseDomainName(qname); err != nil { + return "", err + } + + return qname, nil +} diff --git a/pkg/image/admission/imagequalify/qualify_test.go b/pkg/image/admission/imagequalify/qualify_test.go new file mode 100644 index 000000000000..c280edc55d5d --- /dev/null +++ b/pkg/image/admission/imagequalify/qualify_test.go @@ -0,0 +1,295 @@ +package imagequalify_test + +import ( + "bytes" + "testing" + + configapilatest "github.com/openshift/origin/pkg/cmd/server/api/latest" + "github.com/openshift/origin/pkg/image/admission/imagequalify" + "github.com/openshift/origin/pkg/image/admission/imagequalify/api" +) + +type testcase struct { + image string + expected string +} + +func parseQualifyRules(rules []api.ImageQualifyRule) (*api.ImageQualifyConfig, error) { + config, err := configapilatest.WriteYAML(&api.ImageQualifyConfig{ + Rules: rules, + }) + + if err != nil { + return nil, err + } + + return imagequalify.ReadConfig(bytes.NewReader(config)) +} + +func testQualify(t *testing.T, rules []api.ImageQualifyRule, tests []testcase) { + t.Helper() + + config, err := parseQualifyRules(rules) + if err != nil { + t.Fatalf("failed to parse rules: %v", err) + } + + for i, tc := range tests { + name, err := imagequalify.QualifyImage(tc.image, config.Rules) + if err != nil { + t.Fatalf("test #%v: unexpected error: %s", i, err) + } + if tc.expected != name { + t.Errorf("test #%v: expected %q, got %q", i, tc.expected, name) + } + } +} + +func TestQualifyNoRules(t *testing.T) { + rules := []api.ImageQualifyRule{} + + tests := []testcase{{ + image: "busybox", + expected: "busybox", + }, { + image: "repo/busybox", + expected: "repo/busybox", + }} + + testQualify(t, rules, tests) +} + +func TestQualifyImageNoMatch(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "busybox", + Domain: "production.io", + }, { + Pattern: "busybox:v1*", + Domain: "v1.io", + }, { + Pattern: "busybox:*", + Domain: "next.io", + }} + + tests := []testcase{{ + image: "nginx", + expected: "nginx", + }, { + image: "nginx:latest", + expected: "nginx:latest", + }, { + image: "repo/nginx", + expected: "repo/nginx", + }, { + image: "repo/nginx:latest", + expected: "repo/nginx:latest", + }} + + testQualify(t, rules, tests) +} + +func TestQualifyRepoAndImageAndTagsWithWildcard(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "repo/busybox", + Domain: "production.io", + }, { + Pattern: "repo/busybox:v1*", + Domain: "v1.io", + }, { + Pattern: "repo/busybox:*", + Domain: "next.io", + }} + + tests := []testcase{{ + image: "busybox", + expected: "busybox", + }, { + image: "busybox:latest", + expected: "busybox:latest", + }, { + image: "repo/busybox", + expected: "production.io/repo/busybox", + }, { + image: "repo/busybox:v1.2.3", + expected: "v1.io/repo/busybox:v1.2.3", + }, { + image: "repo/busybox:latest", + expected: "next.io/repo/busybox:latest", + }} + + testQualify(t, rules, tests) +} + +func TestQualifyNoRepoWithImageWildcard(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "*", + Domain: "default.io", + }} + + tests := []testcase{{ + image: "nginx", + expected: "default.io/nginx", + }, { + image: "repo/nginx", + expected: "repo/nginx", + }} + + testQualify(t, rules, tests) +} + +func TestQualifyRepoAndImageWildcard(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "*/*", + Domain: "repo.io", + }, { + Pattern: "*", + Domain: "default.io", + }} + + tests := []testcase{{ + image: "nginx", + expected: "default.io/nginx", + }, { + image: "repo/nginx", + expected: "repo.io/repo/nginx", + }} + + testQualify(t, rules, tests) +} + +func TestQualifyWildcards(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "*/*:*", + Domain: "first.io", + }, { + Pattern: "*/*", + Domain: "second.io", + }, { + Pattern: "*", + Domain: "third.io", + }} + + tests := []testcase{{ + image: "busybox", + expected: "third.io/busybox", + }, { + image: "busybox:latest", + expected: "third.io/busybox:latest", + }, { + image: "nginx", + expected: "third.io/nginx", + }, { + image: "repo/busybox:latest", + expected: "first.io/repo/busybox:latest", + }, { + image: "repo/busybox", + expected: "second.io/repo/busybox", + }, { + image: "repo/nginx", + expected: "second.io/repo/nginx", + }, { + image: "nginx", + expected: "third.io/nginx", + }} + + testQualify(t, rules, tests) +} + +func TestQualifyRepoWithWildcards(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "*/*:*", + Domain: "first.io", + }, { + Pattern: "*/*", + Domain: "second.io", + }, { + Pattern: "*", + Domain: "third.io", + }, { + Pattern: "a*/*", + Domain: "a.io", + }, { + Pattern: "b*/*", + Domain: "b.io", + }, { + Pattern: "a*/*:*", + Domain: "a-with-tag.io", + }, { + Pattern: "b*/*:*", + Domain: "b-with-tag.io", + }} + + tests := []testcase{{ + image: "abc/nginx", + expected: "a.io/abc/nginx", + }, { + image: "bcd/nginx", + expected: "b.io/bcd/nginx", + }, { + image: "nginx", + expected: "third.io/nginx", + }, { + image: "repo/nginx", + expected: "second.io/repo/nginx", + }, { + image: "repo/nginx:latest", + expected: "first.io/repo/nginx:latest", + }, { + image: "abc/nginx:1.0", + expected: "a-with-tag.io/abc/nginx:1.0", + }, { + image: "bcd/nginx:1.0", + expected: "b-with-tag.io/bcd/nginx:1.0", + }} + + testQualify(t, rules, tests) +} + +func TestQualifyTagsWithWildcards(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "a*/*:*v*", + Domain: "v3.io", + }, { + Pattern: "a*/*:*v2*", + Domain: "v2.io", + }, { + Pattern: "a*/*:*v1*", + Domain: "v1.io", + }} + + tests := []testcase{{ + image: "abc/nginx", + expected: "abc/nginx", + }, { + image: "bcd/nginx", + expected: "bcd/nginx", + }, { + image: "abc/nginx:v1.0", + expected: "v1.io/abc/nginx:v1.0", + }, { + image: "abc/nginx:v2.0", + expected: "v2.io/abc/nginx:v2.0", + }, { + image: "abc/nginx:v0", + expected: "v3.io/abc/nginx:v0", + }, { + image: "abc/nginx:latest", + expected: "abc/nginx:latest", + }} + + testQualify(t, rules, tests) +} + +func TestQualifyImagesAlreadyQualified(t *testing.T) { + rules := []api.ImageQualifyRule{{ + Pattern: "foo", + Domain: "foo.com", + }} + + tests := []testcase{{ + image: "foo.com/foo", + expected: "foo.com/foo", + }} + + testQualify(t, rules, tests) +} diff --git a/pkg/image/admission/imagequalify/sort.go b/pkg/image/admission/imagequalify/sort.go new file mode 100644 index 000000000000..839b768d1723 --- /dev/null +++ b/pkg/image/admission/imagequalify/sort.go @@ -0,0 +1,62 @@ +package imagequalify + +import ( + "sort" + + "github.com/openshift/origin/pkg/image/admission/imagequalify/api" +) + +type lessFunc func(x, y *api.ImageQualifyRule) bool + +type multiSorter struct { + rules []api.ImageQualifyRule + less []lessFunc +} + +var _ sort.Interface = &multiSorter{} + +// Sort sorts the argument slice according to the comparator functions +// passed to orderBy. +func (s *multiSorter) Sort(rules []api.ImageQualifyRule) { + s.rules = rules + sort.Sort(s) +} + +// orderBy returns a Sorter that sorts using a number of comparator +// functions. +func orderBy(less ...lessFunc) *multiSorter { + return &multiSorter{ + less: less, + } +} + +// Len is part of sort.Interface. +func (s *multiSorter) Len() int { + return len(s.rules) +} + +// Swap is part of sort.Interface. +func (s *multiSorter) Swap(i, j int) { + s.rules[i], s.rules[j] = s.rules[j], s.rules[i] +} + +// Less is part of sort.Interface. +func (s *multiSorter) Less(i, j int) bool { + p, q := s.rules[i], s.rules[j] + // Try all but the last comparison. + var k int + for k = 0; k < len(s.less)-1; k++ { + less := s.less[k] + switch { + case less(&p, &q): + // p < q, so we have a decision. + return true + case less(&q, &p): + // p > q, so we have a decision. + return false + } + // p == q; try the next comparison. + } + + return s.less[k](&p, &q) +} diff --git a/pkg/image/admission/imagequalify/sort_test.go b/pkg/image/admission/imagequalify/sort_test.go new file mode 100644 index 000000000000..06175c14005d --- /dev/null +++ b/pkg/image/admission/imagequalify/sort_test.go @@ -0,0 +1,115 @@ +package imagequalify_test + +import ( + "bytes" + "fmt" + "reflect" + "strings" + "testing" + + configapilatest "github.com/openshift/origin/pkg/cmd/server/api/latest" + "github.com/openshift/origin/pkg/image/admission/imagequalify" + "github.com/openshift/origin/pkg/image/admission/imagequalify/api" +) + +func patternsFromRules(rules []api.ImageQualifyRule) string { + var bb bytes.Buffer + + for i := range rules { + bb.WriteString(rules[i].Pattern) + bb.WriteString(" ") + } + + return strings.TrimSpace(bb.String()) +} + +func parseTestSortPatterns(input string) (*api.ImageQualifyConfig, error) { + rules := []api.ImageQualifyRule{} + + for i, word := range strings.Fields(input) { + rules = append(rules, api.ImageQualifyRule{ + Pattern: word, + Domain: fmt.Sprintf("domain%v.com", i), + }) + } + + serializedConfig, serializationErr := configapilatest.WriteYAML(&api.ImageQualifyConfig{ + Rules: rules, + }) + + if serializationErr != nil { + return nil, serializationErr + } + + return imagequalify.ReadConfig(bytes.NewReader(serializedConfig)) +} + +func TestSort(t *testing.T) { + var testcases = []struct { + description string + input string + expected string + }{{ + description: "default order is ascending", + input: "a b c", + expected: "c b a", + }, { + description: "explicit patterns come before wildcard patterns", + input: "a b c *b *a *c", + expected: "c b a *c *b *a", + }, { + description: "tags are ordered first", + input: "a:latest b:latest a b", + expected: "b:latest a:latest b a", + }, { + description: "tags that are wildcards come last", + input: "a:* b:* a b a:latest b:latest", + expected: "b:latest a:latest b a b:* a:*", + }, { + description: "digests that have wildcards come last", + input: "a b@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff b@sha256:* b a@sha256:* a@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + expected: "b@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff a@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff b a b@sha256:* a@sha256:*", + }, { + description: "longer patterns sort first", + input: "a a/b a/b/c b b/c b/c/d/e b/c/d", + expected: "b/c/d/e b/c/d a/b/c b/c a/b b a", + }, { + description: "longer patterns with tags appear before other tags", + input: "a a:latest a/b:latest a/b a/b/c a/b/c:latest", + expected: "a/b/c:latest a/b/c a/b:latest a/b a:latest a", + }, { + description: "longer patterns with a digest appear before others with a digest", + input: "a a:latest@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff a/b:latest@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff a/b a/b/c a/b/c:latest@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + expected: "a/b/c:latest@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff a/b/c a/b:latest@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff a/b a:latest@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff a", + }, { + description: "tags ordering is most explicit to least specific", + input: "busybox:* busybox:v1.2.3* a busybox:v1.2* b busybox:v1.2.3 busybox busybox:v1* c nginx busybox:v1 busybox:v1.2", + expected: "busybox:v1.2.3 busybox:v1.2 busybox:v1 nginx c busybox b a busybox:v1.2.3* busybox:v1.2* busybox:v1* busybox:*", + }, { + description: "wildcards with tags list after explicit patterns at the same depth", + input: "* */* */*/* *:latest */*:latest */*/*:latest a/b/c:latest", + expected: "a/b/c:latest */*/*:latest */*/* */*:latest */* *:latest *", + }, { + description: "longer wildcards with tags and digest list first", + input: "* */*/*:latest */* */*/* *:latest */*:latest@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + expected: "*/*/*:latest */*/* */*:latest@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff */* *:latest *", + }, { + description: "pathalogical wildcards", + input: "* */* */*/*/* */*/*", + expected: "*/*/*/* */*/* */* *", + }} + + for i, tc := range testcases { + config, err := parseTestSortPatterns(tc.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + actualPatterns := patternsFromRules(config.Rules) + + if !reflect.DeepEqual(tc.expected, actualPatterns) { + t.Errorf("test #%v: %s: expected [%s], got [%s]", i, tc.description, tc.expected, actualPatterns) + } + } + +} diff --git a/test/integration/imagequalify_admission_test.go b/test/integration/imagequalify_admission_test.go new file mode 100644 index 000000000000..6460c7982536 --- /dev/null +++ b/test/integration/imagequalify_admission_test.go @@ -0,0 +1,83 @@ +package integration + +import ( + "io/ioutil" + "os" + "testing" + + configapi "github.com/openshift/origin/pkg/cmd/server/api" + testutil "github.com/openshift/origin/test/util" + testserver "github.com/openshift/origin/test/util/server" + kapi "k8s.io/kubernetes/pkg/apis/core" +) + +func TestImageQualifyAdmission(t *testing.T) { + pluginFile, err := ioutil.TempFile("", "admission.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(pluginFile.Name()) + + err = ioutil.WriteFile(pluginFile.Name(), []byte(` +apiVersion: admission.config.openshift.io/v1 +kind: ImageQualifyConfig +rules: +- pattern: busybox + domain: domain1.com +- pattern: mylib/* + domain: mydomain.com +`), os.FileMode(0644)) + if err != nil { + t.Fatal(err) + } + + masterConfig, err := testserver.DefaultMasterOptions() + if err != nil { + t.Fatalf("error creating config: %v", err) + } + defer testserver.CleanupMasterEtcd(t, masterConfig) + masterConfig.AdmissionConfig.PluginConfig = map[string]*configapi.AdmissionPluginConfig{ + "openshift.io/ImageQualify": { + Location: pluginFile.Name(), + }, + } + kubeConfigFile, err := testserver.StartConfiguredMaster(masterConfig) + if err != nil { + t.Fatalf("error starting server: %v", err) + } + kubeClientset, err := testutil.GetClusterAdminKubeClient(kubeConfigFile) + if err != nil { + t.Fatalf("error getting client: %v", err) + } + + ns := &kapi.Namespace{} + ns.Name = testutil.Namespace() + _, err = kubeClientset.Core().Namespaces().Create(ns) + if err != nil { + t.Fatalf("error creating namespace: %v", err) + } + if err := testserver.WaitForPodCreationServiceAccounts(kubeClientset, testutil.Namespace()); err != nil { + t.Fatalf("error getting client config: %v", err) + } + + tests := map[string]string{ + "busybox": "domain1.com/busybox", + "mylib/foo": "mydomain.com/mylib/foo", + "yourlib/foo": "yourlib/foo", + } + + for image, result := range tests { + testPod := &kapi.Pod{} + testPod.GenerateName = "test" + testPod.Spec.Containers = []kapi.Container{{Name: "container", Image: image}} + actualPod, err := kubeClientset.Core().Pods(testutil.Namespace()).Create(testPod) + if err != nil { + t.Errorf("unexpected error: %v", err) + continue + } + if actualPod.Spec.Containers[0].Image != result { + t.Errorf("expected %v, got %v", result, actualPod.Spec.Containers[0].Image) + continue + } + } +} diff --git a/tools/gendeepcopy/deep_copy.go b/tools/gendeepcopy/deep_copy.go index d328c4849d5e..b0bc263930fa 100644 --- a/tools/gendeepcopy/deep_copy.go +++ b/tools/gendeepcopy/deep_copy.go @@ -31,6 +31,8 @@ func main() { "github.com/openshift/origin/pkg/cmd/util/pluginconfig/testing", "github.com/openshift/origin/pkg/image/admission/imagepolicy/api", "github.com/openshift/origin/pkg/image/admission/imagepolicy/api/v1", + "github.com/openshift/origin/pkg/image/admission/imagequalify/api", + "github.com/openshift/origin/pkg/image/admission/imagequalify/api/v1", "github.com/openshift/origin/pkg/ingress/admission/api", "github.com/openshift/origin/pkg/ingress/admission/api/v1", "github.com/openshift/origin/pkg/project/admission/lifecycle/testing",