diff --git a/cmd/placeholder-name/main_test.go b/cmd/placeholder-name/main_test.go index 8ef72342..d8484951 100644 --- a/cmd/placeholder-name/main_test.go +++ b/cmd/placeholder-name/main_test.go @@ -63,7 +63,7 @@ func TestRun(t *testing.T) { err := run(envGetter, tokenExchanger, buffer, 30*time.Second) r.EqualError(err, "failed to login: environment variable not set: PLACEHOLDER_NAME_K8S_API_ENDPOINT") }) - }, spec.Parallel()) + }) when("the token exchange fails", func() { it.Before(func() { @@ -76,7 +76,7 @@ func TestRun(t *testing.T) { err := run(envGetter, tokenExchanger, buffer, 30*time.Second) r.EqualError(err, "failed to login: some error") }) - }, spec.Parallel()) + }) when("the JSON encoder fails", func() { it.Before(func() { @@ -89,7 +89,7 @@ func TestRun(t *testing.T) { err := run(envGetter, tokenExchanger, &library.ErrorWriter{ReturnError: fmt.Errorf("some IO error")}, 30*time.Second) r.EqualError(err, "failed to marshal response to stdout: some IO error") }) - }, spec.Parallel()) + }) when("the token exchange times out", func() { it.Before(func() { @@ -107,7 +107,7 @@ func TestRun(t *testing.T) { err := run(envGetter, tokenExchanger, buffer, 1*time.Millisecond) r.EqualError(err, "failed to login: context deadline exceeded") }) - }, spec.Parallel()) + }) when("the token exchange succeeds", func() { var actualToken, actualCaBundle, actualAPIEndpoint string @@ -144,6 +144,6 @@ func TestRun(t *testing.T) { }` r.JSONEq(expected, buffer.String()) }) - }, spec.Parallel()) - }, spec.Report(report.Terminal{})) + }) + }, spec.Parallel(), spec.Report(report.Terminal{})) } diff --git a/deploy/crd.yaml b/deploy/crd.yaml new file mode 100644 index 00000000..2bf7f635 --- /dev/null +++ b/deploy/crd.yaml @@ -0,0 +1,49 @@ +#@ load("@ytt:data", "data") + +#! Example of valid LoginDiscoveryConfig object: +#! --- +#! apiVersion: suzerain-io.github.io/v1alpha1 +#! kind: LoginDiscoveryConfig +#! metadata: +#! name: login-discovery +#! namespace: integration +#! spec: +#! server: https://foo +#! certificateAuthorityData: bar + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: logindiscoveryconfigs.crds.placeholder.suzerain-io.github.io +spec: + group: crds.placeholder.suzerain-io.github.io + versions: + #! Any changes to these schemas should also be reflected in the types.go file(s) + #! in https://github.com/suzerain-io/placeholder-name-api/tree/main/pkg/apis/placeholder + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + required: [spec] + properties: + spec: + type: object + required: [server, certificateAuthorityData] + properties: + server: + type: string + minLength: 1 + pattern: '^https://' + certificateAuthorityData: + type: string + minLength: 1 + scope: Namespaced + names: + plural: logindiscoveryconfigs + singular: logindiscoveryconfig + kind: LoginDiscoveryConfig + shortNames: + - ldc diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index 61d584e8..35024701 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -24,6 +24,8 @@ metadata: data: #@yaml/text-templated-strings placeholder-name.yaml: | + discovery: + url: (@= data.values.discovery_url or "null" @) webhook: url: (@= data.values.webhook_url @) caBundle: (@= data.values.webhook_ca_bundle @) diff --git a/deploy/rbac.yaml b/deploy/rbac.yaml index e3d0858a..98caf1c3 100644 --- a/deploy/rbac.yaml +++ b/deploy/rbac.yaml @@ -38,6 +38,9 @@ rules: - apiGroups: [""] resources: [services] verbs: [create, get, list, patch, update, watch] + - apiGroups: [crds.placeholder.suzerain-io.github.io] + resources: [logindiscoveryconfigs] + verbs: [create, get, list, update, watch] --- kind: RoleBinding apiVersion: rbac.authorization.k8s.io/v1 @@ -108,3 +111,27 @@ roleRef: #! give permissions for a special configmap of CA bundles that is needed by aggregated api servers name: extension-apiserver-authentication-reader apiGroup: rbac.authorization.k8s.io +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: #@ data.values.app_name + "-cluster-info-lister-watcher-role" + namespace: kube-public +rules: + - apiGroups: [""] + resources: [configmaps] + verbs: [list, watch] +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: #@ data.values.app_name + "-cluster-info-lister-watcher-role-binding" + namespace: kube-public +subjects: + - kind: ServiceAccount + name: #@ data.values.app_name + "-service-account" + namespace: #@ data.values.namespace +roleRef: + kind: Role + name: #@ data.values.app_name + "-cluster-info-lister-watcher-role" + apiGroup: rbac.authorization.k8s.io diff --git a/go.mod b/go.mod index 1f950c0e..299112eb 100644 --- a/go.mod +++ b/go.mod @@ -10,8 +10,9 @@ require ( github.com/sclevine/spec v1.4.0 github.com/spf13/cobra v1.0.0 github.com/stretchr/testify v1.6.1 - github.com/suzerain-io/placeholder-name-api v0.0.0-20200731022217-d7e4c306f7fd - github.com/suzerain-io/placeholder-name-client-go v0.0.0-20200731022627-a9c34c8413ac + github.com/suzerain-io/controller-go v0.0.0-20200730212956-7f99b569ca9f + github.com/suzerain-io/placeholder-name-api v0.0.0-20200731224558-ff85679d3364 + github.com/suzerain-io/placeholder-name-client-go v0.0.0-20200731225637-b994efe19486 github.com/suzerain-io/placeholder-name/pkg/client v0.0.0-00010101000000-000000000000 k8s.io/api v0.19.0-rc.0 k8s.io/apimachinery v0.19.0-rc.0 diff --git a/go.sum b/go.sum index b5fe4716..1e580372 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,10 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4 h1:Hs82Z41s6SdL1CELW+XaDYmOH4hkBN4/N9og/AsOv7E= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= @@ -528,10 +530,12 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/suzerain-io/placeholder-name-api v0.0.0-20200731022217-d7e4c306f7fd h1:rJT8jn+6g1Wy40nGOGULz9hQvuDq0MZSA+3JaTaqVgQ= -github.com/suzerain-io/placeholder-name-api v0.0.0-20200731022217-d7e4c306f7fd/go.mod h1:OuYBJDpMMnvMUoBn+XeMWtHghuYk0cq9bNkNa3T8j/g= -github.com/suzerain-io/placeholder-name-client-go v0.0.0-20200731022627-a9c34c8413ac h1:3QfNymuRno/01062Ns/xv70m8TxQjiJQKTYCjIztGJw= -github.com/suzerain-io/placeholder-name-client-go v0.0.0-20200731022627-a9c34c8413ac/go.mod h1:EFFPGm0xrUidCxQkF1g2pGdKifwo4U0Dwi70TTluNGM= +github.com/suzerain-io/controller-go v0.0.0-20200730212956-7f99b569ca9f h1:gZ6rAdl+VE9DT0yE52xY/kJZ/hOJYxwtsgGoPr5vItI= +github.com/suzerain-io/controller-go v0.0.0-20200730212956-7f99b569ca9f/go.mod h1:+v9upryFWBJac6KXKlheGHr7e3kqpk1ldH1iIMFopMs= +github.com/suzerain-io/placeholder-name-api v0.0.0-20200731224558-ff85679d3364 h1:5NaQExCSh8+6YP3QNhWsWyMrEd+7zsnrznRlisydlZo= +github.com/suzerain-io/placeholder-name-api v0.0.0-20200731224558-ff85679d3364/go.mod h1:OuYBJDpMMnvMUoBn+XeMWtHghuYk0cq9bNkNa3T8j/g= +github.com/suzerain-io/placeholder-name-client-go v0.0.0-20200731225637-b994efe19486 h1:JOPwCoRjsUu8E/E81BolafPfovnTmxGAgFnEV0R8T3U= +github.com/suzerain-io/placeholder-name-client-go v0.0.0-20200731225637-b994efe19486/go.mod h1:0lk6jYt88I1632/5TIwpBPhQAewKuesNK+rKhfoegRk= github.com/tdakkota/asciicheck v0.0.0-20200416190851-d7f85be797a2 h1:Xr9gkxfOP0KQWXKNqmwe8vEeSUiUj4Rlee9CMVX2ZUQ= github.com/tdakkota/asciicheck v0.0.0-20200416190851-d7f85be797a2/go.mod h1:yHp0ai0Z9gUljN3o0xMhYJnH/IcvkdTBOX2fmJ93JEM= github.com/tetafro/godot v0.4.2 h1:Dib7un+rYJFUi8vN0Bk6EHheKy6fv6ZzFURHw75g6m8= @@ -740,7 +744,10 @@ golang.org/x/tools v0.0.0-20200519015757-0d0afa43d58a/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200602230032-c00d67ef29d0/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200625211823-6506e20df31f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200626171337-aa94e735be7f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200626171337-aa94e735be7f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200710042808-f1c4188a97a1 h1:rD1FcWVsRaMY+l8biE9jbWP5MS/CJJ/90a9TMkMgNrM= +golang.org/x/tools v0.0.0-20200710042808-f1c4188a97a1 h1:rD1FcWVsRaMY+l8biE9jbWP5MS/CJJ/90a9TMkMgNrM= +golang.org/x/tools v0.0.0-20200710042808-f1c4188a97a1/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200710042808-f1c4188a97a1/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -788,6 +795,7 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0 h1:UhZDfRO8JRQru4/+LlLE0BRKGF8L+PICnvYZmx/fEGA= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/apiserver/apiserver.go b/internal/apiserver/apiserver.go index 2fb42b0f..d7b6d639 100644 --- a/internal/apiserver/apiserver.go +++ b/internal/apiserver/apiserver.go @@ -6,6 +6,7 @@ SPDX-License-Identifier: Apache-2.0 package apiserver import ( + "context" "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -56,8 +57,9 @@ type Config struct { } type ExtraConfig struct { - Webhook authenticator.Token - Issuer loginrequest.CertIssuer + Webhook authenticator.Token + Issuer loginrequest.CertIssuer + StartControllersPostStartHook func(ctx context.Context) } type PlaceHolderServer struct { @@ -122,9 +124,17 @@ func (c completedConfig) New() (*PlaceHolderServer, error) { return nil, fmt.Errorf("install API group error: %w", err) } - s.GenericAPIServer.AddPostStartHookOrDie("place-holder-post-start-hook", - func(context genericapiserver.PostStartHookContext) error { - klog.InfoS("post start hook", "foo", "bar") + s.GenericAPIServer.AddPostStartHookOrDie("start-controllers", + func(postStartContext genericapiserver.PostStartHookContext) error { + klog.InfoS("start-controllers post start hook starting") + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + <-postStartContext.StopCh + cancel() + }() + c.ExtraConfig.StartControllersPostStartHook(ctx) + return nil }, ) diff --git a/internal/controller/logindiscovery/doc.go b/internal/controller/logindiscovery/doc.go new file mode 100644 index 00000000..513f8a1e --- /dev/null +++ b/internal/controller/logindiscovery/doc.go @@ -0,0 +1,7 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Package logindiscovery contains controller(s) for reconciling LoginDiscoveryConfig's. +package logindiscovery diff --git a/internal/controller/logindiscovery/publisher.go b/internal/controller/logindiscovery/publisher.go new file mode 100644 index 00000000..61186c26 --- /dev/null +++ b/internal/controller/logindiscovery/publisher.go @@ -0,0 +1,192 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package logindiscovery + +import ( + "context" + "encoding/base64" + "fmt" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + corev1informers "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/klog/v2" + + "github.com/suzerain-io/controller-go" + crdsplaceholderv1alpha1 "github.com/suzerain-io/placeholder-name-api/pkg/apis/crdsplaceholder/v1alpha1" + placeholderclientset "github.com/suzerain-io/placeholder-name-client-go/pkg/generated/clientset/versioned" + crdsplaceholderv1alpha1informers "github.com/suzerain-io/placeholder-name-client-go/pkg/generated/informers/externalversions/crdsplaceholder/v1alpha1" +) + +const ( + ClusterInfoNamespace = "kube-public" + + clusterInfoName = "cluster-info" + clusterInfoConfigMapKey = "kubeconfig" + + configName = "placeholder-name-config" +) + +func nameAndNamespaceExactMatchFilterFactory(name, namespace string) controller.FilterFuncs { + objMatchesFunc := func(obj metav1.Object) bool { + return obj.GetName() == name && obj.GetNamespace() == namespace + } + return controller.FilterFuncs{ + AddFunc: objMatchesFunc, + UpdateFunc: func(oldObj, newObj metav1.Object) bool { + return objMatchesFunc(oldObj) || objMatchesFunc(newObj) + }, + DeleteFunc: objMatchesFunc, + } +} + +// Same signature as controller.WithInformer(). +type withInformerOptionFunc func( + getter controller.InformerGetter, + filter controller.Filter, + opt controller.InformerOption) controller.Option + +type publisherController struct { + namespace string + serverOverride *string + placeholderClient placeholderclientset.Interface + configMapInformer corev1informers.ConfigMapInformer + loginDiscoveryConfigInformer crdsplaceholderv1alpha1informers.LoginDiscoveryConfigInformer +} + +func NewPublisherController( + namespace string, + serverOverride *string, + placeholderClient placeholderclientset.Interface, + configMapInformer corev1informers.ConfigMapInformer, + loginDiscoveryConfigInformer crdsplaceholderv1alpha1informers.LoginDiscoveryConfigInformer, + withInformer withInformerOptionFunc, +) controller.Controller { + return controller.New( + controller.Config{ + Name: "publisher-controller", + Syncer: &publisherController{ + namespace: namespace, + serverOverride: serverOverride, + placeholderClient: placeholderClient, + configMapInformer: configMapInformer, + loginDiscoveryConfigInformer: loginDiscoveryConfigInformer, + }, + }, + withInformer( + configMapInformer, + nameAndNamespaceExactMatchFilterFactory(clusterInfoName, ClusterInfoNamespace), + controller.InformerOption{}, + ), + withInformer( + loginDiscoveryConfigInformer, + nameAndNamespaceExactMatchFilterFactory(configName, namespace), + controller.InformerOption{}, + ), + ) +} + +func (c *publisherController) Sync(ctx controller.Context) error { + configMap, err := c.configMapInformer. + Lister(). + ConfigMaps(ClusterInfoNamespace). + Get(clusterInfoName) + notFound := k8serrors.IsNotFound(err) + if err != nil && !notFound { + return fmt.Errorf("failed to get %s configmap: %w", clusterInfoName, err) + } + if notFound { + klog.InfoS( + "could not find config map", + "configmap", + klog.KRef(ClusterInfoNamespace, clusterInfoName), + ) + return nil + } + + kubeConfig, kubeConfigPresent := configMap.Data[clusterInfoConfigMapKey] + if !kubeConfigPresent { + klog.InfoS("could not find kubeconfig configmap key") + return nil + } + + config, _ := clientcmd.Load([]byte(kubeConfig)) + + var certificateAuthorityData, server string + for _, v := range config.Clusters { + certificateAuthorityData = base64.StdEncoding.EncodeToString(v.CertificateAuthorityData) + server = v.Server + break + } + + if c.serverOverride != nil { + server = *c.serverOverride + } + + discoveryConfig := crdsplaceholderv1alpha1.LoginDiscoveryConfig{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: configName, + Namespace: c.namespace, + }, + Spec: crdsplaceholderv1alpha1.LoginDiscoveryConfigSpec{ + Server: server, + CertificateAuthorityData: certificateAuthorityData, + }, + } + if err := c.createOrUpdateLoginDiscoveryConfig(ctx.Context, &discoveryConfig); err != nil { + return err + } + + return nil +} + +func (c *publisherController) createOrUpdateLoginDiscoveryConfig( + ctx context.Context, + discoveryConfig *crdsplaceholderv1alpha1.LoginDiscoveryConfig, +) error { + existingDiscoveryConfig, err := c.loginDiscoveryConfigInformer. + Lister(). + LoginDiscoveryConfigs(c.namespace). + Get(discoveryConfig.Name) + notFound := k8serrors.IsNotFound(err) + if err != nil && !notFound { + return fmt.Errorf("could not get logindiscoveryconfig: %w", err) + } + + loginDiscoveryConfigs := c.placeholderClient. + CrdsV1alpha1(). + LoginDiscoveryConfigs(c.namespace) + if notFound { + if _, err := loginDiscoveryConfigs.Create( + ctx, + discoveryConfig, + metav1.CreateOptions{}, + ); err != nil { + return fmt.Errorf("could not create logindiscoveryconfig: %w", err) + } + } else if !equal(existingDiscoveryConfig, discoveryConfig) { + // Update just the fields we care about. + existingDiscoveryConfig.Spec.Server = discoveryConfig.Spec.Server + existingDiscoveryConfig.Spec.CertificateAuthorityData = discoveryConfig.Spec.CertificateAuthorityData + + if _, err := loginDiscoveryConfigs.Update( + ctx, + existingDiscoveryConfig, + metav1.UpdateOptions{}, + ); err != nil { + return fmt.Errorf("could not update logindiscoveryconfig: %w", err) + } + } + + return nil +} + +func equal(a, b *crdsplaceholderv1alpha1.LoginDiscoveryConfig) bool { + return a.Spec.Server == b.Spec.Server && + a.Spec.CertificateAuthorityData == b.Spec.CertificateAuthorityData +} diff --git a/internal/controller/logindiscovery/publisher_test.go b/internal/controller/logindiscovery/publisher_test.go new file mode 100644 index 00000000..8e3c69d9 --- /dev/null +++ b/internal/controller/logindiscovery/publisher_test.go @@ -0,0 +1,475 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package logindiscovery + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + kubeinformers "k8s.io/client-go/informers" + kubernetesfake "k8s.io/client-go/kubernetes/fake" + coretesting "k8s.io/client-go/testing" + + "github.com/suzerain-io/controller-go" + crdsplaceholderv1alpha1 "github.com/suzerain-io/placeholder-name-api/pkg/apis/crdsplaceholder/v1alpha1" + placeholderfake "github.com/suzerain-io/placeholder-name-client-go/pkg/generated/clientset/versioned/fake" + placeholderinformers "github.com/suzerain-io/placeholder-name-client-go/pkg/generated/informers/externalversions" +) + +type ObservableWithInformerOption struct { + InformerToFilterMap map[controller.InformerGetter]controller.Filter +} + +func NewObservableWithInformerOption() *ObservableWithInformerOption { + return &ObservableWithInformerOption{ + InformerToFilterMap: make(map[controller.InformerGetter]controller.Filter), + } +} + +func (owi *ObservableWithInformerOption) WithInformer( + getter controller.InformerGetter, + filter controller.Filter, + opt controller.InformerOption) controller.Option { + owi.InformerToFilterMap[getter] = filter + return controller.WithInformer(getter, filter, opt) +} + +func TestInformerFilters(t *testing.T) { + spec.Run(t, "informer filters", func(t *testing.T, when spec.G, it spec.S) { + const installedInNamespace = "some-namespace" + + var r *require.Assertions + var observableWithInformerOption *ObservableWithInformerOption + var configMapInformerFilter controller.Filter + var loginDiscoveryConfigInformerFilter controller.Filter + + it.Before(func() { + r = require.New(t) + observableWithInformerOption = NewObservableWithInformerOption() + configMapInformer := kubeinformers.NewSharedInformerFactory(nil, 0).Core().V1().ConfigMaps() + loginDiscoveryConfigInformer := placeholderinformers.NewSharedInformerFactory(nil, 0).Crds().V1alpha1().LoginDiscoveryConfigs() + _ = NewPublisherController( + installedInNamespace, + nil, + nil, + configMapInformer, + loginDiscoveryConfigInformer, + observableWithInformerOption.WithInformer, // make it possible to observe the behavior of the Filters + ) + configMapInformerFilter = observableWithInformerOption.InformerToFilterMap[configMapInformer] + loginDiscoveryConfigInformerFilter = observableWithInformerOption.InformerToFilterMap[loginDiscoveryConfigInformer] + }) + + when("watching ConfigMap objects", func() { + var subject controller.Filter + var target, wrongNamespace, wrongName, unrelated *corev1.ConfigMap + + it.Before(func() { + subject = configMapInformerFilter + target = &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cluster-info", Namespace: "kube-public"}} + wrongNamespace = &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cluster-info", Namespace: "wrong-namespace"}} + wrongName = &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "wrong-name", Namespace: "kube-public"}} + unrelated = &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "wrong-name", Namespace: "wrong-namespace"}} + }) + + when("the target ConfigMap changes", func() { + it("returns true to trigger the sync method", func() { + r.True(subject.Add(target)) + r.True(subject.Update(target, unrelated)) + r.True(subject.Update(unrelated, target)) + r.True(subject.Delete(target)) + }) + }) + + when("a ConfigMap from another namespace changes", func() { + it("returns false to avoid triggering the sync method", func() { + r.False(subject.Add(wrongNamespace)) + r.False(subject.Update(wrongNamespace, unrelated)) + r.False(subject.Update(unrelated, wrongNamespace)) + r.False(subject.Delete(wrongNamespace)) + }) + }) + + when("a ConfigMap with a different name changes", func() { + it("returns false to avoid triggering the sync method", func() { + r.False(subject.Add(wrongName)) + r.False(subject.Update(wrongName, unrelated)) + r.False(subject.Update(unrelated, wrongName)) + r.False(subject.Delete(wrongName)) + }) + }) + + when("a ConfigMap with a different name and a different namespace changes", func() { + it("returns false to avoid triggering the sync method", func() { + r.False(subject.Add(unrelated)) + r.False(subject.Update(unrelated, unrelated)) + r.False(subject.Delete(unrelated)) + }) + }) + }) + + when("watching LoginDiscoveryConfig objects", func() { + var subject controller.Filter + var target, wrongNamespace, wrongName, unrelated *crdsplaceholderv1alpha1.LoginDiscoveryConfig + + it.Before(func() { + subject = loginDiscoveryConfigInformerFilter + target = &crdsplaceholderv1alpha1.LoginDiscoveryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "placeholder-name-config", Namespace: installedInNamespace}, + } + wrongNamespace = &crdsplaceholderv1alpha1.LoginDiscoveryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "placeholder-name-config", Namespace: "wrong-namespace"}, + } + wrongName = &crdsplaceholderv1alpha1.LoginDiscoveryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "wrong-name", Namespace: installedInNamespace}, + } + unrelated = &crdsplaceholderv1alpha1.LoginDiscoveryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "wrong-name", Namespace: "wrong-namespace"}, + } + }) + + when("the target LoginDiscoveryConfig changes", func() { + it("returns true to trigger the sync method", func() { + r.True(subject.Add(target)) + r.True(subject.Update(target, unrelated)) + r.True(subject.Update(unrelated, target)) + r.True(subject.Delete(target)) + }) + }) + + when("a LoginDiscoveryConfig from another namespace changes", func() { + it("returns false to avoid triggering the sync method", func() { + r.False(subject.Add(wrongNamespace)) + r.False(subject.Update(wrongNamespace, unrelated)) + r.False(subject.Update(unrelated, wrongNamespace)) + r.False(subject.Delete(wrongNamespace)) + }) + }) + + when("a LoginDiscoveryConfig with a different name changes", func() { + it("returns false to avoid triggering the sync method", func() { + r.False(subject.Add(wrongName)) + r.False(subject.Update(wrongName, unrelated)) + r.False(subject.Update(unrelated, wrongName)) + r.False(subject.Delete(wrongName)) + }) + }) + + when("a LoginDiscoveryConfig with a different name and a different namespace changes", func() { + it("returns false to avoid triggering the sync method", func() { + r.False(subject.Add(unrelated)) + r.False(subject.Update(unrelated, unrelated)) + r.False(subject.Delete(unrelated)) + }) + }) + }) + }, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func TestSync(t *testing.T) { + spec.Run(t, "Sync", func(t *testing.T, when spec.G, it spec.S) { + const installedInNamespace = "some-namespace" + + var r *require.Assertions + + var subject controller.Controller + var serverOverride *string + var kubeInformerClient *kubernetesfake.Clientset + var placeholderInformerClient *placeholderfake.Clientset + var kubeInformers kubeinformers.SharedInformerFactory + var placeholderInformers placeholderinformers.SharedInformerFactory + var placeholderAPIClient *placeholderfake.Clientset + var timeoutContext context.Context + var timeoutContextCancel context.CancelFunc + var syncContext *controller.Context + + var expectedLoginDiscoveryConfig = func(expectedNamespace, expectedServerURL, expectedCAData string) (schema.GroupVersionResource, *crdsplaceholderv1alpha1.LoginDiscoveryConfig) { + expectedLoginDiscoveryConfigGVR := schema.GroupVersionResource{ + Group: crdsplaceholderv1alpha1.GroupName, + Version: "v1alpha1", + Resource: "logindiscoveryconfigs", + } + expectedLoginDiscoveryConfig := &crdsplaceholderv1alpha1.LoginDiscoveryConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "placeholder-name-config", + Namespace: expectedNamespace, + }, + Spec: crdsplaceholderv1alpha1.LoginDiscoveryConfigSpec{ + Server: expectedServerURL, + CertificateAuthorityData: expectedCAData, + }, + } + return expectedLoginDiscoveryConfigGVR, expectedLoginDiscoveryConfig + } + + // Defer starting the informers until the last possible moment so that the + // nested Before's can keep adding things to the informer caches. + var startInformersAndController = func() { + // Set this at the last second to allow for injection of server override. + subject = NewPublisherController( + installedInNamespace, + serverOverride, + placeholderAPIClient, + kubeInformers.Core().V1().ConfigMaps(), + placeholderInformers.Crds().V1alpha1().LoginDiscoveryConfigs(), + controller.WithInformer, + ) + + // Set this at the last second to support calling subject.Name(). + syncContext = &controller.Context{ + Context: timeoutContext, + Name: subject.Name(), + Key: controller.Key{ + Namespace: "kube-public", + Name: "cluster-info", + }, + } + + // Must start informers before calling TestRunSynchronously() + kubeInformers.Start(timeoutContext.Done()) + placeholderInformers.Start(timeoutContext.Done()) + controller.TestRunSynchronously(t, subject) + } + + it.Before(func() { + r = require.New(t) + + timeoutContext, timeoutContextCancel = context.WithTimeout(context.Background(), time.Second*3) + + kubeInformerClient = kubernetesfake.NewSimpleClientset() + kubeInformers = kubeinformers.NewSharedInformerFactory(kubeInformerClient, 0) + placeholderAPIClient = placeholderfake.NewSimpleClientset() + placeholderInformerClient = placeholderfake.NewSimpleClientset() + placeholderInformers = placeholderinformers.NewSharedInformerFactory(placeholderInformerClient, 0) + }) + + it.After(func() { + timeoutContextCancel() + }) + + when("there is a cluster-info ConfigMap in the kube-public namespace", func() { + const caData = "c29tZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YQo=" // "some-certificate-authority-data" base64 encoded + const kubeServerURL = "https://some-server" + + when("the ConfigMap has the expected `kubeconfig` top-level data key", func() { + it.Before(func() { + clusterInfoConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster-info", Namespace: "kube-public"}, + // Note that go fmt puts tabs in our file, which we must remove from our configmap yaml below. + Data: map[string]string{ + "kubeconfig": strings.ReplaceAll(` + kind: Config + apiVersion: v1 + clusters: + - name: "" + cluster: + certificate-authority-data: "`+caData+`" + server: "`+kubeServerURL+`"`, "\t", " "), + "uninteresting-key": "uninteresting-value", + }, + } + err := kubeInformerClient.Tracker().Add(clusterInfoConfigMap) + r.NoError(err) + }) + + when("the LoginDiscoveryConfig does not already exist", func() { + it("creates a LoginDiscoveryConfig", func() { + startInformersAndController() + err := controller.TestSync(t, subject, *syncContext) + r.NoError(err) + + expectedLoginDiscoveryConfigGVR, expectedLoginDiscoveryConfig := expectedLoginDiscoveryConfig( + installedInNamespace, + kubeServerURL, + caData, + ) + + r.Equal( + []coretesting.Action{ + coretesting.NewCreateAction( + expectedLoginDiscoveryConfigGVR, + installedInNamespace, + expectedLoginDiscoveryConfig, + ), + }, + placeholderAPIClient.Actions(), + ) + }) + + when("creating the LoginDiscoveryConfig fails", func() { + it.Before(func() { + placeholderAPIClient.PrependReactor( + "create", + "logindiscoveryconfigs", + func(_ coretesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("create failed") + }, + ) + }) + + it("returns the create error", func() { + startInformersAndController() + err := controller.TestSync(t, subject, *syncContext) + r.EqualError(err, "could not create logindiscoveryconfig: create failed") + }) + }) + + when("a server override is passed to the controller", func() { + it("uses the server override field", func() { + serverOverride = new(string) + *serverOverride = "https://some-server-override" + + startInformersAndController() + err := controller.TestSync(t, subject, *syncContext) + r.NoError(err) + + expectedLoginDiscoveryConfigGVR, expectedLoginDiscoveryConfig := expectedLoginDiscoveryConfig( + installedInNamespace, + kubeServerURL, + caData, + ) + expectedLoginDiscoveryConfig.Spec.Server = "https://some-server-override" + + r.Equal( + []coretesting.Action{ + coretesting.NewCreateAction( + expectedLoginDiscoveryConfigGVR, + installedInNamespace, + expectedLoginDiscoveryConfig, + ), + }, + placeholderAPIClient.Actions(), + ) + }) + }) + }) + + when("the LoginDiscoveryConfig already exists", func() { + when("the LoginDiscoveryConfig is already up to date according to the data in the ConfigMap", func() { + it.Before(func() { + _, expectedLoginDiscoveryConfig := expectedLoginDiscoveryConfig( + installedInNamespace, + kubeServerURL, + caData, + ) + err := placeholderInformerClient.Tracker().Add(expectedLoginDiscoveryConfig) + r.NoError(err) + }) + + it("does not update the LoginDiscoveryConfig to avoid unnecessary etcd writes/api calls", func() { + startInformersAndController() + err := controller.TestSync(t, subject, *syncContext) + r.NoError(err) + + r.Empty(placeholderAPIClient.Actions()) + }) + }) + + when("the LoginDiscoveryConfig is stale compared to the data in the ConfigMap", func() { + it.Before(func() { + _, expectedLoginDiscoveryConfig := expectedLoginDiscoveryConfig( + installedInNamespace, + kubeServerURL, + caData, + ) + expectedLoginDiscoveryConfig.Spec.Server = "https://some-other-server" + r.NoError(placeholderInformerClient.Tracker().Add(expectedLoginDiscoveryConfig)) + r.NoError(placeholderAPIClient.Tracker().Add(expectedLoginDiscoveryConfig)) + }) + + it("updates the existing LoginDiscoveryConfig", func() { + startInformersAndController() + err := controller.TestSync(t, subject, *syncContext) + r.NoError(err) + + expectedLoginDiscoveryConfigGVR, expectedLoginDiscoveryConfig := expectedLoginDiscoveryConfig( + installedInNamespace, + kubeServerURL, + caData, + ) + expectedActions := []coretesting.Action{ + coretesting.NewUpdateAction( + expectedLoginDiscoveryConfigGVR, + installedInNamespace, + expectedLoginDiscoveryConfig, + ), + } + r.Equal(expectedActions, placeholderAPIClient.Actions()) + }) + + when("updating the LoginDiscoveryConfig fails", func() { + it.Before(func() { + placeholderAPIClient.PrependReactor( + "update", + "logindiscoveryconfigs", + func(_ coretesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("update failed") + }, + ) + }) + + it("returns the update error", func() { + startInformersAndController() + err := controller.TestSync(t, subject, *syncContext) + r.EqualError(err, "could not update logindiscoveryconfig: update failed") + }) + }) + }) + }) + }) + + when("the ConfigMap is missing the expected `kubeconfig` top-level data key", func() { + it.Before(func() { + clusterInfoConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster-info", Namespace: "kube-public"}, + Data: map[string]string{ + "these are not the droids you're looking for": "uninteresting-value", + }, + } + err := kubeInformerClient.Tracker().Add(clusterInfoConfigMap) + r.NoError(err) + }) + + it("keeps waiting for it to exist", func() { + startInformersAndController() + err := controller.TestSync(t, subject, *syncContext) + r.NoError(err) + r.Empty(placeholderAPIClient.Actions()) + }) + }) + }) + + when("there is not a cluster-info ConfigMap in the kube-public namespace", func() { + it.Before(func() { + unrelatedConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "oops this is not the cluster-info ConfigMap", + Namespace: "kube-public", + }, + } + err := kubeInformerClient.Tracker().Add(unrelatedConfigMap) + r.NoError(err) + }) + + it("keeps waiting for one", func() { + startInformersAndController() + err := controller.TestSync(t, subject, *syncContext) + r.NoError(err) + r.Empty(placeholderAPIClient.Actions()) + }) + }) + }, spec.Parallel(), spec.Report(report.Terminal{})) +} diff --git a/internal/server/server.go b/internal/server/server.go index 442e4fd5..d6164245 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -25,20 +25,29 @@ import ( "k8s.io/apiserver/pkg/server/dynamiccertificates" genericoptions "k8s.io/apiserver/pkg/server/options" "k8s.io/apiserver/plugin/pkg/authenticator/token/webhook" + k8sinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" - corev1client "k8s.io/client-go/kubernetes/typed/core/v1" restclient "k8s.io/client-go/rest" apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" aggregationv1client "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset" + "github.com/suzerain-io/controller-go" placeholderv1alpha1 "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder/v1alpha1" + placeholderclientset "github.com/suzerain-io/placeholder-name-client-go/pkg/generated/clientset/versioned" + placeholderinformers "github.com/suzerain-io/placeholder-name-client-go/pkg/generated/informers/externalversions" "github.com/suzerain-io/placeholder-name/internal/apiserver" "github.com/suzerain-io/placeholder-name/internal/autoregistration" "github.com/suzerain-io/placeholder-name/internal/certauthority" + "github.com/suzerain-io/placeholder-name/internal/controller/logindiscovery" "github.com/suzerain-io/placeholder-name/internal/downward" "github.com/suzerain-io/placeholder-name/pkg/config" ) +const ( + singletonWorker = 1 + defaultResyncInterval = 3 * time.Minute +) + // App is an object that represents the placeholder-name-server application. type App struct { cmd *cobra.Command @@ -83,18 +92,26 @@ authenticating to the Kubernetes API.`, protoKubeConfig := createProtoKubeConfig(kubeConfig) // Connect to the core Kubernetes API. - k8s, err := kubernetes.NewForConfig(protoKubeConfig) + k8sClient, err := kubernetes.NewForConfig(protoKubeConfig) if err != nil { return fmt.Errorf("could not initialize Kubernetes client: %w", err) } // Connect to the Kubernetes aggregation API. - aggregation, err := aggregationv1client.NewForConfig(protoKubeConfig) + aggregationClient, err := aggregationv1client.NewForConfig(protoKubeConfig) if err != nil { return fmt.Errorf("could not initialize Kubernetes client: %w", err) } - return a.run(ctx, k8s.CoreV1(), aggregation) + // Connect to the placeholder API. + // I think we can't use protobuf encoding here because we are using CRDs + // (for which protobuf encoding is not supported). + placeholderClient, err := placeholderclientset.NewForConfig(kubeConfig) + if err != nil { + return fmt.Errorf("could not initialize placeholder client: %w", err) + } + + return a.run(ctx, k8sClient, aggregationClient, placeholderClient) }, Args: cobra.NoArgs, } @@ -143,8 +160,9 @@ func (a *App) Run() error { func (a *App) run( ctx context.Context, - k8s corev1client.CoreV1Interface, - aggregation aggregationv1client.Interface, + k8sClient kubernetes.Interface, + aggregationClient aggregationv1client.Interface, + placeholderClient placeholderclientset.Interface, ) error { cfg, err := config.FromPath(a.configPath) if err != nil { @@ -166,6 +184,7 @@ func (a *App) run( if err != nil { return fmt.Errorf("could not read pod metadata: %w", err) } + serverInstallationNamespace := podinfo.Namespace // TODO use the postStart hook to generate certs? @@ -177,7 +196,7 @@ func (a *App) run( const serviceName = "placeholder-name-api" cert, err := apiCA.Issue( - pkix.Name{CommonName: serviceName + "." + podinfo.Namespace + ".svc"}, + pkix.Name{CommonName: serviceName + "." + serverInstallationNamespace + ".svc"}, []string{}, 24*365*time.Hour, ) @@ -213,16 +232,27 @@ func (a *App) run( }, } if err := autoregistration.Setup(ctx, autoregistration.SetupOptions{ - CoreV1: k8s, - AggregationV1: aggregation, - Namespace: podinfo.Namespace, + CoreV1: k8sClient.CoreV1(), + AggregationV1: aggregationClient, + Namespace: serverInstallationNamespace, ServiceTemplate: service, APIServiceTemplate: apiService, }); err != nil { return fmt.Errorf("could not register API service: %w", err) } - apiServerConfig, err := a.ConfigServer(cert, webhookTokenAuthenticator, clientCA) + cmrf := wireControllerManagerRunFunc( + serverInstallationNamespace, + cfg.DiscoveryConfig.URL, + k8sClient, + placeholderClient, + ) + apiServerConfig, err := a.configServer( + cert, + webhookTokenAuthenticator, + clientCA, + cmrf, + ) if err != nil { return err } @@ -235,7 +265,12 @@ func (a *App) run( return server.GenericAPIServer.PrepareRun().Run(ctx.Done()) } -func (a *App) ConfigServer(cert *tls.Certificate, webhookTokenAuthenticator *webhook.WebhookTokenAuthenticator, ca *certauthority.CA) (*apiserver.Config, error) { +func (a *App) configServer( + cert *tls.Certificate, + webhookTokenAuthenticator *webhook.WebhookTokenAuthenticator, + ca *certauthority.CA, + startControllersPostStartHook func(context.Context), +) (*apiserver.Config, error) { provider, err := createStaticCertKeyProvider(cert) if err != nil { return nil, fmt.Errorf("could not create static cert key provider: %w", err) @@ -250,8 +285,9 @@ func (a *App) ConfigServer(cert *tls.Certificate, webhookTokenAuthenticator *web apiServerConfig := &apiserver.Config{ GenericConfig: serverConfig, ExtraConfig: apiserver.ExtraConfig{ - Webhook: webhookTokenAuthenticator, - Issuer: ca, + Webhook: webhookTokenAuthenticator, + Issuer: ca, + StartControllersPostStartHook: startControllersPostStartHook, }, } return apiServerConfig, nil @@ -290,3 +326,41 @@ func createStaticCertKeyProvider(cert *tls.Certificate) (dynamiccertificates.Cer return dynamiccertificates.NewStaticCertKeyContent("some-name???", certChainPEM, privateKeyPEM) } + +func wireControllerManagerRunFunc( + serverInstallationNamespace string, + discoveryURLOverride *string, + k8s kubernetes.Interface, + placeholder placeholderclientset.Interface, +) func(ctx context.Context) { + k8sInformers := k8sinformers.NewSharedInformerFactoryWithOptions( + k8s, + defaultResyncInterval, + k8sinformers.WithNamespace( + logindiscovery.ClusterInfoNamespace, + ), + ) + placeholderInformers := placeholderinformers.NewSharedInformerFactoryWithOptions( + placeholder, + defaultResyncInterval, + placeholderinformers.WithNamespace(serverInstallationNamespace), + ) + cm := controller. + NewManager(). + WithController( + logindiscovery.NewPublisherController( + serverInstallationNamespace, + discoveryURLOverride, + placeholder, + k8sInformers.Core().V1().ConfigMaps(), + placeholderInformers.Crds().V1alpha1().LoginDiscoveryConfigs(), + controller.WithInformer, + ), + singletonWorker, + ) + return func(ctx context.Context) { + k8sInformers.Start(ctx.Done()) + placeholderInformers.Start(ctx.Done()) + go cm.Start(ctx) + } +} diff --git a/pkg/config/api/types.go b/pkg/config/api/types.go index 795a2eb2..64cb25c4 100644 --- a/pkg/config/api/types.go +++ b/pkg/config/api/types.go @@ -7,7 +7,8 @@ package api // Config contains knobs to setup an instance of placeholder-name. type Config struct { - WebhookConfig WebhookConfigSpec `json:"webhook"` + WebhookConfig WebhookConfigSpec `json:"webhook"` + DiscoveryConfig DiscoveryConfigSpec `json:"discovery"` } // WebhookConfig contains configuration knobs specific to placeholder-name's use @@ -21,3 +22,12 @@ type WebhookConfigSpec struct { // to validate TLS connections to the WebhookURL. CABundle []byte `json:"caBundle"` } + +// DiscoveryConfigSpec contains configuration knobs specific to +// placeholder-name's publishing of discovery information. These values can be +// viewed as overrides, i.e., if these are set, then placeholder-name will +// publish these values in its discovery document instead of the ones it finds. +type DiscoveryConfigSpec struct { + // URL contains the URL at which placeholder-name can be contacted. + URL *string `json:"url,omitempty"` +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 52917469..82f06379 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -14,14 +14,50 @@ import ( ) func TestFromPath(t *testing.T) { - expect := require.New(t) - - config, err := FromPath("testdata/happy.yaml") - expect.NoError(err) - expect.Equal(config, &api.Config{ - WebhookConfig: api.WebhookConfigSpec{ - URL: "https://tuna.com/fish?marlin", - CABundle: []byte("-----BEGIN CERTIFICATE-----..."), + tests := []struct { + name string + path string + wantConfig *api.Config + }{ + { + name: "Happy", + path: "testdata/happy.yaml", + wantConfig: &api.Config{ + DiscoveryConfig: api.DiscoveryConfigSpec{ + URL: stringPtr("https://some.discovery/url"), + }, + WebhookConfig: api.WebhookConfigSpec{ + URL: "https://tuna.com/fish?marlin", + CABundle: []byte("-----BEGIN CERTIFICATE-----..."), + }, + }, }, - }) + { + name: "NoDiscovery", + path: "testdata/no-discovery.yaml", + wantConfig: &api.Config{ + DiscoveryConfig: api.DiscoveryConfigSpec{ + URL: nil, + }, + WebhookConfig: api.WebhookConfigSpec{ + URL: "https://tuna.com/fish?marlin", + CABundle: []byte("-----BEGIN CERTIFICATE-----..."), + }, + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + config, err := FromPath(test.path) + require.NoError(t, err) + require.Equal(t, test.wantConfig, config) + }) + } +} + +func stringPtr(s string) *string { + sPtr := new(string) + *sPtr = s + return sPtr } diff --git a/pkg/config/testdata/happy.yaml b/pkg/config/testdata/happy.yaml index 00c2e78b..ebbfac72 100644 --- a/pkg/config/testdata/happy.yaml +++ b/pkg/config/testdata/happy.yaml @@ -1,4 +1,6 @@ --- +discovery: + url: https://some.discovery/url webhook: url: https://tuna.com/fish?marlin caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tLi4u diff --git a/pkg/config/testdata/no-discovery.yaml b/pkg/config/testdata/no-discovery.yaml new file mode 100644 index 00000000..00c2e78b --- /dev/null +++ b/pkg/config/testdata/no-discovery.yaml @@ -0,0 +1,5 @@ +--- +webhook: + url: https://tuna.com/fish?marlin + caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tLi4u + diff --git a/test/integration/api_discovery_test.go b/test/integration/api_discovery_test.go new file mode 100644 index 00000000..d35b7abe --- /dev/null +++ b/test/integration/api_discovery_test.go @@ -0,0 +1,100 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package integration + +import ( + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/suzerain-io/placeholder-name/test/library" +) + +func TestGetAPIResourceList(t *testing.T) { + client := library.NewPlaceholderNameClientset(t) + + groups, resources, err := client.Discovery().ServerGroupsAndResources() + require.NoError(t, err) + + groupName := "placeholder.suzerain-io.github.io" + actualGroup := findGroup(groupName, groups) + require.NotNil(t, actualGroup) + + expectedGroup := &metav1.APIGroup{ + Name: "placeholder.suzerain-io.github.io", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "placeholder.suzerain-io.github.io/v1alpha1", + Version: "v1alpha1", + }, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "placeholder.suzerain-io.github.io/v1alpha1", + Version: "v1alpha1", + }, + } + require.Equal(t, expectedGroup, actualGroup) + + actualPlaceHolderResources := findResources("placeholder.suzerain-io.github.io/v1alpha1", resources) + require.NotNil(t, actualPlaceHolderResources) + actualCrdsPlaceHolderResources := findResources("crds.placeholder.suzerain-io.github.io/v1alpha1", resources) + require.NotNil(t, actualPlaceHolderResources) + + expectedLoginRequestAPIResource := metav1.APIResource{ + Name: "loginrequests", + Kind: "LoginRequest", + Verbs: metav1.Verbs([]string{ + "create", + }), + Namespaced: false, + + // This is currently an empty string in the response; maybe it should not be + // empty? Seems like no harm in keeping it like this for now, but feel free + // to update in the future if there is a compelling reason to do so. + SingularName: "", + } + + expectedLDCAPIResource := metav1.APIResource{ + Name: "logindiscoveryconfigs", + SingularName: "logindiscoveryconfig", + Namespaced: true, + Kind: "LoginDiscoveryConfig", + Verbs: metav1.Verbs([]string{ + "delete", "deletecollection", "get", "list", "patch", "create", "update", "watch", + }), + ShortNames: []string{"ldc"}, + StorageVersionHash: "unknown: to be filled in automatically below", + } + + require.Len(t, actualPlaceHolderResources.APIResources, 1) + require.Equal(t, expectedLoginRequestAPIResource, actualPlaceHolderResources.APIResources[0]) + + require.Len(t, actualCrdsPlaceHolderResources.APIResources, 1) + actualAPIResource := actualCrdsPlaceHolderResources.APIResources[0] + // workaround because its hard to predict the storage version hash (e.g. "t/+v41y+3e4=") + // so just don't worry about comparing that field + expectedLDCAPIResource.StorageVersionHash = actualAPIResource.StorageVersionHash + require.Equal(t, expectedLDCAPIResource, actualAPIResource) +} + +func findGroup(name string, groups []*metav1.APIGroup) *metav1.APIGroup { + for _, group := range groups { + if group.Name == name { + return group + } + } + return nil +} + +func findResources(groupVersion string, resources []*metav1.APIResourceList) *metav1.APIResourceList { + for _, resource := range resources { + if resource.GroupVersion == groupVersion { + return resource + } + } + return nil +} diff --git a/test/integration/client_test.go b/test/integration/client_test.go index 2b18f170..c34d3617 100644 --- a/test/integration/client_test.go +++ b/test/integration/client_test.go @@ -7,7 +7,6 @@ package integration import ( "context" - "os" "strings" "testing" "time" @@ -52,8 +51,7 @@ O2D8LtWhMbrYy755Fgq4H9s3vCgfvHY1AQ== ) func TestClient(t *testing.T) { - tmcClusterToken := os.Getenv("PLACEHOLDER_NAME_TMC_CLUSTER_TOKEN") - require.NotEmptyf(t, tmcClusterToken, "must specify PLACEHOLDER_NAME_TMC_CLUSTER_TOKEN env var for integration tests") + tmcClusterToken := library.Getenv(t, "PLACEHOLDER_NAME_TMC_CLUSTER_TOKEN") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/test/integration/deployment_test.go b/test/integration/deployment_test.go index 8b88a14b..b1c2de89 100644 --- a/test/integration/deployment_test.go +++ b/test/integration/deployment_test.go @@ -7,7 +7,6 @@ package integration import ( "context" - "os" "testing" "time" @@ -20,11 +19,8 @@ import ( ) func TestGetDeployment(t *testing.T) { - namespaceName := os.Getenv("PLACEHOLDER_NAME_NAMESPACE") - require.NotEmptyf(t, namespaceName, "must specify PLACEHOLDER_NAME_NAMESPACE env var for integration tests") - - deploymentName := os.Getenv("PLACEHOLDER_NAME_DEPLOYMENT") - require.NotEmptyf(t, deploymentName, "must specify PLACEHOLDER_NAME_DEPLOYMENT env var for integration tests") + namespaceName := library.Getenv(t, "PLACEHOLDER_NAME_NAMESPACE") + deploymentName := library.Getenv(t, "PLACEHOLDER_NAME_DEPLOYMENT") client := library.NewClientset(t) diff --git a/test/integration/logindiscoveryconfig_test.go b/test/integration/logindiscoveryconfig_test.go new file mode 100644 index 00000000..5a5cc169 --- /dev/null +++ b/test/integration/logindiscoveryconfig_test.go @@ -0,0 +1,78 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package integration + +import ( + "context" + "encoding/base64" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + + crdsplaceholderv1alpha1 "github.com/suzerain-io/placeholder-name-api/pkg/apis/crdsplaceholder/v1alpha1" + "github.com/suzerain-io/placeholder-name/test/library" +) + +func TestSuccessfulLoginDiscoveryConfig(t *testing.T) { + namespaceName := library.Getenv(t, "PLACEHOLDER_NAME_NAMESPACE") + + client := library.NewPlaceholderNameClientset(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + config := library.NewClientConfig(t) + expectedLDCSpec := expectedLDCSpec(config) + configList, err := client. + CrdsV1alpha1(). + LoginDiscoveryConfigs(namespaceName). + List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, configList.Items, 1) + require.Equal(t, expectedLDCSpec, &configList.Items[0].Spec) +} + +func TestReconcilingLoginDiscoveryConfig(t *testing.T) { + namespaceName := library.Getenv(t, "PLACEHOLDER_NAME_NAMESPACE") + + client := library.NewPlaceholderNameClientset(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err := client. + CrdsV1alpha1(). + LoginDiscoveryConfigs(namespaceName). + Delete(ctx, "placeholder-name-config", metav1.DeleteOptions{}) + require.NoError(t, err) + + config := library.NewClientConfig(t) + expectedLDCSpec := expectedLDCSpec(config) + + var actualLDC *crdsplaceholderv1alpha1.LoginDiscoveryConfig + for i := 0; i < 10; i++ { + actualLDC, err = client. + CrdsV1alpha1(). + LoginDiscoveryConfigs(namespaceName). + Get(ctx, "placeholder-name-config", metav1.GetOptions{}) + if err == nil { + break + } + time.Sleep(time.Millisecond * 750) + } + require.NoError(t, err) + require.Equal(t, expectedLDCSpec, &actualLDC.Spec) +} + +func expectedLDCSpec(config *rest.Config) *crdsplaceholderv1alpha1.LoginDiscoveryConfigSpec { + return &crdsplaceholderv1alpha1.LoginDiscoveryConfigSpec{ + Server: config.Host, + CertificateAuthorityData: base64.StdEncoding.EncodeToString(config.TLSClientConfig.CAData), + } +} diff --git a/test/integration/loginrequest_test.go b/test/integration/loginrequest_test.go index 53295adf..d39e2c06 100644 --- a/test/integration/loginrequest_test.go +++ b/test/integration/loginrequest_test.go @@ -8,7 +8,6 @@ package integration import ( "context" "net/http" - "os" "testing" "time" @@ -58,8 +57,7 @@ func addTestClusterRoleBinding(ctx context.Context, t *testing.T, adminClient ku } func TestSuccessfulLoginRequest(t *testing.T) { - tmcClusterToken := os.Getenv("PLACEHOLDER_NAME_TMC_CLUSTER_TOKEN") - require.NotEmptyf(t, tmcClusterToken, "must specify PLACEHOLDER_NAME_TMC_CLUSTER_TOKEN env var for integration tests") + tmcClusterToken := library.Getenv(t, "PLACEHOLDER_NAME_TMC_CLUSTER_TOKEN") response, err := makeRequest(t, v1alpha1.LoginRequestSpec{ Type: v1alpha1.TokenLoginCredentialType, @@ -180,78 +178,3 @@ func TestLoginRequest_ShouldFailWhenRequestDoesNotIncludeToken(t *testing.T) { require.Empty(t, response.Spec) require.Nil(t, response.Status.Credential) } - -func TestGetAPIResourceList(t *testing.T) { - client := library.NewPlaceholderNameClientset(t) - - groups, resources, err := client.Discovery().ServerGroupsAndResources() - require.NoError(t, err) - - groupName := "placeholder.suzerain-io.github.io" - actualGroup := findGroup(groupName, groups) - require.NotNil(t, actualGroup) - - expectedGroup := &metav1.APIGroup{ - Name: "placeholder.suzerain-io.github.io", - Versions: []metav1.GroupVersionForDiscovery{ - { - GroupVersion: "placeholder.suzerain-io.github.io/v1alpha1", - Version: "v1alpha1", - }, - }, - PreferredVersion: metav1.GroupVersionForDiscovery{ - GroupVersion: "placeholder.suzerain-io.github.io/v1alpha1", - Version: "v1alpha1", - }, - } - require.Equal(t, expectedGroup, actualGroup) - - resourceGroupVersion := "placeholder.suzerain-io.github.io/v1alpha1" - actualResources := findResources(resourceGroupVersion, resources) - require.NotNil(t, actualResources) - - expectedResources := &metav1.APIResourceList{ - TypeMeta: metav1.TypeMeta{ - Kind: "APIResourceList", - APIVersion: "v1", - }, - GroupVersion: "placeholder.suzerain-io.github.io/v1alpha1", - APIResources: []metav1.APIResource{ - { - Name: "loginrequests", - Kind: "LoginRequest", - SingularName: "", // TODO(akeesler): what should this be? - Verbs: metav1.Verbs([]string{ - "create", - }), - }, - }, - } - require.Equal(t, expectedResources, actualResources) -} - -func TestGetAPIVersion(t *testing.T) { - client := library.NewPlaceholderNameClientset(t) - - version, err := client.Discovery().ServerVersion() - require.NoError(t, err) - require.NotNil(t, version) // TODO(akeesler): what can we assert here? -} - -func findGroup(name string, groups []*metav1.APIGroup) *metav1.APIGroup { - for _, group := range groups { - if group.Name == name { - return group - } - } - return nil -} - -func findResources(groupVersion string, resources []*metav1.APIResourceList) *metav1.APIResourceList { - for _, resource := range resources { - if resource.GroupVersion == groupVersion { - return resource - } - } - return nil -} diff --git a/test/library/env.go b/test/library/env.go new file mode 100644 index 00000000..db0ff67a --- /dev/null +++ b/test/library/env.go @@ -0,0 +1,22 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package library + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// Getenv gets the environment variable with key and asserts that it is not +// empty. It returns the value of the environment variable. +func Getenv(t *testing.T, key string) string { + t.Helper() + value := os.Getenv(key) + require.NotEmptyf(t, value, "must specify %s env var for integration tests", key) + return value +}